diff --git a/pkg/api/server.go b/pkg/api/server.go index f7dd418..4353448 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -226,6 +226,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/webui/api/version", s.handleWebUIVersion) mux.HandleFunc("/webui/api/upload", s.handleWebUIUpload) mux.HandleFunc("/webui/api/nodes", s.handleWebUINodes) + mux.HandleFunc("/webui/api/node_dispatches", s.handleWebUINodeDispatches) mux.HandleFunc("/webui/api/cron", s.handleWebUICron) mux.HandleFunc("/webui/api/skills", s.handleWebUISkills) mux.HandleFunc("/webui/api/sessions", s.handleWebUISessions) @@ -1582,6 +1583,30 @@ func (s *Server) handleWebUINodes(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) handleWebUINodeDispatches(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + limit := 50 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 { + if n > 500 { + n = 500 + } + limit = n + } + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "items": s.webUINodesDispatchPayload(limit), + }) +} + func (s *Server) buildNodeAgentTrees(ctx context.Context, nodeList []nodes.NodeInfo) []map[string]interface{} { trees := make([]map[string]interface{}, 0, len(nodeList)) localRegistry := s.fetchRegistryItems(ctx) diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index 1245ed2..74d7ce9 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -155,6 +155,7 @@ const resources = { internalStream: 'Internal Stream', enable: 'Enable', disable: 'Disable', + action: 'Action', maxRetries: 'Max Retries', retryBackoffMs: 'Retry Backoff (ms)', agentPromptContentPlaceholder: 'AGENT.md content...', @@ -709,6 +710,7 @@ const resources = { internalStream: '内部流', enable: '启用', disable: '停用', + action: '动作', maxRetries: '最大重试次数', retryBackoffMs: '重试退避(毫秒)', agentPromptContentPlaceholder: 'AGENT.md 内容...', diff --git a/webui/src/pages/TaskAudit.tsx b/webui/src/pages/TaskAudit.tsx index f0f3b45..e41147b 100644 --- a/webui/src/pages/TaskAudit.tsx +++ b/webui/src/pages/TaskAudit.tsx @@ -28,11 +28,43 @@ type TaskAuditItem = { [key: string]: any; }; +type NodeDispatchItem = { + time?: string; + node?: string; + action?: string; + ok?: boolean; + used_transport?: string; + fallback_from?: string; + duration_ms?: number; + error?: string; + artifact_count?: number; + artifact_kinds?: string[]; + artifacts?: any[]; + [key: string]: any; +}; + +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 TaskAudit: React.FC = () => { const { t } = useTranslation(); const { q } = useAppContext(); const [items, setItems] = useState([]); const [selected, setSelected] = useState(null); + const [nodeItems, setNodeItems] = useState([]); + const [selectedNode, setSelectedNode] = useState(null); const [loading, setLoading] = useState(false); const [sourceFilter, setSourceFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); @@ -40,18 +72,26 @@ const TaskAudit: React.FC = () => { const fetchData = async () => { setLoading(true); try { - const url = `/webui/api/task_queue${q ? `${q}&limit=300` : '?limit=300'}`; - const r = await fetch(url); - if (!r.ok) throw new Error(await r.text()); - const j = await r.json(); - const arr = Array.isArray(j.items) ? j.items : []; + const taskURL = `/webui/api/task_queue${q ? `${q}&limit=300` : '?limit=300'}`; + const nodeURL = `/webui/api/node_dispatches${q ? `${q}&limit=150` : '?limit=150'}`; + const [taskResp, nodeResp] = await Promise.all([fetch(taskURL), fetch(nodeURL)]); + if (!taskResp.ok) throw new Error(await taskResp.text()); + if (!nodeResp.ok) throw new Error(await nodeResp.text()); + const taskJSON = await taskResp.json(); + const nodeJSON = await nodeResp.json(); + const arr = Array.isArray(taskJSON.items) ? taskJSON.items : []; const sorted = arr.sort((a: any, b: any) => String(b.time || '').localeCompare(String(a.time || ''))); setItems(sorted); if (sorted.length > 0) setSelected(sorted[0]); + const nodeArr = Array.isArray(nodeJSON.items) ? nodeJSON.items : []; + setNodeItems(nodeArr); + if (nodeArr.length > 0) setSelectedNode(nodeArr[0]); } catch (e) { console.error(e); setItems([]); setSelected(null); + setNodeItems([]); + setSelectedNode(null); } finally { setLoading(false); } @@ -91,7 +131,7 @@ const TaskAudit: React.FC = () => { -
+
{t('taskQueue')}
@@ -187,6 +227,89 @@ const TaskAudit: React.FC = () => { )}
+ +
+
{t('dashboardNodeDispatches')}
+
+
+ {nodeItems.length === 0 ? ( +
{t('dashboardNodeDispatchesEmpty')}
+ ) : nodeItems.map((it, idx) => { + const active = selectedNode?.time === it.time && selectedNode?.node === it.node && selectedNode?.action === it.action; + return ( + + ); + })} +
+
+ {!selectedNode ? ( +
{t('selectTask')}
+ ) : ( + <> +
+
{t('nodeP2P')}
{selectedNode.node || '-'}
+
{t('action')}
{selectedNode.action || '-'}
+
{t('dashboardNodeDispatchTransport')}
{selectedNode.used_transport || '-'}
+
{t('dashboardNodeDispatchFallback')}
{selectedNode.fallback_from || '-'}
+
{t('duration')}
{selectedNode.duration_ms || 0}ms
+
{t('status')}
{selectedNode.ok ? 'ok' : 'error'}
+
+ +
+
{t('error')}
+
{selectedNode.error || '-'}
+
+ +
+
{t('dashboardNodeDispatchArtifactPreview')}
+
+ {Array.isArray(selectedNode.artifacts) && selectedNode.artifacts.length > 0 ? selectedNode.artifacts.map((artifact, artifactIndex) => { + 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 ( +
+
{String(artifact?.name || artifact?.source_path || `artifact-${artifactIndex + 1}`)}
+
+ {[artifact?.kind, artifact?.mime_type, formatBytes(artifact?.size_bytes)].filter(Boolean).join(' · ')} +
+
+ {isImage && dataUrl && {String(artifact?.name} + {isVideo && dataUrl &&
+
+ ); + }) : ( +
-
+ )} +
+
+ +
+
{t('rawJson')}
+
{JSON.stringify(selectedNode, null, 2)}
+
+ + )} +
+
+
);