feat(studio): operator-mode pass — Store + audit detail + channel switcher (v0.10.0)
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>
This commit is contained in:
parent
18db6ff164
commit
060c8003d5
9 changed files with 990 additions and 12 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
|
@ -111,6 +112,10 @@ class _AuditPageState extends State<AuditPage> {
|
|||
trailing: e.durationMs != null
|
||||
? '${e.durationMs}ms'
|
||||
: null,
|
||||
onTap: () => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _EventDetailDialog(event: e),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -297,3 +302,163 @@ class _LiveStatusBar extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue