Files
clawgo/webui/src/pages/Nodes.tsx

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;