From be2e025fe58c6369b601a576f795bec60caf29ca Mon Sep 17 00:00:00 2001 From: lpf Date: Mon, 9 Mar 2026 08:49:19 +0800 Subject: [PATCH] feat(webui): add nodes detail page --- webui/src/App.tsx | 2 + webui/src/components/Sidebar.tsx | 1 + webui/src/i18n/index.ts | 12 ++ webui/src/pages/Nodes.tsx | 266 +++++++++++++++++++++++++++++++ 4 files changed, 281 insertions(+) create mode 100644 webui/src/pages/Nodes.tsx diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 6da4774..fb32185 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -12,6 +12,7 @@ const Logs = lazy(() => import('./pages/Logs')); const Skills = lazy(() => import('./pages/Skills')); const MCP = lazy(() => import('./pages/MCP')); const Memory = lazy(() => import('./pages/Memory')); +const Nodes = lazy(() => import('./pages/Nodes')); const TaskAudit = lazy(() => import('./pages/TaskAudit')); const EKG = lazy(() => import('./pages/EKG')); const LogCodes = lazy(() => import('./pages/LogCodes')); @@ -41,6 +42,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 9aa5e00..fd8d97c 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -20,6 +20,7 @@ const Sidebar: React.FC = () => { { title: t('sidebarRuntime'), items: [ + { icon: , label: t('nodes'), to: '/nodes' }, { icon: , label: t('taskAudit'), to: '/task-audit' }, { icon: , label: t('logs'), to: '/logs' }, { icon: , label: t('ekg'), to: '/ekg' }, diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index 74d7ce9..d73b34c 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -12,6 +12,7 @@ const resources = { mcpServicesHint: 'Manage MCP servers, install packages, and inspect discovered remote tools.', cronJobs: 'Cron Jobs', nodes: 'Nodes', + nodesDetailHint: 'Inspect node capabilities, mirrored remote agents, recent dispatches, and returned artifacts.', agentTree: 'Agent Tree', noAgentTree: 'No agent tree available.', readonlyMirror: 'Read-only mirror', @@ -113,6 +114,11 @@ const resources = { online: 'Online', offline: 'Offline', activeSessions: 'Active Sessions', + nodeDetails: 'Node Details', + nodeCapabilities: 'Node Capabilities', + nodeActions: 'Node Actions', + nodeModels: 'Node Models', + nodeAgents: 'Node Agents', nodesOnline: 'Nodes Online', recentCron: 'Recent Cron Jobs', nodesSnapshot: 'Nodes Snapshot', @@ -567,6 +573,7 @@ const resources = { mcpServicesHint: '管理 MCP 服务、安装服务包,并查看已发现的远端工具。', cronJobs: '定时任务', nodes: '节点', + nodesDetailHint: '查看节点能力、远端镜像 agent、最近调度记录以及返回的工件。', agentTree: '代理树', noAgentTree: '当前没有可用的代理树。', readonlyMirror: '只读镜像', @@ -668,6 +675,11 @@ const resources = { online: '在线', offline: '离线', activeSessions: '活跃会话', + nodeDetails: '节点详情', + nodeCapabilities: '节点能力', + nodeActions: '节点动作', + nodeModels: '节点模型', + nodeAgents: '节点 Agents', nodesOnline: '在线节点', recentCron: '最近定时任务', nodesSnapshot: '节点快照', diff --git a/webui/src/pages/Nodes.tsx b/webui/src/pages/Nodes.tsx new file mode 100644 index 0000000..de36b49 --- /dev/null +++ b/webui/src/pages/Nodes.tsx @@ -0,0 +1,266 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppContext } from '../context/AppContext'; +import { formatLocalDateTime } from '../utils/time'; + +function dataUrlForArtifact(artifact: any) { + const mime = String(artifact?.mime_type || '').trim() || 'application/octet-stream'; + const content = String(artifact?.content_base64 || '').trim(); + if (!content) return ''; + return `data:${mime};base64,${content}`; +} + +function formatBytes(value: unknown) { + const size = Number(value || 0); + if (!Number.isFinite(size) || size <= 0) return '-'; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +const Nodes: React.FC = () => { + const { t } = useTranslation(); + const { q, nodes, nodeTrees, nodeP2P, refreshNodes } = useAppContext(); + const [selectedNodeID, setSelectedNodeID] = useState(''); + const [dispatches, setDispatches] = useState([]); + const [loading, setLoading] = useState(false); + + const nodeItems = useMemo(() => { + try { + const parsed = JSON.parse(nodes || '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }, [nodes]); + + const treeItems = useMemo(() => { + try { + const parsed = JSON.parse(nodeTrees || '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }, [nodeTrees]); + + useEffect(() => { + if (!selectedNodeID && nodeItems.length > 0) { + setSelectedNodeID(String(nodeItems[0]?.id || '')); + } + }, [selectedNodeID, nodeItems]); + + useEffect(() => { + let cancelled = false; + const fetchDispatches = async () => { + setLoading(true); + try { + const r = await fetch(`/webui/api/node_dispatches${q ? `${q}&limit=200` : '?limit=200'}`); + if (!r.ok) throw new Error(await r.text()); + const j = await r.json(); + if (!cancelled) { + setDispatches(Array.isArray(j.items) ? j.items : []); + } + } catch (err) { + console.error(err); + if (!cancelled) setDispatches([]); + } finally { + if (!cancelled) setLoading(false); + } + }; + fetchDispatches(); + return () => { cancelled = true; }; + }, [q]); + + const selectedNode = useMemo(() => { + return nodeItems.find((item) => String(item?.id || '') === selectedNodeID) || nodeItems[0] || null; + }, [nodeItems, selectedNodeID]); + + const selectedTree = useMemo(() => { + const nodeID = String(selectedNode?.id || ''); + return treeItems.find((item) => String(item?.node_id || '') === nodeID) || null; + }, [treeItems, selectedNode]); + + const selectedSession = useMemo(() => { + const nodeID = String(selectedNode?.id || ''); + const sessions = Array.isArray(nodeP2P?.nodes) ? nodeP2P.nodes : []; + return sessions.find((item: any) => String(item?.node || '') === nodeID) || null; + }, [nodeP2P, selectedNode]); + + const filteredDispatches = useMemo(() => { + const nodeID = String(selectedNode?.id || ''); + return dispatches.filter((item) => String(item?.node || '') === nodeID); + }, [dispatches, selectedNode]); + + return ( +
+
+
+

{t('nodes')}

+
{t('nodesDetailHint')}
+
+ +
+ +
+
+
{t('nodes')}
+
+ {nodeItems.length === 0 ? ( +
{t('noNodes')}
+ ) : nodeItems.map((node: any, index: number) => { + const nodeID = String(node?.id || `node-${index}`); + const active = String(selectedNode?.id || '') === nodeID; + return ( + + ); + })} +
+
+ +
+
+
{t('nodeDetails')}
+
+ {!selectedNode ? ( +
{t('noNodes')}
+ ) : ( + <> +
+
{t('status')}
{selectedNode.online ? t('online') : t('offline')}
+
{t('time')}
{formatLocalDateTime(selectedNode.last_seen_at)}
+
{t('version')}
{String(selectedNode.version || '-')}
+
OS
{String(selectedNode.os || '-')}
+
Arch
{String(selectedNode.arch || '-')}
+
Endpoint
{String(selectedNode.endpoint || '-')}
+
+ +
+
+
{t('nodeCapabilities')}
+
+ {Object.entries(selectedNode.capabilities || {}).filter(([, enabled]) => Boolean(enabled)).map(([key]) => key).join(', ') || '-'} +
+
+
+
{t('nodeActions')}
+
+ {Array.isArray(selectedNode.actions) && selectedNode.actions.length > 0 ? selectedNode.actions.join(', ') : '-'} +
+
+
+
{t('nodeModels')}
+
+ {Array.isArray(selectedNode.models) && selectedNode.models.length > 0 ? selectedNode.models.join(', ') : '-'} +
+
+
+
{t('nodeAgents')}
+
+ {Array.isArray(selectedNode.agents) && selectedNode.agents.length > 0 ? selectedNode.agents.map((item: any) => String(item?.id || '-')).join(', ') : '-'} +
+
+
+ +
+
{t('nodeP2P')}
+
+ {selectedSession ? ( +
+
{t('status')}
{String(selectedSession.status || 'unknown')}
+
{t('dashboardNodeP2PSessionRetries')}
{Number(selectedSession.retry_count || 0)}
+
{t('dashboardNodeP2PSessionReady')}
{formatLocalDateTime(selectedSession.last_ready_at)}
+
{t('dashboardNodeP2PSessionError')}
{String(selectedSession.last_error || '-')}
+
+ ) : ( +
{t('dashboardNodeP2PSessionsEmpty')}
+ )} +
+
+ +
+
{t('agentTree')}
+
+ {Array.isArray(selectedTree?.items) && selectedTree.items.length > 0 ? selectedTree.items.map((item: any, index: number) => ( +
+
{String(item?.display_name || item?.agent_id || '-')}
+
{String(item?.agent_id || '-')} · {String(item?.transport || '-')} · {String(item?.role || '-')}
+
+ )) : ( +
{t('noAgentTree')}
+ )} +
+
+ + )} +
+
+ +
+
{t('dashboardNodeDispatches')}
+
+ {filteredDispatches.length === 0 ? ( +
{t('dashboardNodeDispatchesEmpty')}
+ ) : filteredDispatches.map((item: any, index: number) => ( +
+
+
+
{String(item?.action || '-')}
+
{formatLocalDateTime(item?.time)}
+
+
+ {item?.ok ? 'ok' : 'error'} +
+
+
+
{t('dashboardNodeDispatchTransport')}
{String(item?.used_transport || '-')}
+
{t('dashboardNodeDispatchFallback')}
{String(item?.fallback_from || '-')}
+
{t('duration')}
{Number(item?.duration_ms || 0)}ms
+
{t('dashboardNodeDispatchArtifacts')}
{Number(item?.artifact_count || 0)}
+
+ {Array.isArray(item?.artifacts) && item.artifacts.length > 0 && ( +
+ {item.artifacts.slice(0, 3).map((artifact: any, artifactIndex: number) => { + const kind = String(artifact?.kind || '').trim().toLowerCase(); + const mime = String(artifact?.mime_type || '').trim().toLowerCase(); + const isImage = kind === 'image' || mime.startsWith('image/'); + const isVideo = kind === 'video' || mime.startsWith('video/'); + const dataUrl = dataUrlForArtifact(artifact); + return ( +
+
{String(artifact?.name || artifact?.source_path || `artifact-${artifactIndex + 1}`)}
+
+ {[artifact?.kind, artifact?.mime_type, formatBytes(artifact?.size_bytes)].filter(Boolean).join(' · ')} +
+
+ {isImage && dataUrl && {String(artifact?.name} + {isVideo && dataUrl &&
+
+ ); + })} +
+ )} +
+ ))} +
+
+
+
+
+ ); +}; + +export default Nodes;