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>
517 lines
16 KiB
Dart
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|