cyberhybridhub/lib/admin/widgets/sync_run_expansion_tile.dart
2026-05-31 11:17:12 -05:00

262 lines
8.0 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../theme/app_theme.dart';
import '../models/backfill_sync_item.dart';
import '../models/sync_run_event.dart';
import '../utils/sync_run_formatters.dart';
import 'sync_run_status_chip.dart';
class SyncRunExpansionTile extends StatelessWidget {
const SyncRunExpansionTile({
super.key,
required this.event,
this.now,
this.emphasizeError = false,
this.onRetry,
});
final SyncRunEvent event;
final DateTime? now;
final bool emphasizeError;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final Color iconColor = severityColor(event.severity);
return Card(
key: Key('sync-run-${event.id}'),
color: emphasizeError
? AppColors.surfaceElevated
: AppColors.surface,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
leading: Icon(severityIcon(event.severity), color: iconColor),
title: Text(
'${event.displayTitle}${shortStatusLabel(event)}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
subtitle: Text(
event.summary,
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
trailing: Text(
formatRelativeTime(event.startedAt, now: now),
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SyncRunStatusChip(event: event),
const SizedBox(height: 12),
..._detailRows(context),
],
),
),
],
),
),
);
}
List<Widget> _detailRows(BuildContext context) {
final List<Widget> rows = <Widget>[
_detailRow('Run ID', '${event.id}'),
_detailRow('Kind', event.kind),
_detailRow('Started', formatLocalTimestamp(event.startedAt)),
_detailRow('Finished', formatLocalTimestamp(event.finishedAt)),
_detailRow('Duration', formatDurationMs(event.durationMs)),
];
if (event.rowsWritten > 0) {
rows.add(_detailRow('Rows written', '${event.rowsWritten}'));
}
if (event.rowsRemoved > 0) {
rows.add(_detailRow('Rows removed', '${event.rowsRemoved}'));
}
if (event.kind == 'backfill' && event.backfillItems.isNotEmpty) {
rows.add(
const Padding(
padding: EdgeInsets.only(top: 8, bottom: 4),
child: Text(
'Backfill fetches (Alpaca start / raw.slot_start)',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
);
for (final BackfillSyncItem item in event.backfillItems) {
rows.add(_backfillFetchRow(item));
}
}
if (event.status == SyncRunStatus.success && event.error == null) {
rows.add(_detailRow('Errors', 'No errors'));
}
if (event.error != null && event.error!.trim().isNotEmpty) {
final String? httpStatus = parseHttpStatus(event.error);
if (httpStatus != null) {
rows.add(_detailRow('HTTP status', httpStatus));
}
rows.add(
Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'Error',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: SingleChildScrollView(
child: SelectableText(
event.error!,
key: Key('sync-run-error-${event.id}'),
style: const TextStyle(
fontSize: 13,
color: AppColors.textPrimary,
),
),
),
),
TextButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: event.error!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error copied')),
);
},
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy error'),
),
if (event.severity == SyncRunSeverity.rateLimit ||
event.status == SyncRunStatus.failed)
const Text(
'Will retry on next scheduled run.',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
if (onRetry != null &&
(event.status == SyncRunStatus.failed ||
event.status == SyncRunStatus.partial ||
event.severity == SyncRunSeverity.rateLimit))
TextButton.icon(
key: Key('retry-run-${event.id}'),
onPressed: onRetry,
icon: const Icon(Icons.replay, size: 16),
label: const Text('Retry now'),
),
],
),
),
);
}
if (event.status == SyncRunStatus.inProgress) {
rows.add(
const Padding(
padding: EdgeInsets.only(top: 8),
child: Row(
children: <Widget>[
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text(
'Run in progress…',
style: TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
],
),
),
);
}
return rows;
}
Widget _backfillFetchRow(BackfillSyncItem item) {
final String wire = item.fetchSlotStartWire;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SelectableText(
wire,
key: Key('backfill-slot-$wire'),
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
'${item.symbols.length} assets: ${item.symbols.join(', ')}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 110,
child: Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 12,
color: AppColors.textPrimary,
),
),
),
],
),
);
}
}