- Add ControlPlaneStatusClient for querying control plane state - Add control plane status widgets for displaying status in client - Update main.dart to integrate control plane status features - Update widget_test.dart - Archive completed task documents
406 lines
11 KiB
Dart
406 lines
11 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:firebase_core/firebase_core.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:iop_console/iop_console.dart';
|
|
|
|
import 'client_config.dart';
|
|
import 'control_plane_status_client.dart';
|
|
import 'control_plane_status_widgets.dart';
|
|
import 'iop_wire/client_wire_client.dart';
|
|
import 'src/integrations/mattermost/mattermost_push_host_integration.dart';
|
|
import 'src/integrations/mattermost/mattermost_push_plugin_client.dart';
|
|
|
|
final _mattermostHost = MattermostPushHostIntegration(
|
|
pushClient: MattermostPushPluginClient(),
|
|
);
|
|
|
|
const iopDefaultCapabilityPack = IopCapabilityPack(
|
|
capabilities: [
|
|
IopCapability(
|
|
name: 'Edge Control',
|
|
description: 'Monitor and configure Edge routing groups.',
|
|
),
|
|
IopCapability(
|
|
name: 'Node Management',
|
|
description: 'Track node registration and execution lanes.',
|
|
),
|
|
IopCapability(
|
|
name: 'Runtime Dispatch',
|
|
description: 'Trigger manual adapter execution sessions.',
|
|
),
|
|
IopCapability(
|
|
name: 'Execution Tracing',
|
|
description: 'View live logs and execution metrics stream.',
|
|
),
|
|
IopCapability(
|
|
name: 'Maintenance Mode',
|
|
description: 'Trigger node bootstrap or cluster repairs.',
|
|
),
|
|
],
|
|
);
|
|
|
|
Future<void> main() => runIopClient();
|
|
|
|
Future<void> applyFullscreenMode() async {
|
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
|
SystemChrome.setSystemUIOverlayStyle(
|
|
const SystemUiOverlayStyle(
|
|
statusBarColor: Colors.transparent,
|
|
systemNavigationBarColor: Colors.transparent,
|
|
systemNavigationBarDividerColor: Colors.transparent,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> bootstrapIopClient() async {
|
|
await applyFullscreenMode();
|
|
await Firebase.initializeApp();
|
|
await _mattermostHost.initialize();
|
|
}
|
|
|
|
Future<void> runIopClient() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
await bootstrapIopClient();
|
|
runApp(IopClientApp(mattermostHost: _mattermostHost));
|
|
}
|
|
|
|
class IopClientApp extends StatelessWidget {
|
|
final ClientWireClient? testClient;
|
|
final MattermostPushHostIntegration? mattermostHost;
|
|
final ControlPlaneStatusRepository? statusRepository;
|
|
|
|
const IopClientApp({
|
|
super.key,
|
|
this.testClient,
|
|
this.mattermostHost,
|
|
this.statusRepository,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
title: 'IOP Client',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: ThemeData.dark(useMaterial3: true).copyWith(
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: const Color(0xFF6366F1),
|
|
brightness: Brightness.dark,
|
|
primary: const Color(0xFF6366F1),
|
|
secondary: const Color(0xFF10B981),
|
|
),
|
|
scaffoldBackgroundColor: const Color(0xFF0F172A),
|
|
),
|
|
home: ClientHomePage(
|
|
testClient: testClient,
|
|
mattermostHost: mattermostHost,
|
|
statusRepository: statusRepository,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ClientHomePage extends StatefulWidget {
|
|
final ClientWireClient? testClient;
|
|
final MattermostPushHostIntegration? mattermostHost;
|
|
final ControlPlaneStatusRepository? statusRepository;
|
|
|
|
const ClientHomePage({
|
|
super.key,
|
|
this.testClient,
|
|
this.mattermostHost,
|
|
this.statusRepository,
|
|
});
|
|
|
|
@override
|
|
State<ClientHomePage> createState() => _ClientHomePageState();
|
|
}
|
|
|
|
class _ClientHomePageState extends State<ClientHomePage> {
|
|
String _wireStatus = 'Disconnected';
|
|
ClientWireClient? _client;
|
|
StreamSubscription? _notificationSubscription;
|
|
|
|
late final ControlPlaneStatusRepository _statusRepo;
|
|
|
|
// HTTP status states
|
|
List<EdgeRegistryView> _edges = [];
|
|
bool _loadingEdges = false;
|
|
String? _edgesError;
|
|
String? _selectedEdgeId;
|
|
|
|
final Map<String, EdgeStatusResponseView> _edgeStatuses = {};
|
|
final Map<String, bool> _loadingStatuses = {};
|
|
final Map<String, String?> _statusErrors = {};
|
|
|
|
final Map<String, List<EdgeNodeEventView>> _edgeEvents = {};
|
|
final Map<String, bool> _loadingEvents = {};
|
|
final Map<String, String?> _eventErrors = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_statusRepo = widget.statusRepository ??
|
|
HttpControlPlaneStatusRepository(
|
|
baseUrl: ClientConfig.controlPlaneHttpUrl,
|
|
);
|
|
|
|
// Auto-connect on start
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
applyFullscreenMode();
|
|
_connectAndFetch();
|
|
});
|
|
|
|
final host = widget.mattermostHost;
|
|
if (host != null) {
|
|
_notificationSubscription = host.onNotification.listen(
|
|
_showMattermostNotification,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_notificationSubscription?.cancel();
|
|
_client?.close();
|
|
super.dispose();
|
|
}
|
|
|
|
void _showMattermostNotification(Map<String, dynamic> data) {
|
|
if (data['type'] != 'message' || !mounted) return;
|
|
|
|
final message = data['message'] as String? ?? '';
|
|
final channel = data['channel_name'] as String? ?? '';
|
|
final sender = data['sender_name'] as String? ?? '';
|
|
final content = sender.isNotEmpty
|
|
? '[$channel] $sender: $message'
|
|
: message;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(content, maxLines: 2, overflow: TextOverflow.ellipsis),
|
|
duration: const Duration(seconds: 4),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _connectAndFetch() async {
|
|
await Future.wait([
|
|
_connectWire(),
|
|
_fetchEdges(),
|
|
]);
|
|
}
|
|
|
|
Future<void> _fetchEdges() async {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_loadingEdges = true;
|
|
_edgesError = null;
|
|
});
|
|
|
|
try {
|
|
final edges = await _statusRepo.fetchEdges();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_edges = edges;
|
|
_loadingEdges = false;
|
|
|
|
// Normalize selection: check if the previously selected edge is still present
|
|
final hasSelected = edges.any((e) => e.edgeId == _selectedEdgeId);
|
|
if (!hasSelected) {
|
|
// Clear stale status/events/loading/error cache to prevent leak/inconsistency
|
|
if (_selectedEdgeId != null) {
|
|
_edgeStatuses.remove(_selectedEdgeId);
|
|
_loadingStatuses.remove(_selectedEdgeId);
|
|
_statusErrors.remove(_selectedEdgeId);
|
|
_edgeEvents.remove(_selectedEdgeId);
|
|
_loadingEvents.remove(_selectedEdgeId);
|
|
_eventErrors.remove(_selectedEdgeId);
|
|
}
|
|
|
|
if (edges.isNotEmpty) {
|
|
_selectedEdgeId = edges.first.edgeId;
|
|
} else {
|
|
_selectedEdgeId = null;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (_selectedEdgeId != null) {
|
|
_fetchEdgeDetails(_selectedEdgeId!);
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_edgesError = e.toString();
|
|
_loadingEdges = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _fetchEdgeDetails(String edgeId) async {
|
|
await Future.wait([
|
|
_fetchEdgeStatus(edgeId),
|
|
_fetchEdgeEvents(edgeId),
|
|
]);
|
|
}
|
|
|
|
Future<void> _fetchEdgeStatus(String edgeId) async {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_loadingStatuses[edgeId] = true;
|
|
_statusErrors[edgeId] = null;
|
|
});
|
|
|
|
try {
|
|
final status = await _statusRepo.fetchEdgeStatus(edgeId);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_edgeStatuses[edgeId] = status;
|
|
_loadingStatuses[edgeId] = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_statusErrors[edgeId] = e.toString();
|
|
_loadingStatuses[edgeId] = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _fetchEdgeEvents(String edgeId) async {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_loadingEvents[edgeId] = true;
|
|
_eventErrors[edgeId] = null;
|
|
});
|
|
|
|
try {
|
|
final events = await _statusRepo.fetchEdgeEvents(edgeId);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_edgeEvents[edgeId] = events;
|
|
_loadingEvents[edgeId] = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_eventErrors[edgeId] = e.toString();
|
|
_loadingEvents[edgeId] = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _connectWire() async {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_wireStatus = 'Connecting';
|
|
});
|
|
|
|
try {
|
|
ClientWireClient client;
|
|
if (widget.testClient != null) {
|
|
client = widget.testClient!;
|
|
} else {
|
|
client = await ClientWireClient.connectToUrl(
|
|
ClientConfig.controlPlaneWireUrl,
|
|
);
|
|
}
|
|
_client = client;
|
|
|
|
// Hello handshake
|
|
final response = await client.hello(
|
|
clientId: 'client-ui',
|
|
clientVersion: '1.0.0',
|
|
);
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_wireStatus = response.ready ? 'Connected' : 'Error';
|
|
});
|
|
|
|
client.addDisconnectListener((_) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_wireStatus = 'Disconnected';
|
|
_client = null;
|
|
});
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_wireStatus = 'Error';
|
|
_client = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final config = IopConsoleConfig(
|
|
controlPlaneHttpUrl: ClientConfig.controlPlaneHttpUrl,
|
|
controlPlaneWireUrl: ClientConfig.controlPlaneWireUrl,
|
|
);
|
|
|
|
final currentEdgeId = _selectedEdgeId;
|
|
|
|
return IopConsoleShell(
|
|
config: config,
|
|
capabilities: iopDefaultCapabilityPack,
|
|
overview: IopConsoleOverview(
|
|
config: config,
|
|
statusText: _wireStatus,
|
|
onRefresh: _connectAndFetch,
|
|
),
|
|
edges: EdgesPanel(
|
|
edges: _edges,
|
|
isLoading: _loadingEdges,
|
|
error: _edgesError,
|
|
selectedEdgeId: currentEdgeId,
|
|
onSelectEdge: (edgeId) {
|
|
setState(() {
|
|
_selectedEdgeId = edgeId;
|
|
});
|
|
_fetchEdgeDetails(edgeId);
|
|
},
|
|
onRefresh: _fetchEdges,
|
|
),
|
|
nodes: NodesPanel(
|
|
edges: _edges,
|
|
selectedEdgeId: currentEdgeId,
|
|
selectedEdgeStatus: currentEdgeId != null ? _edgeStatuses[currentEdgeId] : null,
|
|
isLoading: currentEdgeId != null ? (_loadingStatuses[currentEdgeId] ?? false) : false,
|
|
error: currentEdgeId != null ? _statusErrors[currentEdgeId] : null,
|
|
onSelectEdge: (edgeId) {
|
|
setState(() {
|
|
_selectedEdgeId = edgeId;
|
|
});
|
|
_fetchEdgeDetails(edgeId);
|
|
},
|
|
onRefresh: () {
|
|
if (currentEdgeId != null) _fetchEdgeStatus(currentEdgeId);
|
|
},
|
|
),
|
|
executionLogs: ExecutionLogsPanel(
|
|
edges: _edges,
|
|
selectedEdgeId: currentEdgeId,
|
|
events: currentEdgeId != null ? (_edgeEvents[currentEdgeId] ?? []) : [],
|
|
isLoading: currentEdgeId != null ? (_loadingEvents[currentEdgeId] ?? false) : false,
|
|
error: currentEdgeId != null ? _eventErrors[currentEdgeId] : null,
|
|
onSelectEdge: (edgeId) {
|
|
setState(() {
|
|
_selectedEdgeId = edgeId;
|
|
});
|
|
_fetchEdgeDetails(edgeId);
|
|
},
|
|
onRefresh: () {
|
|
if (currentEdgeId != null) _fetchEdgeEvents(currentEdgeId);
|
|
},
|
|
),
|
|
agent: const IopAgentPanel(capabilities: iopDefaultCapabilityPack),
|
|
);
|
|
}
|
|
|
|
}
|