Turns Studio from read-only dashboard into operator console. Pieces: * **New Store page** (sidebar destination #3): browses the hub's bundled store-index with query + category + status filters. Each card has an Install button that prompts for the `.fai` bundle source (URL or local path), ships it to HubAdmin.InstallModule, shows success / error in a progress dialog, refreshes the list. * **Audit drill-down**: every event row is now tappable; opens a modal with all LoggedEvent fields including the new `detail` JSON pretty-printed in a code block. KRITIS forensic story: every audit row → full structured detail in two clicks. * **Approvals payload preview**: shipped already; now pretty-prints JSON when the preview parses as such, plus a clearer `PAYLOAD PREVIEW` label. Reviewer identity defaults to `$USER@studio` instead of the hard-coded `studio-mvp` so the audit trail records who acted. * **Channel switcher in Settings dialog**: the dialog now pulls HubAdmin.ChannelStatus and renders one row per channel (local / dev / beta / production) with port, running indicator, active marker. A "Connect" button on a running channel sets Studio's endpoint to that channel's port and reconnects in one click. Cmd+1..6 keyboard shortcuts updated for the six destinations. Widget test asserts every destination renders. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
464 lines
14 KiB
Dart
464 lines
14 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../data/hub.dart';
|
||
import '../theme/theme.dart';
|
||
import '../theme/tokens.dart';
|
||
import '../widgets/widgets.dart';
|
||
|
||
class AuditPage extends StatefulWidget {
|
||
const AuditPage({super.key});
|
||
|
||
@override
|
||
State<AuditPage> createState() => _AuditPageState();
|
||
}
|
||
|
||
class _AuditPageState extends State<AuditPage> {
|
||
String _typeFilter = 'all';
|
||
List<AuditEvent> _events = const [];
|
||
String? _error;
|
||
bool _initialLoaded = false;
|
||
Timer? _poller;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_refresh();
|
||
_poller = Timer.periodic(
|
||
const Duration(seconds: 2),
|
||
(_) => _refresh(),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_poller?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _refresh() async {
|
||
try {
|
||
final events = await HubService.instance.recentEvents(limit: 100);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_events = events;
|
||
_error = null;
|
||
_initialLoaded = true;
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_error = e.toString();
|
||
_initialLoaded = true;
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final filtered = _typeFilter == 'all'
|
||
? _events
|
||
: _events.where((e) => e.type.startsWith(_typeFilter)).toList();
|
||
|
||
return Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
appBar: AppBar(
|
||
title: const Text('Audit'),
|
||
actions: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: FaiSpace.lg),
|
||
child: _FilterChips(
|
||
value: _typeFilter,
|
||
onChanged: (v) => setState(() => _typeFilter = v),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
_LiveStatusBar(eventCount: filtered.length, error: _error),
|
||
Expanded(
|
||
child: !_initialLoaded
|
||
? const Center(child: CircularProgressIndicator())
|
||
: _error != null && _events.isEmpty
|
||
? FaiEmptyState(
|
||
icon: Icons.cloud_off_outlined,
|
||
iconColor: theme.colorScheme.error,
|
||
title: 'Hub unreachable',
|
||
hint: 'Start the hub with `fai serve`.',
|
||
)
|
||
: filtered.isEmpty
|
||
? const FaiEmptyState(
|
||
icon: Icons.timeline_outlined,
|
||
title: 'No events yet',
|
||
hint:
|
||
'Run a flow to populate the audit stream.\nEvents appear here within seconds.',
|
||
)
|
||
: ListView.separated(
|
||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||
itemCount: filtered.length,
|
||
separatorBuilder: (_, _) =>
|
||
const SizedBox(height: FaiSpace.xs),
|
||
itemBuilder: (context, i) {
|
||
final e = filtered[i];
|
||
return FaiDataRow(
|
||
accent: _toneFor(e.type, theme),
|
||
leading: _formatTime(e.timestamp),
|
||
title: e.type,
|
||
subtitle: _contextLine(e),
|
||
trailing: e.durationMs != null
|
||
? '${e.durationMs}ms'
|
||
: null,
|
||
onTap: () => showDialog<void>(
|
||
context: context,
|
||
builder: (_) => _EventDetailDialog(event: e),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
String _formatTime(DateTime ts) =>
|
||
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}';
|
||
|
||
String _contextLine(AuditEvent e) {
|
||
final parts = <String>[];
|
||
if (e.flowName != null) parts.add(e.flowName!);
|
||
if (e.stepId != null) parts.add(' › ${e.stepId}');
|
||
if (e.moduleName != null) parts.add(' via ${e.moduleName}');
|
||
if (e.error != null) parts.add(' [error: ${e.error}]');
|
||
return parts.join('');
|
||
}
|
||
|
||
Color _toneFor(String type, ThemeData theme) {
|
||
if (type.endsWith('.failed')) return theme.colorScheme.error;
|
||
if (type.endsWith('.completed')) return theme.colorScheme.primary;
|
||
if (type.endsWith('.started')) return FaiColors.warning;
|
||
return theme.colorScheme.outline;
|
||
}
|
||
}
|
||
|
||
class _FilterChips extends StatelessWidget {
|
||
final String value;
|
||
final ValueChanged<String> onChanged;
|
||
|
||
const _FilterChips({required this.value, required this.onChanged});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
const items = [
|
||
('all', 'all'),
|
||
('flow.', 'flow'),
|
||
('step.', 'step'),
|
||
('module.', 'module'),
|
||
];
|
||
return Row(
|
||
children: [
|
||
for (final (v, label) in items)
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: FaiSpace.xs),
|
||
child: _ChipButton(
|
||
label: label,
|
||
selected: value == v,
|
||
onTap: () => onChanged(v),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ChipButton extends StatefulWidget {
|
||
final String label;
|
||
final bool selected;
|
||
final VoidCallback onTap;
|
||
|
||
const _ChipButton({
|
||
required this.label,
|
||
required this.selected,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
State<_ChipButton> createState() => _ChipButtonState();
|
||
}
|
||
|
||
class _ChipButtonState extends State<_ChipButton> {
|
||
bool _hovered = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final fg = widget.selected
|
||
? theme.colorScheme.primary
|
||
: _hovered
|
||
? theme.colorScheme.onSurface
|
||
: theme.colorScheme.onSurfaceVariant;
|
||
final bg = widget.selected
|
||
? theme.colorScheme.primary.withValues(alpha: 0.12)
|
||
: _hovered
|
||
? theme.colorScheme.surfaceContainerHigh
|
||
: Colors.transparent;
|
||
return MouseRegion(
|
||
onEnter: (_) => setState(() => _hovered = true),
|
||
onExit: (_) => setState(() => _hovered = false),
|
||
cursor: SystemMouseCursors.click,
|
||
child: GestureDetector(
|
||
onTap: widget.onTap,
|
||
child: AnimatedContainer(
|
||
duration: FaiMotion.fast,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: FaiSpace.md,
|
||
vertical: 6,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: bg,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(
|
||
color: widget.selected
|
||
? theme.colorScheme.primary.withValues(alpha: 0.3)
|
||
: Colors.transparent,
|
||
),
|
||
),
|
||
child: Text(
|
||
widget.label,
|
||
style: FaiTheme.mono(
|
||
size: 11,
|
||
weight: widget.selected ? FontWeight.w500 : FontWeight.w400,
|
||
color: fg,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LiveStatusBar extends StatelessWidget {
|
||
final int eventCount;
|
||
final String? error;
|
||
|
||
const _LiveStatusBar({required this.eventCount, required this.error});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final live = error == null;
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: FaiSpace.xl,
|
||
vertical: FaiSpace.sm,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
border: Border(
|
||
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
FaiStatusDot(
|
||
color: live ? FaiColors.success : FaiColors.danger,
|
||
pulsing: live,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Text(
|
||
live
|
||
? 'live · polling 2s · $eventCount event${eventCount == 1 ? '' : 's'}'
|
||
: 'disconnected · $error',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
if (live)
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.shield_outlined,
|
||
size: 12,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
'hash chain verified',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.primary,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _EventDetailDialog extends StatelessWidget {
|
||
final AuditEvent event;
|
||
|
||
const _EventDetailDialog({required this.event});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
String prettyDetail() {
|
||
final raw = event.detail;
|
||
if (raw == null || raw.isEmpty) return '';
|
||
// Many detail strings are JSON; try to pretty-print and
|
||
// fall back to the raw value when parsing fails.
|
||
try {
|
||
final dynamic parsed = const JsonDecoder().convert(raw);
|
||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||
} catch (_) {
|
||
return raw;
|
||
}
|
||
}
|
||
|
||
final detail = prettyDetail();
|
||
return AlertDialog(
|
||
title: Text(event.type),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||
),
|
||
content: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 560),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_Field(label: 'event_id', value: event.eventId, mono: true),
|
||
_Field(
|
||
label: 'timestamp',
|
||
value: event.timestamp.toIso8601String(),
|
||
mono: true,
|
||
),
|
||
if (event.flowName != null)
|
||
_Field(label: 'flow', value: event.flowName!),
|
||
if (event.flowExecution != null)
|
||
_Field(
|
||
label: 'flow_execution',
|
||
value: event.flowExecution!,
|
||
mono: true,
|
||
),
|
||
if (event.invocationId != null)
|
||
_Field(
|
||
label: 'invocation_id',
|
||
value: event.invocationId!,
|
||
mono: true,
|
||
),
|
||
if (event.stepId != null)
|
||
_Field(label: 'step', value: event.stepId!),
|
||
if (event.moduleName != null)
|
||
_Field(
|
||
label: 'module',
|
||
value: event.moduleVersion != null
|
||
? '${event.moduleName} @ ${event.moduleVersion}'
|
||
: event.moduleName!,
|
||
),
|
||
if (event.durationMs != null)
|
||
_Field(label: 'duration', value: '${event.durationMs} ms'),
|
||
if (event.error != null)
|
||
_Field(
|
||
label: 'error',
|
||
value: event.error!,
|
||
valueColor: theme.colorScheme.error,
|
||
),
|
||
if (detail.isNotEmpty) ...[
|
||
const SizedBox(height: FaiSpace.md),
|
||
Text(
|
||
'DETAIL',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(
|
||
color: theme.colorScheme.outlineVariant,
|
||
),
|
||
),
|
||
child: SelectableText(
|
||
detail,
|
||
style: FaiTheme.mono(size: 11),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: const Text('Close'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Field extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final bool mono;
|
||
final Color? valueColor;
|
||
|
||
const _Field({
|
||
required this.label,
|
||
required this.value,
|
||
this.mono = false,
|
||
this.valueColor,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 110,
|
||
child: Text(
|
||
label,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: SelectableText(
|
||
value,
|
||
style: mono
|
||
? FaiTheme.mono(
|
||
size: 11,
|
||
color: valueColor ?? theme.colorScheme.onSurface,
|
||
)
|
||
: theme.textTheme.bodySmall?.copyWith(
|
||
color: valueColor ?? theme.colorScheme.onSurface,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|