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>
156 lines
4.3 KiB
Dart
156 lines
4.3 KiB
Dart
// 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,
|
|
});
|
|
}
|