import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; class AuditPage extends StatefulWidget { const AuditPage({super.key}); @override State createState() => _AuditPageState(); } class _AuditPageState extends State { String _typeFilter = 'all'; List _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 _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: const Text('Audit'), actions: [ Padding( padding: const EdgeInsets.only(right: FaiSpace.lg), child: _FilterChips( value: _typeFilter, onChanged: (v) => setState(() => _typeFilter = v), ), ), ], ), 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: 'Hub unreachable', hint: 'Start the hub with `fai serve`.', ) : filtered.isEmpty ? const FaiEmptyState( icon: Icons.timeline_outlined, title: 'No events yet', hint: 'Run a flow to populate the audit stream.\nEvents appear here within seconds.', ) : ListView.separated( padding: const EdgeInsets.all(FaiSpace.xl), itemCount: filtered.length, separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs), itemBuilder: (context, i) { final e = filtered[i]; return FaiDataRow( accent: _toneFor(e.type, theme), leading: _formatTime(e.timestamp), title: e.type, subtitle: _contextLine(e), trailing: e.durationMs != null ? '${e.durationMs}ms' : null, onTap: () => showDialog( context: context, builder: (_) => _EventDetailDialog(event: e), ), ); }, ), ), ], ), ); } /// 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; final mo = local.month.toString().padLeft(2, '0'); final dd = local.day.toString().padLeft(2, '0'); if (local.year == now.year) return '$mo-$dd $time'; return '${local.year}-$mo-$dd $time'; } String _contextLine(AuditEvent e) { final parts = []; 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; } } class _FilterChips extends StatelessWidget { final String value; final ValueChanged onChanged; const _FilterChips({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { const items = [ ('all', 'all'), ('flow.', 'flow'), ('step.', 'step'), ('module.', 'module'), ]; 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 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 ? 'live · polling 2s · $eventCount event${eventCount == 1 ? '' : 's'}' : 'disconnected · $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( 'hash chain verified', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w500, ), ), ], ), ], ), ); } } class _EventDetailDialog extends StatefulWidget { final AuditEvent event; const _EventDetailDialog({required this.event}); @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 _loadAiStatus() async { try { final s = await HubService.instance.systemAiStatus(); if (!mounted) return; setState(() => _aiStatus = s); } catch (_) { // Stay null → "Explain" stays hidden. } } Future _explain() async { setState(() { _explaining = true; _explanation = null; }); final prompt = _buildPrompt(event, _aiStatus?.privacyMode ?? 'off'); final result = await HubService.instance.askAi(prompt); 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 F∆I 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); 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( 'DETAIL', 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', ), ], ], ), ), ), actions: [ if (_aiStatus?.enabled == true && (event.error != null)) OutlinedButton.icon( onPressed: _explaining ? null : _explain, icon: const Icon(Icons.auto_awesome, size: 16), label: Text( _explaining ? 'Asking…' : _explanation == null ? 'Explain' : 'Re-ask', ), ) else if (_aiStatus != null && !_aiStatus!.enabled && (event.error != null)) Tooltip( message: 'Configure System AI in Settings (Cmd+,)', child: OutlinedButton.icon( onPressed: null, icon: const Icon(Icons.auto_awesome_outlined, size: 16), label: const Text('Explain'), ), ), TextButton( onPressed: () => Navigator.pop(context), child: const Text('Close'), ), ], ); } } 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; const _ExplanationPanel({ required this.explaining, required this.result, required this.privacyMode, }); @override Widget build(BuildContext context) { final theme = Theme.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( 'SYSTEM AI', 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, ), const Spacer(), if (result != null && result!.isSuccess && result!.latencyMs > 0) Text( '${result!.latencyMs} ms', style: FaiTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), ), ], ), 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( 'Asking the configured System AI…', 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.isNotEmpty) ...[ const SizedBox(height: FaiSpace.sm), Text( 'FIX', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, ), ), const SizedBox(height: 2), Text( result!.fixHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface, ), ), ], ], ], ), ); } }