feat(audit): free-text search, JSONL export of the view, labeled dev reset
Some checks failed
Security / Security check (push) Failing after 1s
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:
parent
0e53572589
commit
5aa69104e7
7 changed files with 273 additions and 7 deletions
|
|
@ -561,6 +561,13 @@
|
||||||
"auditFilterModule": "Modul",
|
"auditFilterModule": "Modul",
|
||||||
"auditFilterTooltip": "Ereignisse filtern",
|
"auditFilterTooltip": "Ereignisse filtern",
|
||||||
"auditClearLogTooltip": "Protokoll löschen (nur local/dev Kanal)",
|
"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": "{n} Ereignisse auf Kanal „{channel}\" gelöscht. Kette mit Marker neu geseedet.",
|
||||||
"@auditClearedToast": {
|
"@auditClearedToast": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|
|
||||||
|
|
@ -579,6 +579,13 @@
|
||||||
"auditFilterModule": "module",
|
"auditFilterModule": "module",
|
||||||
"auditFilterTooltip": "Filter events",
|
"auditFilterTooltip": "Filter events",
|
||||||
"auditClearLogTooltip": "Clear log (local/dev channels only)",
|
"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": "Cleared {n} events on channel \"{channel}\". Chain reseeded with marker.",
|
||||||
"@auditClearedToast": {
|
"@auditClearedToast": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|
|
||||||
|
|
@ -2126,6 +2126,42 @@ abstract class AppLocalizations {
|
||||||
/// **'Clear log (local/dev channels only)'**
|
/// **'Clear log (local/dev channels only)'**
|
||||||
String get auditClearLogTooltip;
|
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.
|
/// No description provided for @auditClearedToast.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1181,6 +1181,29 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get auditClearLogTooltip => 'Protokoll löschen (nur local/dev Kanal)';
|
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
|
@override
|
||||||
String auditClearedToast(int n, String channel) {
|
String auditClearedToast(int n, String channel) {
|
||||||
return '$n Ereignisse auf Kanal „$channel\" gelöscht. Kette mit Marker neu geseedet.';
|
return '$n Ereignisse auf Kanal „$channel\" gelöscht. Kette mit Marker neu geseedet.';
|
||||||
|
|
|
||||||
|
|
@ -1198,6 +1198,29 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get auditClearLogTooltip => 'Clear log (local/dev channels only)';
|
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
|
@override
|
||||||
String auditClearedToast(int n, String channel) {
|
String auditClearedToast(int n, String channel) {
|
||||||
return 'Cleared $n events on channel \"$channel\". Chain reseeded with marker.';
|
return 'Cleared $n events on channel \"$channel\". Chain reseeded with marker.';
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/error_presentation.dart';
|
import '../data/error_presentation.dart';
|
||||||
|
|
@ -13,6 +14,21 @@ import '../theme/tokens.dart';
|
||||||
import '../widgets/widgets.dart';
|
import '../widgets/widgets.dart';
|
||||||
import 'welcome.dart' show showFaiDoc;
|
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 {
|
class AuditPage extends StatefulWidget {
|
||||||
const AuditPage({super.key});
|
const AuditPage({super.key});
|
||||||
|
|
||||||
|
|
@ -22,6 +38,7 @@ class AuditPage extends StatefulWidget {
|
||||||
|
|
||||||
class _AuditPageState extends State<AuditPage> {
|
class _AuditPageState extends State<AuditPage> {
|
||||||
String _typeFilter = 'all';
|
String _typeFilter = 'all';
|
||||||
|
String _search = '';
|
||||||
List<AuditEvent> _events = const [];
|
List<AuditEvent> _events = const [];
|
||||||
String? _error;
|
String? _error;
|
||||||
bool _initialLoaded = false;
|
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 {
|
Future<void> _onClearPressed() async {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final outcome = await _ClearAuditDialog.show(context);
|
final outcome = await _ClearAuditDialog.show(context);
|
||||||
|
|
@ -144,9 +217,7 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final filtered = _typeFilter == 'all'
|
final filtered = _visibleEvents();
|
||||||
? _events
|
|
||||||
: _events.where((e) => e.type.startsWith(_typeFilter)).toList();
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
|
@ -175,10 +246,40 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
tooltip: AppLocalizations.of(context)!.helpTooltip,
|
tooltip: AppLocalizations.of(context)!.helpTooltip,
|
||||||
onPressed: () => showFaiDoc(context, 'audit'),
|
onPressed: () => showFaiDoc(context, 'audit'),
|
||||||
),
|
),
|
||||||
IconButton(
|
// The dev-only reset used to sit here as a bare trash icon —
|
||||||
icon: const Icon(Icons.delete_sweep_outlined, size: 18),
|
// on an audit log that read as "delete evidence" (usertest
|
||||||
tooltip: AppLocalizations.of(context)!.auditClearLogTooltip,
|
// panel, security auditor). It now lives in a labeled
|
||||||
onPressed: _onClearPressed,
|
// 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),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
],
|
],
|
||||||
|
|
@ -186,6 +287,26 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
_LiveStatusBar(eventCount: filtered.length, error: _error),
|
_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(
|
Expanded(
|
||||||
child: !_initialLoaded
|
child: !_initialLoaded
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
|
|
||||||
49
test/audit_filter_test.dart
Normal file
49
test/audit_filter_test.dart
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// Free-text audit search — the match function behind the audit
|
||||||
|
// page's search field and JSONL export ("current view" semantics).
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chain_studio/data/hub.dart';
|
||||||
|
import 'package:chain_studio/pages/audit.dart';
|
||||||
|
|
||||||
|
AuditEvent _event({
|
||||||
|
String type = 'step.completed',
|
||||||
|
String? flow,
|
||||||
|
String? step,
|
||||||
|
String? module,
|
||||||
|
String? error,
|
||||||
|
String? detail,
|
||||||
|
String project = '',
|
||||||
|
}) {
|
||||||
|
return AuditEvent(
|
||||||
|
eventId: 'evt-1',
|
||||||
|
timestamp: DateTime.utc(2026, 7, 18),
|
||||||
|
type: type,
|
||||||
|
flowName: flow,
|
||||||
|
stepId: step,
|
||||||
|
moduleName: module,
|
||||||
|
error: error,
|
||||||
|
detail: detail,
|
||||||
|
project: project,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('matches on every displayed field, case-insensitive', () {
|
||||||
|
expect(matchesAuditQuery(_event(flow: 'Rechnungslauf'), 'rechnung'), isTrue);
|
||||||
|
expect(matchesAuditQuery(_event(step: 'extract'), 'extract'), isTrue);
|
||||||
|
expect(matchesAuditQuery(_event(module: 'debug.echo'), 'echo'), isTrue);
|
||||||
|
expect(matchesAuditQuery(_event(error: 'Timeout nach 30s'), 'timeout'),
|
||||||
|
isTrue);
|
||||||
|
expect(matchesAuditQuery(_event(detail: '{"anchor":"tsa"}'), 'anchor'),
|
||||||
|
isTrue);
|
||||||
|
expect(matchesAuditQuery(_event(project: 'stromnetz'), 'strom'), isTrue);
|
||||||
|
expect(matchesAuditQuery(_event(), 'evt-1'), isTrue);
|
||||||
|
expect(matchesAuditQuery(_event(), 'step.'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-matching queries and null fields', () {
|
||||||
|
expect(matchesAuditQuery(_event(), 'nichtvorhanden'), isFalse);
|
||||||
|
expect(matchesAuditQuery(_event(flow: null, error: null), 'flow'), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue