feat: live gRPC pages — drop mock data, talk to the real hub

Studio's three MVP pages now pull live data from a running hub
via fai_dart_sdk. Mock fixture removed.

- data/hub.dart: HubService singleton wraps fai_dart_sdk's
  HubClient and exposes UI-friendly types (ModuleSummary,
  AuditEvent, PendingApproval) so pages don't import protobuf.
- pages/modules.dart: FutureBuilder against listModules,
  groups CapabilityEntry rows by module, retry-on-error UI.
- pages/audit.dart: 2s polling Timer, status bar shows
  "live (polling 2s)" or "disconnected — <error>".
- pages/approvals.dart: live listApprovals, Approve sends
  DecideApproval(APPROVE), Reject opens a reason dialog and
  sends DecideApproval(REJECT). Both refresh after success.
- Connection-error states across all pages: cloud_off icon +
  hint to start `fai serve` + Retry button.

When no hub is running the pages render their disconnected
state instead of crashing. When a hub is running, the data is
real — no more mock fixture.

Bumps fai_studio 0.1.0 -> 0.2.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-05 16:27:54 +02:00
parent 7c11598846
commit 127c497f73
6 changed files with 602 additions and 354 deletions

View file

@ -1,6 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../data/mock.dart';
import '../data/hub.dart';
class AuditPage extends StatefulWidget {
const AuditPage({super.key});
@ -11,13 +13,57 @@ class AuditPage extends StatefulWidget {
class _AuditPageState extends State<AuditPage> {
String _typeFilter = 'all';
List<AuditEvent> _events = const [];
String? _error;
bool _initialLoaded = false;
Timer? _poller;
@override
void initState() {
super.initState();
_refresh();
_poller = Timer.periodic(
const Duration(seconds: 2),
(_) => _refresh(),
);
}
@override
void dispose() {
_poller?.cancel();
super.dispose();
}
Future<void> _refresh() async {
final types = _typeFilter == 'all'
? <String>[]
: <String>[]; // server-side filter not yet clientside below
try {
final events = await HubService.instance.recentEvents(
limit: 100,
types: types,
);
if (!mounted) return;
setState(() {
_events = events;
_error = null;
_initialLoaded = true;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_initialLoaded = true;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = _typeFilter == 'all'
? mockEvents
: mockEvents.where((e) => e.type.startsWith(_typeFilter)).toList();
? _events
: _events.where((e) => e.type.startsWith(_typeFilter)).toList();
return Scaffold(
appBar: AppBar(
@ -41,84 +87,114 @@ class _AuditPageState extends State<AuditPage> {
),
body: Column(
children: [
_StatusBar(eventCount: filtered.length),
_StatusBar(eventCount: filtered.length, error: _error),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.all(24),
itemCount: filtered.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, i) {
final e = filtered[i];
final ts = e.timestamp;
final tsStr =
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}';
final tone = _toneFor(e.type);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: theme.colorScheme.outlineVariant,
child: !_initialLoaded
? const Center(child: CircularProgressIndicator())
: _error != null && _events.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
'Hub unreachable: $_error\n\n'
'Start the hub with `fai serve`.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
),
)
: filtered.isEmpty
? Center(
child: Text(
'(no events yet)',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ListView.separated(
padding: const EdgeInsets.all(24),
itemCount: filtered.length,
separatorBuilder: (_, _) =>
const SizedBox(height: 8),
itemBuilder: (context, i) {
final e = filtered[i];
final ts = e.timestamp;
final tsStr =
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}';
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: theme.colorScheme.outlineVariant,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Container(
width: 4,
height: 32,
decoration: BoxDecoration(
color: _toneFor(e.type, theme),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
SizedBox(
width: 90,
child: Text(
tsStr,
style:
theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme
.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
width: 180,
child: Text(
e.type,
style:
theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
_contextLine(e),
style:
theme.textTheme.bodyMedium?.copyWith(
color: theme
.colorScheme.onSurfaceVariant,
),
),
),
if (e.durationMs != null)
Text(
'${e.durationMs}ms',
style:
theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme
.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
},
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Container(
width: 4,
height: 32,
decoration: BoxDecoration(
color: tone,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
SizedBox(
width: 90,
child: Text(
tsStr,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
width: 180,
child: Text(
e.type,
style: theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
_contextLine(e),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (e.durationMs != null)
Text(
'${e.durationMs}ms',
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
},
),
),
],
),
@ -134,23 +210,24 @@ class _AuditPageState extends State<AuditPage> {
return parts.join('');
}
Color _toneFor(String type) {
final scheme = Theme.of(context).colorScheme;
if (type.endsWith('.failed')) return scheme.error;
if (type.endsWith('.completed')) return scheme.primary;
if (type.endsWith('.started')) return scheme.tertiary;
return scheme.outline;
Color _toneFor(String type, ThemeData theme) {
if (type.endsWith('.failed')) return theme.colorScheme.error;
if (type.endsWith('.completed')) return theme.colorScheme.primary;
if (type.endsWith('.started')) return theme.colorScheme.tertiary;
return theme.colorScheme.outline;
}
}
class _StatusBar extends StatelessWidget {
final int eventCount;
final String? error;
const _StatusBar({required this.eventCount});
const _StatusBar({required this.eventCount, required this.error});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final live = error == null;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
@ -160,20 +237,15 @@ class _StatusBar extends StatelessWidget {
Icon(
Icons.fiber_manual_record,
size: 12,
color: theme.colorScheme.tertiary,
color: live ? theme.colorScheme.tertiary : theme.colorScheme.error,
),
const SizedBox(width: 8),
Text(
'live (mock data) — $eventCount events',
live
? 'live (polling 2s) — $eventCount events'
: 'disconnected — $error',
style: theme.textTheme.bodySmall,
),
const Spacer(),
Text(
'hash chain: verified',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
);