mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-15 00:27:29 +08:00
feat: document and surface node p2p telemetry
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user