feat(audit): full-history JSONL export in the actions menu
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
ae8fdcc762
commit
b47d8c4646
10 changed files with 180 additions and 23 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -6,6 +6,17 @@ lockstep.
|
||||||
|
|
||||||
## Unreleased
|
## 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)
|
### Changed (usertest low-findings tail)
|
||||||
|
|
||||||
- **Honest hero badge.** The store hero wears "TODAY"/"HEUTE" only
|
- **Honest hero badge.** The store hero wears "TODAY"/"HEUTE" only
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
/// Studio's own build version. Bump on every UI release so the
|
/// Studio's own build version. Bump on every UI release so the
|
||||||
/// running app self-identifies.
|
/// running app self-identifies.
|
||||||
const String kStudioVersion = '0.72.1';
|
const String kStudioVersion = '0.73.0';
|
||||||
|
|
||||||
const String kProductName = 'Ch∆In Studio';
|
const String kProductName = 'Ch∆In Studio';
|
||||||
const String kVendorName = 'Flemming.AI (F∆I)';
|
const String kVendorName = 'Flemming.AI (F∆I)';
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,8 @@
|
||||||
"auditMoreTooltip": "Weitere Aktionen",
|
"auditMoreTooltip": "Weitere Aktionen",
|
||||||
"auditSearchHint": "Ereignisse durchsuchen (Flow, Schritt, Modul, Fehlertext …)",
|
"auditSearchHint": "Ereignisse durchsuchen (Flow, Schritt, Modul, Fehlertext …)",
|
||||||
"auditExportAction": "Aktuelle Ansicht als JSONL exportieren …",
|
"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.",
|
"auditExportNothing": "Keine Ereignisse in der aktuellen Ansicht — nichts zu exportieren.",
|
||||||
"auditExportSaved": "{n} Ereignisse exportiert nach {path}",
|
"auditExportSaved": "{n} Ereignisse exportiert nach {path}",
|
||||||
"@auditExportSaved": {"placeholders": {"n": {"type": "int"}, "path": {"type": "String"}}},
|
"@auditExportSaved": {"placeholders": {"n": {"type": "int"}, "path": {"type": "String"}}},
|
||||||
|
|
|
||||||
|
|
@ -587,6 +587,8 @@
|
||||||
"auditMoreTooltip": "More actions",
|
"auditMoreTooltip": "More actions",
|
||||||
"auditSearchHint": "Search events (flow, step, module, error text …)",
|
"auditSearchHint": "Search events (flow, step, module, error text …)",
|
||||||
"auditExportAction": "Export current view as JSONL …",
|
"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.",
|
"auditExportNothing": "No events in the current view — nothing to export.",
|
||||||
"auditExportSaved": "Exported {n} events to {path}",
|
"auditExportSaved": "Exported {n} events to {path}",
|
||||||
"@auditExportSaved": {"placeholders": {"n": {"type": "int"}, "path": {"type": "String"}}},
|
"@auditExportSaved": {"placeholders": {"n": {"type": "int"}, "path": {"type": "String"}}},
|
||||||
|
|
|
||||||
|
|
@ -2174,6 +2174,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Export current view as JSONL …'**
|
/// **'Export current view as JSONL …'**
|
||||||
String get auditExportAction;
|
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.
|
/// No description provided for @auditExportNothing.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1209,6 +1209,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get auditExportAction => 'Aktuelle Ansicht als JSONL exportieren …';
|
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
|
@override
|
||||||
String get auditExportNothing =>
|
String get auditExportNothing =>
|
||||||
'Keine Ereignisse in der aktuellen Ansicht — nichts zu exportieren.';
|
'Keine Ereignisse in der aktuellen Ansicht — nichts zu exportieren.';
|
||||||
|
|
|
||||||
|
|
@ -1225,6 +1225,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get auditExportAction => 'Export current view as JSONL …';
|
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
|
@override
|
||||||
String get auditExportNothing =>
|
String get auditExportNothing =>
|
||||||
'No events in the current view — nothing to export.';
|
'No events in the current view — nothing to export.';
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,35 @@ bool matchesAuditQuery(AuditEvent e, String query) {
|
||||||
has(e.eventId);
|
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<AuditEvent> 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 {
|
class AuditPage extends StatefulWidget {
|
||||||
const AuditPage({super.key});
|
const AuditPage({super.key});
|
||||||
|
|
||||||
|
|
@ -123,8 +152,8 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
|
|
||||||
/// Export the currently visible (type- + search-filtered) events
|
/// Export the currently visible (type- + search-filtered) events
|
||||||
/// as JSONL — one JSON object per line, newest first, exactly what
|
/// as JSONL — one JSON object per line, newest first, exactly what
|
||||||
/// the list shows. For full-history exports with WORM guarantees
|
/// the list shows. For exports with WORM guarantees the CLI
|
||||||
/// the CLI `chain audit export` stays the canonical tool.
|
/// `chain admin events --json` stays the canonical tool.
|
||||||
Future<void> _onExportPressed() async {
|
Future<void> _onExportPressed() async {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final events = _visibleEvents();
|
final events = _visibleEvents();
|
||||||
|
|
@ -134,28 +163,58 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final path = await FilePicker.saveFile(
|
await _saveEventsAsJsonl(
|
||||||
|
events: events,
|
||||||
dialogTitle: l.auditExportAction,
|
dialogTitle: l.auditExportAction,
|
||||||
fileName: 'audit-export.jsonl',
|
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<void> _onExportAllPressed() async {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final List<AuditEvent> 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<void> _saveEventsAsJsonl({
|
||||||
|
required List<AuditEvent> 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;
|
if (path == null || !mounted) return;
|
||||||
try {
|
try {
|
||||||
final lines = events.map((e) => jsonEncode({
|
await File(path).writeAsString(auditEventsToJsonl(events), flush: true);
|
||||||
'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;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(l.auditExportSaved(events.length, path))),
|
SnackBar(content: Text(l.auditExportSaved(events.length, path))),
|
||||||
|
|
@ -255,6 +314,7 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
tooltip: AppLocalizations.of(context)!.auditMoreTooltip,
|
tooltip: AppLocalizations.of(context)!.auditMoreTooltip,
|
||||||
onSelected: (v) => switch (v) {
|
onSelected: (v) => switch (v) {
|
||||||
'export' => _onExportPressed(),
|
'export' => _onExportPressed(),
|
||||||
|
'export-all' => _onExportAllPressed(),
|
||||||
'clear' => _onClearPressed(),
|
'clear' => _onClearPressed(),
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
|
|
@ -269,6 +329,16 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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(
|
PopupMenuItem(
|
||||||
value: 'clear',
|
value: 'clear',
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: chain_studio
|
name: chain_studio
|
||||||
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.72.1
|
version: 0.73.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
// Free-text audit search — the match function behind the audit
|
// Free-text audit search and JSONL serialisation — the functions
|
||||||
// page's search field and JSONL export ("current view" semantics).
|
// 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';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
|
@ -46,4 +49,47 @@ void main() {
|
||||||
expect(matchesAuditQuery(_event(), 'nichtvorhanden'), isFalse);
|
expect(matchesAuditQuery(_event(), 'nichtvorhanden'), isFalse);
|
||||||
expect(matchesAuditQuery(_event(flow: null, error: null), 'flow'), 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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue