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:
flemming-it 2026-05-07 11:54:31 +02:00
parent 18db6ff164
commit 060c8003d5
9 changed files with 990 additions and 12 deletions

View file

@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import '../data/hub.dart';
@ -14,9 +17,17 @@ class ApprovalsPage extends StatefulWidget {
class _ApprovalsPageState extends State<ApprovalsPage> {
late Future<List<PendingApproval>> _future;
// Hard-coded reviewer for the MVP; Phase 1+ wires this to an
// authenticated session.
final String _reviewer = 'studio-mvp';
// Reviewer identity recorded in the audit log. Defaults to
// the OS user (closest stable identity Studio has without an
// auth backend); operators can override it per session.
late final String _reviewer = _defaultReviewer();
static String _defaultReviewer() {
final user = Platform.environment['USER'] ??
Platform.environment['USERNAME'] ??
'studio';
return '$user@studio';
}
@override
void initState() {
@ -199,6 +210,17 @@ class _ApprovalCard extends StatelessWidget {
),
if (approval.showPreview != null) ...[
const SizedBox(height: FaiSpace.md),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'PAYLOAD PREVIEW',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.md),
@ -207,8 +229,8 @@ class _ApprovalCard extends StatelessWidget {
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Text(
approval.showPreview!,
child: SelectableText(
_prettyPreview(approval.showPreview!),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
@ -251,6 +273,16 @@ class _ApprovalCard extends StatelessWidget {
);
}
String _prettyPreview(String raw) {
if (raw.isEmpty) return raw;
try {
final dynamic parsed = const JsonDecoder().convert(raw);
return const JsonEncoder.withIndent(' ').convert(parsed);
} catch (_) {
return raw;
}
}
String _expiresIn(DateTime t) {
final remaining = t.difference(DateTime.now());
if (remaining.isNegative) return 'expired';

View file

@ -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,
),
),
),
],
),
);
}
}

517
lib/pages/store.dart Normal file
View file

@ -0,0 +1,517 @@
// Store browser. Lists modules from the hub's bundled
// store-index, with category + status filters and a one-click
// install action. Mirrors `fai store search` on the CLI side.
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class StorePage extends StatefulWidget {
const StorePage({super.key});
@override
State<StorePage> createState() => _StorePageState();
}
class _StorePageState extends State<StorePage> {
final _queryCtrl = TextEditingController();
String _category = '';
String _status = '';
late Future<List<StoreItem>> _future;
@override
void initState() {
super.initState();
_future = _load();
}
@override
void dispose() {
_queryCtrl.dispose();
super.dispose();
}
Future<List<StoreItem>> _load() {
return HubService.instance.searchStore(
query: _queryCtrl.text.trim(),
category: _category,
status: _status,
);
}
void _runSearch() => setState(() => _future = _load());
Future<void> _install(StoreItem item) async {
// The store entry doesn't carry the bundle URL; for Phase 0.5
// we prompt the operator for the source (URL or local path).
// Future store entries will include `download_url` per
// platform.
final source = await _SourcePromptDialog.show(context, item);
if (source == null || source.isEmpty) return;
if (!mounted) return;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _InstallProgressDialog(item: item, source: source),
).then((_) => _runSearch());
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('Store'),
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reload',
onPressed: _runSearch,
),
const SizedBox(width: FaiSpace.sm),
],
),
body: Padding(
padding: const EdgeInsets.all(FaiSpace.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SearchBar(
controller: _queryCtrl,
category: _category,
status: _status,
onCategoryChanged: (v) {
setState(() => _category = v);
_runSearch();
},
onStatusChanged: (v) {
setState(() => _status = v);
_runSearch();
},
onSubmit: _runSearch,
),
const SizedBox(height: FaiSpace.lg),
Expanded(
child: FutureBuilder<List<StoreItem>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai daemon start`.',
action: FilledButton.tonal(
onPressed: _runSearch,
child: const Text('Retry'),
),
);
}
final items = snap.data ?? const [];
if (items.isEmpty) {
return const FaiEmptyState(
icon: Icons.store_outlined,
title: 'No matches',
hint:
'Adjust the filters or clear the search to see all entries.',
);
}
return ListView.separated(
padding: EdgeInsets.zero,
itemCount: items.length,
separatorBuilder: (_, _) =>
const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) =>
_StoreCard(item: items[i], onInstall: () => _install(items[i])),
);
},
),
),
],
),
),
);
}
}
class _SearchBar extends StatelessWidget {
final TextEditingController controller;
final String category;
final String status;
final void Function(String) onCategoryChanged;
final void Function(String) onStatusChanged;
final VoidCallback onSubmit;
const _SearchBar({
required this.controller,
required this.category,
required this.status,
required this.onCategoryChanged,
required this.onStatusChanged,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: TextField(
controller: controller,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search, size: 18),
hintText: 'search name, tagline, tag…',
border: OutlineInputBorder(),
isDense: true,
),
onSubmitted: (_) => onSubmit(),
),
),
const SizedBox(width: FaiSpace.md),
_DropdownFilter(
label: 'Category',
value: category,
options: const ['', 'llm', 'text', 'system', 'orchestrator'],
onChanged: onCategoryChanged,
),
const SizedBox(width: FaiSpace.md),
_DropdownFilter(
label: 'Status',
value: status,
options: const ['', 'published', 'alpha', 'planned'],
onChanged: onStatusChanged,
),
],
);
}
}
class _DropdownFilter extends StatelessWidget {
final String label;
final String value;
final List<String> options;
final void Function(String) onChanged;
const _DropdownFilter({
required this.label,
required this.value,
required this.options,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 160,
child: DropdownButtonFormField<String>(
initialValue: value,
isDense: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: true,
),
items: options
.map(
(o) => DropdownMenuItem(
value: o,
child: Text(o.isEmpty ? 'any' : o),
),
)
.toList(),
onChanged: (v) => onChanged(v ?? ''),
),
);
}
}
class _StoreCard extends StatelessWidget {
final StoreItem item;
final VoidCallback onInstall;
const _StoreCard({required this.item, required this.onInstall});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FaiCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
item.installed ? Icons.check_circle_outline : Icons.extension_outlined,
size: 20,
color: item.installed
? FaiColors.success
: theme.colorScheme.primary,
),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: FaiSpace.sm),
if (item.bestVersion.isNotEmpty)
FaiPill(
label: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
const SizedBox(width: FaiSpace.xs),
if (item.status.isNotEmpty)
FaiPill(
label: item.status,
tone: _toneForStatus(item.status),
),
],
),
if (item.taglineEn.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
item.taglineEn,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: FaiSpace.xs),
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
if (item.category.isNotEmpty)
FaiPill(
label: item.category,
tone: FaiPillTone.neutral,
),
for (final t in item.tags.take(4))
FaiPill(label: t, tone: FaiPillTone.neutral),
],
),
],
),
),
const SizedBox(width: FaiSpace.md),
if (item.installed)
FilledButton.tonalIcon(
onPressed: null,
icon: const Icon(Icons.check, size: 16),
label: const Text('Installed'),
)
else
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.download, size: 16),
label: const Text('Install'),
),
],
),
);
}
FaiPillTone _toneForStatus(String s) {
switch (s) {
case 'published':
return FaiPillTone.success;
case 'alpha':
return FaiPillTone.warning;
case 'planned':
return FaiPillTone.neutral;
default:
return FaiPillTone.neutral;
}
}
}
class _SourcePromptDialog extends StatefulWidget {
final StoreItem item;
const _SourcePromptDialog({required this.item});
static Future<String?> show(BuildContext context, StoreItem item) {
return showDialog<String>(
context: context,
builder: (_) => _SourcePromptDialog(item: item),
);
}
@override
State<_SourcePromptDialog> createState() => _SourcePromptDialogState();
}
class _SourcePromptDialogState extends State<_SourcePromptDialog> {
final _ctrl = TextEditingController();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Text('Install ${widget.item.name}'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'The store index does not yet ship binary URLs. Paste the '
'`.fai` bundle source — a https URL or a local filesystem '
'path — and the hub will fetch + verify + unpack.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
TextField(
controller: _ctrl,
autofocus: true,
style: FaiTheme.mono(size: 12),
decoration: const InputDecoration(
labelText: 'source',
hintText: 'https://… or /path/to/bundle.fai',
border: OutlineInputBorder(),
isDense: true,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text('Cancel'),
),
FilledButton.icon(
icon: const Icon(Icons.download, size: 16),
label: const Text('Install'),
onPressed: () => Navigator.pop(context, _ctrl.text.trim()),
),
],
);
}
}
class _InstallProgressDialog extends StatefulWidget {
final StoreItem item;
final String source;
const _InstallProgressDialog({required this.item, required this.source});
@override
State<_InstallProgressDialog> createState() => _InstallProgressDialogState();
}
class _InstallProgressDialogState extends State<_InstallProgressDialog> {
late final Future<({String name, String version})> _future;
@override
void initState() {
super.initState();
_future = HubService.instance.installModule(source: widget.source);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: Text('Installing ${widget.item.name}'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 360),
child: FutureBuilder<({String name, String version})>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: FaiSpace.md),
const CircularProgressIndicator(),
const SizedBox(height: FaiSpace.md),
Text(
'Fetching, verifying, unpacking…',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
if (snap.hasError) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 32,
),
const SizedBox(height: FaiSpace.md),
SelectableText(
snap.error.toString(),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.error,
),
),
],
),
);
}
final r = snap.data!;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle_outline,
size: 32,
color: FaiColors.success,
),
const SizedBox(height: FaiSpace.md),
Text(
'${r.name} v${r.version} installed.',
style: theme.textTheme.titleMedium,
),
],
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
);
}
}