Some checks failed
Security / Security check (push) Failing after 2s
Studio follows the platform rename: product branding F∆I -> Ch∆In in UI strings, command examples fai -> chain, and — critically — the spawned hub binary path ~/.fai/bin/fai -> ~/.fai/bin/chain so Studio launches the renamed binary. The fai_* Dart identifiers (FaiLog, widget files, the generated SDK) stay = vendor/internal namespace. flutter analyze: no issues. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1121 lines
36 KiB
Dart
1121 lines
36 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../data/error_presentation.dart';
|
||
import '../data/hub.dart';
|
||
import '../l10n/app_localizations.dart';
|
||
import '../theme/theme.dart';
|
||
import '../theme/tokens.dart';
|
||
import '../widgets/widgets.dart';
|
||
import 'welcome.dart' show showFaiDoc;
|
||
|
||
class AuditPage extends StatefulWidget {
|
||
const AuditPage({super.key});
|
||
|
||
@override
|
||
State<AuditPage> createState() => _AuditPageState();
|
||
}
|
||
|
||
class _AuditPageState extends State<AuditPage> {
|
||
String _typeFilter = 'all';
|
||
List<AuditEvent> _events = const [];
|
||
String? _error;
|
||
bool _initialLoaded = false;
|
||
Timer? _poller;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_refresh();
|
||
_poller = Timer.periodic(const Duration(seconds: 2), (_) => _refresh());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_poller?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _onClearPressed() async {
|
||
final l = AppLocalizations.of(context)!;
|
||
final outcome = await _ClearAuditDialog.show(context);
|
||
if (outcome == null || !mounted) return;
|
||
try {
|
||
final r = await HubService.instance.clearEventLog(
|
||
reviewer: outcome.reviewer,
|
||
reason: outcome.reason,
|
||
);
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(l.auditClearedToast(r.purged, r.channel))),
|
||
);
|
||
_refresh();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
showFaiErrorSnack(context, 'audit.clear', e);
|
||
}
|
||
}
|
||
|
||
// _friendly was an inline shadow of friendlyError() in
|
||
// data/friendly_error.dart — kept only because the original
|
||
// call site predated the central mapper. Removed in favour
|
||
// of showFaiErrorSnack, which uses friendlyError under the
|
||
// hood (with proper gRPC-code mapping + selectable
|
||
// copy-affordance).
|
||
|
||
Future<void> _refresh() async {
|
||
try {
|
||
final events = await HubService.instance.recentEvents(limit: 100);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_events = events;
|
||
_error = null;
|
||
_initialLoaded = true;
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_error = e.toString();
|
||
_initialLoaded = true;
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final filtered = _typeFilter == 'all'
|
||
? _events
|
||
: _events.where((e) => e.type.startsWith(_typeFilter)).toList();
|
||
|
||
return Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
appBar: AppBar(
|
||
title: Text(AppLocalizations.of(context)!.auditTitle),
|
||
actions: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: FaiSpace.lg),
|
||
child: _FilterChips(
|
||
value: _typeFilter,
|
||
onChanged: (v) => setState(() => _typeFilter = v),
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.help_outline, size: 18),
|
||
tooltip: AppLocalizations.of(context)!.helpTooltip,
|
||
onPressed: () => showFaiDoc(context, 'audit'),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.delete_sweep_outlined, size: 18),
|
||
tooltip: AppLocalizations.of(context)!.auditClearLogTooltip,
|
||
onPressed: _onClearPressed,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
_LiveStatusBar(eventCount: filtered.length, error: _error),
|
||
Expanded(
|
||
child: !_initialLoaded
|
||
? const Center(child: CircularProgressIndicator())
|
||
: _error != null && _events.isEmpty
|
||
? FaiEmptyState(
|
||
icon: Icons.cloud_off_outlined,
|
||
iconColor: theme.colorScheme.error,
|
||
title: AppLocalizations.of(context)!.hubUnreachable,
|
||
hint: AppLocalizations.of(context)!.hubUnreachableHint,
|
||
)
|
||
: filtered.isEmpty
|
||
? FaiEmptyState(
|
||
icon: Icons.timeline_outlined,
|
||
title: AppLocalizations.of(context)!.auditNoEvents,
|
||
hint: AppLocalizations.of(context)!.auditNoEventsHint,
|
||
)
|
||
: _GroupedEventList(
|
||
events: filtered,
|
||
allEvents: _events,
|
||
toneFor: (t) => _toneFor(t, theme),
|
||
formatTime: _formatTime,
|
||
contextLine: _contextLine,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Render the event timestamp so the operator never has to
|
||
/// guess what day "21:25" was. Same-day events keep the
|
||
/// compact `HH:mm:ss` form; older events get a `MM-dd` prefix
|
||
/// or a full `YYYY-MM-dd` for last year's tail of the log.
|
||
/// Always rendered in the operator's local time zone — the
|
||
/// dialog still shows the full ISO timestamp for unambiguous
|
||
/// cross-checking.
|
||
String _formatTime(DateTime ts) {
|
||
final local = ts.toLocal();
|
||
final now = DateTime.now();
|
||
final hh = local.hour.toString().padLeft(2, '0');
|
||
final mm = local.minute.toString().padLeft(2, '0');
|
||
final ss = local.second.toString().padLeft(2, '0');
|
||
final time = '$hh:$mm:$ss';
|
||
final sameDay =
|
||
local.year == now.year &&
|
||
local.month == now.month &&
|
||
local.day == now.day;
|
||
if (sameDay) return time;
|
||
// Older events always carry the full ISO date. `MM-dd`
|
||
// alone is locale-ambiguous (US reads it as May-04, EU as
|
||
// 5 April) — operators should not have to guess.
|
||
final mo = local.month.toString().padLeft(2, '0');
|
||
final dd = local.day.toString().padLeft(2, '0');
|
||
return '${local.year}-$mo-$dd $time';
|
||
}
|
||
|
||
String _contextLine(AuditEvent e) {
|
||
final parts = <String>[];
|
||
if (e.flowName != null) parts.add(e.flowName!);
|
||
if (e.stepId != null) parts.add(' › ${e.stepId}');
|
||
if (e.moduleName != null) parts.add(' via ${e.moduleName}');
|
||
if (e.error != null) parts.add(' [error: ${e.error}]');
|
||
return parts.join('');
|
||
}
|
||
|
||
Color _toneFor(String type, ThemeData theme) {
|
||
if (type.endsWith('.failed')) return theme.colorScheme.error;
|
||
if (type.endsWith('.completed')) return theme.colorScheme.primary;
|
||
if (type.endsWith('.started')) return FaiColors.warning;
|
||
return theme.colorScheme.outline;
|
||
}
|
||
}
|
||
|
||
/// Audit list with time-bucket section headers. Walks the
|
||
/// already-filtered events once and inserts a tiny header row
|
||
/// whenever the relative-day bucket changes (Today / Yesterday
|
||
/// / Earlier this week / Older). Reads the same events twice
|
||
/// — one filtered slice for the visible rows, the full set
|
||
/// for the per-flow-execution detail view triggered from the
|
||
/// event-detail dialog.
|
||
class _GroupedEventList extends StatelessWidget {
|
||
final List<AuditEvent> events;
|
||
final List<AuditEvent> allEvents;
|
||
final Color Function(String type) toneFor;
|
||
final String Function(DateTime ts) formatTime;
|
||
final String Function(AuditEvent e) contextLine;
|
||
|
||
const _GroupedEventList({
|
||
required this.events,
|
||
required this.allEvents,
|
||
required this.toneFor,
|
||
required this.formatTime,
|
||
required this.contextLine,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final items = _itemsWithHeaders(events, l);
|
||
return ListView.builder(
|
||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||
itemCount: items.length,
|
||
itemBuilder: (context, i) {
|
||
final item = items[i];
|
||
if (item is _GroupHeader) {
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
top: i == 0 ? 0 : FaiSpace.lg,
|
||
bottom: FaiSpace.sm,
|
||
),
|
||
child: Text(
|
||
item.label,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
final e = (item as _EventItem).event;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: FaiSpace.xs),
|
||
child: FaiDataRow(
|
||
accent: toneFor(e.type),
|
||
leading: formatTime(e.timestamp),
|
||
title: e.type,
|
||
subtitle: contextLine(e),
|
||
trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
|
||
onTap: () => showDialog<void>(
|
||
context: context,
|
||
builder: (_) =>
|
||
_EventDetailDialog(event: e, allEvents: allEvents),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
List<_ListItem> _itemsWithHeaders(
|
||
List<AuditEvent> events,
|
||
AppLocalizations l,
|
||
) {
|
||
final out = <_ListItem>[];
|
||
String? lastBucket;
|
||
final now = DateTime.now();
|
||
for (final e in events) {
|
||
final bucket = _bucketLabel(e.timestamp, now, l);
|
||
if (bucket != lastBucket) {
|
||
out.add(_GroupHeader(bucket));
|
||
lastBucket = bucket;
|
||
}
|
||
out.add(_EventItem(e));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/// Day-bucket label. Comparing in the operator's local
|
||
/// timezone so an event at 23:55 yesterday in Berlin doesn't
|
||
/// land in "today" because UTC happened to spill into a new
|
||
/// day.
|
||
static String _bucketLabel(DateTime ts, DateTime now, AppLocalizations l) {
|
||
final local = ts.toLocal();
|
||
final localNow = now.toLocal();
|
||
final today = DateTime(localNow.year, localNow.month, localNow.day);
|
||
final eventDay = DateTime(local.year, local.month, local.day);
|
||
final daysAgo = today.difference(eventDay).inDays;
|
||
if (daysAgo <= 0) return l.auditGroupToday;
|
||
if (daysAgo == 1) return l.auditGroupYesterday;
|
||
if (daysAgo <= 6) return l.auditGroupThisWeek;
|
||
return l.auditGroupOlder;
|
||
}
|
||
}
|
||
|
||
sealed class _ListItem {
|
||
const _ListItem();
|
||
}
|
||
|
||
class _GroupHeader extends _ListItem {
|
||
final String label;
|
||
const _GroupHeader(this.label);
|
||
}
|
||
|
||
class _EventItem extends _ListItem {
|
||
final AuditEvent event;
|
||
const _EventItem(this.event);
|
||
}
|
||
|
||
class _FilterChips extends StatelessWidget {
|
||
final String value;
|
||
final ValueChanged<String> onChanged;
|
||
|
||
const _FilterChips({required this.value, required this.onChanged});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l = AppLocalizations.of(context)!;
|
||
final items = [
|
||
('all', l.auditFilterAll),
|
||
('flow.', l.auditFilterFlow),
|
||
('step.', l.auditFilterStep),
|
||
('module.', l.auditFilterModule),
|
||
];
|
||
return Row(
|
||
children: [
|
||
for (final (v, label) in items)
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: FaiSpace.xs),
|
||
child: _ChipButton(
|
||
label: label,
|
||
selected: value == v,
|
||
onTap: () => onChanged(v),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ChipButton extends StatefulWidget {
|
||
final String label;
|
||
final bool selected;
|
||
final VoidCallback onTap;
|
||
|
||
const _ChipButton({
|
||
required this.label,
|
||
required this.selected,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
State<_ChipButton> createState() => _ChipButtonState();
|
||
}
|
||
|
||
class _ChipButtonState extends State<_ChipButton> {
|
||
bool _hovered = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final fg = widget.selected
|
||
? theme.colorScheme.primary
|
||
: _hovered
|
||
? theme.colorScheme.onSurface
|
||
: theme.colorScheme.onSurfaceVariant;
|
||
final bg = widget.selected
|
||
? theme.colorScheme.primary.withValues(alpha: 0.12)
|
||
: _hovered
|
||
? theme.colorScheme.surfaceContainerHigh
|
||
: Colors.transparent;
|
||
return MouseRegion(
|
||
onEnter: (_) => setState(() => _hovered = true),
|
||
onExit: (_) => setState(() => _hovered = false),
|
||
cursor: SystemMouseCursors.click,
|
||
child: GestureDetector(
|
||
onTap: widget.onTap,
|
||
child: AnimatedContainer(
|
||
duration: FaiMotion.fast,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: FaiSpace.md,
|
||
vertical: 6,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: bg,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(
|
||
color: widget.selected
|
||
? theme.colorScheme.primary.withValues(alpha: 0.3)
|
||
: Colors.transparent,
|
||
),
|
||
),
|
||
child: Text(
|
||
widget.label,
|
||
style: FaiTheme.mono(
|
||
size: 11,
|
||
weight: widget.selected ? FontWeight.w500 : FontWeight.w400,
|
||
color: fg,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LiveStatusBar extends StatelessWidget {
|
||
final int eventCount;
|
||
final String? error;
|
||
|
||
const _LiveStatusBar({required this.eventCount, required this.error});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final live = error == null;
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: FaiSpace.xl,
|
||
vertical: FaiSpace.sm,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
border: Border(
|
||
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
FaiStatusDot(
|
||
color: live ? FaiColors.success : FaiColors.danger,
|
||
pulsing: live,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
live ? l.auditLiveStatus(eventCount) : l.auditDisconnected(error!),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
if (live)
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.shield_outlined,
|
||
size: 12,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
l.auditHashChainVerified,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.primary,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _EventDetailDialog extends StatefulWidget {
|
||
final AuditEvent event;
|
||
|
||
/// Full event window the audit page already fetched. Lets
|
||
/// the dialog surface every event sharing this event's
|
||
/// `flow_execution` without a fresh round-trip — flow runs
|
||
/// fit comfortably inside the 100-event window the audit
|
||
/// page polls on.
|
||
final List<AuditEvent> allEvents;
|
||
|
||
const _EventDetailDialog({required this.event, required this.allEvents});
|
||
|
||
@override
|
||
State<_EventDetailDialog> createState() => _EventDetailDialogState();
|
||
}
|
||
|
||
class _EventDetailDialogState extends State<_EventDetailDialog> {
|
||
AskAiResult? _explanation;
|
||
bool _explaining = false;
|
||
SystemAiStatus? _aiStatus;
|
||
|
||
AuditEvent get event => widget.event;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadAiStatus();
|
||
}
|
||
|
||
Future<void> _loadAiStatus() async {
|
||
try {
|
||
final s = await HubService.instance.systemAiStatus();
|
||
if (!mounted) return;
|
||
setState(() => _aiStatus = s);
|
||
} catch (_) {
|
||
// Stay null → "Explain" stays hidden.
|
||
}
|
||
}
|
||
|
||
Future<void> _explain({bool forceFresh = false}) async {
|
||
setState(() {
|
||
_explaining = true;
|
||
_explanation = null;
|
||
});
|
||
final prompt = _buildPrompt(event, _aiStatus?.privacyMode ?? 'off');
|
||
final result = await HubService.instance.askAi(
|
||
prompt,
|
||
forceFresh: forceFresh,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_explaining = false;
|
||
_explanation = result;
|
||
});
|
||
}
|
||
|
||
String _buildPrompt(AuditEvent e, String mode) {
|
||
// Privacy: redacted = no detail JSON, full = include it.
|
||
final buf = StringBuffer()
|
||
..writeln('A Ch∆In Platform audit event was logged. Explain what')
|
||
..writeln('happened in plain language and suggest one concrete')
|
||
..writeln('fix the operator can apply now.')
|
||
..writeln()
|
||
..writeln('event_type: ${e.type}')
|
||
..writeln('flow: ${e.flowName ?? "(none)"}')
|
||
..writeln('step: ${e.stepId ?? "(none)"}')
|
||
..writeln(
|
||
'module: ${e.moduleName ?? "(none)"}'
|
||
'${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}',
|
||
)
|
||
..writeln('error: ${e.error ?? "(none)"}')
|
||
..writeln('duration: ${e.durationMs ?? 0}ms');
|
||
if (mode == 'full' && e.detail != null) {
|
||
buf
|
||
..writeln()
|
||
..writeln('detail:')
|
||
..writeln(e.detail);
|
||
}
|
||
return buf.toString();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
String prettyDetail() {
|
||
final raw = event.detail;
|
||
if (raw == null || raw.isEmpty) return '';
|
||
// Many detail strings are JSON; try to pretty-print and
|
||
// fall back to the raw value when parsing fails.
|
||
try {
|
||
final dynamic parsed = const JsonDecoder().convert(raw);
|
||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||
} catch (_) {
|
||
return raw;
|
||
}
|
||
}
|
||
|
||
final detail = prettyDetail();
|
||
return AlertDialog(
|
||
title: Text(event.type),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
),
|
||
content: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 560),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_Field(label: 'event_id', value: event.eventId, mono: true),
|
||
_Field(
|
||
label: 'timestamp',
|
||
value: event.timestamp.toIso8601String(),
|
||
mono: true,
|
||
),
|
||
if (event.flowName != null)
|
||
_Field(label: 'flow', value: event.flowName!),
|
||
if (event.flowExecution != null)
|
||
_Field(
|
||
label: 'flow_execution',
|
||
value: event.flowExecution!,
|
||
mono: true,
|
||
),
|
||
if (event.invocationId != null)
|
||
_Field(
|
||
label: 'invocation_id',
|
||
value: event.invocationId!,
|
||
mono: true,
|
||
),
|
||
if (event.stepId != null)
|
||
_Field(label: 'step', value: event.stepId!),
|
||
if (event.moduleName != null)
|
||
_Field(
|
||
label: 'module',
|
||
value: event.moduleVersion != null
|
||
? '${event.moduleName} @ ${event.moduleVersion}'
|
||
: event.moduleName!,
|
||
),
|
||
if (event.durationMs != null)
|
||
_Field(label: 'duration', value: '${event.durationMs} ms'),
|
||
if (event.error != null)
|
||
_Field(
|
||
label: 'error',
|
||
value: event.error!,
|
||
valueColor: theme.colorScheme.error,
|
||
),
|
||
if (detail.isNotEmpty) ...[
|
||
const SizedBox(height: FaiSpace.md),
|
||
Text(
|
||
l.auditDetailHeader,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: SelectableText(detail, style: FaiTheme.mono(size: 11)),
|
||
),
|
||
],
|
||
if (_explanation != null || _explaining) ...[
|
||
const SizedBox(height: FaiSpace.lg),
|
||
_ExplanationPanel(
|
||
explaining: _explaining,
|
||
result: _explanation,
|
||
privacyMode: _aiStatus?.privacyMode ?? 'off',
|
||
onRegenerate: _explaining
|
||
? null
|
||
: () => _explain(forceFresh: true),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
if (event.flowExecution != null)
|
||
OutlinedButton.icon(
|
||
onPressed: () {
|
||
Navigator.pop(context);
|
||
showDialog<void>(
|
||
context: context,
|
||
builder: (_) => _FlowRunDialog(
|
||
flowExecution: event.flowExecution!,
|
||
flowName: event.flowName ?? event.flowExecution!,
|
||
allEvents: widget.allEvents,
|
||
),
|
||
);
|
||
},
|
||
icon: const Icon(Icons.account_tree_outlined, size: 16),
|
||
label: Text(l.auditEventViewFlowRun),
|
||
),
|
||
if (_aiStatus?.enabled == true && (event.error != null))
|
||
OutlinedButton.icon(
|
||
onPressed: _explaining ? null : _explain,
|
||
icon: const Icon(Icons.auto_awesome, size: 16),
|
||
label: Text(
|
||
_explaining
|
||
? l.auditAsking
|
||
: _explanation == null
|
||
? l.auditExplain
|
||
: l.auditReask,
|
||
),
|
||
)
|
||
else if (_aiStatus != null &&
|
||
!_aiStatus!.enabled &&
|
||
(event.error != null))
|
||
Tooltip(
|
||
message: l.auditConfigureSystemAi,
|
||
child: OutlinedButton.icon(
|
||
onPressed: null,
|
||
icon: const Icon(Icons.auto_awesome_outlined, size: 16),
|
||
label: Text(l.auditExplain),
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: Text(l.buttonClose),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Filtered drill-down: shows every event in `allEvents`
|
||
/// whose `flowExecution` matches the picked one. Sorted by
|
||
/// timestamp ascending so the operator reads the run from
|
||
/// step.started top → flow.completed bottom. Read-only — the
|
||
/// individual event-detail dialog is not re-opened from here
|
||
/// to avoid recursive flow-run lookups; selecting a row in a
|
||
/// future iteration could surface the same Explain UI.
|
||
class _FlowRunDialog extends StatelessWidget {
|
||
final String flowExecution;
|
||
final String flowName;
|
||
final List<AuditEvent> allEvents;
|
||
|
||
const _FlowRunDialog({
|
||
required this.flowExecution,
|
||
required this.flowName,
|
||
required this.allEvents,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final related =
|
||
allEvents.where((e) => e.flowExecution == flowExecution).toList()
|
||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||
final maxHeight = MediaQuery.of(context).size.height * 0.75;
|
||
return AlertDialog(
|
||
title: Text(l.auditFlowRunDialogTitle(flowName)),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
),
|
||
content: ConstrainedBox(
|
||
constraints: BoxConstraints(maxWidth: 640, maxHeight: maxHeight),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
l.auditFlowRunDialogSubtitle(related.length, flowExecution),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
Flexible(
|
||
child: ListView.separated(
|
||
shrinkWrap: true,
|
||
itemCount: related.length,
|
||
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs),
|
||
itemBuilder: (context, i) {
|
||
final e = related[i];
|
||
return FaiDataRow(
|
||
accent: _toneFor(e.type, theme),
|
||
leading: _formatRelativeTime(e.timestamp),
|
||
title: e.type,
|
||
subtitle: _stepLine(e),
|
||
trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: Text(l.buttonClose),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
String _formatRelativeTime(DateTime ts) {
|
||
final local = ts.toLocal();
|
||
final hh = local.hour.toString().padLeft(2, '0');
|
||
final mm = local.minute.toString().padLeft(2, '0');
|
||
final ss = local.second.toString().padLeft(2, '0');
|
||
return '$hh:$mm:$ss';
|
||
}
|
||
|
||
String _stepLine(AuditEvent e) {
|
||
final parts = <String>[];
|
||
if (e.stepId != null) parts.add(e.stepId!);
|
||
if (e.moduleName != null) parts.add('via ${e.moduleName}');
|
||
if (e.error != null) parts.add('[error: ${e.error}]');
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
Color _toneFor(String type, ThemeData theme) {
|
||
if (type.endsWith('.failed')) return theme.colorScheme.error;
|
||
if (type.endsWith('.completed')) return theme.colorScheme.primary;
|
||
if (type.endsWith('.started')) return FaiColors.warning;
|
||
return theme.colorScheme.outline;
|
||
}
|
||
}
|
||
|
||
class _Field extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final bool mono;
|
||
final Color? valueColor;
|
||
|
||
const _Field({
|
||
required this.label,
|
||
required this.value,
|
||
this.mono = false,
|
||
this.valueColor,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 110,
|
||
child: Text(
|
||
label,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: SelectableText(
|
||
value,
|
||
style: mono
|
||
? FaiTheme.mono(
|
||
size: 11,
|
||
color: valueColor ?? theme.colorScheme.onSurface,
|
||
)
|
||
: theme.textTheme.bodySmall?.copyWith(
|
||
color: valueColor ?? theme.colorScheme.onSurface,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ExplanationPanel extends StatelessWidget {
|
||
final bool explaining;
|
||
final AskAiResult? result;
|
||
final String privacyMode;
|
||
|
||
/// Triggered by the "Regenerate" affordance — passes
|
||
/// `forceFresh: true` so the next askAi skips the cache and
|
||
/// hits the live provider. Null while [explaining] is true.
|
||
final VoidCallback? onRegenerate;
|
||
|
||
const _ExplanationPanel({
|
||
required this.explaining,
|
||
required this.result,
|
||
required this.privacyMode,
|
||
required this.onRegenerate,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
final ok = result?.isSuccess ?? false;
|
||
final color = explaining
|
||
? theme.colorScheme.primary
|
||
: ok
|
||
? theme.colorScheme.primary
|
||
: theme.colorScheme.error;
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(Icons.auto_awesome, size: 14, color: color),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
Text(
|
||
l.auditSystemAi,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.xs),
|
||
FaiPill(
|
||
label: privacyMode,
|
||
tone: privacyMode == 'full'
|
||
? FaiPillTone.warning
|
||
: FaiPillTone.neutral,
|
||
),
|
||
if (result != null && result!.isSuccess && result!.cached) ...[
|
||
const SizedBox(width: FaiSpace.xs),
|
||
Tooltip(
|
||
message: () {
|
||
final hits = result!.cacheHits > 1
|
||
? l.auditCachedTooltipHits(result!.cacheHits)
|
||
: '';
|
||
return result!.cachedAt.isEmpty
|
||
? l.auditCachedTooltipUnknown(hits)
|
||
: l.auditCachedTooltipKnown(result!.cachedAt, hits);
|
||
}(),
|
||
child: FaiPill(
|
||
label: l.auditCachedPill,
|
||
tone: FaiPillTone.success,
|
||
icon: Icons.bolt,
|
||
),
|
||
),
|
||
],
|
||
const Spacer(),
|
||
if (result != null && result!.isSuccess && result!.latencyMs > 0)
|
||
Text(
|
||
result!.cached
|
||
? l.auditOriginalLatency(result!.latencyMs)
|
||
: l.auditLatency(result!.latencyMs),
|
||
style: FaiTheme.mono(
|
||
size: 10,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
if (result != null &&
|
||
result!.isSuccess &&
|
||
onRegenerate != null) ...[
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Tooltip(
|
||
message: l.auditRegenerateTooltip,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.refresh, size: 14),
|
||
visualDensity: VisualDensity.compact,
|
||
onPressed: onRegenerate,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.sm),
|
||
if (explaining)
|
||
Row(
|
||
children: [
|
||
const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
l.auditAskingFull,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
)
|
||
else if (result != null) ...[
|
||
SelectableText(
|
||
result!.text,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: ok
|
||
? theme.colorScheme.onSurface
|
||
: theme.colorScheme.error,
|
||
),
|
||
),
|
||
if (result!.fixHint(l).isNotEmpty) ...[
|
||
const SizedBox(height: FaiSpace.sm),
|
||
Text(
|
||
l.auditFixLabel,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
result!.fixHint(l),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurface,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Outcome of the clear-audit confirmation dialog.
|
||
class _ClearOutcome {
|
||
final String reviewer;
|
||
final String reason;
|
||
const _ClearOutcome({required this.reviewer, required this.reason});
|
||
}
|
||
|
||
/// Two-field confirmation dialog for "clear the audit log".
|
||
/// Reviewer defaults to the OS user (closest stable identity
|
||
/// without an auth backend); reason has no default so the
|
||
/// operator has to type *something* — the chain.reset marker
|
||
/// must carry context.
|
||
class _ClearAuditDialog extends StatefulWidget {
|
||
const _ClearAuditDialog();
|
||
|
||
static Future<_ClearOutcome?> show(BuildContext context) {
|
||
return showDialog<_ClearOutcome>(
|
||
context: context,
|
||
builder: (_) => const _ClearAuditDialog(),
|
||
);
|
||
}
|
||
|
||
@override
|
||
State<_ClearAuditDialog> createState() => _ClearAuditDialogState();
|
||
}
|
||
|
||
class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
||
late final TextEditingController _reviewer;
|
||
late final TextEditingController _reason;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final user =
|
||
Platform.environment['USER'] ??
|
||
Platform.environment['USERNAME'] ??
|
||
'operator';
|
||
_reviewer = TextEditingController(text: '$user@studio');
|
||
_reason = TextEditingController();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_reviewer.dispose();
|
||
_reason.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return AlertDialog(
|
||
title: Text(l.auditClearDialogTitle),
|
||
content: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 460),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
l.auditClearDialogBody,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
TextField(
|
||
controller: _reviewer,
|
||
decoration: InputDecoration(
|
||
labelText: l.auditClearReviewerLabel,
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
ValueListenableBuilder<TextEditingValue>(
|
||
valueListenable: _reason,
|
||
builder: (_, _, _) => TextField(
|
||
controller: _reason,
|
||
autofocus: true,
|
||
decoration: InputDecoration(
|
||
labelText: l.auditClearReasonLabel,
|
||
helperText: _reason.text.trim().isEmpty
|
||
? l.auditClearReasonHelper
|
||
: null,
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, null),
|
||
child: Text(l.buttonCancel),
|
||
),
|
||
ValueListenableBuilder<TextEditingValue>(
|
||
valueListenable: _reason,
|
||
builder: (_, _, _) => FilledButton(
|
||
onPressed: _reason.text.trim().isEmpty
|
||
? null
|
||
: () => Navigator.pop(
|
||
context,
|
||
_ClearOutcome(
|
||
reviewer: _reviewer.text.trim(),
|
||
reason: _reason.text.trim(),
|
||
),
|
||
),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: Theme.of(context).colorScheme.error,
|
||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||
),
|
||
child: Text(l.auditClearLogButton),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|