chain-studio/lib/data/hub.dart
flemming-it b3fdd69139 feat(ui): module-detail bottom sheet on tap
Tapping a module card on the Modules page slides up a detail
sheet showing the full manifest data: directory path
(monospaced + selectable), every capability with version, and
every declared permission with a semantic icon (net = globe,
fs.read = folder, fs.write = edit, env = terminal, hub =
shield).

Empty permission list shows "(none — pure-computation module)"
explicitly — the operator should see *why* a module needed
zero perms, not be left guessing.

Backed by HubAdmin.ModuleInfo. New widget FaiModuleSheet with a
small drag-handle, FutureBuilder for the load state, error
surfacing if the RPC fails.

Bumps fai_studio 0.5.0 -> 0.6.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-05 22:15:41 +02:00

289 lines
8.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';
import 'package:shared_preferences/shared_preferences.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();
HubEndpoint get currentEndpoint => _client.endpoint;
static const _kHostKey = 'hub.host';
static const _kPortKey = 'hub.port';
static const _kSecureKey = 'hub.secure';
/// Read persisted endpoint and reconnect if it differs from
/// the default. Called once at app start.
Future<void> loadPersistedEndpoint() async {
final prefs = await SharedPreferences.getInstance();
final host = prefs.getString(_kHostKey);
final port = prefs.getInt(_kPortKey);
final secure = prefs.getBool(_kSecureKey);
if (host == null) return;
final endpoint = HubEndpoint(
host: host,
port: port ?? 50051,
secure: secure ?? false,
);
if (endpoint.toString() != _client.endpoint.toString()) {
await reconnect(endpoint);
}
}
/// Reconnect to a new endpoint and persist for next launch.
Future<void> reconnect(HubEndpoint endpoint) async {
await _client.close();
_client = HubClient(endpoint: endpoint);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kHostKey, endpoint.host);
await prefs.setInt(_kPortKey, endpoint.port);
await prefs.setBool(_kSecureKey, endpoint.secure);
}
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));
}
/// Fully-detailed manifest for one installed module.
Future<ModuleDetail> moduleInfo(String name) async {
final r = await _client.moduleInfo(name);
return ModuleDetail(
name: r.name,
version: r.version,
capabilities: r.provides
.map((c) => '${c.namespace}.${c.name}@${c.versionReq}')
.toList(),
permissions: r.permissions,
directory: r.directory,
);
}
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 ModuleDetail {
final String name;
final String version;
final List<String> capabilities;
final List<String> permissions;
final String directory;
const ModuleDetail({
required this.name,
required this.version,
required this.capabilities,
required this.permissions,
required this.directory,
});
}
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,
});
}