Compare commits

..

No commits in common. "5aa69104e70c8acb0eb03b70a5b54ae9d0201685" and "b52590c7d3d400daba1f0ac8cf41141a7118cf2a" have entirely different histories.

8 changed files with 15 additions and 290 deletions

View file

@ -561,13 +561,6 @@
"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": {

View file

@ -579,13 +579,6 @@
"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": {

View file

@ -2126,42 +2126,6 @@ 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:

View file

@ -1181,29 +1181,6 @@ 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.';

View file

@ -1198,29 +1198,6 @@ 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.';

View file

@ -2,7 +2,6 @@ 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';
@ -14,21 +13,6 @@ 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});
@ -38,7 +22,6 @@ 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;
@ -110,62 +93,6 @@ 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);
@ -217,7 +144,9 @@ 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 = _visibleEvents(); final filtered = _typeFilter == 'all'
? _events
: _events.where((e) => e.type.startsWith(_typeFilter)).toList();
return Scaffold( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
@ -246,40 +175,10 @@ class _AuditPageState extends State<AuditPage> {
tooltip: AppLocalizations.of(context)!.helpTooltip, tooltip: AppLocalizations.of(context)!.helpTooltip,
onPressed: () => showFaiDoc(context, 'audit'), onPressed: () => showFaiDoc(context, 'audit'),
), ),
// The dev-only reset used to sit here as a bare trash icon IconButton(
// on an audit log that read as "delete evidence" (usertest icon: const Icon(Icons.delete_sweep_outlined, size: 18),
// panel, security auditor). It now lives in a labeled tooltip: AppLocalizations.of(context)!.auditClearLogTooltip,
// overflow menu next to the export action. onPressed: _onClearPressed,
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),
], ],
@ -287,26 +186,6 @@ 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())

View file

@ -93,19 +93,6 @@ class _FederationPageState extends State<FederationPage> {
appBar: AppBar( appBar: AppBar(
title: Text(l.federationTitle), title: Text(l.federationTitle),
actions: [ actions: [
// Primary action lives in the AppBar like every other
// page (Flows: "New flow", Store: "Add store") no FAB.
Padding(
padding: const EdgeInsets.only(right: ChainSpace.sm),
child: OutlinedButton.icon(
icon: const Icon(Icons.add_link, size: 16),
label: Text(l.federationAddSatellite),
onPressed: _addSatellite,
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
),
IconButton( IconButton(
icon: const Icon(Icons.help_outline, size: 18), icon: const Icon(Icons.help_outline, size: 18),
tooltip: l.helpTooltip, tooltip: l.helpTooltip,
@ -119,6 +106,11 @@ class _FederationPageState extends State<FederationPage> {
const SizedBox(width: ChainSpace.sm), const SizedBox(width: ChainSpace.sm),
], ],
), ),
floatingActionButton: FloatingActionButton.extended(
onPressed: _addSatellite,
icon: const Icon(Icons.add_link),
label: Text(l.federationAddSatellite),
),
body: FutureBuilder<List<Satellite>>( body: FutureBuilder<List<Satellite>>(
future: _future, future: _future,
builder: (context, snapshot) { builder: (context, snapshot) {
@ -139,14 +131,13 @@ class _FederationPageState extends State<FederationPage> {
} }
final sats = snapshot.data ?? []; final sats = snapshot.data ?? [];
if (sats.isEmpty) { if (sats.isEmpty) {
// The "add satellite" action lives in the FAB (always
// visible); don't repeat it here — that showed the button
// twice on the empty page.
return ChainEmptyState( return ChainEmptyState(
icon: Icons.hub_outlined, icon: Icons.hub_outlined,
title: l.federationEmptyTitle, title: l.federationEmptyTitle,
hint: l.federationEmptyHint, hint: l.federationEmptyHint,
action: FilledButton.tonal(
onPressed: _addSatellite,
child: Text(l.federationAddSatellite),
),
); );
} }
return ListView.separated( return ListView.separated(

View file

@ -1,49 +0,0 @@
// 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);
});
}