diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ad82cf9..049af98 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 55b1cf2..a655ae0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a19dbb2..1c18855 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 017c2a0..e286065 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 89f3b27..6073599 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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.'; diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 5eb2a0a..ad7ebcf 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -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 { String _typeFilter = 'all'; + String _search = ''; List _events = const []; String? _error; bool _initialLoaded = false; @@ -93,6 +110,62 @@ class _AuditPageState extends State { }); } + /// The list as the user currently sees it: type chip first, then + /// the free-text query over every displayed/exported field. + List _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 _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 _onClearPressed() async { final l = AppLocalizations.of(context)!; final outcome = await _ClearAuditDialog.show(context); @@ -144,9 +217,7 @@ class _AuditPageState extends State { @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 { 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( + 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 { 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()) diff --git a/test/audit_filter_test.dart b/test/audit_filter_test.dart new file mode 100644 index 0000000..c76392b --- /dev/null +++ b/test/audit_filter_test.dart @@ -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); + }); +}