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

156
lib/data/hub.dart Normal file
View file

@ -0,0 +1,156 @@
// HubService singleton wrapper around fai_dart_sdk's HubClient.
//
// Centralises the gRPC connection so each page just imports
// `HubService.instance` instead of constructing its own client.
// Methods return UI-friendly types so pages stay free of
// protobuf imports.
import 'package:fai_dart_sdk/fai_dart_sdk.dart';
class HubService {
HubService._();
static final HubService instance = HubService._();
HubClient _client = HubClient();
/// The endpoint string ("http://host:port") the connection
/// targets. Shown in the navigation rail.
String get endpointLabel => _client.endpoint.toString();
/// Reconnect to a new endpoint. Useful if the user changes
/// the hub URL at runtime (Phase 1+).
Future<void> reconnect(HubEndpoint endpoint) async {
await _client.close();
_client = HubClient(endpoint: endpoint);
}
Future<bool> healthy() => _client.healthy();
Future<List<ModuleSummary>> listModules() async {
final caps = await _client.listCapabilities();
// Group capabilities by module so Studio's UI maps a card
// to a module rather than a capability.
final byModule = <String, List<CapabilityEntry>>{};
for (final c in caps) {
byModule.putIfAbsent(c.moduleName, () => []).add(c);
}
return byModule.entries.map((e) {
final caps = e.value;
return ModuleSummary(
name: e.key,
version: caps.first.moduleVersion,
capabilities: caps
.map((c) => '${c.capability}@${c.version}')
.toList(),
);
}).toList()
..sort((a, b) => a.name.compareTo(b.name));
}
Future<List<AuditEvent>> recentEvents({
int limit = 50,
List<String> types = const [],
}) async {
final events = await _client.eventLog(limit: limit, types: types);
return events
.map(
(e) => AuditEvent(
eventId: e.eventId,
timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(),
type: e.eventType,
flowName: e.flowName.isEmpty ? null : e.flowName,
stepId: e.stepId.isEmpty ? null : e.stepId,
moduleName: e.moduleName.isEmpty ? null : e.moduleName,
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
error: e.error.isEmpty ? null : e.error,
),
)
.toList();
}
Future<List<PendingApproval>> pendingApprovals() async {
final entries = await _client.listApprovals(statuses: ['pending']);
return entries
.map(
(e) => PendingApproval(
id: e.approvalId,
flowName: e.flowName,
stepId: e.stepId,
prompt: e.prompt,
showPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(),
expiresAt: e.expiresAt.isEmpty
? null
: DateTime.tryParse(e.expiresAt),
),
)
.toList();
}
Future<void> approve(String id, String reviewer) =>
_client.approve(approvalId: id, reviewer: reviewer);
Future<void> reject(String id, String reviewer, String reason) =>
_client.reject(
approvalId: id,
reviewer: reviewer,
reason: reason,
);
}
/// UI-side type, decoupled from the proto wire type so pages
/// don't import protobuf packages.
class ModuleSummary {
final String name;
final String version;
final List<String> capabilities;
const ModuleSummary({
required this.name,
required this.version,
required this.capabilities,
});
}
class AuditEvent {
final String eventId;
final DateTime timestamp;
final String type;
final String? flowName;
final String? stepId;
final String? moduleName;
final int? durationMs;
final String? error;
const AuditEvent({
required this.eventId,
required this.timestamp,
required this.type,
this.flowName,
this.stepId,
this.moduleName,
this.durationMs,
this.error,
});
}
class PendingApproval {
final String id;
final String flowName;
final String stepId;
final String prompt;
final String? showPreview;
final DateTime createdAt;
final DateTime? expiresAt;
const PendingApproval({
required this.id,
required this.flowName,
required this.stepId,
required this.prompt,
this.showPreview,
required this.createdAt,
this.expiresAt,
});
}

View file

@ -1,165 +0,0 @@
// Mock data for the MVP. Replaced by live gRPC calls via
// fai_dart_sdk in the next iteration.
class ModuleSummary {
final String name;
final String version;
final List<String> capabilities;
final List<String> permissions;
final List<String> requiresServices;
const ModuleSummary({
required this.name,
required this.version,
required this.capabilities,
required this.permissions,
required this.requiresServices,
});
}
class AuditEvent {
final String eventId;
final DateTime timestamp;
final String type;
final String? flowName;
final String? stepId;
final String? moduleName;
final int? durationMs;
final String? error;
const AuditEvent({
required this.eventId,
required this.timestamp,
required this.type,
this.flowName,
this.stepId,
this.moduleName,
this.durationMs,
this.error,
});
}
class PendingApproval {
final String id;
final String flowName;
final String stepId;
final String prompt;
final String? showPreview;
final DateTime createdAt;
final DateTime? expiresAt;
const PendingApproval({
required this.id,
required this.flowName,
required this.stepId,
required this.prompt,
this.showPreview,
required this.createdAt,
this.expiresAt,
});
}
const mockModules = <ModuleSummary>[
ModuleSummary(
name: 'echo',
version: '0.1.0',
capabilities: ['debug.echo@0.1.0'],
permissions: [],
requiresServices: [],
),
ModuleSummary(
name: 'text-extract',
version: '0.1.1',
capabilities: ['text.extract@0.1.0'],
permissions: [],
requiresServices: [],
),
ModuleSummary(
name: 'llm-chat',
version: '0.1.0',
capabilities: ['llm.chat@0.1.0'],
permissions: [
'net: localhost',
'net: 127.0.0.1',
'net: api.openai.com',
'net: api.anthropic.com',
],
requiresServices: ['ollama'],
),
ModuleSummary(
name: 'text-translate',
version: '0.1.0',
capabilities: ['text.translate@0.1.0'],
permissions: [
'net: localhost',
'net: 127.0.0.1',
],
requiresServices: ['ollama'],
),
ModuleSummary(
name: 'text-summarize',
version: '0.1.0',
capabilities: ['text.summarize@0.1.0'],
permissions: [
'net: localhost',
'net: 127.0.0.1',
],
requiresServices: ['ollama'],
),
];
final mockEvents = <AuditEvent>[
AuditEvent(
eventId: '01926f8a-7c34-7b2c-9e4f-a8d3c1f9e2b4',
timestamp: DateTime.now().subtract(const Duration(seconds: 2)),
type: 'flow.completed',
flowName: 'extract-translate-summarize',
durationMs: 8421,
),
AuditEvent(
eventId: '01926f8a-7b00-7000-aaaa-bbbbcccc0042',
timestamp: DateTime.now().subtract(const Duration(seconds: 3)),
type: 'step.completed',
flowName: 'extract-translate-summarize',
stepId: 'summarize',
moduleName: 'text-summarize',
durationMs: 3204,
),
AuditEvent(
eventId: '01926f8a-7a90-7000-aaaa-bbbbcccc0041',
timestamp: DateTime.now().subtract(const Duration(seconds: 9)),
type: 'step.completed',
flowName: 'extract-translate-summarize',
stepId: 'review',
moduleName: 'system.approval',
durationMs: 5102,
),
AuditEvent(
eventId: '01926f8a-7a00-7000-aaaa-bbbbcccc0040',
timestamp: DateTime.now().subtract(const Duration(seconds: 14)),
type: 'step.completed',
flowName: 'extract-translate-summarize',
stepId: 'translate',
moduleName: 'text-translate',
durationMs: 4815,
),
AuditEvent(
eventId: '01926f8a-7800-7000-aaaa-bbbbcccc003f',
timestamp: DateTime.now().subtract(const Duration(seconds: 19)),
type: 'flow.started',
flowName: 'extract-translate-summarize',
),
];
final mockApprovals = <PendingApproval>[
PendingApproval(
id: '01926f8a-9000-7b2c-9e4f-aaaa00000001',
flowName: 'extract-translate-summarize',
stepId: 'review',
prompt: 'Review the German→English translation before summarisation.',
showPreview:
'{ "translated": "The quarterly report shows..." }',
createdAt: DateTime.now().subtract(const Duration(minutes: 2)),
expiresAt: DateTime.now().add(const Duration(minutes: 28)),
),
];