feat: document and surface node p2p telemetry

This commit is contained in:
lpf
2026-03-09 00:56:32 +08:00
parent f441972c56
commit c0fe977bce
9 changed files with 571 additions and 50 deletions

View File

@@ -10,6 +10,7 @@ type RuntimeSnapshot = {
nodes?: any[];
trees?: any[];
p2p?: Record<string, any>;
dispatches?: any[];
};
sessions?: {
sessions?: Array<{ key: string; title?: string; channel?: string }>;
@@ -46,6 +47,8 @@ interface AppContextType {
setNodeTrees: (trees: string) => void;
nodeP2P: Record<string, any>;
setNodeP2P: React.Dispatch<React.SetStateAction<Record<string, any>>>;
nodeDispatchItems: any[];
setNodeDispatchItems: React.Dispatch<React.SetStateAction<any[]>>;
cron: CronJob[];
setCron: (cron: CronJob[]) => void;
skills: Skill[];
@@ -107,6 +110,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [nodes, setNodes] = useState('[]');
const [nodeTrees, setNodeTrees] = useState('[]');
const [nodeP2P, setNodeP2P] = useState<Record<string, any>>({});
const [nodeDispatchItems, setNodeDispatchItems] = useState<any[]>([]);
const [cron, setCron] = useState<CronJob[]>([]);
const [skills, setSkills] = useState<Skill[]>([]);
const [clawhubInstalled, setClawhubInstalled] = useState(false);
@@ -166,6 +170,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
setNodes(JSON.stringify(j.nodes || [], null, 2));
setNodeTrees(JSON.stringify(j.trees || [], null, 2));
setNodeP2P(j.p2p || {});
setNodeDispatchItems(Array.isArray(j.dispatches) ? j.dispatches : []);
setIsGatewayOnline(true);
} catch (e) {
setIsGatewayOnline(false);
@@ -271,6 +276,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
setNodes(JSON.stringify(Array.isArray(snapshot.nodes.nodes) ? snapshot.nodes.nodes : [], null, 2));
setNodeTrees(JSON.stringify(Array.isArray(snapshot.nodes.trees) ? snapshot.nodes.trees : [], null, 2));
setNodeP2P(snapshot.nodes.p2p || {});
setNodeDispatchItems(Array.isArray(snapshot.nodes.dispatches) ? snapshot.nodes.dispatches : []);
}
if (snapshot.sessions) {
const arr = Array.isArray(snapshot.sessions.sessions) ? snapshot.sessions.sessions : [];
@@ -349,7 +355,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
return (
<AppContext.Provider value={{
token, setToken, sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, isGatewayOnline, setIsGatewayOnline,
cfg, setCfg, cfgRaw, setCfgRaw, configEditing, setConfigEditing, nodes, setNodes, nodeTrees, setNodeTrees, nodeP2P, setNodeP2P,
cfg, setCfg, cfgRaw, setCfgRaw, configEditing, setConfigEditing, nodes, setNodes, nodeTrees, setNodeTrees, nodeP2P, setNodeP2P, nodeDispatchItems, setNodeDispatchItems,
cron, setCron, skills, setSkills, clawhubInstalled, clawhubPath,
sessions, setSessions,
taskQueueItems, setTaskQueueItems, ekgSummary, setEkgSummary,

View File

@@ -122,6 +122,20 @@ const resources = {
dashboardNodeP2PTransport: 'Transport',
dashboardNodeP2PIce: 'ICE Config',
dashboardNodeP2PHealth: 'Health',
dashboardNodeP2PSessions: 'Node P2P Sessions',
dashboardNodeP2PSessionsEmpty: 'No node P2P sessions yet.',
dashboardNodeP2PSessionCreated: 'Created',
dashboardNodeP2PSessionRetries: 'Retries',
dashboardNodeP2PSessionReady: 'Last Ready',
dashboardNodeP2PSessionAttempt: 'Last Attempt',
dashboardNodeP2PSessionError: 'Last Error',
dashboardNodeDispatches: 'Recent Node Dispatches',
dashboardNodeDispatchesHint: 'Actual dispatch path and fallback audit from recent node actions.',
dashboardNodeDispatchesEmpty: 'No node dispatch records yet.',
dashboardNodeDispatchTransport: 'Used Transport',
dashboardNodeDispatchFallback: 'Fallback From',
dashboardNodeDispatchDuration: 'Duration',
dashboardNodeDispatchError: 'Error',
configNodeP2P: 'Node P2P',
configNodeP2PHint: 'Configure websocket tunnel or WebRTC transport for remote nodes.',
configNodeP2PStunPlaceholder: 'Comma-separated STUN URLs',
@@ -660,6 +674,20 @@ const resources = {
dashboardNodeP2PTransport: '传输方式',
dashboardNodeP2PIce: 'ICE 配置',
dashboardNodeP2PHealth: '健康状态',
dashboardNodeP2PSessions: '节点 P2P 会话',
dashboardNodeP2PSessionsEmpty: '当前还没有节点 P2P 会话。',
dashboardNodeP2PSessionCreated: '创建时间',
dashboardNodeP2PSessionRetries: '重试次数',
dashboardNodeP2PSessionReady: '最近就绪',
dashboardNodeP2PSessionAttempt: '最近尝试',
dashboardNodeP2PSessionError: '最近错误',
dashboardNodeDispatches: '最近节点调度',
dashboardNodeDispatchesHint: '展示最近节点动作实际走过的传输路径和回退记录。',
dashboardNodeDispatchesEmpty: '当前还没有节点调度记录。',
dashboardNodeDispatchTransport: '实际传输',
dashboardNodeDispatchFallback: '回退来源',
dashboardNodeDispatchDuration: '耗时',
dashboardNodeDispatchError: '错误',
configNodeP2P: '节点 P2P',
configNodeP2PHint: '为远端节点配置 websocket tunnel 或 WebRTC 传输。',
configNodeP2PStunPlaceholder: '逗号分隔的 STUN URL',

View File

@@ -4,6 +4,14 @@ import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import StatCard from '../components/StatCard';
function formatRuntimeTime(value: unknown) {
const raw = String(value || '').trim();
if (!raw || raw === '0001-01-01T00:00:00Z') return '-';
const ts = Date.parse(raw);
if (Number.isNaN(ts)) return raw;
return new Date(ts).toLocaleString();
}
const Dashboard: React.FC = () => {
const { t } = useTranslation();
const {
@@ -15,6 +23,7 @@ const Dashboard: React.FC = () => {
skills,
cfg,
nodeP2P,
nodeDispatchItems,
taskQueueItems,
ekgSummary,
} = useAppContext();
@@ -45,6 +54,35 @@ const Dashboard: React.FC = () => {
const p2pRetryCount = Array.isArray(nodeP2P?.nodes)
? nodeP2P.nodes.reduce((sum: number, session: any) => sum + Number(session?.retry_count || 0), 0)
: 0;
const p2pNodeSessions = useMemo(() => {
if (!Array.isArray(nodeP2P?.nodes)) return [];
return [...nodeP2P.nodes]
.map((session: any) => ({
node: String(session?.node || '-'),
status: String(session?.status || 'unknown'),
retryCount: Number(session?.retry_count || 0),
lastError: String(session?.last_error || '').trim(),
lastReadyAt: formatRuntimeTime(session?.last_ready_at),
lastAttempt: formatRuntimeTime(session?.last_attempt),
createdAt: formatRuntimeTime(session?.created_at),
}))
.sort((a, b) => a.node.localeCompare(b.node));
}, [nodeP2P]);
const recentNodeDispatches = useMemo(() => {
return [...nodeDispatchItems]
.slice(0, 8)
.map((item: any, index: number) => ({
id: `${item?.time || 'dispatch'}-${index}`,
time: formatRuntimeTime(item?.time),
node: String(item?.node || '-'),
action: String(item?.action || '-'),
usedTransport: String(item?.used_transport || '-'),
fallbackFrom: String(item?.fallback_from || '').trim(),
durationMs: Number(item?.duration_ms || 0),
ok: Boolean(item?.ok),
error: String(item?.error || '').trim(),
}));
}, [nodeDispatchItems]);
return (
<div className="p-4 md:p-6 xl:p-8 w-full space-y-6 xl:space-y-8">
@@ -148,6 +186,119 @@ const Dashboard: React.FC = () => {
</div>
</div>
</div>
<div className="brand-card rounded-[30px] border border-zinc-800/80 p-6">
<div className="flex items-center justify-between gap-3 mb-5 flex-wrap">
<div>
<div className="flex items-center gap-2 text-zinc-200">
<Workflow className="w-5 h-5 text-zinc-400" />
<h2 className="text-lg font-medium">{t('dashboardNodeP2PSessions')}</h2>
</div>
<div className="text-xs text-zinc-500 mt-1">
{t('dashboardNodeP2PDetail', { transport: p2pTransport, sessions: p2pSessions, retries: p2pRetryCount })}
</div>
</div>
<div className="text-xs text-zinc-500">
{`${p2pConfiguredIce} ICE · ${p2pConfiguredStun} STUN`}
</div>
</div>
{p2pNodeSessions.length === 0 ? (
<div className="text-sm text-zinc-500 text-center py-8">{t('dashboardNodeP2PSessionsEmpty')}</div>
) : (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
{p2pNodeSessions.map((session) => {
const isOpen = session.status.toLowerCase() === 'open';
const isConnecting = session.status.toLowerCase() === 'connecting';
return (
<div key={session.node} className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-zinc-100 truncate">{session.node}</div>
<div className="text-xs text-zinc-500 mt-1">
{t('dashboardNodeP2PSessionCreated')}: {session.createdAt}
</div>
</div>
<div className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${isOpen ? 'bg-emerald-500/10 text-emerald-300' : isConnecting ? 'bg-amber-500/10 text-amber-300' : 'bg-rose-500/10 text-rose-300'}`}>
{session.status}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4 text-xs">
<div>
<div className="text-zinc-400">{t('dashboardNodeP2PSessionRetries')}</div>
<div className="text-zinc-200 mt-1">{session.retryCount}</div>
</div>
<div>
<div className="text-zinc-400">{t('dashboardNodeP2PSessionReady')}</div>
<div className="text-zinc-200 mt-1">{session.lastReadyAt}</div>
</div>
<div>
<div className="text-zinc-400">{t('dashboardNodeP2PSessionAttempt')}</div>
<div className="text-zinc-200 mt-1">{session.lastAttempt}</div>
</div>
<div>
<div className="text-zinc-400">{t('dashboardNodeP2PSessionError')}</div>
<div className={`mt-1 break-all ${session.lastError ? 'text-rose-300' : 'text-zinc-500'}`}>
{session.lastError || '-'}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="brand-card rounded-[30px] border border-zinc-800/80 p-6">
<div className="flex items-center justify-between gap-3 mb-5 flex-wrap">
<div>
<div className="flex items-center gap-2 text-zinc-200">
<Activity className="w-5 h-5 text-zinc-400" />
<h2 className="text-lg font-medium">{t('dashboardNodeDispatches')}</h2>
</div>
<div className="text-xs text-zinc-500 mt-1">{t('dashboardNodeDispatchesHint')}</div>
</div>
</div>
{recentNodeDispatches.length === 0 ? (
<div className="text-sm text-zinc-500 text-center py-8">{t('dashboardNodeDispatchesEmpty')}</div>
) : (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
{recentNodeDispatches.map((item) => (
<div key={item.id} className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-zinc-100 truncate">{`${item.node} · ${item.action}`}</div>
<div className="text-xs text-zinc-500 mt-1">{item.time}</div>
</div>
<div className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${item.ok ? 'bg-emerald-500/10 text-emerald-300' : 'bg-rose-500/10 text-rose-300'}`}>
{item.ok ? 'ok' : 'error'}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4 text-xs">
<div>
<div className="text-zinc-400">{t('dashboardNodeDispatchTransport')}</div>
<div className="text-zinc-200 mt-1">{item.usedTransport}</div>
</div>
<div>
<div className="text-zinc-400">{t('dashboardNodeDispatchFallback')}</div>
<div className="text-zinc-200 mt-1">{item.fallbackFrom || '-'}</div>
</div>
<div>
<div className="text-zinc-400">{t('dashboardNodeDispatchDuration')}</div>
<div className="text-zinc-200 mt-1">{`${item.durationMs}ms`}</div>
</div>
<div>
<div className="text-zinc-400">{t('dashboardNodeDispatchError')}</div>
<div className={`mt-1 break-all ${item.error ? 'text-rose-300' : 'text-zinc-500'}`}>
{item.error || '-'}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -227,6 +227,14 @@ function formatStreamTime(ts?: number): string {
return new Date(ts).toLocaleTimeString([], { hour12: false });
}
function formatRuntimeTimestamp(value?: string): string {
const raw = `${value || ''}`.trim();
if (!raw || raw === '0001-01-01T00:00:00Z') return '-';
const ts = Date.parse(raw);
if (Number.isNaN(ts)) return raw;
return new Date(ts).toLocaleString();
}
function summarizePreviewText(value?: string, limit = 180): string {
const compact = `${value || ''}`.replace(/\s+/g, ' ').trim();
if (!compact) return '(empty)';
@@ -360,7 +368,7 @@ function GraphCard({
const Subagents: React.FC = () => {
const { t } = useTranslation();
const { q, nodeTrees, subagentRuntimeItems, subagentRegistryItems } = useAppContext();
const { q, nodeTrees, nodeP2P, nodeDispatchItems, subagentRuntimeItems, subagentRegistryItems } = useAppContext();
const ui = useUI();
const [items, setItems] = useState<SubagentTask[]>([]);
@@ -510,6 +518,26 @@ const Subagents: React.FC = () => {
return acc;
}, {});
}, [items]);
const p2pSessionByNode = useMemo(() => {
const out: Record<string, any> = {};
const sessions = Array.isArray(nodeP2P?.nodes) ? nodeP2P.nodes : [];
sessions.forEach((session: any) => {
const nodeID = normalizeTitle(session?.node, '');
if (!nodeID) return;
out[nodeID] = session;
});
return out;
}, [nodeP2P]);
const recentDispatchByNode = useMemo(() => {
const out: Record<string, any> = {};
const rows = Array.isArray(nodeDispatchItems) ? nodeDispatchItems : [];
rows.forEach((row: any) => {
const nodeID = normalizeTitle(row?.node, '');
if (!nodeID || out[nodeID]) return;
out[nodeID] = row;
});
return out;
}, [nodeDispatchItems]);
const topologyGraph = useMemo(() => {
const scale = topologyZoom;
const originX = 56;
@@ -645,6 +673,9 @@ const Subagents: React.FC = () => {
remoteClusters.forEach((cluster, treeIndex) => {
const { tree, root: treeRoot, children } = cluster;
const branch = `node:${normalizeTitle(tree.node_id, `remote-${treeIndex}`)}`;
const nodeID = normalizeTitle(tree.node_id, '');
const p2pSession = p2pSessionByNode[nodeID];
const recentDispatch = recentDispatchByNode[nodeID];
const rootX = remoteOffsetX + Math.max(0, (cluster.width - cardWidth) / 2);
if (!treeRoot) return;
const rootCard: GraphCardSpec = {
@@ -662,10 +693,15 @@ const Subagents: React.FC = () => {
meta: [
`status=${tree.online ? t('online') : t('offline')}`,
`transport=${normalizeTitle(treeRoot.transport, 'node')} type=${normalizeTitle(treeRoot.type, 'router')}`,
`p2p=${normalizeTitle(nodeP2P?.transport, 'disabled')} session=${normalizeTitle(p2pSession?.status, 'unknown')}`,
`last_transport=${normalizeTitle(recentDispatch?.used_transport, '-')}${recentDispatch?.fallback_from ? ` fallback=${normalizeTitle(recentDispatch?.fallback_from, '-')}` : ''}`,
`last_ready=${formatRuntimeTimestamp(p2pSession?.last_ready_at)}`,
`retry=${Number(p2pSession?.retry_count || 0)}`,
`${t('error')}=${normalizeTitle(p2pSession?.last_error, '-')}`,
`source=${normalizeTitle(treeRoot.managed_by, tree.source || '-')}`,
t('remoteTasksUnavailable'),
],
accent: tree.online ? 'bg-fuchsia-400' : 'bg-zinc-500',
accent: !tree.online ? 'bg-zinc-500' : normalizeTitle(p2pSession?.status, '').toLowerCase() === 'open' ? 'bg-emerald-400' : normalizeTitle(p2pSession?.status, '').toLowerCase() === 'connecting' ? 'bg-amber-400' : 'bg-fuchsia-400',
clickable: true,
scale,
onClick: () => {
@@ -695,10 +731,15 @@ const Subagents: React.FC = () => {
subtitle: `${normalizeTitle(child.agent_id, '-')} · ${normalizeTitle(child.role, '-')}`,
meta: [
`transport=${normalizeTitle(child.transport, 'node')} type=${normalizeTitle(child.type, 'worker')}`,
`p2p=${normalizeTitle(nodeP2P?.transport, 'disabled')} session=${normalizeTitle(p2pSession?.status, 'unknown')}`,
`last_transport=${normalizeTitle(recentDispatch?.used_transport, '-')}${recentDispatch?.fallback_from ? ` fallback=${normalizeTitle(recentDispatch?.fallback_from, '-')}` : ''}`,
`last_ready=${formatRuntimeTimestamp(p2pSession?.last_ready_at)}`,
`retry=${Number(p2pSession?.retry_count || 0)}`,
`${t('error')}=${normalizeTitle(p2pSession?.last_error, '-')}`,
`source=${normalizeTitle(child.managed_by, 'remote_webui')}`,
t('remoteTasksUnavailable'),
],
accent: 'bg-violet-400',
accent: normalizeTitle(p2pSession?.status, '').toLowerCase() === 'open' ? 'bg-emerald-400' : normalizeTitle(p2pSession?.status, '').toLowerCase() === 'connecting' ? 'bg-amber-400' : 'bg-violet-400',
clickable: true,
scale,
onClick: () => {
@@ -787,7 +828,7 @@ const Subagents: React.FC = () => {
}));
return { width, height, cards: decoratedCards, lines: decoratedLines };
}, [parsedNodeTrees, registryItems, taskStats, recentTaskByAgent, selectedTopologyBranch, topologyFilter, t, topologyZoom, nodeOverrides]);
}, [parsedNodeTrees, registryItems, taskStats, recentTaskByAgent, selectedTopologyBranch, topologyFilter, t, topologyZoom, nodeOverrides, nodeP2P, p2pSessionByNode, recentDispatchByNode]);
const fitView = () => {
const viewport = topologyViewportRef.current;