mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-14 17:07:29 +08:00
555 lines
30 KiB
TypeScript
555 lines
30 KiB
TypeScript
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<any[]>([]);
|
|
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<any>(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<string, any> = {};
|
|
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 (
|
|
<div className="h-full p-4 md:p-6 xl:p-8 flex flex-col gap-4">
|
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
<div>
|
|
<h1 className="text-xl md:text-2xl font-semibold">{t('nodes')}</h1>
|
|
<div className="text-sm text-zinc-500 mt-1">{t('nodesDetailHint')}</div>
|
|
</div>
|
|
<FixedButton
|
|
onClick={() => { refreshNodes(); setReloadTick((value) => value + 1); }}
|
|
variant="primary"
|
|
label={loading ? t('loading') : t('refresh')}
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
|
</FixedButton>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 xl:grid-cols-[300px_1fr_1.1fr] gap-4 flex-1 min-h-0">
|
|
<div className="brand-card ui-panel rounded-[28px] overflow-hidden flex flex-col min-h-0">
|
|
<div className="px-3 py-2 border-b border-zinc-800 dark:border-zinc-700 space-y-2">
|
|
<input
|
|
value={nodeFilter}
|
|
onChange={(e) => setNodeFilter(e.target.value)}
|
|
placeholder={t('nodesFilterPlaceholder')}
|
|
className="ui-input rounded-xl px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="overflow-y-auto min-h-0">
|
|
{filteredNodes.length === 0 ? (
|
|
<div className="p-4 text-sm text-zinc-500">{t('noNodes')}</div>
|
|
) : 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 (
|
|
<button
|
|
key={nodeID}
|
|
onClick={() => setSelectedNodeID(nodeID)}
|
|
className="w-full text-left px-3 py-3 border-b border-zinc-800/60 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-zinc-100 truncate">{String(node?.name || nodeID)}</div>
|
|
<div className="text-xs text-zinc-400 truncate">{nodeID} · {String(node?.os || '-')} / {String(node?.arch || '-')}</div>
|
|
<div className="text-[11px] text-zinc-500 truncate">{String(node?.online ? t('online') : t('offline'))} · {String(node?.version || '-')}</div>
|
|
{tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{tags.slice(0, 4).map((tag: string) => (
|
|
<span key={`${nodeID}-${tag}`} className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[10px] text-zinc-300">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{active && (
|
|
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-indigo-500/15 text-indigo-300 self-center">
|
|
<Check className="w-3.5 h-3.5" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="brand-card ui-panel rounded-[28px] overflow-hidden flex flex-col min-h-0">
|
|
<div className="px-3 py-2 border-b border-zinc-800 dark:border-zinc-700 text-xs text-zinc-400 uppercase tracking-wider">{t('nodeDetails')}</div>
|
|
<div className="p-4 overflow-y-auto min-h-0 space-y-4 text-sm">
|
|
{!selectedNode ? (
|
|
<div className="text-zinc-500">{t('noNodes')}</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
<div><div className="text-zinc-500 text-xs">{t('status')}</div><div>{selectedNode.online ? t('online') : t('offline')}</div></div>
|
|
<div><div className="text-zinc-500 text-xs">{t('time')}</div><div>{formatLocalDateTime(selectedNode.last_seen_at)}</div></div>
|
|
<div><div className="text-zinc-500 text-xs">{t('version')}</div><div>{String(selectedNode.version || '-')}</div></div>
|
|
<div><div className="text-zinc-500 text-xs">OS</div><div>{String(selectedNode.os || '-')}</div></div>
|
|
<div><div className="text-zinc-500 text-xs">Arch</div><div>{String(selectedNode.arch || '-')}</div></div>
|
|
<div><div className="text-zinc-500 text-xs">Endpoint</div><div className="break-all">{String(selectedNode.endpoint || '-')}</div></div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Link to={`/node-artifacts?node=${encodeURIComponent(String(selectedNode.id || ''))}`} className="contents">
|
|
<Button size="xs">{t('nodeArtifacts')}</Button>
|
|
</Link>
|
|
<LinkButton href={`/webui/api/node_artifacts/export${q ? `${q}&node=${encodeURIComponent(String(selectedNode.id || ''))}` : `?node=${encodeURIComponent(String(selectedNode.id || ''))}`}`} size="xs">
|
|
{t('export')}
|
|
</LinkButton>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('nodeTags')}</div>
|
|
<div className="ui-code-panel p-3 break-all">
|
|
{Array.isArray(selectedNode.tags) && selectedNode.tags.length > 0 ? selectedNode.tags.join(', ') : '-'}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('nodeCapabilities')}</div>
|
|
<div className="ui-code-panel p-3 break-all">
|
|
{Object.entries(selectedNode.capabilities || {}).filter(([, enabled]) => Boolean(enabled)).map(([key]) => key).join(', ') || '-'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('nodeActions')}</div>
|
|
<div className="ui-code-panel p-3 break-all">
|
|
{Array.isArray(selectedNode.actions) && selectedNode.actions.length > 0 ? selectedNode.actions.join(', ') : '-'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('nodeModels')}</div>
|
|
<div className="ui-code-panel p-3 break-all">
|
|
{Array.isArray(selectedNode.models) && selectedNode.models.length > 0 ? selectedNode.models.join(', ') : '-'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('nodeAgents')}</div>
|
|
<div className="ui-code-panel p-3 break-all">
|
|
{Array.isArray(selectedNode.agents) && selectedNode.agents.length > 0 ? selectedNode.agents.map((item: any) => String(item?.id || '-')).join(', ') : '-'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('nodeAlerts')}</div>
|
|
<div className="space-y-2">
|
|
{selectedNodeAlerts.length > 0 ? selectedNodeAlerts.map((alert: any, index: number) => {
|
|
const severity = String(alert?.severity || 'warning');
|
|
return (
|
|
<div key={`${alert?.kind || index}-${index}`} className={`rounded-2xl border p-3 ${severity === 'critical' ? 'ui-notice-danger' : 'ui-notice-warning'}`}>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-sm font-medium text-zinc-100">{String(alert?.title || '-')}</div>
|
|
<div className={`ui-pill rounded-full px-2 py-0.5 text-[10px] font-medium ${severity === 'critical' ? 'ui-pill-danger' : 'ui-pill-warning'}`}>{severity}</div>
|
|
</div>
|
|
<div className="mt-2 text-xs text-zinc-300 whitespace-pre-wrap break-words">{String(alert?.detail || '-')}</div>
|
|
</div>
|
|
);
|
|
}) : (
|
|
<div className="ui-code-panel p-3 text-zinc-500">{t('nodeAlertsEmpty')}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('nodeP2P')}</div>
|
|
<div className="ui-code-panel p-3">
|
|
{selectedSession ? (
|
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
<div><div className="text-zinc-500">{t('status')}</div><div>{String(selectedSession.status || 'unknown')}</div></div>
|
|
<div><div className="text-zinc-500">{t('dashboardNodeP2PSessionRetries')}</div><div>{Number(selectedSession.retry_count || 0)}</div></div>
|
|
<div><div className="text-zinc-500">{t('dashboardNodeP2PSessionReady')}</div><div>{formatLocalDateTime(selectedSession.last_ready_at)}</div></div>
|
|
<div><div className="text-zinc-500">{t('dashboardNodeP2PSessionError')}</div><div className="break-all">{String(selectedSession.last_error || '-')}</div></div>
|
|
</div>
|
|
) : (
|
|
<div className="text-zinc-500">{t('dashboardNodeP2PSessionsEmpty')}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('agentTree')}</div>
|
|
<div className="ui-code-panel p-3 space-y-2">
|
|
{Array.isArray(selectedTree?.items) && selectedTree.items.length > 0 ? selectedTree.items.map((item: any, index: number) => (
|
|
<div key={`${item?.agent_id || index}`} className="ui-media-surface rounded-xl border border-zinc-800/80 p-3">
|
|
<div className="text-sm font-medium text-zinc-100">{String(item?.display_name || item?.agent_id || '-')}</div>
|
|
<div className="text-xs text-zinc-500 mt-1">{String(item?.agent_id || '-')} · {String(item?.transport || '-')} · {String(item?.role || '-')}</div>
|
|
</div>
|
|
)) : (
|
|
<div className="text-zinc-500">{t('noAgentTree')}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="brand-card ui-panel rounded-[28px] overflow-hidden flex flex-col min-h-0">
|
|
<div className="px-3 py-2 border-b border-zinc-800 dark:border-zinc-700 space-y-2">
|
|
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('nodeDispatchDetail')}</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
|
<select value={dispatchActionFilter} onChange={(e) => setDispatchActionFilter(e.target.value)} className="ui-select rounded-xl px-2 py-2 text-xs">
|
|
<option value="all">{t('allActions')}</option>
|
|
{dispatchActions.map((action) => <option key={action} value={action}>{action}</option>)}
|
|
</select>
|
|
<select value={dispatchTransportFilter} onChange={(e) => setDispatchTransportFilter(e.target.value)} className="ui-select rounded-xl px-2 py-2 text-xs">
|
|
<option value="all">{t('allTransports')}</option>
|
|
{dispatchTransports.map((transport) => <option key={transport} value={transport}>{transport}</option>)}
|
|
</select>
|
|
<select value={dispatchStatusFilter} onChange={(e) => setDispatchStatusFilter(e.target.value)} className="ui-select rounded-xl px-2 py-2 text-xs">
|
|
<option value="all">{t('allStatus')}</option>
|
|
<option value="ok">ok</option>
|
|
<option value="error">error</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-rows-[220px_1fr] min-h-0 flex-1">
|
|
<div className="overflow-y-auto min-h-0 border-b border-zinc-800/60 dark:border-zinc-700/60">
|
|
{filteredDispatches.length === 0 ? (
|
|
<div className="p-4 text-sm text-zinc-500">{t('dashboardNodeDispatchesEmpty')}</div>
|
|
) : filteredDispatches.map((item: any, index: number) => {
|
|
const key = `${item?.time || ''}:${item?.node || ''}:${item?.action || ''}`;
|
|
const active = `${selectedDispatch?.time || ''}:${selectedDispatch?.node || ''}:${selectedDispatch?.action || ''}` === key;
|
|
return (
|
|
<button
|
|
key={key || `dispatch-${index}`}
|
|
onClick={() => setSelectedDispatchKey(key)}
|
|
className={`w-full text-left px-3 py-2 border-b border-zinc-800/60 transition-colors ${active ? 'bg-indigo-500/15' : ''}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-zinc-100 truncate">{`${item?.action || '-'} · ${item?.used_transport || '-'}`}</div>
|
|
<div className="text-xs text-zinc-400 truncate">{formatLocalDateTime(item?.time)} · {Number(item?.duration_ms || 0)}ms · {Number(item?.artifact_count || 0)} {t('dashboardNodeDispatchArtifacts')}</div>
|
|
</div>
|
|
{active && (
|
|
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-indigo-500/15 text-indigo-300">
|
|
<Check className="w-3.5 h-3.5" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="p-4 overflow-y-auto min-h-0 space-y-3 text-sm">
|
|
{!selectedDispatch ? (
|
|
<div className="text-zinc-500">{t('dashboardNodeDispatchesEmpty')}</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-sm font-medium text-zinc-200">{t('nodeDispatchDetail')}</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button onClick={resetReplayDraft} disabled={replayPending} size="xs">{t('resetReplayDraft')}</Button>
|
|
<Button onClick={replayDispatch} disabled={replayPending} variant="primary" size="xs">
|
|
{replayPending ? t('replaying') : t('replayDispatch')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div><div className="text-zinc-500 text-xs">{t('node')}</div><div>{selectedDispatch.node || '-'}</div></div>
|
|
<div><div className="text-zinc-500 text-xs">{t('action')}</div><div>{selectedDispatch.action || '-'}</div></div>
|
|
<div><div className="text-zinc-500 text-xs">{t('dashboardNodeDispatchTransport')}</div><div>{selectedDispatch.used_transport || '-'}</div></div>
|
|
<div><div className="text-zinc-500 text-xs">{t('dashboardNodeDispatchFallback')}</div><div>{selectedDispatch.fallback_from || '-'}</div></div>
|
|
<div><div className="text-zinc-500 text-xs">{t('duration')}</div><div>{Number(selectedDispatch.duration_ms || 0)}ms</div></div>
|
|
<div><div className="text-zinc-500 text-xs">{t('status')}</div><div>{selectedDispatch.ok ? 'ok' : 'error'}</div></div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('error')}</div>
|
|
<div className="ui-code-panel p-3 whitespace-pre-wrap">{selectedDispatch.error || '-'}</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('nodeReplayRequest')}</div>
|
|
<div className="space-y-2">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<label className="space-y-1">
|
|
<div className="text-zinc-500 text-[11px]">{t('mode')}</div>
|
|
<select value={replayModeDraft} onChange={(e) => setReplayModeDraft(e.target.value)} className="ui-select w-full rounded-xl px-2 py-2 text-xs">
|
|
<option value="auto">auto</option>
|
|
<option value="p2p">p2p</option>
|
|
<option value="relay">relay</option>
|
|
</select>
|
|
</label>
|
|
<label className="space-y-1 col-span-2">
|
|
<div className="text-zinc-500 text-[11px]">{t('model')}</div>
|
|
<input value={replayModelDraft} onChange={(e) => setReplayModelDraft(e.target.value)} className="ui-input w-full rounded-xl px-3 py-2 text-xs" />
|
|
</label>
|
|
</div>
|
|
<label className="space-y-1 block">
|
|
<div className="text-zinc-500 text-[11px]">{t('task')}</div>
|
|
<textarea value={replayTaskDraft} onChange={(e) => setReplayTaskDraft(e.target.value)} className="ui-textarea min-h-24 w-full p-3 text-xs" />
|
|
</label>
|
|
<label className="space-y-1 block">
|
|
<div className="text-zinc-500 text-[11px]">{t('args')}</div>
|
|
<textarea value={replayArgsDraft} onChange={(e) => setReplayArgsDraft(e.target.value)} className="ui-textarea min-h-40 w-full p-3 text-xs font-mono" />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('nodeReplayResult')}</div>
|
|
{replayError ? (
|
|
<div className="ui-notice-danger rounded-2xl border p-3 text-xs whitespace-pre-wrap">{replayError}</div>
|
|
) : (
|
|
<pre className="ui-code-panel p-3 text-xs overflow-auto">{replayResult ? JSON.stringify(replayResult, null, 2) : '-'}</pre>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('dashboardNodeDispatchArtifactPreview')}</div>
|
|
<div className="space-y-3">
|
|
{Array.isArray(selectedDispatch.artifacts) && selectedDispatch.artifacts.length > 0 ? selectedDispatch.artifacts.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 (
|
|
<div key={`artifact-${artifactIndex}`} className="ui-media-surface rounded-xl border border-zinc-800/80 p-3">
|
|
<div className="text-xs font-medium text-zinc-200 truncate">{String(artifact?.name || artifact?.source_path || `artifact-${artifactIndex + 1}`)}</div>
|
|
<div className="text-[11px] text-zinc-500 mt-1 truncate">
|
|
{[artifact?.kind, artifact?.mime_type, formatBytes(artifact?.size_bytes)].filter(Boolean).join(' · ')}
|
|
</div>
|
|
<div className="mt-2">
|
|
{isImage && dataUrl && <img src={dataUrl} alt={String(artifact?.name || 'artifact')} className="ui-media-surface-strong max-h-56 rounded-xl border object-contain" />}
|
|
{isVideo && dataUrl && <video src={dataUrl} controls className="ui-media-surface-strong max-h-56 w-full rounded-xl border" />}
|
|
{!isImage && !isVideo && String(artifact?.content_text || '').trim() !== '' && (
|
|
<pre className="ui-media-surface rounded-xl border p-3 text-[11px] text-zinc-300 whitespace-pre-wrap overflow-auto max-h-48">{String(artifact?.content_text || '')}</pre>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}) : (
|
|
<div className="text-zinc-500">{t('dashboardNodeDispatchesEmpty')}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-zinc-500 text-xs mb-1">{t('rawJson')}</div>
|
|
<pre className="ui-code-panel p-3 text-xs overflow-auto">{selectedDispatchPretty}</pre>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Nodes;
|