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
517
lib/pages/store.dart
Normal file
517
lib/pages/store.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue