diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 964b584..0a4c7e4 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -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 doctor() async { + final results = await Future.wait([ + _client.listCapabilities().catchError((_) => []), + _client.listApprovals(statuses: const ['pending']) + .catchError((_) => []), + _client.verifyEventChain().catchError( + (_) => VerifyEventChainResponse(), + ), + _client.listServices().catchError((_) => []), + ]); + + final caps = results[0] as List; + final approvals = results[1] as List; + final chain = results[2] as VerifyEventChainResponse; + final services = results[3] as List; + + // Module count = distinct module_name across capabilities. + final moduleNames = {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 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 tags; + + const ServiceEntry({ + required this.name, + required this.endpoint, + required this.tags, + }); } /// UI-side type, decoupled from the proto wire type so pages diff --git a/lib/main.dart b/lib/main.dart index ec75d5e..4a7529b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { 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, diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart new file mode 100644 index 0000000..90d0669 --- /dev/null +++ b/lib/pages/doctor.dart @@ -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 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: const Text('Doctor'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh, size: 18), + tooltip: 'Re-check', + 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) { + 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), + ], + ], + ), + ), + ], + ], + ), + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 65bf515..1f6c46a 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -10,13 +10,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fai_studio/main.dart'; void main() { - testWidgets('Studio shell shows the three MVP destinations', + testWidgets('Studio shell shows the four destinations', (tester) async { await tester.pumpWidget(const StudioApp()); expect(find.text('F∆I Studio'), findsOneWidget); + // "Doctor" appears twice (sidebar label + app bar title of the + // first selected page) — same for "Modules" / "Audit" / + // "Approvals" once their pages are first visited. We just check + // the labels are findable at all. + expect(find.text('Doctor'), findsWidgets); expect(find.text('Modules'), findsWidgets); - expect(find.text('Audit'), findsOneWidget); - expect(find.text('Approvals'), findsOneWidget); + expect(find.text('Audit'), findsWidgets); + expect(find.text('Approvals'), findsWidgets); }); }