feat(ui): add Doctor page (4th destination) — full health snapshot
Composes the platform's health picture in one view: - Summary strip: 4 stat tiles for modules / approvals / audit chain / services with semantic accent colour. - Event-log panel: WORM-1 badge + chain status, points to the first tampered event id when integrity fails. - Modules & approvals panel: counts plus an "attention" pill when approvals are pending. - Services panel: declared services from operator config or a helpful empty-state pointing at ~/.fai/config.yaml. Backed by the new HubAdmin RPCs (VerifyEventChain, ListServices) plus the existing ListCapabilities and ListApprovals. One HubService.doctor() call fans out to all four in parallel. Doctor is the new first destination in the sidebar — that's the page an operator wants to land on after starting the app. Bumps fai_studio 0.3.0 -> 0.4.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
c94504247f
commit
4352ecc28c
4 changed files with 520 additions and 3 deletions
|
|
@ -97,6 +97,81 @@ class HubService {
|
|||
reviewer: reviewer,
|
||||
reason: reason,
|
||||
);
|
||||
|
||||
/// Composite "doctor" snapshot. One round-trip per piece, run
|
||||
/// in parallel so the page populates fast.
|
||||
Future<DoctorSnapshot> doctor() async {
|
||||
final results = await Future.wait([
|
||||
_client.listCapabilities().catchError((_) => <CapabilityEntry>[]),
|
||||
_client.listApprovals(statuses: const ['pending'])
|
||||
.catchError((_) => <PendingApprovalEntry>[]),
|
||||
_client.verifyEventChain().catchError(
|
||||
(_) => VerifyEventChainResponse(),
|
||||
),
|
||||
_client.listServices().catchError((_) => <DeclaredService>[]),
|
||||
]);
|
||||
|
||||
final caps = results[0] as List<CapabilityEntry>;
|
||||
final approvals = results[1] as List<PendingApprovalEntry>;
|
||||
final chain = results[2] as VerifyEventChainResponse;
|
||||
final services = results[3] as List<DeclaredService>;
|
||||
|
||||
// Module count = distinct module_name across capabilities.
|
||||
final moduleNames = <String>{for (final c in caps) c.moduleName};
|
||||
|
||||
return DoctorSnapshot(
|
||||
moduleCount: moduleNames.length,
|
||||
capabilityCount: caps.length,
|
||||
pendingApprovals: approvals.length,
|
||||
eventChainTotal: chain.total.toInt(),
|
||||
eventChainVerified: chain.verified.toInt(),
|
||||
eventChainTamperedAt:
|
||||
chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
|
||||
services: services
|
||||
.map(
|
||||
(s) => ServiceEntry(
|
||||
name: s.name,
|
||||
endpoint: s.endpoint,
|
||||
tags: s.tags,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DoctorSnapshot {
|
||||
final int moduleCount;
|
||||
final int capabilityCount;
|
||||
final int pendingApprovals;
|
||||
final int eventChainTotal;
|
||||
final int eventChainVerified;
|
||||
final String? eventChainTamperedAt;
|
||||
final List<ServiceEntry> services;
|
||||
|
||||
const DoctorSnapshot({
|
||||
required this.moduleCount,
|
||||
required this.capabilityCount,
|
||||
required this.pendingApprovals,
|
||||
required this.eventChainTotal,
|
||||
required this.eventChainVerified,
|
||||
required this.eventChainTamperedAt,
|
||||
required this.services,
|
||||
});
|
||||
|
||||
bool get chainHealthy => eventChainTamperedAt == null;
|
||||
}
|
||||
|
||||
class ServiceEntry {
|
||||
final String name;
|
||||
final String endpoint;
|
||||
final List<String> tags;
|
||||
|
||||
const ServiceEntry({
|
||||
required this.name,
|
||||
required this.endpoint,
|
||||
required this.tags,
|
||||
});
|
||||
}
|
||||
|
||||
/// UI-side type, decoupled from the proto wire type so pages
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
|||
import 'data/hub.dart';
|
||||
import 'pages/approvals.dart';
|
||||
import 'pages/audit.dart';
|
||||
import 'pages/doctor.dart';
|
||||
import 'pages/modules.dart';
|
||||
import 'theme/theme.dart';
|
||||
import 'theme/tokens.dart';
|
||||
|
|
@ -48,6 +49,12 @@ class _StudioShellState extends State<StudioShell> {
|
|||
Timer? _healthPoll;
|
||||
|
||||
static const _pages = <_NavPage>[
|
||||
_NavPage(
|
||||
label: 'Doctor',
|
||||
icon: Icons.health_and_safety_outlined,
|
||||
selectedIcon: Icons.health_and_safety,
|
||||
page: DoctorPage(),
|
||||
),
|
||||
_NavPage(
|
||||
label: 'Modules',
|
||||
icon: Icons.extension_outlined,
|
||||
|
|
|
|||
430
lib/pages/doctor.dart
Normal file
430
lib/pages/doctor.dart
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
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 chain',
|
||||
value: snapshot.chainHealthy ? '✓' : '⚠',
|
||||
subtitle:
|
||||
'${snapshot.eventChainVerified}/${snapshot.eventChainTotal} verified',
|
||||
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: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
label.toUpperCase(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue