From b32b4b5bdaac2114513bf60337a5ddbef347e13a Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 7 May 2026 17:10:02 +0200 Subject: [PATCH] feat(studio): clear-audit-log button on Audit page (v0.15.0) 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 --- lib/data/hub.dart | 17 +++++ lib/main.dart | 2 +- lib/pages/audit.dart | 166 +++++++++++++++++++++++++++++++++++++++++++ pubspec.lock | 2 +- pubspec.yaml | 2 +- 5 files changed, 186 insertions(+), 3 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index ab6ff41..175f6f4 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -270,6 +270,23 @@ class HubService { ); } + /// Wipe the audit log on the active channel and seed a fresh + /// `chain.reset` marker. Refused server-side on `beta` / + /// `production`; the gRPC error surfaces as an exception so + /// the caller can show the operator why it was blocked. + /// Returns `(purged, channel)` so the UI can confirm what + /// just happened. + Future<({int purged, String channel})> clearEventLog({ + required String reviewer, + required String reason, + }) async { + final r = await _client.clearEventLog( + reviewer: reviewer, + reason: reason, + ); + return (purged: r.purged.toInt(), channel: r.channel); + } + /// Active channel + per-channel daemon status snapshot. Future channelStatus() async { final r = await _client.channelStatus(); diff --git a/lib/main.dart b/lib/main.dart index a3e7ff0..dfe311f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,7 +23,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.14.2'; +const String kStudioVersion = '0.15.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 5fab3bd..41ea460 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; @@ -38,6 +39,42 @@ class _AuditPageState extends State { super.dispose(); } + Future _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 _refresh() async { try { final events = await HubService.instance.recentEvents(limit: 100); @@ -75,6 +112,12 @@ class _AuditPageState extends State { 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( @@ -687,3 +730,126 @@ class _ExplanationPanel extends StatelessWidget { ); } } + +/// 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( + 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( + 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'), + ), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 43bd6b2..e4d6122 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.7.0" + version: "0.7.1" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0822910..ea397cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.14.2 +version: 0.15.0 environment: sdk: ^3.11.0-200.1.beta