import 'dart:async'; import 'package:flutter/material.dart'; import '../data/hub.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 { final types = _typeFilter == 'all' ? [] : []; // server-side filter not yet — clientside below try { final events = await HubService.instance.recentEvents( limit: 100, types: types, ); 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( appBar: AppBar( title: const Text('Audit Stream'), centerTitle: false, actions: [ Padding( padding: const EdgeInsets.only(right: 16), child: DropdownButton( value: _typeFilter, onChanged: (v) => setState(() => _typeFilter = v ?? 'all'), items: const [ DropdownMenuItem(value: 'all', child: Text('all events')), DropdownMenuItem(value: 'flow.', child: Text('flow.*')), DropdownMenuItem(value: 'step.', child: Text('step.*')), DropdownMenuItem(value: 'module.', child: Text('module.*')), ], ), ), ], ), body: Column( children: [ _StatusBar(eventCount: filtered.length, error: _error), Expanded( child: !_initialLoaded ? const Center(child: CircularProgressIndicator()) : _error != null && _events.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(32), child: Text( 'Hub unreachable: $_error\n\n' 'Start the hub with `fai serve`.', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium, ), ), ) : filtered.isEmpty ? Center( child: Text( '(no events yet)', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ) : ListView.separated( padding: const EdgeInsets.all(24), itemCount: filtered.length, separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, i) { final e = filtered[i]; final ts = e.timestamp; final tsStr = '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}'; return Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), side: BorderSide( color: theme.colorScheme.outlineVariant, ), ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), child: Row( children: [ Container( width: 4, height: 32, decoration: BoxDecoration( color: _toneFor(e.type, theme), borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 12), SizedBox( width: 90, child: Text( tsStr, style: theme.textTheme.bodySmall?.copyWith( fontFamily: 'monospace', color: theme .colorScheme.onSurfaceVariant, ), ), ), SizedBox( width: 180, child: Text( e.type, style: theme.textTheme.bodyMedium?.copyWith( fontFamily: 'monospace', fontWeight: FontWeight.w500, ), ), ), Expanded( child: Text( _contextLine(e), style: theme.textTheme.bodyMedium?.copyWith( color: theme .colorScheme.onSurfaceVariant, ), ), ), if (e.durationMs != null) Text( '${e.durationMs}ms', style: theme.textTheme.bodySmall?.copyWith( fontFamily: 'monospace', color: theme .colorScheme.onSurfaceVariant, ), ), ], ), ), ); }, ), ), ], ), ); } 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 theme.colorScheme.tertiary; return theme.colorScheme.outline; } } class _StatusBar extends StatelessWidget { final int eventCount; final String? error; const _StatusBar({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: 24, vertical: 8), color: theme.colorScheme.surfaceContainerHigh, child: Row( children: [ Icon( Icons.fiber_manual_record, size: 12, color: live ? theme.colorScheme.tertiary : theme.colorScheme.error, ), const SizedBox(width: 8), Text( live ? 'live (polling 2s) — $eventCount events' : 'disconnected — $error', style: theme.textTheme.bodySmall, ), ], ), ); } }