feat(audit): full-history JSONL export in the actions menu
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:
flemming-it 2026-07-18 01:49:20 +02:00
parent ae8fdcc762
commit b47d8c4646
10 changed files with 180 additions and 23 deletions

View file

@ -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)';

View file

@ -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"}}},

View file

@ -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"}}},

View file

@ -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:

View file

@ -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.';

View file

@ -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.';

View file

@ -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<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 {
const AuditPage({super.key});
@ -123,8 +152,8 @@ class _AuditPageState extends State<AuditPage> {
/// 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<void> _onExportPressed() async {
final l = AppLocalizations.of(context)!;
final events = _visibleEvents();
@ -134,28 +163,58 @@ class _AuditPageState extends State<AuditPage> {
);
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<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;
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<AuditPage> {
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<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(
value: 'clear',
child: Row(