chain-studio/lib/pages/doctor.dart
flemming-it 18db6ff164 feat(doctor): update banner via HubAdmin.CheckUpdate (v0.9.0)
Doctor page now opens with a slim banner above the summary strip
in two cases: an actually newer version on the channel ("Update
available — v0.x.y", success-tone "new" pill, release-notes URL
when present) or the release host being unreachable
("Release host unreachable", neutral "offline" pill, with the
network reason). When local matches latest the banner stays
hidden — quiet UI is the right default.

Hooks into the existing parallel-fetch in `HubService.doctor` so
the new RPC adds zero latency to the page load.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-06 15:53:52 +02:00

507 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
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: const Text('Doctor'),
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Re-check',
onPressed: _refresh,
),
const SizedBox(width: FaiSpace.sm),
],
),
body: FutureBuilder<DoctorSnapshot>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: Theme.of(context).colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
action: FilledButton.tonal(
onPressed: _refresh,
child: const Text('Retry'),
),
);
}
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: 'Event log',
child: _EventLogPanel(snapshot: s),
),
const SizedBox(height: FaiSpace.lg),
_Section(
title: 'Modules & approvals',
child: _ModulesPanel(snapshot: s),
),
const SizedBox(height: FaiSpace.lg),
_Section(
title: 'Host services',
child: _ServicesPanel(snapshot: s),
),
],
);
},
),
);
}
}
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) {
return Row(
children: [
_StatTile(
label: 'Modules',
value: snapshot.moduleCount.toString(),
subtitle: '${snapshot.capabilityCount} capabilities',
icon: Icons.extension_outlined,
),
const SizedBox(width: FaiSpace.md),
_StatTile(
label: 'Approvals',
value: snapshot.pendingApprovals.toString(),
subtitle: 'pending',
icon: Icons.inbox_outlined,
tone: snapshot.pendingApprovals > 0
? FaiPillTone.warning
: FaiPillTone.neutral,
),
const SizedBox(width: FaiSpace.md),
_StatTile(
label: 'Audit',
value: snapshot.chainHealthy ? '' : '',
subtitle:
'${snapshot.eventChainVerified}/${snapshot.eventChainTotal} chain',
icon: Icons.shield_outlined,
tone: snapshot.chainHealthy
? FaiPillTone.success
: FaiPillTone.danger,
),
const SizedBox(width: FaiSpace.md),
_StatTile(
label: 'Services',
value: snapshot.services.length.toString(),
subtitle: 'declared',
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;
const _EventLogPanel({required this.snapshot});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final healthy = snapshot.chainHealthy;
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
? 'Hash chain intact'
: 'Tampering detected',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
healthy
? '${snapshot.eventChainTotal} events · prev_event_sha256 verified end-to-end'
: '${snapshot.eventChainVerified} of ${snapshot.eventChainTotal} verified before mismatch at ${snapshot.eventChainTamperedAt}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
FaiPill(
label: healthy ? 'WORM-1' : 'TAMPER',
tone: healthy ? FaiPillTone.success : FaiPillTone.danger,
),
],
),
);
}
}
class _ModulesPanel extends StatelessWidget {
final DoctorSnapshot snapshot;
const _ModulesPanel({required this.snapshot});
@override
Widget build(BuildContext context) {
final theme = Theme.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(
'${snapshot.moduleCount} modules · ${snapshot.capabilityCount} capabilities',
style: theme.textTheme.bodyMedium,
),
const Spacer(),
FaiPill(
label: snapshot.moduleCount > 0 ? 'loaded' : 'empty',
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
? 'No pending approvals'
: '${snapshot.pendingApprovals} approval${snapshot.pendingApprovals == 1 ? '' : 's'} awaiting review',
style: theme.textTheme.bodyMedium,
),
const Spacer(),
if (snapshot.pendingApprovals > 0)
FaiPill(
label: 'attention',
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);
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(
'No host services declared',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
Text(
'add to ~/.fai/config.yaml under services:',
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 StatelessWidget {
final UpdateStatus status;
const _UpdateBanner({required this.status});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
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
? 'Update available — ${status.latestVersion}'
: 'Release host unreachable';
final body = available
? 'Channel ${status.channel} now offers ${status.latestVersion}. '
'Apply with: fai update apply --channel ${status.channel}'
: (status.reason ?? 'Could not contact the release feed for ${status.channel}.');
return FaiCard(
child: 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(
'notes: ${status.releaseNotesUrl}',
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.primary,
),
),
],
],
),
),
FaiPill(
label: available ? 'new' : 'offline',
tone: available ? FaiPillTone.success : FaiPillTone.neutral,
),
],
),
);
}
}