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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 17:10:02 +02:00
parent 6e3c6df200
commit b32b4b5bda
5 changed files with 186 additions and 3 deletions

View file

@ -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<ChannelStatusSnapshot> channelStatus() async {
final r = await _client.channelStatus();

View file

@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -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<AuditPage> {
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);
@ -75,6 +112,12 @@ class _AuditPageState extends State<AuditPage> {
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<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'),
),
),
],
);
}
}