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:
parent
7c11598846
commit
127c497f73
6 changed files with 602 additions and 354 deletions
156
lib/data/hub.dart
Normal file
156
lib/data/hub.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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)),
|
||||
),
|
||||
];
|
||||
Loading…
Add table
Add a link
Reference in a new issue