chain-studio/lib/widgets/fai_settings_dialog.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

328 lines
9.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// FaiSettingsDialog — modal for changing the hub endpoint.
// Reads current values from HubService, persists on save via
// HubService.reconnect.
import 'package:fai_dart_sdk/fai_dart_sdk.dart';
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
class FaiSettingsDialog extends StatefulWidget {
const FaiSettingsDialog({super.key});
/// Convenience launcher used from the sidebar gear icon.
static Future<bool> show(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => const FaiSettingsDialog(),
);
return ok ?? false;
}
@override
State<FaiSettingsDialog> createState() => _FaiSettingsDialogState();
}
class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
late final TextEditingController _host;
late final TextEditingController _port;
bool _secure = false;
bool _saving = false;
String? _error;
ChannelStatusSnapshot? _channels;
@override
void initState() {
super.initState();
final ep = HubService.instance.currentEndpoint;
_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
void dispose() {
_host.dispose();
_port.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() {
_saving = true;
_error = null;
});
final port = int.tryParse(_port.text.trim());
if (port == null || port <= 0 || port > 65535) {
setState(() {
_saving = false;
_error = 'port must be 165535';
});
return;
}
final endpoint = HubEndpoint(
host: _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(),
port: port,
secure: _secure,
);
try {
await HubService.instance.reconnect(endpoint);
if (!mounted) return;
Navigator.pop(context, true);
} catch (e) {
setState(() {
_saving = false;
_error = e.toString();
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: const Text('Hub endpoint'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: FaiSpace.xl,
vertical: FaiSpace.lg,
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Where should Studio connect? Default is the local hub at 127.0.0.1:50051.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.lg),
TextField(
controller: _host,
decoration: const InputDecoration(
labelText: 'Host',
border: OutlineInputBorder(),
isDense: true,
),
autofocus: true,
),
const SizedBox(height: FaiSpace.md),
Row(
children: [
Expanded(
flex: 2,
child: TextField(
controller: _port,
decoration: const InputDecoration(
labelText: 'Port',
border: OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: FaiSpace.md),
Expanded(
flex: 3,
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: const Text('TLS'),
subtitle: Text(
'https/grpc-secure',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
value: _secure,
onChanged: (v) => setState(() => _secure = v),
),
),
],
),
if (_error != null) ...[
const SizedBox(height: FaiSpace.md),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
const SizedBox(height: FaiSpace.md),
Container(
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
child: Row(
children: [
Icon(
Icons.link,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
Text(
_previewUrl(),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
],
),
),
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),
),
],
],
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save & connect'),
),
],
);
}
String _previewUrl() {
final scheme = _secure ? 'https' : 'http';
final host =
_host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim();
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'),
),
],
),
);
}
}