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>
231 lines
6.5 KiB
Dart
231 lines
6.5 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,
|
|
);
|
|
|
|
/// Composite "doctor" snapshot. One round-trip per piece, run
|
|
/// in parallel so the page populates fast.
|
|
Future<DoctorSnapshot> doctor() async {
|
|
final results = await Future.wait([
|
|
_client.listCapabilities().catchError((_) => <CapabilityEntry>[]),
|
|
_client.listApprovals(statuses: const ['pending'])
|
|
.catchError((_) => <PendingApprovalEntry>[]),
|
|
_client.verifyEventChain().catchError(
|
|
(_) => VerifyEventChainResponse(),
|
|
),
|
|
_client.listServices().catchError((_) => <DeclaredService>[]),
|
|
]);
|
|
|
|
final caps = results[0] as List<CapabilityEntry>;
|
|
final approvals = results[1] as List<PendingApprovalEntry>;
|
|
final chain = results[2] as VerifyEventChainResponse;
|
|
final services = results[3] as List<DeclaredService>;
|
|
|
|
// Module count = distinct module_name across capabilities.
|
|
final moduleNames = <String>{for (final c in caps) c.moduleName};
|
|
|
|
return DoctorSnapshot(
|
|
moduleCount: moduleNames.length,
|
|
capabilityCount: caps.length,
|
|
pendingApprovals: approvals.length,
|
|
eventChainTotal: chain.total.toInt(),
|
|
eventChainVerified: chain.verified.toInt(),
|
|
eventChainTamperedAt:
|
|
chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
|
|
services: services
|
|
.map(
|
|
(s) => ServiceEntry(
|
|
name: s.name,
|
|
endpoint: s.endpoint,
|
|
tags: s.tags,
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DoctorSnapshot {
|
|
final int moduleCount;
|
|
final int capabilityCount;
|
|
final int pendingApprovals;
|
|
final int eventChainTotal;
|
|
final int eventChainVerified;
|
|
final String? eventChainTamperedAt;
|
|
final List<ServiceEntry> services;
|
|
|
|
const DoctorSnapshot({
|
|
required this.moduleCount,
|
|
required this.capabilityCount,
|
|
required this.pendingApprovals,
|
|
required this.eventChainTotal,
|
|
required this.eventChainVerified,
|
|
required this.eventChainTamperedAt,
|
|
required this.services,
|
|
});
|
|
|
|
bool get chainHealthy => eventChainTamperedAt == null;
|
|
}
|
|
|
|
class ServiceEntry {
|
|
final String name;
|
|
final String endpoint;
|
|
final List<String> tags;
|
|
|
|
const ServiceEntry({
|
|
required this.name,
|
|
required this.endpoint,
|
|
required this.tags,
|
|
});
|
|
}
|
|
|
|
/// 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,
|
|
});
|
|
}
|