Studio is no longer hardcoded to localhost:50051. A gear icon in the sidebar footer opens a small settings dialog where the operator picks host / port / TLS-on-or-off. Saved values persist via shared_preferences and Studio reconnects to the new endpoint immediately. - data/hub.dart: HubService.loadPersistedEndpoint() called once before the first frame; reconnect() persists on every change. - widgets/fai_settings_dialog.dart: typed form with port range validation, live URL preview pill, error surfacing if the reconnect fails. - main.dart: gear icon at the sidebar bottom, replaces the static "platform v0.10.42" footer. shared_preferences added as dependency. flutter analyze clean, flutter test 2/2, macOS debug build succeeds. Bumps fai_studio 0.4.0 -> 0.5.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
259 lines
7.5 KiB
Dart
259 lines
7.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';
|
|
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));
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|