iop/apps/client/lib/main.dart
toki b77f44a706 feat: control plane status client and UI components
- 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
2026-06-02 19:56:42 +09:00

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),
);
}
}