chain-studio/lib/pages/audit.dart
flemming-it 64c2a77dc9 feat(workspace): one global switcher anchor in the shell sidebar
The switcher used to be embedded per page (Flows/Runs/Audit/
Approvals) — invisible on the other five pages and sitting in a
different corner depending on the page (persona review 2026-08-27,
consensus finding). It now lives ONCE in the sidebar, above the
destinations: active project/area always visible, opens the same
menu everywhere, Cmd+P from anywhere. The shell listens to the
workspace, so the sidebar endpoint label can no longer lag a
sealed switch until the next health tick.

Also in this rebuild:

* Stopped sealed areas ask before starting ("Start area X?") —
  a context switch must never boot a hub daemon as a click
  side-effect; running areas keep switching with one click.
* The switcher tooltip told a wrong scope ("filters this view") —
  it now says the choice applies everywhere and stamps new runs.
* The aggregated sealed row explains itself in place (names can
  reveal client identities) and links to the Settings toggle
  (Settings dialog gained an initialCategory jump).
* The active entry carries a checkmark in the menu.
* The Cmd+K palette knows projects and areas, ranked by recent
  use; sealed names honour the privacy setting — while hidden,
  the palette offers the guarded picker instead of the names.
* The runs empty state names the active project filter as the
  cause ("No runs in project X" + show-all action) instead of
  claiming the feature is off.

Tests updated to the anchor and made hermetic (scriptable
projects on the fake hub, sealed-area fake); new coverage for the
checkmark, the why-line, and the start confirmation.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-08-28 00:00:06 +02:00

1396 lines
47 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/friendly_error.dart';
import '../data/hub.dart';
import '../data/reviewer_identity.dart';
import '../data/workspace.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
import 'welcome.dart' show showFaiDoc;
/// Case-insensitive free-text match over every field the audit list
/// displays or exports. [query] must already be lowercased/trimmed.
/// Top-level so the filter behaviour is unit-testable.
bool matchesAuditQuery(AuditEvent e, String query) {
bool has(String? s) => s != null && s.toLowerCase().contains(query);
return has(e.type) ||
has(e.flowName) ||
has(e.stepId) ||
has(e.moduleName) ||
has(e.error) ||
has(e.detail) ||
has(e.project) ||
has(e.eventId);
}
/// Serialise audit events as JSONL — one JSON object per line, in
/// the given order (newest first, as the hub returns them), with a
/// trailing newline. Optional fields are omitted rather than written
/// as null. Top-level so the export format is unit-testable.
String auditEventsToJsonl(List<AuditEvent> events) {
final lines = events.map((e) => jsonEncode({
'event_id': e.eventId,
'timestamp': e.timestamp.toIso8601String(),
'type': e.type,
if (e.project.isNotEmpty) 'project': e.project,
if (e.flowName != null) 'flow': e.flowName,
if (e.stepId != null) 'step': e.stepId,
if (e.moduleName != null) 'module': e.moduleName,
if (e.moduleVersion != null) 'module_version': e.moduleVersion,
if (e.invocationId != null) 'invocation_id': e.invocationId,
if (e.flowExecution != null) 'flow_execution': e.flowExecution,
if (e.durationMs != null) 'duration_ms': e.durationMs,
if (e.error != null) 'error': e.error,
if (e.detail != null) 'detail': e.detail,
}));
return '${lines.join('\n')}\n';
}
/// Upper bound for the full-history export. The EventLog RPC has no
/// offset/cursor to page with and no server-side cap, so one request
/// with a bound well beyond any local log fetches everything while
/// still bounding the response.
const int _fullExportLimit = 100000;
class AuditPage extends StatefulWidget {
const AuditPage({super.key});
@override
State<AuditPage> createState() => _AuditPageState();
}
class _AuditPageState extends State<AuditPage> {
String _typeFilter = 'all';
String _search = '';
List<AuditEvent> _events = const [];
Object? _error;
bool _initialLoaded = false;
Timer? _poller;
/// Live event feed. New events nudge an immediate refresh so the
/// page updates instantly instead of waiting up to 2 s. The poll
/// stays as the safety net: if the stream errors or the hub drops,
/// the page is still fresh within the poll interval. Reusing the
/// proven `_refresh()` (re-fetch latest 100) keeps list management,
/// filtering, and the hash-chain ordering exactly as before.
StreamSubscription<AuditEvent>? _eventSub;
Timer? _nudgeDebounce;
Timer? _reconnect;
@override
void initState() {
super.initState();
Workspace.instance.addListener(_onWorkspaceChanged);
Workspace.instance.ensureLoaded();
_refresh();
_poller = Timer.periodic(const Duration(seconds: 2), (_) => _refresh());
_subscribeLive();
}
@override
void dispose() {
Workspace.instance.removeListener(_onWorkspaceChanged);
_poller?.cancel();
_nudgeDebounce?.cancel();
_reconnect?.cancel();
_eventSub?.cancel();
super.dispose();
}
/// The workspace filter is applied hub-side, so both the list
/// query and the live stream have to be re-established.
void _onWorkspaceChanged() {
if (!mounted) return;
// Mid-switch the client points between contexts — hold off; the
// end-of-switch notify lands here again with a settled client.
if (Workspace.instance.switching) return;
_refresh();
_subscribeLive();
}
void _subscribeLive() {
_eventSub?.cancel();
_eventSub = HubService.instance
.streamEvents(backfill: 0, project: Workspace.instance.activeSlug)
.listen(
(_) => _nudge(),
// On a persistent error the 2 s poll keeps the page fresh, so we
// do nothing. On a clean close (hub restart / stream end) try to
// re-establish the live feed once after a short delay.
onError: (_) {},
onDone: () {
_eventSub = null;
if (mounted) {
// Held in a field so dispose() can cancel it — an
// anonymous timer here outlives the page when the
// stream closes right before navigation (the a11y
// suite caught this as a pending-timer flake).
_reconnect?.cancel();
_reconnect = Timer(const Duration(seconds: 3), () {
// Not mid-switch: the end-of-switch notify resubscribes.
if (mounted &&
_eventSub == null &&
!Workspace.instance.switching) {
_subscribeLive();
}
});
}
},
cancelOnError: true,
);
}
/// Coalesce a burst of events into a single refresh.
void _nudge() {
if (_nudgeDebounce?.isActive ?? false) return;
_nudgeDebounce = Timer(const Duration(milliseconds: 300), () {
if (mounted) _refresh();
});
}
/// The list as the user currently sees it: type chip first, then
/// the free-text query over every displayed/exported field.
List<AuditEvent> _visibleEvents() {
final byType = _typeFilter == 'all'
? _events
: _events.where((e) => e.type.startsWith(_typeFilter)).toList();
final q = _search.trim().toLowerCase();
if (q.isEmpty) return byType;
return byType.where((e) => matchesAuditQuery(e, q)).toList();
}
/// Export the currently visible (type- + search-filtered) events
/// as JSONL — one JSON object per line, newest first, exactly what
/// the list shows. For exports with WORM guarantees the CLI
/// `chain admin events --json` stays the canonical tool.
Future<void> _onExportPressed() async {
final l = AppLocalizations.of(context)!;
final events = _visibleEvents();
if (events.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.auditExportNothing)),
);
return;
}
await _saveEventsAsJsonl(
events: events,
dialogTitle: l.auditExportAction,
fileName: 'audit-export.jsonl',
);
}
/// Export the full event history of the active project scope —
/// independent of the type chip, the search field and the page's
/// 100-event window. Keeps the project filter: the page is
/// project-scoped, matching the per-project compliance export the
/// CLI offers via `chain admin events --project`.
Future<void> _onExportAllPressed() async {
final l = AppLocalizations.of(context)!;
final List<AuditEvent> events;
try {
events = await HubService.instance.recentEvents(
limit: _fullExportLimit,
project: Workspace.instance.activeSlug,
);
} catch (e) {
if (!mounted) return;
showChainErrorSnack(context, 'audit.export', e);
return;
}
if (!mounted) return;
if (events.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.auditExportAllNothing)),
);
return;
}
await _saveEventsAsJsonl(
events: events,
dialogTitle: l.auditExportAllAction,
fileName: 'audit-export-full.jsonl',
);
}
Future<void> _saveEventsAsJsonl({
required List<AuditEvent> events,
required String dialogTitle,
required String fileName,
}) async {
final l = AppLocalizations.of(context)!;
final path = await FilePicker.saveFile(
dialogTitle: dialogTitle,
fileName: fileName,
);
if (path == null || !mounted) return;
try {
await File(path).writeAsString(auditEventsToJsonl(events), flush: true);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.auditExportSaved(events.length, path))),
);
} catch (e) {
if (!mounted) return;
showChainErrorSnack(context, 'audit.export', e);
}
}
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;
showChainErrorSnack(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 showChainErrorSnack, which uses friendlyError under the
// hood (with proper gRPC-code mapping + selectable
// copy-affordance).
Future<void> _refresh() async {
final ws = Workspace.instance;
// Paused during a sealed-area switch: the hub client may already
// point at the other hub while this page still renders the old
// context — fetching now would show data under the wrong marking.
if (ws.switching) return;
final epoch = ws.contextEpoch;
try {
final events = await HubService.instance.recentEvents(
limit: 100,
project: Workspace.instance.activeSlug,
);
if (!mounted || epoch != ws.contextEpoch) return;
setState(() {
_events = events;
_error = null;
_initialLoaded = true;
});
} catch (e) {
if (!mounted || epoch != ws.contextEpoch) return;
setState(() {
_error = e;
_initialLoaded = true;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = _visibleEvents();
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.auditTitle),
actions: [
// Narrow windows can't fit the inline chip row — collapse
// to a checkmark menu so the app bar never overflows
// (responsive_test.dart pins this).
Padding(
padding: const EdgeInsets.only(right: ChainSpace.lg),
child: MediaQuery.sizeOf(context).width < 900
? _FilterMenu(
value: _typeFilter,
onChanged: (v) => setState(() => _typeFilter = v),
)
: _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'),
),
// The dev-only reset used to sit here as a bare trash icon —
// on an audit log that read as "delete evidence" (usertest
// panel, security auditor). It now lives in a labeled
// overflow menu next to the export action.
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 18),
tooltip: AppLocalizations.of(context)!.auditMoreTooltip,
onSelected: (v) => switch (v) {
'export' => _onExportPressed(),
'export-all' => _onExportAllPressed(),
'clear' => _onClearPressed(),
_ => null,
},
// Labels wrap instead of running past the menu edge: a
// popup menu is at most 280px wide, and these entries
// spell out what they do ("Entwicklungs-Reset: Protokoll
// löschen … (nur local/dev)"). Unwrapped, the tail was
// clipped — and the clipped part is exactly the
// "nur local/dev" that keeps the entry from reading as
// "delete evidence".
itemBuilder: (ctx) => [
PopupMenuItem(
value: 'export',
child: _menuLabel(
Icons.download_outlined,
AppLocalizations.of(ctx)!.auditExportAction,
),
),
PopupMenuItem(
value: 'export-all',
child: _menuLabel(
Icons.archive_outlined,
AppLocalizations.of(ctx)!.auditExportAllAction,
),
),
PopupMenuItem(
value: 'clear',
child: _menuLabel(
Icons.delete_sweep_outlined,
AppLocalizations.of(ctx)!.auditDevResetAction,
),
),
],
),
const SizedBox(width: ChainSpace.sm),
],
),
body: Column(
children: [
// The status bar shows the CLASSIFIED one-liner, never the
// raw thrown object — walls of gRPC text belong behind the
// error state's detail expander (state-matrix invariant).
_LiveStatusBar(
eventCount: filtered.length,
error: _error == null
? null
: friendlyError(
_error!,
AppLocalizations.of(context)!,
).headline,
),
Padding(
padding: const EdgeInsets.fromLTRB(
ChainSpace.xl,
ChainSpace.sm,
ChainSpace.xl,
0,
),
child: TextField(
onChanged: (v) => setState(() => _search = v),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.auditSearchHint,
prefixIcon: const Icon(Icons.search, size: 16),
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
style: theme.textTheme.bodySmall,
),
),
Expanded(
child: !_initialLoaded
? const Center(child: CircularProgressIndicator())
: _error != null && _events.isEmpty
? HubLoadErrorView(error: _error!, onRetry: _refresh)
: filtered.isEmpty
? ChainEmptyState(
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('');
}
/// Icon + label for an overflow-menu entry. [Flexible] is the
/// point: the menu is width-capped, so a long label has to wrap
/// rather than run off the edge.
Widget _menuLabel(IconData icon, String label) => Row(
children: [
Icon(icon, size: 16),
const SizedBox(width: ChainSpace.sm),
Flexible(child: Text(label)),
],
);
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 ChainColors.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(ChainSpace.xl),
itemCount: items.length,
itemBuilder: (context, i) {
final item = items[i];
if (item is _GroupHeader) {
return Padding(
padding: EdgeInsets.only(
top: i == 0 ? 0 : ChainSpace.lg,
bottom: ChainSpace.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: ChainSpace.xs),
child: ChainDataRow(
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);
}
List<(String, String)> _filterItems(AppLocalizations l) => [
('all', l.auditFilterAll),
('flow.', l.auditFilterFlow),
('step.', l.auditFilterStep),
('module.', l.auditFilterModule),
];
/// Narrow-window replacement for [_FilterChips]: one icon button
/// with a checkmark menu. The icon takes the accent colour while a
/// filter other than "all" is active, so a narrowed window never
/// hides that the list is filtered.
class _FilterMenu extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
const _FilterMenu({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return PopupMenuButton<String>(
tooltip: l.auditFilterTooltip,
icon: Icon(
Icons.filter_list,
size: 18,
color: value == 'all'
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.primary,
),
onSelected: onChanged,
itemBuilder: (_) => [
for (final (v, label) in _filterItems(l))
CheckedPopupMenuItem(
value: v,
checked: value == v,
child: Text(label),
),
],
);
}
}
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 = _filterItems(l);
return ChainSegments<String>(
items: [for (final (v, label) in items) ChainSegmentItem(v, label)],
value: value,
onChanged: onChanged,
);
}
}
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: ChainSpace.xl,
vertical: ChainSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
border: Border(
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Row(
children: [
ChainStatusDot(
color: live ? ChainColors.success : ChainColors.danger,
pulsing: live,
),
const SizedBox(width: ChainSpace.sm),
// Disconnected → SelectableText so the raw error is copyable
// (it's the only place the underlying failure is surfaced;
// the empty-state below shows only a generic hint).
//
// The left text is Expanded (not natural-width + Spacer):
// it must yield when the window narrows — otherwise this
// row overflows (responsive_test.dart pins it) — and its
// tight fill keeps the hash-chain trailing right-aligned.
Expanded(
child: live
? Text(
l.auditLiveStatus(eventCount),
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
: SelectableText(
l.auditDisconnected(error!),
maxLines: 2,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (live) ...[
const SizedBox(width: ChainSpace.md),
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(ChainRadius.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: ChainSpace.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(ChainSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(ChainRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(detail, style: ChainTheme.mono(size: 11)),
),
],
if (_explanation != null || _explaining) ...[
const SizedBox(height: ChainSpace.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(ChainRadius.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: ChainSpace.md),
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: related.length,
separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.xs),
itemBuilder: (context, i) {
final e = related[i];
return ChainDataRow(
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 ChainColors.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
? ChainTheme.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(ChainSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(ChainRadius.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: ChainSpace.xs),
Text(
l.auditSystemAi,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
const SizedBox(width: ChainSpace.xs),
ChainPill(
label: privacyMode,
tone: privacyMode == 'full'
? ChainPillTone.warning
: ChainPillTone.neutral,
),
if (result != null && result!.isSuccess && result!.cached) ...[
const SizedBox(width: ChainSpace.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: ChainPill(
label: l.auditCachedPill,
tone: ChainPillTone.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: ChainTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (result != null &&
result!.isSuccess &&
onRegenerate != null) ...[
const SizedBox(width: ChainSpace.sm),
Tooltip(
message: l.auditRegenerateTooltip,
child: IconButton(
icon: const Icon(Icons.refresh, size: 14),
visualDensity: VisualDensity.compact,
onPressed: onRegenerate,
),
),
],
],
),
const SizedBox(height: ChainSpace.sm),
if (explaining)
Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: ChainSpace.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: ChainSpace.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. [reviewer] is
/// already the wire value — the marked, unchecked claim that lands
/// in the `chain.reset` marker (see `data/reviewer_identity.dart`).
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 prefills with the local handle (the OS account is a
/// label, not an identity — [HubService.clearEventLog] marks it as
/// an unchecked claim on the wire, and the field's helper says so);
/// 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();
_reviewer = TextEditingController(text: ReviewerIdentity.localHandle);
_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: ChainSpace.lg),
TextField(
controller: _reviewer,
decoration: InputDecoration(
labelText: l.auditClearReviewerLabel,
helperText: l.auditClearReviewerHelper,
helperMaxLines: 3,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: ChainSpace.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: ReviewerIdentity.wire(_reviewer.text),
reason: _reason.text.trim(),
),
),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: Text(l.auditClearLogButton),
),
),
],
);
}
}