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

@ -106,6 +106,74 @@ class HubService {
); );
} }
/// Active channel + per-channel daemon status snapshot.
Future<ChannelStatusSnapshot> channelStatus() async {
final r = await _client.channelStatus();
return ChannelStatusSnapshot(
active: r.active,
channels: r.channels
.map(
(c) => ChannelInfo(
name: c.name,
port: c.port,
running: c.running,
endpoint: c.endpoint,
),
)
.toList(),
);
}
/// Browse the bundled store index. All filters optional;
/// empty query returns the first [limit] entries.
Future<List<StoreItem>> searchStore({
String query = '',
String category = '',
String tag = '',
String status = '',
int limit = 50,
}) async {
final entries = await _client.searchStore(
query: query,
category: category,
tag: tag,
status: status,
limit: limit,
);
return entries
.map(
(e) => StoreItem(
name: e.name,
taglineEn: e.taglineEn,
descriptionEn: e.descriptionEn,
category: e.category,
tags: e.tags,
requiresCapabilities: e.requiresCapabilities,
requiresServices: e.requiresServices,
license: e.license,
repository: e.repository,
bestVersion: e.bestVersion,
status: e.status,
installed: e.installed,
),
)
.toList();
}
/// Install a module from a `.fai` bundle (URL or local path).
/// Returns the installed module's name + version on success;
/// throws on hub error (signature mismatch, sha256 fail, etc.).
Future<({String name, String version})> installModule({
required String source,
String expectedSha256 = '',
}) async {
final r = await _client.installModule(
source: source,
expectedSha256: expectedSha256,
);
return (name: r.name, version: r.version);
}
/// Saved flows known to the hub. /// Saved flows known to the hub.
Future<List<SavedFlow>> listFlows() async { Future<List<SavedFlow>> listFlows() async {
final flows = await _client.listFlows(); final flows = await _client.listFlows();
@ -163,8 +231,15 @@ class HubService {
flowName: e.flowName.isEmpty ? null : e.flowName, flowName: e.flowName.isEmpty ? null : e.flowName,
stepId: e.stepId.isEmpty ? null : e.stepId, stepId: e.stepId.isEmpty ? null : e.stepId,
moduleName: e.moduleName.isEmpty ? null : e.moduleName, moduleName: e.moduleName.isEmpty ? null : e.moduleName,
moduleVersion:
e.moduleVersion.isEmpty ? null : e.moduleVersion,
invocationId:
e.invocationId.isEmpty ? null : e.invocationId,
flowExecution:
e.flowExecution.isEmpty ? null : e.flowExecution,
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(), durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
error: e.error.isEmpty ? null : e.error, error: e.error.isEmpty ? null : e.error,
detail: e.detail.isEmpty ? null : e.detail,
), ),
) )
.toList(); .toList();
@ -378,8 +453,14 @@ class AuditEvent {
final String? flowName; final String? flowName;
final String? stepId; final String? stepId;
final String? moduleName; final String? moduleName;
final String? moduleVersion;
final String? invocationId;
final String? flowExecution;
final int? durationMs; final int? durationMs;
final String? error; final String? error;
/// Free-form JSON-string carrying event-type-specific fields.
/// Surfaced verbatim in the audit drill-down dialog.
final String? detail;
const AuditEvent({ const AuditEvent({
required this.eventId, required this.eventId,
@ -388,8 +469,12 @@ class AuditEvent {
this.flowName, this.flowName,
this.stepId, this.stepId,
this.moduleName, this.moduleName,
this.moduleVersion,
this.invocationId,
this.flowExecution,
this.durationMs, this.durationMs,
this.error, this.error,
this.detail,
}); });
} }
@ -412,3 +497,59 @@ class PendingApproval {
this.expiresAt, this.expiresAt,
}); });
} }
class ChannelInfo {
final String name;
final int port;
final bool running;
final String endpoint;
const ChannelInfo({
required this.name,
required this.port,
required this.running,
required this.endpoint,
});
}
class ChannelStatusSnapshot {
final String active;
final List<ChannelInfo> channels;
const ChannelStatusSnapshot({
required this.active,
required this.channels,
});
}
/// One row from the store-index search.
class StoreItem {
final String name;
final String taglineEn;
final String descriptionEn;
final String category;
final List<String> tags;
final List<String> requiresCapabilities;
final List<String> requiresServices;
final String license;
final String repository;
final String bestVersion;
/// "published", "alpha", "planned", or empty when unknown.
final String status;
final bool installed;
const StoreItem({
required this.name,
required this.taglineEn,
required this.descriptionEn,
required this.category,
required this.tags,
required this.requiresCapabilities,
required this.requiresServices,
required this.license,
required this.repository,
required this.bestVersion,
required this.status,
required this.installed,
});
}

View file

@ -15,6 +15,7 @@ import 'pages/audit.dart';
import 'pages/doctor.dart'; import 'pages/doctor.dart';
import 'pages/flows.dart'; import 'pages/flows.dart';
import 'pages/modules.dart'; import 'pages/modules.dart';
import 'pages/store.dart';
import 'theme/theme.dart'; import 'theme/theme.dart';
import 'theme/tokens.dart'; import 'theme/tokens.dart';
import 'widgets/widgets.dart'; import 'widgets/widgets.dart';
@ -22,7 +23,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.9.0'; const String kStudioVersion = '0.10.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -124,6 +125,12 @@ class _StudioShellState extends State<StudioShell> {
selectedIcon: Icons.extension, selectedIcon: Icons.extension,
page: ModulesPage(), page: ModulesPage(),
), ),
_NavPage(
label: 'Store',
icon: Icons.storefront_outlined,
selectedIcon: Icons.storefront,
page: StorePage(),
),
_NavPage( _NavPage(
label: 'Flows', label: 'Flows',
icon: Icons.account_tree_outlined, icon: Icons.account_tree_outlined,
@ -171,7 +178,7 @@ class _StudioShellState extends State<StudioShell> {
final theme = Theme.of(context); final theme = Theme.of(context);
return Shortcuts( return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{ shortcuts: <ShortcutActivator, Intent>{
// Cmd+1..5 jumps to the matching destination. Numbered // Cmd+1..6 jumps to the matching destination. Numbered
// 1-based to match the visual order in the sidebar. // 1-based to match the visual order in the sidebar.
for (var i = 0; i < _pages.length; i++) for (var i = 0; i < _pages.length; i++)
SingleActivator( SingleActivator(

View file

@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/hub.dart'; import '../data/hub.dart';
@ -14,9 +17,17 @@ class ApprovalsPage extends StatefulWidget {
class _ApprovalsPageState extends State<ApprovalsPage> { class _ApprovalsPageState extends State<ApprovalsPage> {
late Future<List<PendingApproval>> _future; late Future<List<PendingApproval>> _future;
// Hard-coded reviewer for the MVP; Phase 1+ wires this to an // Reviewer identity recorded in the audit log. Defaults to
// authenticated session. // the OS user (closest stable identity Studio has without an
final String _reviewer = 'studio-mvp'; // 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 @override
void initState() { void initState() {
@ -199,6 +210,17 @@ class _ApprovalCard extends StatelessWidget {
), ),
if (approval.showPreview != null) ...[ if (approval.showPreview != null) ...[
const SizedBox(height: FaiSpace.md), 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( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.md), padding: const EdgeInsets.all(FaiSpace.md),
@ -207,8 +229,8 @@ class _ApprovalCard extends StatelessWidget {
borderRadius: BorderRadius.circular(FaiRadius.sm), borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(color: theme.colorScheme.outlineVariant),
), ),
child: Text( child: SelectableText(
approval.showPreview!, _prettyPreview(approval.showPreview!),
style: FaiTheme.mono( style: FaiTheme.mono(
size: 11, size: 11,
color: theme.colorScheme.onSurface, 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) { String _expiresIn(DateTime t) {
final remaining = t.difference(DateTime.now()); final remaining = t.difference(DateTime.now());
if (remaining.isNegative) return 'expired'; if (remaining.isNegative) return 'expired';

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -111,6 +112,10 @@ class _AuditPageState extends State<AuditPage> {
trailing: e.durationMs != null trailing: e.durationMs != null
? '${e.durationMs}ms' ? '${e.durationMs}ms'
: null, : 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'),
),
],
);
}
}

View file

@ -31,6 +31,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
bool _secure = false; bool _secure = false;
bool _saving = false; bool _saving = false;
String? _error; String? _error;
ChannelStatusSnapshot? _channels;
@override @override
void initState() { void initState() {
@ -39,6 +40,28 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
_host = TextEditingController(text: ep.host); _host = TextEditingController(text: ep.host);
_port = TextEditingController(text: ep.port.toString()); _port = TextEditingController(text: ep.port.toString());
_secure = ep.secure; _secure = ep.secure;
_loadChannels();
}
Future<void> _loadChannels() async {
try {
final snap = await HubService.instance.channelStatus();
if (!mounted) return;
setState(() => _channels = snap);
} catch (_) {
// Silent: channels block stays hidden when the hub is
// unreachable (the endpoint section is the operator's
// path back to a working connection).
}
}
Future<void> _connectToChannel(ChannelInfo ch) async {
setState(() {
_host.text = '127.0.0.1';
_port.text = ch.port.toString();
_secure = false;
});
await _save();
} }
@override @override
@ -180,6 +203,24 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
], ],
), ),
), ),
if (_channels != null) ...[
const SizedBox(height: FaiSpace.lg),
Text(
'CHANNELS',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 4),
for (final ch in _channels!.channels)
_ChannelRow(
channel: ch,
active: ch.name == _channels!.active,
onConnect: _saving ? null : () => _connectToChannel(ch),
),
],
], ],
), ),
), ),
@ -210,3 +251,78 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
return '$scheme://$host:$port'; return '$scheme://$host:$port';
} }
} }
class _ChannelRow extends StatelessWidget {
final ChannelInfo channel;
final bool active;
final VoidCallback? onConnect;
const _ChannelRow({
required this.channel,
required this.active,
required this.onConnect,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(
channel.running ? Icons.circle : Icons.circle_outlined,
size: 10,
color: channel.running
? FaiColors.success
: theme.colorScheme.outline,
),
const SizedBox(width: FaiSpace.sm),
SizedBox(
width: 88,
child: Text(
channel.name,
style: FaiTheme.mono(
size: 11,
weight: active ? FontWeight.w600 : FontWeight.w400,
color: theme.colorScheme.onSurface,
),
),
),
SizedBox(
width: 64,
child: Text(
':${channel.port}',
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
channel.running ? 'running' : 'stopped',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (active)
Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm),
child: Text(
'active',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
),
TextButton(
onPressed: channel.running ? onConnect : null,
child: const Text('Connect'),
),
],
),
);
}
}

View file

@ -79,7 +79,7 @@ packages:
path: "../fai_dart_sdk" path: "../fai_dart_sdk"
relative: true relative: true
source: path source: path
version: "0.2.0" version: "0.3.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.9.0 version: 0.10.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta

View file

@ -1,12 +1,11 @@
// Smoke test: app boots without crashing and shows the four // Smoke test: app boots and shows every navigation destination.
// MVP destinations in the sidebar.
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:fai_studio/data/hub.dart'; import 'package:fai_studio/data/hub.dart';
import 'package:fai_studio/main.dart'; import 'package:fai_studio/main.dart';
void main() { void main() {
testWidgets('Studio shell shows the five destinations', testWidgets('Studio shell shows every navigation destination',
(tester) async { (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
const StudioApp(initialThemeMode: ThemeModeValue.system), const StudioApp(initialThemeMode: ThemeModeValue.system),
@ -15,6 +14,7 @@ void main() {
expect(find.text('F∆I Studio'), findsOneWidget); expect(find.text('F∆I Studio'), findsOneWidget);
expect(find.text('Doctor'), findsWidgets); expect(find.text('Doctor'), findsWidgets);
expect(find.text('Modules'), findsWidgets); expect(find.text('Modules'), findsWidgets);
expect(find.text('Store'), findsOneWidget);
expect(find.text('Flows'), findsOneWidget); expect(find.text('Flows'), findsOneWidget);
expect(find.text('Audit'), findsOneWidget); expect(find.text('Audit'), findsOneWidget);
expect(find.text('Approvals'), findsOneWidget); expect(find.text('Approvals'), findsOneWidget);