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>
365 lines
9.8 KiB
Dart
365 lines
9.8 KiB
Dart
// F∆I Studio — desktop GUI for the F∆I hub.
|
|
//
|
|
// Visual language: warm minimalism / deep tech with soul.
|
|
// Tokens in lib/theme/, primitives in lib/widgets/.
|
|
|
|
import 'dart:async';
|
|
|
|
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';
|
|
import 'widgets/widgets.dart';
|
|
|
|
void main() {
|
|
runApp(const StudioApp());
|
|
}
|
|
|
|
class StudioApp extends StatelessWidget {
|
|
const StudioApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
title: 'F∆I Studio',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: FaiTheme.light(),
|
|
darkTheme: FaiTheme.dark(),
|
|
themeMode: ThemeMode.system,
|
|
home: const StudioShell(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class StudioShell extends StatefulWidget {
|
|
const StudioShell({super.key});
|
|
|
|
@override
|
|
State<StudioShell> createState() => _StudioShellState();
|
|
}
|
|
|
|
class _StudioShellState extends State<StudioShell> {
|
|
int _selectedIndex = 0;
|
|
bool? _connected; // null = unknown, polled on a timer
|
|
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,
|
|
selectedIcon: Icons.extension,
|
|
page: ModulesPage(),
|
|
),
|
|
_NavPage(
|
|
label: 'Audit',
|
|
icon: Icons.timeline_outlined,
|
|
selectedIcon: Icons.timeline,
|
|
page: AuditPage(),
|
|
),
|
|
_NavPage(
|
|
label: 'Approvals',
|
|
icon: Icons.inbox_outlined,
|
|
selectedIcon: Icons.inbox,
|
|
page: ApprovalsPage(),
|
|
),
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_checkHealth();
|
|
_healthPoll = Timer.periodic(
|
|
const Duration(seconds: 5),
|
|
(_) => _checkHealth(),
|
|
);
|
|
}
|
|
|
|
Future<void> _checkHealth() async {
|
|
final ok = await HubService.instance.healthy();
|
|
if (!mounted) return;
|
|
if (_connected != ok) setState(() => _connected = ok);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_healthPoll?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Scaffold(
|
|
body: Row(
|
|
children: [
|
|
_Sidebar(
|
|
selectedIndex: _selectedIndex,
|
|
onSelect: (i) => setState(() => _selectedIndex = i),
|
|
pages: _pages,
|
|
connected: _connected,
|
|
endpointLabel: HubService.instance.endpointLabel,
|
|
),
|
|
Container(
|
|
width: 1,
|
|
color: theme.colorScheme.outlineVariant,
|
|
),
|
|
Expanded(
|
|
child: AnimatedSwitcher(
|
|
duration: FaiMotion.base,
|
|
switchInCurve: FaiMotion.easing,
|
|
switchOutCurve: FaiMotion.easing,
|
|
transitionBuilder: (child, anim) => FadeTransition(
|
|
opacity: anim,
|
|
child: SlideTransition(
|
|
position: Tween<Offset>(
|
|
begin: const Offset(0, 0.02),
|
|
end: Offset.zero,
|
|
).animate(anim),
|
|
child: child,
|
|
),
|
|
),
|
|
child: KeyedSubtree(
|
|
key: ValueKey(_selectedIndex),
|
|
child: _pages[_selectedIndex].page,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Sidebar extends StatelessWidget {
|
|
final int selectedIndex;
|
|
final ValueChanged<int> onSelect;
|
|
final List<_NavPage> pages;
|
|
final bool? connected;
|
|
final String endpointLabel;
|
|
|
|
const _Sidebar({
|
|
required this.selectedIndex,
|
|
required this.onSelect,
|
|
required this.pages,
|
|
required this.connected,
|
|
required this.endpointLabel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final mode = connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle;
|
|
return Container(
|
|
width: 220,
|
|
color: theme.colorScheme.surfaceContainerLow,
|
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xl),
|
|
child: Column(
|
|
children: [
|
|
// Brand mark.
|
|
FaiDeltaMark(color: theme.colorScheme.primary, mode: mode),
|
|
const SizedBox(height: FaiSpace.md),
|
|
Text(
|
|
'F∆I Studio',
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.xs),
|
|
// Connection pill.
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
|
|
child: _ConnectionPill(
|
|
connected: connected,
|
|
label: endpointLabel,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.xxl),
|
|
// Destinations.
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
|
children: [
|
|
for (var i = 0; i < pages.length; i++)
|
|
_SidebarItem(
|
|
page: pages[i],
|
|
selected: i == selectedIndex,
|
|
onTap: () => onSelect(i),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Footer hint.
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg),
|
|
child: Text(
|
|
'platform v0.10.42',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ConnectionPill extends StatelessWidget {
|
|
final bool? connected;
|
|
final String label;
|
|
|
|
const _ConnectionPill({required this.connected, required this.label});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final dotColor = connected == true
|
|
? FaiColors.success
|
|
: connected == false
|
|
? FaiColors.danger
|
|
: FaiColors.muted;
|
|
final caption = connected == true
|
|
? 'connected'
|
|
: connected == false
|
|
? 'unreachable'
|
|
: 'connecting…';
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: FaiSpace.sm,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
FaiStatusDot(color: dotColor, pulsing: connected == true),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
caption,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: dotColor == FaiColors.danger
|
|
? theme.colorScheme.error
|
|
: theme.colorScheme.onSurface,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
style: FaiTheme.mono(
|
|
size: 10,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SidebarItem extends StatefulWidget {
|
|
final _NavPage page;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
const _SidebarItem({
|
|
required this.page,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
State<_SidebarItem> createState() => _SidebarItemState();
|
|
}
|
|
|
|
class _SidebarItemState extends State<_SidebarItem> {
|
|
bool _hovered = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final color = widget.selected
|
|
? theme.colorScheme.primary
|
|
: _hovered
|
|
? theme.colorScheme.onSurface
|
|
: theme.colorScheme.onSurfaceVariant;
|
|
final bg = widget.selected
|
|
? theme.colorScheme.primary.withValues(alpha: 0.08)
|
|
: _hovered
|
|
? theme.colorScheme.surfaceContainerHigh
|
|
: Colors.transparent;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: MouseRegion(
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: widget.onTap,
|
|
child: AnimatedContainer(
|
|
duration: FaiMotion.fast,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: FaiSpace.md,
|
|
vertical: FaiSpace.md,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
widget.selected ? widget.page.selectedIcon : widget.page.icon,
|
|
size: 18,
|
|
color: color,
|
|
),
|
|
const SizedBox(width: FaiSpace.md),
|
|
Text(
|
|
widget.page.label,
|
|
style: theme.textTheme.labelMedium?.copyWith(color: color),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NavPage {
|
|
final String label;
|
|
final IconData icon;
|
|
final IconData selectedIcon;
|
|
final Widget page;
|
|
|
|
const _NavPage({
|
|
required this.label,
|
|
required this.icon,
|
|
required this.selectedIcon,
|
|
required this.page,
|
|
});
|
|
}
|