chain-studio/lib/pages/store.dart
flemming-it 060c8003d5 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>
2026-05-07 11:54:31 +02:00

517 lines
16 KiB
Dart

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