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:
flemming-it 2026-05-05 22:09:49 +02:00
parent c94504247f
commit 4352ecc28c
4 changed files with 520 additions and 3 deletions

View file

@ -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