chain-studio/lib/pages/doctor.dart
flemming-it d5631177e0 fix(ui): stat-tile overflow + visible build version
Two issues:

1. _StatTile in the Doctor page overflowed by ~6px on narrow
   widths because the Row containing the icon + label "AUDIT
   CHAIN" with letterSpacing=0.6 didn't fit in the ~90px
   constraint per tile (4 tiles in a Row sharing the page
   width). Fix: smaller icon (16→14), tighter gap, label wrapped
   in Flexible with TextOverflow.ellipsis, and the longest label
   shortened "AUDIT CHAIN" → "AUDIT" with the chain count moved
   to the subtitle.

2. The new redesign was easy to mistake for the previous build
   from the outside. Add a small monospaced version line right
   under "F∆I Studio" in the sidebar header:

       F∆I Studio
       v0.7.1 · warm-minimalism

   Self-identifying — "wrong build" is now visible without
   reading the about dialog.

Bumps fai_studio 0.7.0 -> 0.7.1.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-05 22:41:26 +02:00

433 lines
13 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!;
return ListView(
padding: const EdgeInsets.all(FaiSpace.xl),
children: [
_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),
],
],
),
),
],
],
),
);
}
}