chain-studio/lib/data/hub.dart
flemming-it 060c8003d5 feat(studio): operator-mode pass — Store + audit detail + channel switcher (v0.10.0)
Turns Studio from read-only dashboard into operator console.
Pieces:

  * **New Store page** (sidebar destination #3): browses the
    hub's bundled store-index with query + category + status
    filters. Each card has an Install button that prompts for
    the `.fai` bundle source (URL or local path), ships it to
    HubAdmin.InstallModule, shows success / error in a progress
    dialog, refreshes the list.

  * **Audit drill-down**: every event row is now tappable;
    opens a modal with all LoggedEvent fields including the
    new `detail` JSON pretty-printed in a code block. KRITIS
    forensic story: every audit row → full structured detail
    in two clicks.

  * **Approvals payload preview**: shipped already; now
    pretty-prints JSON when the preview parses as such, plus
    a clearer `PAYLOAD PREVIEW` label. Reviewer identity
    defaults to `$USER@studio` instead of the hard-coded
    `studio-mvp` so the audit trail records who acted.

  * **Channel switcher in Settings dialog**: the dialog now
    pulls HubAdmin.ChannelStatus and renders one row per
    channel (local / dev / beta / production) with port,
    running indicator, active marker. A "Connect" button
    on a running channel sets Studio's endpoint to that
    channel's port and reconnects in one click.

Cmd+1..6 keyboard shortcuts updated for the six destinations.
Widget test asserts every destination renders.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-07 11:54:31 +02:00

555 lines
15 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);
}
static const _kThemeKey = 'theme.mode';
/// Read the persisted theme mode (system / light / dark) for
/// initial app startup. Defaults to system.
Future<ThemeModeValue> loadThemeMode() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_kThemeKey);
return ThemeModeValue.fromWire(raw) ?? ThemeModeValue.system;
}
/// Persist the theme mode the user chose.
Future<void> saveThemeMode(ThemeModeValue mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kThemeKey, mode.wire);
}
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,
);
}
/// Active channel + per-channel daemon status snapshot.
Future<ChannelStatusSnapshot> channelStatus() async {
final r = await _client.channelStatus();
return ChannelStatusSnapshot(
active: r.active,
channels: r.channels
.map(
(c) => ChannelInfo(
name: c.name,
port: c.port,
running: c.running,
endpoint: c.endpoint,
),
)
.toList(),
);
}
/// Browse the bundled store index. All filters optional;
/// empty query returns the first [limit] entries.
Future<List<StoreItem>> searchStore({
String query = '',
String category = '',
String tag = '',
String status = '',
int limit = 50,
}) async {
final entries = await _client.searchStore(
query: query,
category: category,
tag: tag,
status: status,
limit: limit,
);
return entries
.map(
(e) => StoreItem(
name: e.name,
taglineEn: e.taglineEn,
descriptionEn: e.descriptionEn,
category: e.category,
tags: e.tags,
requiresCapabilities: e.requiresCapabilities,
requiresServices: e.requiresServices,
license: e.license,
repository: e.repository,
bestVersion: e.bestVersion,
status: e.status,
installed: e.installed,
),
)
.toList();
}
/// Install a module from a `.fai` bundle (URL or local path).
/// Returns the installed module's name + version on success;
/// throws on hub error (signature mismatch, sha256 fail, etc.).
Future<({String name, String version})> installModule({
required String source,
String expectedSha256 = '',
}) async {
final r = await _client.installModule(
source: source,
expectedSha256: expectedSha256,
);
return (name: r.name, version: r.version);
}
/// Saved flows known to the hub.
Future<List<SavedFlow>> listFlows() async {
final flows = await _client.listFlows();
return flows
.map(
(f) => SavedFlow(
name: f.name,
path: f.path,
sizeBytes: f.sizeBytes.toInt(),
),
)
.toList()
..sort((a, b) => a.name.compareTo(b.name));
}
/// Run a saved flow with the supplied text inputs. Returns
/// the named outputs as a map of UI-friendly strings.
Future<Map<String, String>> runSavedFlow({
required String name,
required Map<String, String> textInputs,
}) async {
final r = await _client.runSavedFlow(
name: name,
textInputs: textInputs,
);
return {
for (final entry in r.outputs.entries)
entry.key: _payloadToText(entry.value),
};
}
String _payloadToText(Payload p) {
if (p.hasText()) return p.text;
if (p.hasJson()) return p.json.toString();
if (p.hasBytes()) {
return '<bytes: ${p.bytes.data.length} bytes, ${p.bytes.mimeType}>';
}
if (p.hasFile()) {
return '<file: ${p.file.uri}>';
}
return '<unknown payload variant>';
}
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,
moduleVersion:
e.moduleVersion.isEmpty ? null : e.moduleVersion,
invocationId:
e.invocationId.isEmpty ? null : e.invocationId,
flowExecution:
e.flowExecution.isEmpty ? null : e.flowExecution,
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
error: e.error.isEmpty ? null : e.error,
detail: e.detail.isEmpty ? null : e.detail,
),
)
.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>[]),
_client.checkUpdate().catchError(
(_) => CheckUpdateResponse(),
),
]);
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>;
final update = results[4] as CheckUpdateResponse;
// 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(),
update: UpdateStatus(
channel: update.channel,
localVersion: update.localVersion,
latestVersion: update.latestVersion,
updateAvailable: update.updateAvailable,
manifestReachable: update.manifestReachable,
releaseNotesUrl:
update.releaseNotesUrl.isEmpty ? null : update.releaseNotesUrl,
reason: update.reason.isEmpty ? null : update.reason,
),
);
}
}
class UpdateStatus {
final String channel;
final String localVersion;
final String latestVersion;
final bool updateAvailable;
final bool manifestReachable;
final String? releaseNotesUrl;
final String? reason;
const UpdateStatus({
required this.channel,
required this.localVersion,
required this.latestVersion,
required this.updateAvailable,
required this.manifestReachable,
this.releaseNotesUrl,
this.reason,
});
}
class DoctorSnapshot {
final int moduleCount;
final int capabilityCount;
final int pendingApprovals;
final int eventChainTotal;
final int eventChainVerified;
final String? eventChainTamperedAt;
final List<ServiceEntry> services;
final UpdateStatus update;
const DoctorSnapshot({
required this.moduleCount,
required this.capabilityCount,
required this.pendingApprovals,
required this.eventChainTotal,
required this.eventChainVerified,
required this.eventChainTamperedAt,
required this.services,
required this.update,
});
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,
});
}
/// Three-way switch persisted across launches.
enum ThemeModeValue {
system('system'),
light('light'),
dark('dark');
final String wire;
const ThemeModeValue(this.wire);
static ThemeModeValue? fromWire(String? s) {
if (s == null) return null;
for (final v in values) {
if (v.wire == s) return v;
}
return null;
}
}
/// 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 SavedFlow {
final String name;
final String path;
final int sizeBytes;
const SavedFlow({
required this.name,
required this.path,
required this.sizeBytes,
});
}
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 String? moduleVersion;
final String? invocationId;
final String? flowExecution;
final int? durationMs;
final String? error;
/// Free-form JSON-string carrying event-type-specific fields.
/// Surfaced verbatim in the audit drill-down dialog.
final String? detail;
const AuditEvent({
required this.eventId,
required this.timestamp,
required this.type,
this.flowName,
this.stepId,
this.moduleName,
this.moduleVersion,
this.invocationId,
this.flowExecution,
this.durationMs,
this.error,
this.detail,
});
}
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,
});
}
class ChannelInfo {
final String name;
final int port;
final bool running;
final String endpoint;
const ChannelInfo({
required this.name,
required this.port,
required this.running,
required this.endpoint,
});
}
class ChannelStatusSnapshot {
final String active;
final List<ChannelInfo> channels;
const ChannelStatusSnapshot({
required this.active,
required this.channels,
});
}
/// One row from the store-index search.
class StoreItem {
final String name;
final String taglineEn;
final String descriptionEn;
final String category;
final List<String> tags;
final List<String> requiresCapabilities;
final List<String> requiresServices;
final String license;
final String repository;
final String bestVersion;
/// "published", "alpha", "planned", or empty when unknown.
final String status;
final bool installed;
const StoreItem({
required this.name,
required this.taglineEn,
required this.descriptionEn,
required this.category,
required this.tags,
required this.requiresCapabilities,
required this.requiresServices,
required this.license,
required this.repository,
required this.bestVersion,
required this.status,
required this.installed,
});
}