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
|
|
@ -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.
|
||||
Future<List<SavedFlow>> listFlows() async {
|
||||
final flows = await _client.listFlows();
|
||||
|
|
@ -163,8 +231,15 @@ class HubService {
|
|||
flowName: e.flowName.isEmpty ? null : e.flowName,
|
||||
stepId: e.stepId.isEmpty ? null : e.stepId,
|
||||
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(),
|
||||
error: e.error.isEmpty ? null : e.error,
|
||||
detail: e.detail.isEmpty ? null : e.detail,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
|
@ -378,8 +453,14 @@ class AuditEvent {
|
|||
final String? flowName;
|
||||
final String? stepId;
|
||||
final String? moduleName;
|
||||
final String? moduleVersion;
|
||||
final String? invocationId;
|
||||
final String? flowExecution;
|
||||
final int? durationMs;
|
||||
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({
|
||||
required this.eventId,
|
||||
|
|
@ -388,8 +469,12 @@ class AuditEvent {
|
|||
this.flowName,
|
||||
this.stepId,
|
||||
this.moduleName,
|
||||
this.moduleVersion,
|
||||
this.invocationId,
|
||||
this.flowExecution,
|
||||
this.durationMs,
|
||||
this.error,
|
||||
this.detail,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -412,3 +497,59 @@ class PendingApproval {
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import 'pages/audit.dart';
|
|||
import 'pages/doctor.dart';
|
||||
import 'pages/flows.dart';
|
||||
import 'pages/modules.dart';
|
||||
import 'pages/store.dart';
|
||||
import 'theme/theme.dart';
|
||||
import 'theme/tokens.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
|
||||
/// running app self-identifies — visible in the sidebar header
|
||||
/// 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 {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
@ -124,6 +125,12 @@ class _StudioShellState extends State<StudioShell> {
|
|||
selectedIcon: Icons.extension,
|
||||
page: ModulesPage(),
|
||||
),
|
||||
_NavPage(
|
||||
label: 'Store',
|
||||
icon: Icons.storefront_outlined,
|
||||
selectedIcon: Icons.storefront,
|
||||
page: StorePage(),
|
||||
),
|
||||
_NavPage(
|
||||
label: 'Flows',
|
||||
icon: Icons.account_tree_outlined,
|
||||
|
|
@ -171,7 +178,7 @@ class _StudioShellState extends State<StudioShell> {
|
|||
final theme = Theme.of(context);
|
||||
return Shortcuts(
|
||||
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.
|
||||
for (var i = 0; i < _pages.length; i++)
|
||||
SingleActivator(
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
bool _secure = false;
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
ChannelStatusSnapshot? _channels;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -39,6 +40,28 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
_host = TextEditingController(text: ep.host);
|
||||
_port = TextEditingController(text: ep.port.toString());
|
||||
_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
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue