import 'package:flutter/material.dart'; import '../control_plane_status_client.dart'; /// Renders the node area below the edge selector across load states. class NodesPanelContent extends StatelessWidget { final EdgeStatusResponseView? status; final bool isLoading; final String? error; const NodesPanelContent({ super.key, required this.status, required this.isLoading, required this.error, }); @override Widget build(BuildContext context) { if (isLoading) { return const _NodesLoadingState(); } if (error != null) { return _NodesErrorState(error!); } final nodes = status?.nodes ?? []; if (nodes.isEmpty) { return const _NodesEmptyState(); } return _NodesList(nodes); } } class _NodesLoadingState extends StatelessWidget { const _NodesLoadingState(); @override Widget build(BuildContext context) { return const Center( child: CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), ), ); } } class _NodesErrorState extends StatelessWidget { final String error; const _NodesErrorState(this.error); @override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 48), const SizedBox(height: 16), const Text( 'Failed to Load Nodes Status', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white, ), ), const SizedBox(height: 8), Text( error, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13), ), ], ), ); } } class _NodesEmptyState extends StatelessWidget { const _NodesEmptyState(); @override Widget build(BuildContext context) { return const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.developer_board_off_outlined, color: Color(0xFF64748B), size: 56, ), SizedBox(height: 16), Text( 'No Nodes Registered', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white, ), ), SizedBox(height: 4), Text( 'No active nodes have registered on this edge.', style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13), ), ], ), ); } } class _NodesList extends StatelessWidget { final List nodes; const _NodesList(this.nodes); @override Widget build(BuildContext context) { return ListView.builder( itemCount: nodes.length, itemBuilder: (context, index) { return NodeStatusCard(nodes[index]); }, ); } } /// Renders a single node: header, optional label, adapter summary, and catalog. class NodeStatusCard extends StatelessWidget { final EdgeNodeSnapshotView node; const NodeStatusCard(this.node, {super.key}); @override Widget build(BuildContext context) { return Card( color: const Color(0xFF1E293B), margin: const EdgeInsets.only(bottom: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide( color: node.connected ? const Color(0xFF10B981).withValues(alpha: 0.3) : const Color(0xFF334155), ), ), child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildHeader(node), if (node.label.isNotEmpty) ...[ const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: const Color(0xFF334155), borderRadius: BorderRadius.circular(4), ), child: Text( 'Label: ${node.label}', style: const TextStyle(color: Colors.white, fontSize: 12), ), ), ], if (node.config != null) ..._buildAdapters(node.config!), if (node.providerSnapshots.isNotEmpty) ..._buildProviderCatalog(node), ], ), ), ); } Widget _buildHeader(EdgeNodeSnapshotView node) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( node.alias.isNotEmpty ? node.alias : node.nodeId, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white, ), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( 'ID: ${node.nodeId}', style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: node.connected ? const Color(0xFF10B981).withValues(alpha: 0.1) : const Color(0xFFEF4444).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Text( node.connected ? 'CONNECTED' : 'OFFLINE', style: TextStyle( color: node.connected ? const Color(0xFF10B981) : const Color(0xFFEF4444), fontSize: 10, fontWeight: FontWeight.bold, ), ), ), ], ); } List _buildAdapters(NodeConfigSummaryView config) { return [ const SizedBox(height: 16), const Divider(color: Color(0xFF334155)), const SizedBox(height: 12), Row( children: [ const Icon(Icons.bolt, size: 16, color: Color(0xFF6366F1)), const SizedBox(width: 6), Text( 'Concurrency Limit: ${config.concurrency}', style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13), ), ], ), const SizedBox(height: 12), const Text( 'Adapters', style: TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), if (config.adapters.isEmpty) const Text( 'No active adapters configured', style: TextStyle(color: Color(0xFF94A3B8), fontSize: 12), ) else Wrap( spacing: 8, runSpacing: 8, children: config.adapters.map((adapter) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: adapter.enabled ? const Color(0xFF10B981).withValues(alpha: 0.1) : const Color(0xFFEF4444).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(6), border: Border.all( color: adapter.enabled ? const Color(0xFF10B981) : const Color(0xFFEF4444), width: 1, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( adapter.type, style: TextStyle( color: adapter.enabled ? const Color(0xFF10B981) : const Color(0xFFEF4444), fontSize: 12, fontWeight: FontWeight.w500, ), ), const SizedBox(width: 4), Icon( adapter.enabled ? Icons.check : Icons.close, size: 12, color: adapter.enabled ? const Color(0xFF10B981) : const Color(0xFFEF4444), ), ], ), ); }).toList(), ), ]; } List _buildProviderCatalog(EdgeNodeSnapshotView node) { return [ const SizedBox(height: 16), const Divider(color: Color(0xFF334155)), const SizedBox(height: 12), const Text( 'Provider Catalog', style: TextStyle( color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), ...node.providerSnapshots.map((p) => ProviderSnapshotCard(p)), ]; } } /// Renders one provider snapshot: identity/state badge, models, load, chips. class ProviderSnapshotCard extends StatelessWidget { final ProviderSnapshotView provider; const ProviderSnapshotCard(this.provider, {super.key}); @override Widget build(BuildContext context) { final p = provider; return Padding( padding: const EdgeInsets.only(bottom: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildIdentityRow(p), if (p.servedModels.isNotEmpty) ...[ const SizedBox(height: 2), Text( 'Models: ${_truncateModels(p.servedModels)}', style: const TextStyle(color: Color(0xFFCBD5E1), fontSize: 10), overflow: TextOverflow.ellipsis, ), ], const SizedBox(height: 2), _buildLoadRow(p), if (p.lifecycleCapabilities.isNotEmpty) ...[ const SizedBox(height: 2), _buildLifecycleChips(p), ], ], ), ); } Widget _buildIdentityRow(ProviderSnapshotView p) { final displayName = p.id.isNotEmpty ? p.id : p.adapter; final displayState = p.health.isNotEmpty ? p.health : p.status; return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 6, height: 6, decoration: BoxDecoration( color: _stateColor(p), shape: BoxShape.circle, ), ), const SizedBox(width: 4), Flexible( child: Text( displayName, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600, ), overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 1), Text( '${p.type} / ${p.category}', style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10), ), ], ), ), const SizedBox(width: 4), Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: _stateColor(p).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(3), ), child: Text( displayState.toUpperCase(), style: TextStyle( color: _stateColor(p), fontSize: 8, fontWeight: FontWeight.bold, ), ), ), ], ); } Widget _buildLoadRow(ProviderSnapshotView p) { return Wrap( spacing: 8, children: [ Text( 'Load: ${p.inFlight}/${p.capacity}', style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10), ), Text( 'Q: ${p.queued}', style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10), ), Text( '${(p.loadRatio * 100).toStringAsFixed(1)}%', style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10), ), ], ); } Widget _buildLifecycleChips(ProviderSnapshotView p) { return Wrap( spacing: 3, runSpacing: 2, children: p.lifecycleCapabilities.take(3).map((c) { return Container( padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1), decoration: BoxDecoration( color: const Color(0xFF6366F1).withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2), ), child: Text( c, style: const TextStyle(color: Color(0xFFA5B4FC), fontSize: 8), ), ); }).toList(), ); } } Color _stateColor(ProviderSnapshotView p) { final health = p.health.toLowerCase(); final status = p.status.toLowerCase(); // Health가 있으면 health가 status를 우선한다. if (health.isNotEmpty) { if (health == 'healthy' || health == 'ok' || health == 'available') { return const Color(0xFF10B981); } if (health == 'unhealthy' || health == 'error' || health == 'failed' || health == 'unavailable') { return const Color(0xFFEF4444); } if (health == 'degraded' || health == 'draining') { return const Color(0xFFF59E0B); } if (health == 'unknown') { return const Color(0xFF64748B); } } // Health가 없으면 status를 기준으로 한다. if (status == 'active' || status == 'ready' || status == 'available') { return const Color(0xFF10B981); } if (status == 'error' || status == 'failed' || status == 'unavailable') { return const Color(0xFFEF4444); } if (status == 'draining') { return const Color(0xFFF59E0B); } if (status == 'unknown') { return const Color(0xFF64748B); } return const Color(0xFF64748B); } String _truncateModels(List models) { if (models.isEmpty) return ''; if (models.length <= 3) return models.join(', '); return '${models.take(3).join(', ')}...'; }