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
|
|
@ -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.
|
/// Active channel + per-channel daemon status snapshot.
|
||||||
Future<ChannelStatusSnapshot> channelStatus() async {
|
Future<ChannelStatusSnapshot> channelStatus() async {
|
||||||
final r = await _client.channelStatus();
|
final r = await _client.channelStatus();
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import 'widgets/widgets.dart';
|
||||||
/// Studio's own build version. Bump on every UI commit so the
|
/// Studio's own build version. Bump on every UI commit so the
|
||||||
/// running app self-identifies — visible in the sidebar header
|
/// running app self-identifies — visible in the sidebar header
|
||||||
/// and quick-glance proof that you're seeing the current build.
|
/// 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 {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
@ -38,6 +39,42 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
super.dispose();
|
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 {
|
Future<void> _refresh() async {
|
||||||
try {
|
try {
|
||||||
final events = await HubService.instance.recentEvents(limit: 100);
|
final events = await HubService.instance.recentEvents(limit: 100);
|
||||||
|
|
@ -75,6 +112,12 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
onChanged: (v) => setState(() => _typeFilter = v),
|
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(
|
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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ packages:
|
||||||
path: "../fai_dart_sdk"
|
path: "../fai_dart_sdk"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.7.0"
|
version: "0.7.1"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: fai_studio
|
name: fai_studio
|
||||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.14.2
|
version: 0.15.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue