From b47d8c4646eb3b7081aed8165fd883548c5147bd Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 18 Jul 2026 01:49:20 +0200 Subject: [PATCH] feat(audit): full-history JSONL export in the actions menu The menu so far only exported the current view (type + search filter over the page 100-event window). A second action now fetches the complete event history of the active project scope in one EventLog call (the RPC has no cursor and no server-side cap) and writes it as JSONL. Serialisation extracted to a top-level function with unit tests; a stale comment advertising the never-shipped "chain audit export" command now names "chain admin events --json". Studio 0.73.0. Signed-off-by: flemming-it --- CHANGELOG.md | 11 +++ lib/data/about_info.dart | 2 +- lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 2 + lib/l10n/app_localizations.dart | 12 ++++ lib/l10n/app_localizations_de.dart | 8 +++ lib/l10n/app_localizations_en.dart | 6 ++ lib/pages/audit.dart | 108 ++++++++++++++++++++++++----- pubspec.yaml | 2 +- test/audit_filter_test.dart | 50 ++++++++++++- 10 files changed, 180 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1922f9b..34a16e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ lockstep. ## Unreleased +### Added + +- **Full-history audit export.** The audit page's actions menu gains + "Export full log as JSONL" next to the current-view export: fetches + the complete event history of the active project scope (independent + of the type chip, the search field and the page's 100-event window) + and writes it as JSONL. The current-view export is unchanged; the + CLI `chain admin events --json` remains the canonical tool for + WORM-guaranteed exports (a stale code comment still advertised the + never-shipped `chain audit export` — fixed). + ### Changed (usertest low-findings tail) - **Honest hero badge.** The store hero wears "TODAY"/"HEUTE" only diff --git a/lib/data/about_info.dart b/lib/data/about_info.dart index e5cd30a..790d978 100644 --- a/lib/data/about_info.dart +++ b/lib/data/about_info.dart @@ -4,7 +4,7 @@ /// Studio's own build version. Bump on every UI release so the /// running app self-identifies. -const String kStudioVersion = '0.72.1'; +const String kStudioVersion = '0.73.0'; const String kProductName = 'Ch∆In Studio'; const String kVendorName = 'Flemming.AI (F∆I)'; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c8c92f5..32357d0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -569,6 +569,8 @@ "auditMoreTooltip": "Weitere Aktionen", "auditSearchHint": "Ereignisse durchsuchen (Flow, Schritt, Modul, Fehlertext …)", "auditExportAction": "Aktuelle Ansicht als JSONL exportieren …", + "auditExportAllAction": "Vollständiges Protokoll als JSONL exportieren …", + "auditExportAllNothing": "Das Protokoll ist leer — nichts zu 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"}}}, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f0cdbcb..c08eea7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -587,6 +587,8 @@ "auditMoreTooltip": "More actions", "auditSearchHint": "Search events (flow, step, module, error text …)", "auditExportAction": "Export current view as JSONL …", + "auditExportAllAction": "Export full log as JSONL …", + "auditExportAllNothing": "The log is empty — nothing to export.", "auditExportNothing": "No events in the current view — nothing to export.", "auditExportSaved": "Exported {n} events to {path}", "@auditExportSaved": {"placeholders": {"n": {"type": "int"}, "path": {"type": "String"}}}, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 79717f0..db946a6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2174,6 +2174,18 @@ abstract class AppLocalizations { /// **'Export current view as JSONL …'** String get auditExportAction; + /// No description provided for @auditExportAllAction. + /// + /// In en, this message translates to: + /// **'Export full log as JSONL …'** + String get auditExportAllAction; + + /// No description provided for @auditExportAllNothing. + /// + /// In en, this message translates to: + /// **'The log is empty — nothing to export.'** + String get auditExportAllNothing; + /// No description provided for @auditExportNothing. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c281544..f714a53 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1209,6 +1209,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get auditExportAction => 'Aktuelle Ansicht als JSONL exportieren …'; + @override + String get auditExportAllAction => + 'Vollständiges Protokoll als JSONL exportieren …'; + + @override + String get auditExportAllNothing => + 'Das Protokoll ist leer — nichts zu exportieren.'; + @override String get auditExportNothing => 'Keine Ereignisse in der aktuellen Ansicht — nichts zu exportieren.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d177ddc..2e6ee2c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1225,6 +1225,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get auditExportAction => 'Export current view as JSONL …'; + @override + String get auditExportAllAction => 'Export full log as JSONL …'; + + @override + String get auditExportAllNothing => 'The log is empty — nothing to export.'; + @override String get auditExportNothing => 'No events in the current view — nothing to export.'; diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 4497bf3..ce454a3 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -29,6 +29,35 @@ bool matchesAuditQuery(AuditEvent e, String query) { has(e.eventId); } +/// Serialise audit events as JSONL — one JSON object per line, in +/// the given order (newest first, as the hub returns them), with a +/// trailing newline. Optional fields are omitted rather than written +/// as null. Top-level so the export format is unit-testable. +String auditEventsToJsonl(List events) { + 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, + })); + return '${lines.join('\n')}\n'; +} + +/// Upper bound for the full-history export. The EventLog RPC has no +/// offset/cursor to page with and no server-side cap, so one request +/// with a bound well beyond any local log fetches everything while +/// still bounding the response. +const int _fullExportLimit = 100000; + class AuditPage extends StatefulWidget { const AuditPage({super.key}); @@ -123,8 +152,8 @@ class _AuditPageState extends State { /// 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. + /// the list shows. For exports with WORM guarantees the CLI + /// `chain admin events --json` stays the canonical tool. Future _onExportPressed() async { final l = AppLocalizations.of(context)!; final events = _visibleEvents(); @@ -134,28 +163,58 @@ class _AuditPageState extends State { ); return; } - final path = await FilePicker.saveFile( + await _saveEventsAsJsonl( + events: events, dialogTitle: l.auditExportAction, fileName: 'audit-export.jsonl', ); + } + + /// Export the full event history of the active project scope — + /// independent of the type chip, the search field and the page's + /// 100-event window. Keeps the project filter: the page is + /// project-scoped, matching the per-project compliance export the + /// CLI offers via `chain admin events --project`. + Future _onExportAllPressed() async { + final l = AppLocalizations.of(context)!; + final List events; + try { + events = await HubService.instance.recentEvents( + limit: _fullExportLimit, + project: Workspace.instance.activeSlug, + ); + } catch (e) { + if (!mounted) return; + showChainErrorSnack(context, 'audit.export', e); + return; + } + if (!mounted) return; + if (events.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.auditExportAllNothing)), + ); + return; + } + await _saveEventsAsJsonl( + events: events, + dialogTitle: l.auditExportAllAction, + fileName: 'audit-export-full.jsonl', + ); + } + + Future _saveEventsAsJsonl({ + required List events, + required String dialogTitle, + required String fileName, + }) async { + final l = AppLocalizations.of(context)!; + final path = await FilePicker.saveFile( + dialogTitle: dialogTitle, + fileName: fileName, + ); 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); + await File(path).writeAsString(auditEventsToJsonl(events), flush: true); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.auditExportSaved(events.length, path))), @@ -255,6 +314,7 @@ class _AuditPageState extends State { tooltip: AppLocalizations.of(context)!.auditMoreTooltip, onSelected: (v) => switch (v) { 'export' => _onExportPressed(), + 'export-all' => _onExportAllPressed(), 'clear' => _onClearPressed(), _ => null, }, @@ -269,6 +329,16 @@ class _AuditPageState extends State { ], ), ), + PopupMenuItem( + value: 'export-all', + child: Row( + children: [ + const Icon(Icons.archive_outlined, size: 16), + const SizedBox(width: ChainSpace.sm), + Text(AppLocalizations.of(ctx)!.auditExportAllAction), + ], + ), + ), PopupMenuItem( value: 'clear', child: Row( diff --git a/pubspec.yaml b/pubspec.yaml index c2e33fd..362f75e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: chain_studio description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" publish_to: 'none' -version: 0.72.1 +version: 0.73.0 environment: sdk: ^3.11.0-200.1.beta diff --git a/test/audit_filter_test.dart b/test/audit_filter_test.dart index c76392b..acd6674 100644 --- a/test/audit_filter_test.dart +++ b/test/audit_filter_test.dart @@ -1,5 +1,8 @@ -// Free-text audit search — the match function behind the audit -// page's search field and JSONL export ("current view" semantics). +// Free-text audit search and JSONL serialisation — the functions +// behind the audit page's search field and both export actions +// ("current view" and full history). + +import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; @@ -46,4 +49,47 @@ void main() { expect(matchesAuditQuery(_event(), 'nichtvorhanden'), isFalse); expect(matchesAuditQuery(_event(flow: null, error: null), 'flow'), isFalse); }); + + test('JSONL export: one line per event, order kept, trailing newline', () { + final out = auditEventsToJsonl([ + _event(flow: 'first'), + _event(flow: 'second'), + ]); + expect(out.endsWith('\n'), isTrue); + final lines = out.trimRight().split('\n'); + expect(lines, hasLength(2)); + expect(jsonDecode(lines[0])['flow'], 'first'); + expect(jsonDecode(lines[1])['flow'], 'second'); + }); + + test('JSONL export: absent optional fields are omitted, not null', () { + final line = + jsonDecode(auditEventsToJsonl([_event()]).trimRight()) as Map; + expect(line['event_id'], 'evt-1'); + expect(line['type'], 'step.completed'); + expect(line['timestamp'], '2026-07-18T00:00:00.000Z'); + expect(line.containsKey('flow'), isFalse); + expect(line.containsKey('error'), isFalse); + expect(line.containsKey('project'), isFalse); + expect(line.containsKey('duration_ms'), isFalse); + }); + + test('JSONL export: set fields appear under their wire names', () { + final line = jsonDecode(auditEventsToJsonl([ + _event( + flow: 'rechnungslauf', + step: 'extract', + module: 'debug.echo', + error: 'timeout', + detail: '{"n":1}', + project: 'stromnetz', + ), + ]).trimRight()) as Map; + expect(line['flow'], 'rechnungslauf'); + expect(line['step'], 'extract'); + expect(line['module'], 'debug.echo'); + expect(line['error'], 'timeout'); + expect(line['detail'], '{"n":1}'); + expect(line['project'], 'stromnetz'); + }); }