import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { Check, RefreshCw } from 'lucide-react'; import { useAppContext } from '../context/AppContext'; import { formatLocalDateTime } from '../utils/time'; import { Button, FixedButton, LinkButton } from '../components/Button'; 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, nodeAlerts, refreshNodes } = useAppContext(); const [selectedNodeID, setSelectedNodeID] = useState(''); const [dispatches, setDispatches] = useState([]); const [selectedDispatchKey, setSelectedDispatchKey] = useState(''); const [loading, setLoading] = useState(false); const [reloadTick, setReloadTick] = useState(0); const [nodeFilter, setNodeFilter] = useState(''); const [dispatchActionFilter, setDispatchActionFilter] = useState('all'); const [dispatchTransportFilter, setDispatchTransportFilter] = useState('all'); const [dispatchStatusFilter, setDispatchStatusFilter] = useState('all'); const [replayPending, setReplayPending] = useState(false); const [replayResult, setReplayResult] = useState(null); const [replayError, setReplayError] = useState(''); const [replayModeDraft, setReplayModeDraft] = useState('auto'); const [replayTaskDraft, setReplayTaskDraft] = useState(''); const [replayModelDraft, setReplayModelDraft] = useState(''); const [replayArgsDraft, setReplayArgsDraft] = useState('{}'); 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=300` : '?limit=300'}`); if (!r.ok) throw new Error(await r.text()); const j = await r.json(); if (!cancelled) { const items = Array.isArray(j.items) ? j.items : []; setDispatches(items); if (items.length > 0 && !selectedDispatchKey) { const first = items[0]; setSelectedDispatchKey(`${first?.time || ''}:${first?.node || ''}:${first?.action || ''}`); } } } catch (err) { console.error(err); if (!cancelled) setDispatches([]); } finally { if (!cancelled) setLoading(false); } }; fetchDispatches(); return () => { cancelled = true; }; }, [q, reloadTick]); const filteredNodes = useMemo(() => { const keyword = nodeFilter.trim().toLowerCase(); if (!keyword) return nodeItems; return nodeItems.filter((item: any) => { const tags = Array.isArray(item?.tags) ? item.tags.join(' ') : ''; const haystack = [ item?.id, item?.name, item?.os, item?.arch, item?.version, tags, ].join(' ').toLowerCase(); return haystack.includes(keyword); }); }, [nodeItems, nodeFilter]); const selectedNode = useMemo(() => { return nodeItems.find((item) => String(item?.id || '') === selectedNodeID) || filteredNodes[0] || nodeItems[0] || null; }, [nodeItems, filteredNodes, 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 selectedNodeAlerts = useMemo(() => { const nodeID = String(selectedNode?.id || ''); return (Array.isArray(nodeAlerts) ? nodeAlerts : []).filter((item: any) => String(item?.node || '') === nodeID); }, [nodeAlerts, selectedNode]); const filteredDispatches = useMemo(() => { const nodeID = String(selectedNode?.id || ''); return dispatches.filter((item) => { if (String(item?.node || '') !== nodeID) return false; if (dispatchActionFilter !== 'all' && String(item?.action || '') !== dispatchActionFilter) return false; if (dispatchTransportFilter !== 'all' && String(item?.used_transport || '-') !== dispatchTransportFilter) return false; if (dispatchStatusFilter === 'ok' && !item?.ok) return false; if (dispatchStatusFilter === 'error' && item?.ok) return false; return true; }); }, [dispatches, selectedNode, dispatchActionFilter, dispatchTransportFilter, dispatchStatusFilter]); const dispatchActions = useMemo(() => { return Array.from(new Set(dispatches.map((item) => String(item?.action || '').trim()).filter(Boolean))).sort(); }, [dispatches]); const dispatchTransports = useMemo(() => { return Array.from(new Set(dispatches.map((item) => String(item?.used_transport || '').trim()).filter(Boolean))).sort(); }, [dispatches]); const selectedDispatch = useMemo(() => { if (!selectedDispatchKey) return filteredDispatches[0] || null; return filteredDispatches.find((item) => `${item?.time || ''}:${item?.node || ''}:${item?.action || ''}` === selectedDispatchKey) || filteredDispatches[0] || null; }, [filteredDispatches, selectedDispatchKey]); const selectedDispatchPretty = useMemo(() => selectedDispatch ? JSON.stringify(selectedDispatch, null, 2) : '', [selectedDispatch]); useEffect(() => { if (!selectedDispatch) { setReplayModeDraft('auto'); setReplayTaskDraft(''); setReplayModelDraft(''); setReplayArgsDraft('{}'); return; } setReplayModeDraft(String(selectedDispatch.mode || 'auto')); setReplayTaskDraft(String(selectedDispatch.task || '')); setReplayModelDraft(String(selectedDispatch.model || '')); setReplayArgsDraft(JSON.stringify(selectedDispatch.request_args || {}, null, 2)); }, [selectedDispatch]); function resetReplayDraft() { if (!selectedDispatch) return; setReplayModeDraft(String(selectedDispatch.mode || 'auto')); setReplayTaskDraft(String(selectedDispatch.task || '')); setReplayModelDraft(String(selectedDispatch.model || '')); setReplayArgsDraft(JSON.stringify(selectedDispatch.request_args || {}, null, 2)); setReplayError(''); } async function replayDispatch() { if (!selectedDispatch) return; setReplayPending(true); setReplayError(''); setReplayResult(null); try { let parsedArgs: Record = {}; try { parsedArgs = replayArgsDraft.trim() ? JSON.parse(replayArgsDraft) : {}; } catch { throw new Error(t('nodeReplayInvalidArgs')); } const r = await fetch(`/webui/api/node_dispatches/replay${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ node: selectedDispatch.node, action: selectedDispatch.action, mode: replayModeDraft || 'auto', task: replayTaskDraft, model: replayModelDraft, args: parsedArgs, }), }); const text = await r.text(); if (!r.ok) throw new Error(text || 'replay failed'); const json = text ? JSON.parse(text) : {}; setReplayResult(json.result || null); setReloadTick((value) => value + 1); refreshNodes(); } catch (err: any) { setReplayError(String(err?.message || err || 'replay failed')); } finally { setReplayPending(false); } } return (

{t('nodes')}

{t('nodesDetailHint')}
{ refreshNodes(); setReloadTick((value) => value + 1); }} variant="primary" label={loading ? t('loading') : t('refresh')} >
setNodeFilter(e.target.value)} placeholder={t('nodesFilterPlaceholder')} className="ui-input rounded-xl px-3 py-2 text-sm" />
{filteredNodes.length === 0 ? (
{t('noNodes')}
) : filteredNodes.map((node: any, index: number) => { const nodeID = String(node?.id || `node-${index}`); const active = String(selectedNode?.id || '') === nodeID; const tags = Array.isArray(node?.tags) ? node.tags : []; 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('export')}
{t('nodeTags')}
{Array.isArray(selectedNode.tags) && selectedNode.tags.length > 0 ? selectedNode.tags.join(', ') : '-'}
{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('nodeAlerts')}
{selectedNodeAlerts.length > 0 ? selectedNodeAlerts.map((alert: any, index: number) => { const severity = String(alert?.severity || 'warning'); return (
{String(alert?.title || '-')}
{severity}
{String(alert?.detail || '-')}
); }) : (
{t('nodeAlertsEmpty')}
)}
{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('nodeDispatchDetail')}
{filteredDispatches.length === 0 ? (
{t('dashboardNodeDispatchesEmpty')}
) : filteredDispatches.map((item: any, index: number) => { const key = `${item?.time || ''}:${item?.node || ''}:${item?.action || ''}`; const active = `${selectedDispatch?.time || ''}:${selectedDispatch?.node || ''}:${selectedDispatch?.action || ''}` === key; return ( ); })}
{!selectedDispatch ? (
{t('dashboardNodeDispatchesEmpty')}
) : ( <>
{t('nodeDispatchDetail')}
{t('node')}
{selectedDispatch.node || '-'}
{t('action')}
{selectedDispatch.action || '-'}
{t('dashboardNodeDispatchTransport')}
{selectedDispatch.used_transport || '-'}
{t('dashboardNodeDispatchFallback')}
{selectedDispatch.fallback_from || '-'}
{t('duration')}
{Number(selectedDispatch.duration_ms || 0)}ms
{t('status')}
{selectedDispatch.ok ? 'ok' : 'error'}
{t('error')}
{selectedDispatch.error || '-'}
{t('nodeReplayRequest')}