chain-studio/lib/pages/doctor.dart
flemming-it 14f9217007 feat(studio): show what a running update is doing, not just that it runs
Applying an update downloads and swaps a binary and takes minutes; the
doctor showed a spinner inside a disabled button for the whole time.
SystemActions gained a streaming CLI runner that hands each line to the
caller as it arrives, with CHAIN_PLAIN=1 set for the child so the CLI
emits one line per transition instead of its redraw-in-place block.

The update card now shows an indeterminate bar labelled with the
running step and an elapsed counter on its own timer, so the counter
keeps moving between lines that can be minutes apart. No invented
percentage: the CLI reports steps, not a measurable total.

The streaming path still defers to debugRunFaiOverride, so tests stay
hermetic and never spawn a process; three guards pin that.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-09-08 17:41:21 +02:00

1534 lines
51 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../data/error_presentation.dart';
import '../data/chain_log.dart';
import '../data/hub.dart';
import '../data/hub_auth_token.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
import '../main.dart' show StudioShellState;
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
import 'welcome.dart' show showFaiDoc;
class DoctorPage extends StatefulWidget {
const DoctorPage({super.key});
@override
State<DoctorPage> createState() => _DoctorPageState();
}
class _DoctorPageState extends State<DoctorPage> {
late Future<DoctorSnapshot> _future;
@override
void initState() {
super.initState();
_future = HubService.instance.doctor();
}
void _refresh() => setState(() {
_future = HubService.instance.doctor();
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.doctorTitle),
actions: [
IconButton(
icon: const Icon(Icons.help_outline, size: 18),
tooltip: AppLocalizations.of(context)!.helpTooltip,
onPressed: () => showFaiDoc(context, 'architecture'),
),
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: AppLocalizations.of(context)!.doctorRecheckTooltip,
onPressed: _refresh,
),
const SizedBox(width: ChainSpace.sm),
],
),
body: FutureBuilder<DoctorSnapshot>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return HubLoadErrorView(error: snapshot.error!, onRetry: _refresh);
}
final s = snapshot.data!;
final showUpdate =
s.update.updateAvailable ||
(!s.update.manifestReachable && s.update.localVersion.isNotEmpty);
// Findings that have a dedicated page link straight to it —
// "approvals waiting for review" must be one tap away from
// the approvals inbox, not a dead-end statement.
final shell = StudioShellState.of(context);
final openStore = shell == null
? null
: () => shell.navigateTo('store');
final openApprovals = shell == null
? null
: () => shell.navigateTo('approvals');
final openAudit = shell == null
? null
: () => shell.navigateTo('audit');
return ListView(
padding: const EdgeInsets.all(ChainSpace.xl),
children: [
if (showUpdate) _UpdateBanner(status: s.update),
if (showUpdate) const SizedBox(height: ChainSpace.lg),
_SummaryStrip(
snapshot: s,
onOpenStore: openStore,
onOpenApprovals: openApprovals,
onOpenAudit: openAudit,
),
const SizedBox(height: ChainSpace.xl),
_Section(
title: AppLocalizations.of(context)!.doctorEventLogSection,
child: _EventLogPanel(
snapshot: s,
onRefresh: _refresh,
onOpenAudit: openAudit,
),
),
const SizedBox(height: ChainSpace.lg),
_Section(
title: AppLocalizations.of(
context,
)!.doctorModulesApprovalsSection,
child: DoctorModulesPanel(
snapshot: s,
onOpenStore: openStore,
onOpenApprovals: openApprovals,
),
),
const SizedBox(height: ChainSpace.lg),
_Section(
title: AppLocalizations.of(context)!.doctorHostServicesSection,
child: _ServicesPanel(snapshot: s),
),
const SizedBox(height: ChainSpace.lg),
_Section(
title: AppLocalizations.of(context)!.doctorDaemonFilesSection,
child: _DaemonPathsPanel(paths: s.paths),
),
const SizedBox(height: ChainSpace.lg),
_Section(
title: AppLocalizations.of(context)!.doctorDaemonControlSection,
child: _DaemonActionsCard(),
),
],
);
},
),
);
}
}
class _Section extends StatelessWidget {
final String title;
final Widget child;
const _Section({required this.title, required this.child});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: ChainSpace.xs,
bottom: ChainSpace.sm,
),
child: Text(
title.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
),
child,
],
);
}
}
class _SummaryStrip extends StatelessWidget {
final DoctorSnapshot snapshot;
final VoidCallback? onOpenStore;
final VoidCallback? onOpenApprovals;
final VoidCallback? onOpenAudit;
const _SummaryStrip({
required this.snapshot,
this.onOpenStore,
this.onOpenApprovals,
this.onOpenAudit,
});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Row(
children: [
DoctorStatTile(
label: l.doctorSummaryModules,
value: snapshot.moduleCount.toString(),
subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount),
icon: Icons.extension_outlined,
onTap: onOpenStore,
linkLabel: l.doctorLinkStore,
),
const SizedBox(width: ChainSpace.md),
DoctorStatTile(
label: l.doctorSummaryApprovals,
value: snapshot.pendingApprovals.toString(),
subtitle: l.doctorSummaryPending,
icon: Icons.inbox_outlined,
tone: snapshot.pendingApprovals > 0
? ChainPillTone.warning
: ChainPillTone.neutral,
onTap: onOpenApprovals,
linkLabel: l.doctorLinkApprovals,
),
const SizedBox(width: ChainSpace.md),
DoctorStatTile(
label: l.doctorSummaryAudit,
value: snapshot.chainHealthy ? '' : '',
subtitle: l.doctorSummaryChain(
snapshot.eventChainVerified,
snapshot.eventChainTotal,
),
icon: Icons.shield_outlined,
tone: snapshot.chainHealthy
? ChainPillTone.success
: ChainPillTone.danger,
onTap: onOpenAudit,
linkLabel: l.doctorLinkAudit,
),
const SizedBox(width: ChainSpace.md),
DoctorStatTile(
label: l.doctorSummaryServices,
value: snapshot.services.length.toString(),
subtitle: l.doctorSummaryDeclared,
icon: Icons.dns_outlined,
),
],
);
}
}
/// One summary tile of the doctor page. Public + callback-driven so
/// the deep-link widget test can pump it directly: when [onTap] is
/// set, the whole tile becomes a button (chevron affordance,
/// tooltip + semantics from [linkLabel]) that jumps to the page
/// where the finding can be acted on.
class DoctorStatTile extends StatelessWidget {
final String label;
final String value;
final String subtitle;
final IconData icon;
final ChainPillTone tone;
final VoidCallback? onTap;
/// Human-readable tap target ("Open approvals"). Required when
/// [onTap] is set; doubles as tooltip and semantics label.
final String? linkLabel;
const DoctorStatTile({
super.key,
required this.label,
required this.value,
required this.subtitle,
required this.icon,
this.tone = ChainPillTone.neutral,
this.onTap,
this.linkLabel,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accentColor = switch (tone) {
ChainPillTone.success => ChainColors.success,
ChainPillTone.warning => ChainColors.warning,
ChainPillTone.danger => theme.colorScheme.error,
_ => theme.colorScheme.primary,
};
Widget card = ChainCard(
accentLeft: accentColor,
padding: const EdgeInsets.all(ChainSpace.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: ChainSpace.xs),
Flexible(
child: Text(
label.toUpperCase(),
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
fontSize: 10,
),
),
),
if (onTap != null) ...[
const Spacer(),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
const SizedBox(height: ChainSpace.sm),
Text(
value,
style: theme.textTheme.displaySmall?.copyWith(
color: accentColor,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
if (onTap != null) {
// ChainCard is a plain Container, so the InkWell needs its own
// transparent Material to paint the hover/ripple feedback.
card = Tooltip(
message: linkLabel ?? '',
waitDuration: const Duration(milliseconds: 400),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(ChainRadius.md),
child: Semantics(button: true, label: linkLabel, child: card),
),
),
);
}
return Expanded(child: card);
}
}
class _EventLogPanel extends StatelessWidget {
final DoctorSnapshot snapshot;
/// Re-runs the doctor() call which re-verifies the chain.
/// Wired to a "Verify now" button so operators can re-check
/// after import / restore without restarting the daemon.
final VoidCallback onRefresh;
/// Jumps to the audit page — the finding's detail view (per-event
/// list, forensic exports) lives there.
final VoidCallback? onOpenAudit;
const _EventLogPanel({
required this.snapshot,
required this.onRefresh,
this.onOpenAudit,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final healthy = snapshot.chainHealthy;
final l = AppLocalizations.of(context)!;
Widget headline = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
healthy ? l.doctorChainIntact : l.doctorChainTampered,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
healthy
? l.doctorChainIntactDetail(snapshot.eventChainTotal)
: l.doctorChainTamperedDetail(
snapshot.eventChainVerified,
snapshot.eventChainTotal,
snapshot.eventChainTamperedAt ?? '',
),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
if (onOpenAudit != null) {
headline = Tooltip(
message: l.doctorLinkAudit,
waitDuration: const Duration(milliseconds: 400),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onOpenAudit,
borderRadius: BorderRadius.circular(ChainRadius.sm),
child: Semantics(
button: true,
label: l.doctorLinkAudit,
child: headline,
),
),
),
);
}
return ChainCard(
child: Row(
children: [
Icon(
healthy ? Icons.verified_outlined : Icons.gpp_bad_outlined,
size: 24,
color: healthy ? ChainColors.success : theme.colorScheme.error,
),
const SizedBox(width: ChainSpace.lg),
Expanded(child: headline),
OutlinedButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.fact_check_outlined, size: 16),
label: Text(l.doctorVerifyNow),
),
const SizedBox(width: ChainSpace.sm),
ChainPill(
label: healthy ? l.doctorChainPillOk : l.doctorChainPillTamper,
tone: healthy ? ChainPillTone.success : ChainPillTone.danger,
),
],
),
);
}
}
/// True when [path] points into an OS-managed temporary
/// directory (macOS `/var/folders`, Unix `/tmp`, the Windows
/// user/system temp folders) — locations the OS may clean up
/// at any time. The doctor page warns when the audit DB lives
/// in one: an evidence log the OS can silently delete is not
/// evidence. Top-level so the unit test can exercise the
/// classification directly.
bool isVolatilePath(String path) {
if (path.isEmpty) return false;
final p = path.toLowerCase().replaceAll('\\', '/');
return p.startsWith('/tmp/') ||
p.startsWith('/private/tmp/') ||
p.startsWith('/var/folders/') ||
p.startsWith('/private/var/folders/') ||
p.contains('/appdata/local/temp/') ||
p.contains('/windows/temp/');
}
/// Lists the daemon's filesystem paths with one "Open" button
/// per row. Windows operators who never touch a shell still
/// need a way to inspect the audit DB or edit the operator
/// config; this panel is that escape hatch.
class _DaemonPathsPanel extends StatelessWidget {
final DaemonPathsSnapshot paths;
const _DaemonPathsPanel({required this.paths});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
// Per-row layout: (label, path, icon, isDirectory). The
// isDirectory bit drives `openOrReveal` so binary files
// (the SQLite audit DB, the PID file) reveal in Finder /
// Explorer instead of failing the OS "open" handler.
final entries = <(String, String, IconData, bool)>[
(l.doctorPathLog, paths.logPath, Icons.description_outlined, false),
(
l.doctorPathStudioErrors,
ChainLog.instance.path,
Icons.report_problem_outlined,
false,
),
(l.doctorPathConfig, paths.configPath, Icons.settings_outlined, false),
(l.doctorPathDb, paths.dbPath, Icons.storage_outlined, false),
(l.doctorPathModules, paths.modulesDir, Icons.extension_outlined, true),
(l.doctorPathFlows, paths.flowsDir, Icons.account_tree_outlined, true),
(l.doctorPathPid, paths.pidPath, Icons.fingerprint, false),
].where((e) => e.$2.isNotEmpty).toList();
if (entries.isEmpty) {
return ChainCard(
child: Text(
l.doctorPathsEmpty,
style: Theme.of(context).textTheme.bodySmall,
),
);
}
final theme = Theme.of(context);
return ChainCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isVolatilePath(paths.dbPath)) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.warning_amber_outlined,
size: 16,
color: ChainColors.warning,
),
const SizedBox(width: ChainSpace.sm),
Expanded(
child: Text(
l.doctorDbVolatileWarning,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: ChainSpace.sm),
],
for (final e in entries)
_PathRow(label: e.$1, path: e.$2, icon: e.$3, isDirectory: e.$4),
],
),
);
}
}
class _PathRow extends StatelessWidget {
final String label;
final String path;
final IconData icon;
final bool isDirectory;
const _PathRow({
required this.label,
required this.path,
required this.icon,
required this.isDirectory,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final lower = path.toLowerCase();
final isLog = !isDirectory && lower.endsWith('.log');
// Text config files get an in-Studio viewer too (read top-down,
// no log colouring) so the operator can read config.yaml
// without leaving Studio or hunting for an external editor.
final isConfig =
!isDirectory &&
(lower.endsWith('.yaml') ||
lower.endsWith('.yml') ||
lower.endsWith('.toml'));
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: ChainSpace.sm),
SizedBox(
width: 90,
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: SelectableText(
path,
style: ChainTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
const SizedBox(width: ChainSpace.sm),
IconButton(
icon: const Icon(Icons.copy_outlined, size: 14),
tooltip: l.buttonCopy,
visualDensity: VisualDensity.compact,
onPressed: () async {
await Clipboard.setData(ClipboardData(text: path));
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.aboutCopiedToast)));
},
),
const SizedBox(width: ChainSpace.xs),
if (isLog || isConfig) ...[
OutlinedButton.icon(
onPressed: () => isConfig
? showFaiConfigViewer(context, path: path, title: label)
: showFaiLogViewer(context, path: path, title: label),
icon: const Icon(Icons.visibility_outlined, size: 14),
label: Text(l.buttonView),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: ChainSpace.xs),
],
OutlinedButton.icon(
onPressed: () async {
final r = await SystemActions.openOrReveal(
path,
isDirectory: isDirectory,
);
if (!context.mounted) return;
if (!r.ok) {
showChainErrorSnack(
context,
'doctor.open-path',
r.stderr.isEmpty ? 'open failed' : r.stderr,
);
}
},
icon: Icon(
isDirectory ? Icons.folder_open : Icons.open_in_new,
size: 14,
),
label: Text(l.buttonOpen),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
);
}
}
/// Daemon control affordances: Restart, Apply update, Stop.
/// Each shells out to the `fai` binary (resolved via PATH or
/// `~/.chain/bin/chain`). Studio is the GUI shell; the binary owns
/// the supervisor logic so the daemon and CLI stay in lockstep.
class _DaemonActionsCard extends StatefulWidget {
@override
State<_DaemonActionsCard> createState() => _DaemonActionsCardState();
}
class _DaemonActionsCardState extends State<_DaemonActionsCard> {
bool _busy = false;
String? _output;
/// True when the last daemon action failed because no `fai`
/// binary could be located. Swaps the raw output line for the
/// actionable [ChainBinaryRecovery] block.
bool _binaryMissing = false;
ChannelStatusSnapshot? _channels;
/// Trimmed length of `~/.chain/hub-auth-token`, or null when
/// Studio talks to the hub anonymously. Length only — the
/// secret itself never reaches the UI.
int? _tokenChars;
@override
void initState() {
super.initState();
_refreshStatus();
HubAuthToken.charCount()
.then((n) {
if (mounted) setState(() => _tokenChars = n);
})
.catchError((_) {});
}
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".
}
}
String _connectionLine(AppLocalizations l) {
final ep = HubService.instance.currentEndpoint;
final isLocal =
ep.host == '127.0.0.1' || ep.host == 'localhost' || ep.host == '::1';
final transport = ep.secure
? l.doctorConnTls
: isLocal
? l.doctorConnPlainLocal
: l.doctorConnPlainRemote;
final auth = _tokenChars == null
? l.doctorConnAnonymous
: l.doctorConnToken(_tokenChars!);
return l.doctorConnLine(ep.toString(), transport, auth);
}
Future<void> _run(
String label,
Future<({bool ok, String stdout, String stderr})> Function() action,
) async {
setState(() {
_busy = true;
_output = null;
});
final r = await action();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
final binaryMissing = !r.ok && r.stderr.trim() == kFaiBinaryNotFound;
setState(() {
_busy = false;
_binaryMissing = binaryMissing;
// When the binary is missing we render the actionable
// recovery block instead of a raw output line — the
// sentinel is not a user-facing string.
_output = binaryMissing
? null
: (r.ok
? '${l.daemonActionResultOk(label)}\n${r.stdout}'
: '${l.daemonActionResultFailed(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);
final l = AppLocalizations.of(context)!;
ChannelInfo? active;
if (_channels != null) {
for (final c in _channels!.channels) {
if (c.name == _channels!.active) {
active = c;
break;
}
}
}
return ChainCard(
// 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: [
Icon(
active?.running == true
? Icons.check_circle_outline
: Icons.power_off_outlined,
size: 18,
color: active?.running == true
? ChainColors.success
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Expanded(
child: Text(
active == null
? l.doctorStatusUnknown
: active.running
? l.doctorRunningOn(active.name, active.endpoint)
: l.doctorStoppedOn(active.name),
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (active != null) ...[
const SizedBox(width: ChainSpace.sm),
ChainPill(
label: active.running
? l.doctorPillRunning
: l.doctorPillStopped,
tone: active.running
? ChainPillTone.success
: ChainPillTone.neutral,
),
],
],
),
const SizedBox(height: ChainSpace.sm),
Text(
l.doctorDaemonControlHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
// Who-talks-to-whom-how in one line: endpoint,
// transport security, and whether a bearer token is
// attached — the auditor's baseline questions the
// green "connected" dot alone cannot answer.
SelectableText(
_connectionLine(l),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: ChainSpace.md),
Wrap(
spacing: ChainSpace.sm,
runSpacing: ChainSpace.sm,
children: [
FilledButton.tonalIcon(
onPressed: _busy
? null
: () => _run(
l.daemonActionRestart,
() => SystemActions.chainDaemon(['restart']),
),
icon: const Icon(Icons.restart_alt, size: 16),
label: Text(l.doctorRestart),
),
if (active?.running != true)
FilledButton.icon(
onPressed: _busy
? null
: () => _run(
l.daemonActionStart,
() => SystemActions.chainDaemon(['start']),
),
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(l.doctorStart),
),
OutlinedButton.icon(
onPressed: _busy
? null
: () => _run(
l.daemonActionStop,
() => SystemActions.chainDaemon(['stop']),
),
icon: const Icon(Icons.stop_circle_outlined, size: 16),
label: Text(l.doctorStop),
),
OutlinedButton.icon(
onPressed: _busy
? null
: () => _run(
l.daemonActionStatus,
() => SystemActions.chainDaemon(['status']),
),
icon: const Icon(Icons.health_and_safety_outlined, size: 16),
label: Text(l.doctorStatusAction),
),
],
),
if (_busy) ...[
const SizedBox(height: ChainSpace.md),
Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: ChainSpace.sm),
Text(
l.doctorDaemonControlWorking,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
if (_binaryMissing) ...[
const SizedBox(height: ChainSpace.md),
ChainBinaryRecovery(
onLocated: () => _run(
l.daemonActionStatus,
() => SystemActions.chainDaemon(['status']),
),
),
],
if (_output != null) ...[
const SizedBox(height: ChainSpace.md),
Container(
width: double.infinity,
padding: const EdgeInsets.all(ChainSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(ChainRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
_output!,
style: ChainTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
],
],
),
),
);
}
}
/// Format the per-source-kind capability count map as a
/// human-readable line like "6 Bundle · 3 MCP · 1 System".
/// Sorted alphabetically for deterministic rendering. The hub
/// reports lowercase wire identifiers; render them as display
/// names so no raw English tokens leak into the localized UI.
String _sourceBreakdown(Map<String, int> bySource) {
final entries = bySource.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
return entries
.map((e) => '${e.value} ${_sourceKindLabel(e.key)}')
.join(' · ');
}
String _sourceKindLabel(String kind) {
switch (kind) {
case 'mcp':
return 'MCP';
case 'bundle':
return 'Bundle';
case 'system':
return 'System';
default:
return kind.isEmpty ? kind : kind[0].toUpperCase() + kind.substring(1);
}
}
/// The modules + approvals findings card. Public + callback-driven
/// so the deep-link widget test can pump it without a hub: each row
/// that has a dedicated page is a tap target ("approvals waiting
/// for review" jumps to the approvals inbox, the modules line to
/// the store).
class DoctorModulesPanel extends StatelessWidget {
final DoctorSnapshot snapshot;
final VoidCallback? onOpenStore;
final VoidCallback? onOpenApprovals;
const DoctorModulesPanel({
super.key,
required this.snapshot,
this.onOpenStore,
this.onOpenApprovals,
});
Widget _linkRow(
BuildContext context, {
required Widget child,
required VoidCallback? onTap,
required String linkLabel,
}) {
if (onTap == null) return child;
return Tooltip(
message: linkLabel,
waitDuration: const Duration(milliseconds: 400),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(ChainRadius.sm),
child: Semantics(button: true, label: linkLabel, child: child),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return ChainCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_linkRow(
context,
onTap: onOpenStore,
linkLabel: l.doctorLinkStore,
child: Row(
children: [
Icon(
Icons.extension_outlined,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.doctorModulesPanelSummary(
snapshot.moduleCount,
snapshot.capabilityCount,
),
style: theme.textTheme.bodyMedium,
),
if (snapshot.capabilitiesBySource.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
_sourceBreakdown(snapshot.capabilitiesBySource),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
ChainPill(
label: snapshot.moduleCount > 0
? l.doctorPillLoaded
: l.doctorPillEmpty,
tone: snapshot.moduleCount > 0
? ChainPillTone.success
: ChainPillTone.neutral,
),
if (onOpenStore != null) ...[
const SizedBox(width: ChainSpace.xs),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
),
const Divider(height: ChainSpace.xl),
_linkRow(
context,
onTap: onOpenApprovals,
linkLabel: l.doctorLinkApprovals,
child: Row(
children: [
Icon(
Icons.inbox_outlined,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(
snapshot.pendingApprovals == 0
? l.doctorApprovalsNone
: l.doctorApprovalsCount(snapshot.pendingApprovals),
style: theme.textTheme.bodyMedium,
),
const Spacer(),
if (snapshot.pendingApprovals > 0)
ChainPill(
label: l.doctorApprovalsAttentionPill,
tone: ChainPillTone.warning,
),
if (onOpenApprovals != null) ...[
const SizedBox(width: ChainSpace.xs),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
),
],
),
);
}
}
class _ServicesPanel extends StatefulWidget {
final DoctorSnapshot snapshot;
const _ServicesPanel({required this.snapshot});
@override
State<_ServicesPanel> createState() => _ServicesPanelState();
}
class _ServicesPanelState extends State<_ServicesPanel> {
DoctorSnapshot get snapshot => widget.snapshot;
/// Best-effort system-AI probe for the Ollama suggestion; null
/// (no suggestion) on any failure.
SystemAiStatus? _ai;
/// True once the operator declared the suggested service this
/// session — the hub's services() stays the boot snapshot, so
/// the row flips to the honest "takes effect after restart"
/// note instead of re-suggesting.
bool _declared = false;
bool _declaring = false;
@override
void initState() {
super.initState();
HubService.instance
.systemAiStatus()
.then((st) {
if (mounted) setState(() => _ai = st);
})
.catchError((_) {});
}
Future<void> _declare(String endpoint) async {
final l = AppLocalizations.of(context)!;
setState(() => _declaring = true);
try {
await HubService.instance.declareService(
name: 'ollama',
endpoint: endpoint,
healthPath: '/',
tags: const ['llm'],
);
if (!mounted) return;
setState(() {
_declared = true;
_declaring = false;
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l.doctorOllamaDeclared)));
} catch (e) {
if (!mounted) return;
setState(() => _declaring = false);
showChainErrorSnack(context, 'doctor.declare-service', e);
}
}
/// The suggestion strip, or null when there is nothing to say.
Widget? _suggestionRow(AppLocalizations l) {
final ai = _ai;
if (ai == null || _declared) {
return _declared
? ChainInlineHelp(
icon: Icons.check_circle_outline,
text: l.doctorOllamaDeclared,
)
: null;
}
final endpoint = suggestOllamaServiceEndpoint(
ai: ai,
services: snapshot.services,
);
if (endpoint == null) return null;
return ChainInlineHelp(
icon: Icons.dns_outlined,
text: l.doctorOllamaSuggest(endpoint),
onLearnMore: _declaring ? null : () => _declare(endpoint),
learnMoreLabel: l.doctorOllamaDeclareButton,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final suggestion = _suggestionRow(l);
if (snapshot.services.isEmpty) {
// Wrap on narrow windows so the mono-spaced hint
// (`add to ~/.chain/config.yaml under services:`) does not
// overflow horizontally past the card's right edge.
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (suggestion != null) ...[
suggestion,
const SizedBox(height: ChainSpace.sm),
],
ChainCard(
child: Wrap(
spacing: ChainSpace.sm,
runSpacing: ChainSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.dns_outlined,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(
l.doctorServicesEmpty,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
Text(
l.doctorServicesEmptyHint,
style: ChainTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
// The hint names config.yaml — put the file one tap away
// instead of making the operator hunt for it.
if (snapshot.paths.configPath.isNotEmpty)
OutlinedButton.icon(
onPressed: () => showFaiConfigViewer(
context,
path: snapshot.paths.configPath,
title: l.doctorPathConfig,
),
icon: const Icon(Icons.settings_outlined, size: 14),
label: Text(l.doctorLinkConfig),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (suggestion != null) ...[
suggestion,
const SizedBox(height: ChainSpace.sm),
],
ChainCard(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var i = 0; i < snapshot.services.length; i++) ...[
if (i > 0)
Divider(height: 1, color: theme.colorScheme.outlineVariant),
Padding(
padding: const EdgeInsets.all(ChainSpace.lg),
child: Row(
children: [
Icon(
Icons.dns_outlined,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: ChainSpace.sm),
Text(
snapshot.services[i].name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: ChainSpace.md),
Text(
snapshot.services[i].endpoint,
style: ChainTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
_ServiceExposurePill(
exposure: snapshot.services[i].exposure,
),
for (final tag in snapshot.services[i].tags) ...[
ChainPill(label: tag, tone: ChainPillTone.neutral),
const SizedBox(width: ChainSpace.xs),
],
],
),
),
],
],
),
),
],
);
}
}
/// The system AI's Ollama endpoint as a host-service suggestion,
/// or null when there is nothing to suggest: AI disabled, another
/// provider, or a declared service already covers the same
/// host:port. Returns the base URL (the /v1 suffix the OpenAI-
/// compatible config carries is stripped — the service entry
/// describes the server, not one API flavour). Top-level so the
/// unit test drives every branch.
String? suggestOllamaServiceEndpoint({
required SystemAiStatus ai,
required List<ServiceEntry> services,
}) {
if (!ai.enabled || ai.provider != 'ollama' || ai.endpoint.trim().isEmpty) {
return null;
}
var base = ai.endpoint.trim();
for (final suffix in ['/v1/', '/v1']) {
if (base.endsWith(suffix)) {
base = base.substring(0, base.length - suffix.length);
break;
}
}
final target = Uri.tryParse(base);
if (target == null || target.host.isEmpty) return null;
for (final s in services) {
final u = Uri.tryParse(s.endpoint);
if (u != null && u.host == target.host && u.port == target.port) {
return null;
}
}
return base;
}
/// Network-reach pill per declared service, from the hub's
/// endpoint classification (`DeclaredService.exposure`). Public
/// endpoints get the warning tone — a host service reachable from
/// outside is the one thing an operator should notice here.
/// Empty (pre-0.23 hub) renders nothing.
class _ServiceExposurePill extends StatelessWidget {
final String exposure;
const _ServiceExposurePill({required this.exposure});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final (label, tone, hint) = switch (exposure) {
'loopback' => (l.svcExposureLoopback, ChainPillTone.neutral, ''),
'private' => (l.svcExposurePrivate, ChainPillTone.neutral, ''),
'public' => (
l.svcExposurePublic,
ChainPillTone.warning,
l.svcExposurePublicHint,
),
'unknown' => (
l.svcExposureUnknown,
ChainPillTone.neutral,
l.svcExposureUnknownHint,
),
_ => ('', ChainPillTone.neutral, ''),
};
if (label.isEmpty) return const SizedBox.shrink();
final pill = ChainPill(label: label, tone: tone);
return Padding(
padding: const EdgeInsets.only(right: ChainSpace.xs),
child: hint.isEmpty ? pill : Tooltip(message: hint, child: pill),
);
}
}
class _UpdateBanner extends StatefulWidget {
final UpdateStatus status;
const _UpdateBanner({required this.status});
@override
State<_UpdateBanner> createState() => _UpdateBannerState();
}
class _UpdateBannerState extends State<_UpdateBanner> {
bool _applying = false;
String? _applyOutput;
/// Last line the CLI printed, shown as the bar's label.
String? _applyLine;
/// When the apply started, for the elapsed counter.
DateTime? _applySince;
/// Redraws the elapsed counter between CLI lines, which can be
/// minutes apart during a download.
Timer? _applyClock;
@override
void dispose() {
_applyClock?.cancel();
super.dispose();
}
Future<void> _applyUpdate() async {
setState(() {
_applying = true;
_applyOutput = null;
_applyLine = null;
_applySince = DateTime.now();
});
_applyClock?.cancel();
_applyClock = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted && _applying) setState(() {});
});
final r = await SystemActions.chainUpdateApply(
widget.status.channel,
// Each line the CLI prints becomes the label under the bar, so
// the operator sees which step is running rather than a spinner
// that could mean anything.
onLine: (line) {
if (mounted) setState(() => _applyLine = line);
},
);
_applyClock?.cancel();
_applyClock = null;
if (!mounted) return;
setState(() {
_applying = false;
_applyLine = null;
_applyOutput = r.ok
? AppLocalizations.of(context)!.doctorApplyDone
: (r.stderr.isEmpty ? r.stdout : r.stderr).trim();
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final status = widget.status;
final available = status.updateAvailable;
final iconData = available
? Icons.system_update_alt_outlined
: Icons.cloud_off_outlined;
final iconColor = available
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant;
final title = available
? l.doctorUpdateAvailable(status.latestVersion)
: l.doctorUpdateUnreachable;
final body = available
? l.doctorUpdateBody(status.channel, status.latestVersion)
: (status.reason ?? l.doctorUpdateUnreachableBody(status.channel));
return ChainCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(iconData, size: 20, color: iconColor),
const SizedBox(width: ChainSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
body,
style: ChainTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (available && status.releaseNotesUrl != null) ...[
const SizedBox(height: 4),
// A URL the operator cannot click is a finding
// without a link — open it in the browser.
Tooltip(
message: l.doctorLinkReleaseNotes,
waitDuration: const Duration(milliseconds: 400),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () =>
SystemActions.openInOs(status.releaseNotesUrl!),
child: Semantics(
button: true,
label: l.doctorLinkReleaseNotes,
child: Text(
l.doctorReleaseNotes(status.releaseNotesUrl!),
style:
ChainTheme.mono(
size: 11,
color: theme.colorScheme.primary,
).copyWith(
decoration: TextDecoration.underline,
decorationColor:
theme.colorScheme.primary,
),
),
),
),
),
),
],
],
),
),
if (available)
FilledButton.icon(
onPressed: _applying ? null : _applyUpdate,
icon: _applying
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt, size: 16),
label: Text(
_applying ? l.doctorApplying : l.doctorApplyUpdate,
),
),
const SizedBox(width: ChainSpace.sm),
ChainPill(
label: available ? l.doctorPillNew : l.doctorPillOffline,
tone: available ? ChainPillTone.success : ChainPillTone.neutral,
),
],
),
if (_applying) ...[
const SizedBox(height: ChainSpace.md),
// No percentage to be had: the CLI reports steps, not a
// measurable total. So the bar runs indeterminate and
// carries the running step plus an elapsed counter, which
// is what separates "slow" from "stuck".
ChainProgressBar(
stage: _applyLine ?? l.doctorApplying,
detail: _applySince == null
? null
: l.installElapsed(
DateTime.now().difference(_applySince!).inSeconds,
),
),
],
if (_applyOutput != null) ...[
const SizedBox(height: ChainSpace.md),
ChainErrorBox(text: _applyOutput!, maxHeight: 240),
],
],
),
);
}
}