Previously the audit list only rendered HH:mm:ss, so a row showing "21:25:39" gave no hint whether it was today, last week, or last year. The detail dialog already had the full ISO timestamp, but the operator had to click each row to find out. _formatTime now keeps the compact HH:mm:ss form for same-day events, prefixes MM-dd for older days in the current year, and shows YYYY-MM-dd for the tail of the log. Always rendered in the operator's local time zone. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
687 lines
21 KiB
Dart
687 lines
21 KiB
Dart
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<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> _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<void>(
|
||
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 = <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;
|
||
}
|
||
}
|
||
|
||
class _FilterChips extends StatelessWidget {
|
||
final String value;
|
||
final ValueChanged<String> 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<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() 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,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|