feat(audit): free-text search, JSONL export of the view, labeled dev reset
Some checks failed
Security / Security check (push) Failing after 1s

- search field over flow/step/module/error/detail/project/id backs
  the list and the export ('current view' semantics); match logic
  is a top-level function with unit tests
- export writes one JSON object per line via the save dialog; the
  CLI stays the canonical WORM-grade export
- the bare trash icon on the audit toolbar read as 'delete
  evidence' (security-auditor finding) — the dev-only reset now
  sits in a labeled overflow menu next to the export action

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-18 00:08:51 +02:00
parent 0e53572589
commit 5aa69104e7
7 changed files with 273 additions and 7 deletions

View file

@ -561,6 +561,13 @@
"auditFilterModule": "Modul",
"auditFilterTooltip": "Ereignisse filtern",
"auditClearLogTooltip": "Protokoll löschen (nur local/dev Kanal)",
"auditMoreTooltip": "Weitere Aktionen",
"auditSearchHint": "Ereignisse durchsuchen (Flow, Schritt, Modul, Fehlertext …)",
"auditExportAction": "Aktuelle Ansicht als JSONL exportieren …",
"auditExportNothing": "Keine Ereignisse in der aktuellen Ansicht — nichts zu exportieren.",
"auditExportSaved": "{n} Ereignisse exportiert nach {path}",
"@auditExportSaved": {"placeholders": {"n": {"type": "int"}, "path": {"type": "String"}}},
"auditDevResetAction": "Entwicklungs-Reset: Protokoll löschen … (nur local/dev)",
"auditClearedToast": "{n} Ereignisse auf Kanal „{channel}\" gelöscht. Kette mit Marker neu geseedet.",
"@auditClearedToast": {
"placeholders": {

View file

@ -579,6 +579,13 @@
"auditFilterModule": "module",
"auditFilterTooltip": "Filter events",
"auditClearLogTooltip": "Clear log (local/dev channels only)",
"auditMoreTooltip": "More actions",
"auditSearchHint": "Search events (flow, step, module, error text …)",
"auditExportAction": "Export current view as JSONL …",
"auditExportNothing": "No events in the current view — nothing to export.",
"auditExportSaved": "Exported {n} events to {path}",
"@auditExportSaved": {"placeholders": {"n": {"type": "int"}, "path": {"type": "String"}}},
"auditDevResetAction": "Development reset: clear log … (local/dev only)",
"auditClearedToast": "Cleared {n} events on channel \"{channel}\". Chain reseeded with marker.",
"@auditClearedToast": {
"placeholders": {

View file

@ -2126,6 +2126,42 @@ abstract class AppLocalizations {
/// **'Clear log (local/dev channels only)'**
String get auditClearLogTooltip;
/// No description provided for @auditMoreTooltip.
///
/// In en, this message translates to:
/// **'More actions'**
String get auditMoreTooltip;
/// No description provided for @auditSearchHint.
///
/// In en, this message translates to:
/// **'Search events (flow, step, module, error text …)'**
String get auditSearchHint;
/// No description provided for @auditExportAction.
///
/// In en, this message translates to:
/// **'Export current view as JSONL …'**
String get auditExportAction;
/// No description provided for @auditExportNothing.
///
/// In en, this message translates to:
/// **'No events in the current view — nothing to export.'**
String get auditExportNothing;
/// No description provided for @auditExportSaved.
///
/// In en, this message translates to:
/// **'Exported {n} events to {path}'**
String auditExportSaved(int n, String path);
/// No description provided for @auditDevResetAction.
///
/// In en, this message translates to:
/// **'Development reset: clear log … (local/dev only)'**
String get auditDevResetAction;
/// No description provided for @auditClearedToast.
///
/// In en, this message translates to:

View file

@ -1181,6 +1181,29 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get auditClearLogTooltip => 'Protokoll löschen (nur local/dev Kanal)';
@override
String get auditMoreTooltip => 'Weitere Aktionen';
@override
String get auditSearchHint =>
'Ereignisse durchsuchen (Flow, Schritt, Modul, Fehlertext …)';
@override
String get auditExportAction => 'Aktuelle Ansicht als JSONL exportieren …';
@override
String get auditExportNothing =>
'Keine Ereignisse in der aktuellen Ansicht — nichts zu exportieren.';
@override
String auditExportSaved(int n, String path) {
return '$n Ereignisse exportiert nach $path';
}
@override
String get auditDevResetAction =>
'Entwicklungs-Reset: Protokoll löschen … (nur local/dev)';
@override
String auditClearedToast(int n, String channel) {
return '$n Ereignisse auf Kanal „$channel\" gelöscht. Kette mit Marker neu geseedet.';

View file

@ -1198,6 +1198,29 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get auditClearLogTooltip => 'Clear log (local/dev channels only)';
@override
String get auditMoreTooltip => 'More actions';
@override
String get auditSearchHint =>
'Search events (flow, step, module, error text …)';
@override
String get auditExportAction => 'Export current view as JSONL …';
@override
String get auditExportNothing =>
'No events in the current view — nothing to export.';
@override
String auditExportSaved(int n, String path) {
return 'Exported $n events to $path';
}
@override
String get auditDevResetAction =>
'Development reset: clear log … (local/dev only)';
@override
String auditClearedToast(int n, String channel) {
return 'Cleared $n events on channel \"$channel\". Chain reseeded with marker.';

View file

@ -2,6 +2,7 @@ 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';
@ -13,6 +14,21 @@ 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);
}
class AuditPage extends StatefulWidget {
const AuditPage({super.key});
@ -22,6 +38,7 @@ class AuditPage extends StatefulWidget {
class _AuditPageState extends State<AuditPage> {
String _typeFilter = 'all';
String _search = '';
List<AuditEvent> _events = const [];
String? _error;
bool _initialLoaded = false;
@ -93,6 +110,62 @@ class _AuditPageState extends State<AuditPage> {
});
}
/// 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 full-history exports with WORM guarantees
/// the CLI `chain audit export` 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;
}
final path = await FilePicker.saveFile(
dialogTitle: l.auditExportAction,
fileName: 'audit-export.jsonl',
);
if (path == null || !mounted) return;
try {
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,
}));
await File(path).writeAsString('${lines.join('\n')}\n', 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);
@ -144,9 +217,7 @@ class _AuditPageState extends State<AuditPage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = _typeFilter == 'all'
? _events
: _events.where((e) => e.type.startsWith(_typeFilter)).toList();
final filtered = _visibleEvents();
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
@ -175,10 +246,40 @@ class _AuditPageState extends State<AuditPage> {
tooltip: AppLocalizations.of(context)!.helpTooltip,
onPressed: () => showFaiDoc(context, 'audit'),
),
IconButton(
icon: const Icon(Icons.delete_sweep_outlined, size: 18),
tooltip: AppLocalizations.of(context)!.auditClearLogTooltip,
onPressed: _onClearPressed,
// 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(),
'clear' => _onClearPressed(),
_ => null,
},
itemBuilder: (ctx) => [
PopupMenuItem(
value: 'export',
child: Row(
children: [
const Icon(Icons.download_outlined, size: 16),
const SizedBox(width: ChainSpace.sm),
Text(AppLocalizations.of(ctx)!.auditExportAction),
],
),
),
PopupMenuItem(
value: 'clear',
child: Row(
children: [
const Icon(Icons.delete_sweep_outlined, size: 16),
const SizedBox(width: ChainSpace.sm),
Text(AppLocalizations.of(ctx)!.auditDevResetAction),
],
),
),
],
),
const SizedBox(width: ChainSpace.sm),
],
@ -186,6 +287,26 @@ class _AuditPageState extends State<AuditPage> {
body: Column(
children: [
_LiveStatusBar(eventCount: filtered.length, error: _error),
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())