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:
parent
6e3c6df200
commit
b32b4b5bda
5 changed files with 186 additions and 3 deletions
|
|
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue