import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; class DoctorPage extends StatefulWidget { const DoctorPage({super.key}); @override State createState() => _DoctorPageState(); } class _DoctorPageState extends State { late Future _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.refresh, size: 18), tooltip: AppLocalizations.of(context)!.doctorRecheckTooltip, onPressed: _refresh, ), const SizedBox(width: FaiSpace.sm), ], ), body: FutureBuilder( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { final l = AppLocalizations.of(context)!; return FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: Theme.of(context).colorScheme.error, title: l.hubUnreachable, hint: l.hubUnreachableHint, action: FilledButton.tonal( onPressed: _refresh, child: Text(l.buttonRetry), ), ); } final s = snapshot.data!; final showUpdate = s.update.updateAvailable || (!s.update.manifestReachable && s.update.localVersion.isNotEmpty); return ListView( padding: const EdgeInsets.all(FaiSpace.xl), children: [ if (showUpdate) _UpdateBanner(status: s.update), if (showUpdate) const SizedBox(height: FaiSpace.lg), _SummaryStrip(snapshot: s), const SizedBox(height: FaiSpace.xl), _Section( title: AppLocalizations.of(context)!.doctorEventLogSection, child: _EventLogPanel( snapshot: s, onRefresh: _refresh, ), ), const SizedBox(height: FaiSpace.lg), _Section( title: AppLocalizations.of(context)!.doctorModulesApprovalsSection, child: _ModulesPanel(snapshot: s), ), const SizedBox(height: FaiSpace.lg), _Section( title: AppLocalizations.of(context)!.doctorHostServicesSection, child: _ServicesPanel(snapshot: s), ), const SizedBox(height: FaiSpace.lg), _Section( title: AppLocalizations.of(context)!.doctorDaemonFilesSection, child: _DaemonPathsPanel(paths: s.paths), ), const SizedBox(height: FaiSpace.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: FaiSpace.xs, bottom: FaiSpace.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; const _SummaryStrip({required this.snapshot}); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return Row( children: [ _StatTile( label: l.doctorSummaryModules, value: snapshot.moduleCount.toString(), subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount), icon: Icons.extension_outlined, ), const SizedBox(width: FaiSpace.md), _StatTile( label: l.doctorSummaryApprovals, value: snapshot.pendingApprovals.toString(), subtitle: l.doctorSummaryPending, icon: Icons.inbox_outlined, tone: snapshot.pendingApprovals > 0 ? FaiPillTone.warning : FaiPillTone.neutral, ), const SizedBox(width: FaiSpace.md), _StatTile( label: l.doctorSummaryAudit, value: snapshot.chainHealthy ? '✓' : '⚠', subtitle: l.doctorSummaryChain( snapshot.eventChainVerified, snapshot.eventChainTotal, ), icon: Icons.shield_outlined, tone: snapshot.chainHealthy ? FaiPillTone.success : FaiPillTone.danger, ), const SizedBox(width: FaiSpace.md), _StatTile( label: l.doctorSummaryServices, value: snapshot.services.length.toString(), subtitle: l.doctorSummaryDeclared, icon: Icons.dns_outlined, ), ], ); } } class _StatTile extends StatelessWidget { final String label; final String value; final String subtitle; final IconData icon; final FaiPillTone tone; const _StatTile({ required this.label, required this.value, required this.subtitle, required this.icon, this.tone = FaiPillTone.neutral, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final accentColor = switch (tone) { FaiPillTone.success => FaiColors.success, FaiPillTone.warning => FaiColors.warning, FaiPillTone.danger => theme.colorScheme.error, _ => theme.colorScheme.primary, }; return Expanded( child: FaiCard( accentLeft: accentColor, padding: const EdgeInsets.all(FaiSpace.lg), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: FaiSpace.xs), Flexible( child: Text( label.toUpperCase(), overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.5, fontSize: 10, ), ), ), ], ), const SizedBox(height: FaiSpace.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, ), ), ], ), ), ); } } 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; const _EventLogPanel({required this.snapshot, required this.onRefresh}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final healthy = snapshot.chainHealthy; final l = AppLocalizations.of(context)!; return FaiCard( child: Row( children: [ Icon( healthy ? Icons.verified_outlined : Icons.gpp_bad_outlined, size: 24, color: healthy ? FaiColors.success : theme.colorScheme.error, ), const SizedBox(width: FaiSpace.lg), Expanded( child: 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, ), ), ], ), ), OutlinedButton.icon( onPressed: onRefresh, icon: const Icon(Icons.fact_check_outlined, size: 16), label: Text(l.doctorVerifyNow), ), const SizedBox(width: FaiSpace.sm), FaiPill( label: healthy ? l.doctorChainPillOk : l.doctorChainPillTamper, tone: healthy ? FaiPillTone.success : FaiPillTone.danger, ), ], ), ); } } /// 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)!; final entries = <(String, String, IconData)>[ (l.doctorPathLog, paths.logPath, Icons.description_outlined), (l.doctorPathConfig, paths.configPath, Icons.settings_outlined), (l.doctorPathDb, paths.dbPath, Icons.storage_outlined), (l.doctorPathModules, paths.modulesDir, Icons.extension_outlined), (l.doctorPathFlows, paths.flowsDir, Icons.account_tree_outlined), (l.doctorPathPid, paths.pidPath, Icons.fingerprint), ].where((e) => e.$2.isNotEmpty).toList(); if (entries.isEmpty) { return FaiCard( child: Text( l.doctorPathsEmpty, style: Theme.of(context).textTheme.bodySmall, ), ); } return FaiCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final e in entries) _PathRow(label: e.$1, path: e.$2, icon: e.$3), ], ), ); } } class _PathRow extends StatelessWidget { final String label; final String path; final IconData icon; const _PathRow({required this.label, required this.path, required this.icon}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: FaiSpace.sm), SizedBox( width: 90, child: Text( label, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: SelectableText( path, style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, ), ), ), const SizedBox(width: FaiSpace.sm), OutlinedButton.icon( onPressed: () async { final r = await SystemActions.openInOs(path); if (!context.mounted) return; if (!r.ok) { final l = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.doctorOpenError(r.stderr))), ); } }, icon: const Icon(Icons.open_in_new, size: 14), label: Text(AppLocalizations.of(context)!.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 /// `~/.fai/bin/fai`). 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; ChannelStatusSnapshot? _channels; @override void initState() { super.initState(); _refreshStatus(); } Future _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 _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; setState(() { _busy = false; _output = (r.ok ? 'OK · $label\n${r.stdout}' : '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); 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 FaiCard( // 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 ? FaiColors.success : theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.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: FaiSpace.sm), FaiPill( label: active.running ? l.doctorPillRunning : l.doctorPillStopped, tone: active.running ? FaiPillTone.success : FaiPillTone.neutral, ), ], ], ), const SizedBox(height: FaiSpace.sm), Text( l.doctorDaemonControlHint, 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: Text(l.doctorRestart), ), if (active?.running != true) FilledButton.icon( onPressed: _busy ? null : () => _run( 'daemon start', () => SystemActions.faiDaemon(['start']), ), icon: const Icon(Icons.play_arrow, size: 16), label: Text(l.doctorStart), ), OutlinedButton.icon( onPressed: _busy ? null : () => _run( 'daemon stop', () => SystemActions.faiDaemon(['stop']), ), icon: const Icon(Icons.stop_circle_outlined, size: 16), label: Text(l.doctorStop), ), OutlinedButton.icon( onPressed: _busy ? null : () => _run( 'daemon status', () => SystemActions.faiDaemon(['status']), ), icon: const Icon(Icons.health_and_safety_outlined, size: 16), label: Text(l.doctorStatusAction), ), ], ), 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( l.doctorDaemonControlWorking, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ], 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, ), ), ), ], ], ), ), ); } } class _ModulesPanel extends StatelessWidget { final DoctorSnapshot snapshot; const _ModulesPanel({required this.snapshot}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return FaiCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.extension_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.sm), Text( l.doctorModulesPanelSummary( snapshot.moduleCount, snapshot.capabilityCount, ), style: theme.textTheme.bodyMedium, ), const Spacer(), FaiPill( label: snapshot.moduleCount > 0 ? l.doctorPillLoaded : l.doctorPillEmpty, tone: snapshot.moduleCount > 0 ? FaiPillTone.success : FaiPillTone.neutral, ), ], ), const Divider(height: FaiSpace.xl), Row( children: [ Icon( Icons.inbox_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.sm), Text( snapshot.pendingApprovals == 0 ? l.doctorApprovalsNone : l.doctorApprovalsCount(snapshot.pendingApprovals), style: theme.textTheme.bodyMedium, ), const Spacer(), if (snapshot.pendingApprovals > 0) FaiPill( label: l.doctorApprovalsAttentionPill, tone: FaiPillTone.warning, ), ], ), ], ), ); } } class _ServicesPanel extends StatelessWidget { final DoctorSnapshot snapshot; const _ServicesPanel({required this.snapshot}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; if (snapshot.services.isEmpty) { return FaiCard( child: Row( children: [ Icon( Icons.dns_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: FaiSpace.sm), Text( l.doctorServicesEmpty, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), Text( l.doctorServicesEmptyHint, style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ); } return FaiCard( 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(FaiSpace.lg), child: Row( children: [ Icon( Icons.dns_outlined, size: 16, color: theme.colorScheme.primary, ), const SizedBox(width: FaiSpace.sm), Text( snapshot.services[i].name, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(width: FaiSpace.md), Text( snapshot.services[i].endpoint, style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), for (final tag in snapshot.services[i].tags) ...[ FaiPill(label: tag, tone: FaiPillTone.neutral), const SizedBox(width: FaiSpace.xs), ], ], ), ), ], ], ), ); } } 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; Future _applyUpdate() async { setState(() { _applying = true; _applyOutput = null; }); final r = await SystemActions.faiUpdateApply(widget.status.channel); if (!mounted) return; setState(() { _applying = false; _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 FaiCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(iconData, size: 20, color: iconColor), const SizedBox(width: FaiSpace.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: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), if (available && status.releaseNotesUrl != null) ...[ const SizedBox(height: 4), Text( l.doctorReleaseNotes(status.releaseNotesUrl!), style: FaiTheme.mono( size: 11, color: 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: FaiSpace.sm), FaiPill( label: available ? l.doctorPillNew : l.doctorPillOffline, tone: available ? FaiPillTone.success : FaiPillTone.neutral, ), ], ), if (_applyOutput != 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( _applyOutput!, style: FaiTheme.mono( size: 11, color: theme.colorScheme.onSurface, ), ), ), ], ], ), ); } }