fix(studio): live-search exception, daemon-card width, featured tile size; add Start-hub button (v0.20.1)

Three reported bugs and one onramp gap:

- Store live-search threw `setState() callback returned a
  Future`. The `=>` body of `_runSearch` evaluated to the
  Future returned by `_load()`. Switched to a block body so
  the closure resolves to void; the future itself is still
  awaited by the FutureBuilder.

- Doctor's Daemon-control card was visibly narrower than its
  siblings — its only stretching child was a Wrap of small
  buttons. Wrapped the content in `SizedBox(width: infinity)`
  so the card fills the section's width like every other
  panel. Card now leads with the live `running on local:
  127.0.0.1:50051` status row + status pill so operators see
  state at a glance, then offers Restart / Start (when
  stopped) / Stop / Status.

- Featured tiles were 320×152 against the regular grid's
  ~360×168, so the editorial picks looked subordinate to
  the dense grid. Bumped to 480×220 with bigger icon, larger
  title, larger tagline so the strip reads as a hero band.

- ConnectionPill in the sidebar now offers an inline "Start
  hub" button when the daemon is unreachable. Spawns
  `fai daemon start` via SystemActions; the shell-level
  health poll picks the daemon up on its next tick. Closes
  the chicken-and-egg for Windows users who can't open a
  shell to bootstrap.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 21:27:09 +02:00
parent 26691b7471
commit fb7352d182
4 changed files with 256 additions and 96 deletions

View file

@ -424,6 +424,23 @@ class _DaemonActionsCard extends StatefulWidget {
class _DaemonActionsCardState extends State<_DaemonActionsCard> {
bool _busy = false;
String? _output;
ChannelStatusSnapshot? _channels;
@override
void initState() {
super.initState();
_refreshStatus();
}
Future<void> _refreshStatus() async {
try {
final snap = await HubService.instance.channelStatus();
if (!mounted) return;
setState(() => _channels = snap);
} catch (_) {
// Best-effort; the status pill simply renders "unknown".
}
}
Future<void> _run(
String label,
@ -442,94 +459,168 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
: 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
.trim();
});
// Refresh the status pill restart / stop / start all
// change the running flag.
await _refreshStatus();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
ChannelInfo? active;
if (_channels != null) {
for (final c in _channels!.channels) {
if (c.name == _channels!.active) {
active = c;
break;
}
}
}
return FaiCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Spawn the platform binary to manage the running daemon. '
'Mirrors the CLI: `fai daemon …`.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
Wrap(
spacing: FaiSpace.sm,
runSpacing: FaiSpace.sm,
children: [
FilledButton.tonalIcon(
onPressed: _busy
? null
: () =>
_run('daemon restart', () => SystemActions.faiDaemon(['restart'])),
icon: const Icon(Icons.restart_alt, size: 16),
label: const Text('Restart daemon'),
),
OutlinedButton.icon(
onPressed: _busy
? null
: () =>
_run('daemon stop', () => SystemActions.faiDaemon(['stop'])),
icon: const Icon(Icons.stop_circle_outlined, size: 16),
label: const Text('Stop daemon'),
),
OutlinedButton.icon(
onPressed: _busy
? null
: () => _run(
'daemon status',
() => SystemActions.faiDaemon(['status']),
),
icon: const Icon(Icons.health_and_safety_outlined, size: 16),
label: const Text('Status'),
),
],
),
if (_busy) ...[
const SizedBox(height: FaiSpace.md),
// Row + Expanded forces the card to take the full width
// its parent offers. Without this, the card hugs the
// intrinsic width of the longest button row and ends up
// visibly narrower than its siblings.
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
Icon(
active?.running == true
? Icons.check_circle_outline
: Icons.power_off_outlined,
size: 18,
color: active?.running == true
? FaiColors.success
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
Text(
'Working…',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
Expanded(
child: Text(
active == null
? 'Daemon status: …'
: active.running
? 'Running on ${active.name}: ${active.endpoint}'
: '${active.name} daemon stopped',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (active != null) ...[
const SizedBox(width: FaiSpace.sm),
Tooltip(
message: 'PID file at the channel\'s run/<name>.pid',
child: FaiPill(
label: active.running ? 'running' : 'stopped',
tone: active.running
? FaiPillTone.success
: FaiPillTone.neutral,
),
),
],
],
),
const SizedBox(height: FaiSpace.sm),
Text(
'Studio shells out to the platform binary — mirrors '
'`fai daemon …` exactly so the CLI and UI stay in lockstep.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
Wrap(
spacing: FaiSpace.sm,
runSpacing: FaiSpace.sm,
children: [
FilledButton.tonalIcon(
onPressed: _busy
? null
: () => _run(
'daemon restart',
() => SystemActions.faiDaemon(['restart']),
),
icon: const Icon(Icons.restart_alt, size: 16),
label: const Text('Restart'),
),
if (active?.running != true)
FilledButton.icon(
onPressed: _busy
? null
: () => _run(
'daemon start',
() => SystemActions.faiDaemon(['start']),
),
icon: const Icon(Icons.play_arrow, size: 16),
label: const Text('Start'),
),
OutlinedButton.icon(
onPressed: _busy
? null
: () => _run(
'daemon stop',
() => SystemActions.faiDaemon(['stop']),
),
icon: const Icon(Icons.stop_circle_outlined, size: 16),
label: const Text('Stop'),
),
OutlinedButton.icon(
onPressed: _busy
? null
: () => _run(
'daemon status',
() => SystemActions.faiDaemon(['status']),
),
icon: const Icon(Icons.health_and_safety_outlined, size: 16),
label: const Text('Status'),
),
],
),
],
if (_output != null) ...[
const SizedBox(height: FaiSpace.md),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
if (_busy) ...[
const SizedBox(height: FaiSpace.md),
Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: FaiSpace.sm),
Text(
'Working…',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
child: SelectableText(
_output!,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
],
if (_output != null) ...[
const SizedBox(height: FaiSpace.md),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
_output!,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
),
],
],
],
),
),
);
}