Operators can now clear dev-noise from the audit log without dropping to the CLI. Top-bar broom icon next to the filter chips opens a two-field dialog (reviewer pre-filled from $USER, reason required). On submit the hub call returns the purged count + active channel; both are shown back in a snackbar. Compliance-locked channels (beta/production) are refused server-side; the dialog surfaces the rejection reason via a friendly error message instead of the raw gRPC status string. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
855 lines
27 KiB
Dart
855 lines
27 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
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> _onClearPressed() async {
|
||
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(
|
||
'Cleared ${r.purged} events on channel "${r.channel}". '
|
||
'Chain reseeded with marker.',
|
||
),
|
||
),
|
||
);
|
||
_refresh();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Clear failed: ${_friendly(e)}')),
|
||
);
|
||
}
|
||
}
|
||
|
||
String _friendly(Object e) {
|
||
final s = e.toString();
|
||
// The hub returns FailedPrecondition for compliance-locked
|
||
// channels; tonic stringifies that as "failed_precondition:
|
||
// …". Strip the noise so the operator sees the real reason.
|
||
final marker = 'failed_precondition: ';
|
||
final idx = s.toLowerCase().indexOf(marker);
|
||
return idx >= 0 ? s.substring(idx + marker.length) : s;
|
||
}
|
||
|
||
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),
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.delete_sweep_outlined, size: 18),
|
||
tooltip: 'Clear log (local/dev channels only)',
|
||
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: '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;
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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);
|
||
return AlertDialog(
|
||
title: const Text('Clear audit log?'),
|
||
content: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 460),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Wipes every audit event on the active channel and seeds a '
|
||
'fresh chain.reset marker carrying reviewer + reason. '
|
||
'Refused on beta / production. Irreversible.',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
TextField(
|
||
controller: _reviewer,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Reviewer',
|
||
border: OutlineInputBorder(),
|
||
isDense: true,
|
||
),
|
||
),
|
||
const SizedBox(height: FaiSpace.md),
|
||
ValueListenableBuilder<TextEditingValue>(
|
||
valueListenable: _reason,
|
||
builder: (_, _, _) => TextField(
|
||
controller: _reason,
|
||
autofocus: true,
|
||
decoration: InputDecoration(
|
||
labelText: 'Reason (recorded in chain.reset marker)',
|
||
helperText: _reason.text.trim().isEmpty
|
||
? 'Required — auditors will read this.'
|
||
: null,
|
||
border: const OutlineInputBorder(),
|
||
isDense: true,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, null),
|
||
child: const Text('Cancel'),
|
||
),
|
||
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: const Text('Clear log'),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|