Three-way switch in the sidebar footer next to the settings gear. Cycles system → light → dark → system. Choice persists via shared_preferences across app launches; restored before the first frame so there's no theme flicker on startup. The icon changes per state (auto / sun / moon) and the tooltip spells it out so the cycling behaviour is discoverable. - StudioApp is now stateful; exposes StudioApp.of(context) to let descendants flip the theme without prop-drilling. - ThemeModeValue enum lives in data/hub.dart so the persistence layer stays free of flutter/material imports. Bumps fai_studio 0.6.0 -> 0.7.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
323 lines
9.2 KiB
Dart
323 lines
9.2 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,
|
|
);
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
/// 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 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,
|
|
});
|
|
}
|