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

@ -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())