mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 06:47:30 +08:00
feat: document and surface node p2p telemetry
This commit is contained in:
@@ -245,6 +245,7 @@ user -> main -> worker -> main -> user
|
||||
|
||||
- `webrtc` 建连失败时,调度层仍会回退到现有 relay / tunnel 路径
|
||||
- Dashboard、`status`、`/webui/api/nodes` 会显示当前 Node P2P 状态和会话摘要
|
||||
- 两台公网机器的实网验证流程见 [docs/node-p2p-e2e.md](/Users/lpf/Desktop/project/clawgo/docs/node-p2p-e2e.md)
|
||||
|
||||
## MCP 服务支持
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ Notes:
|
||||
|
||||
- when `webrtc` session setup fails, dispatch still falls back to the existing relay / tunnel path
|
||||
- Dashboard, `status`, and `/webui/api/nodes` expose the current Node P2P runtime summary
|
||||
- a reusable public-network validation flow is documented in [docs/node-p2p-e2e.md](/Users/lpf/Desktop/project/clawgo/docs/node-p2p-e2e.md)
|
||||
|
||||
## MCP Server Support
|
||||
|
||||
|
||||
287
docs/node-p2p-e2e.md
Normal file
287
docs/node-p2p-e2e.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Node P2P E2E
|
||||
|
||||
这份文档用于验证 `gateway.nodes.p2p` 的两条真实数据面:
|
||||
|
||||
- `websocket_tunnel`
|
||||
- `webrtc`
|
||||
|
||||
目标不是单元测试,而是两台公网机器上的真实联通性验证。
|
||||
|
||||
## 验证目标
|
||||
|
||||
验证通过需要同时满足:
|
||||
|
||||
1. 两台远端 node 都能成功注册到同一个 gateway
|
||||
2. `websocket_tunnel` 模式下,远端 node 任务可成功完成
|
||||
3. `webrtc` 模式下,远端 node 任务可成功完成
|
||||
4. `webrtc` 模式下,`/webui/api/nodes` 的 `p2p.active_sessions` 大于 `0`
|
||||
5. `Dashboard` / `Subagents` 能看到 node P2P 会话状态和最近调度路径
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 一台 gateway 机器
|
||||
- 两台远端 node 机器
|
||||
- 三台机器都能运行 `clawgo`
|
||||
- 远端机器有 `python3`
|
||||
- gateway 机器对外开放 WebUI / node registry 端口
|
||||
|
||||
推荐:
|
||||
|
||||
- 先验证 `websocket_tunnel`
|
||||
- 再切到 `webrtc`
|
||||
- `webrtc` 至少配置一个可用的 `stun_servers`
|
||||
|
||||
## 测试思路
|
||||
|
||||
为了排除 HTTP relay 误判,建议让目标 node 的 `endpoint` 故意写成只对目标 node 本机有效的地址,例如:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:<port>
|
||||
```
|
||||
|
||||
这样如果任务仍能完成,就说明请求不是靠 gateway 直接 HTTP relay 打过去的,而是走了 node P2P 通道。
|
||||
|
||||
## 建议配置
|
||||
|
||||
### 1. websocket_tunnel
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 18790,
|
||||
"token": "YOUR_GATEWAY_TOKEN",
|
||||
"nodes": {
|
||||
"p2p": {
|
||||
"enabled": true,
|
||||
"transport": "websocket_tunnel",
|
||||
"stun_servers": [],
|
||||
"ice_servers": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. webrtc
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 18790,
|
||||
"token": "YOUR_GATEWAY_TOKEN",
|
||||
"nodes": {
|
||||
"p2p": {
|
||||
"enabled": true,
|
||||
"transport": "webrtc",
|
||||
"stun_servers": ["stun:stun.l.google.com:19302"],
|
||||
"ice_servers": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最小 node endpoint
|
||||
|
||||
在每台远端 node 上启动一个最小 HTTP 服务,用于返回固定结果:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
PORT = int(os.environ.get("PORT", "19081"))
|
||||
LABEL = os.environ.get("NODE_LABEL", socket.gethostname())
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", "0") or 0)
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
try:
|
||||
req = json.loads(raw.decode("utf-8") or "{}")
|
||||
except Exception:
|
||||
req = {}
|
||||
action = req.get("action") or self.path.strip("/")
|
||||
payload = {
|
||||
"handler": LABEL,
|
||||
"hostname": socket.gethostname(),
|
||||
"path": self.path,
|
||||
"echo": req,
|
||||
}
|
||||
if action == "agent_task":
|
||||
payload["result"] = f"agent_task from {LABEL}"
|
||||
else:
|
||||
payload["result"] = f"{action} from {LABEL}"
|
||||
body = json.dumps({
|
||||
"ok": True,
|
||||
"code": "ok",
|
||||
"node": LABEL,
|
||||
"action": action,
|
||||
"payload": payload,
|
||||
}).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
HTTPServer(("0.0.0.0", PORT), H).serve_forever()
|
||||
```
|
||||
|
||||
## 注册远端 node
|
||||
|
||||
在每台 node 上执行:
|
||||
|
||||
```bash
|
||||
clawgo node register \
|
||||
--gateway http://<gateway-host>:18790 \
|
||||
--token YOUR_GATEWAY_TOKEN \
|
||||
--id <node-id> \
|
||||
--name <node-name> \
|
||||
--endpoint http://127.0.0.1:<endpoint-port> \
|
||||
--actions run,agent_task \
|
||||
--models gpt-4o-mini \
|
||||
--capabilities run,invoke,model \
|
||||
--watch \
|
||||
--heartbeat-sec 10
|
||||
```
|
||||
|
||||
验证注册成功:
|
||||
|
||||
```bash
|
||||
curl -s -H 'Authorization: Bearer YOUR_GATEWAY_TOKEN' \
|
||||
http://<gateway-host>:18790/webui/api/nodes
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- 远端 node 出现在 `nodes`
|
||||
- `online = true`
|
||||
- 主拓扑中出现 `node.<id>.main`
|
||||
|
||||
## 建议的任务验证方式
|
||||
|
||||
不要通过普通聊天 prompt 让模型“自己决定是否调用 nodes 工具”作为主判据。
|
||||
更稳定的方式是直接调用 subagent runtime,把任务派给远端 node branch:
|
||||
|
||||
```bash
|
||||
curl -s \
|
||||
-H 'Authorization: Bearer YOUR_GATEWAY_TOKEN' \
|
||||
-H 'Content-Type: application/json' \
|
||||
http://<gateway-host>:18790/webui/api/subagents_runtime \
|
||||
-d '{
|
||||
"action": "dispatch_and_wait",
|
||||
"agent_id": "node.<node-id>.main",
|
||||
"task": "Return exactly the string NODE_P2P_OK",
|
||||
"wait_timeout_sec": 30
|
||||
}'
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `ok = true`
|
||||
- `result.reply.status = completed`
|
||||
- `result.reply.result` 含远端 endpoint 返回内容
|
||||
|
||||
## websocket_tunnel 判定
|
||||
|
||||
在 `websocket_tunnel` 模式下,上面的任务应能成功完成。
|
||||
|
||||
如果目标 node 的 `endpoint` 配成了 `127.0.0.1:<port>`,且任务仍成功,则说明:
|
||||
|
||||
- 不是 gateway 直接 HTTP relay 到远端公网地址
|
||||
- 实际请求已经通过 node websocket 隧道送达目标 node
|
||||
|
||||
## webrtc 判定
|
||||
|
||||
切到 `webrtc` 配置后,重复同样的 `dispatch_and_wait`。
|
||||
|
||||
随后查看:
|
||||
|
||||
```bash
|
||||
curl -s -H 'Authorization: Bearer YOUR_GATEWAY_TOKEN' \
|
||||
http://<gateway-host>:18790/webui/api/nodes
|
||||
```
|
||||
|
||||
预期 `p2p` 段包含:
|
||||
|
||||
- `transport = "webrtc"`
|
||||
- `active_sessions > 0`
|
||||
- `nodes[].status = "open"`
|
||||
- `nodes[].last_ready_at` 非空
|
||||
|
||||
这表示 WebRTC DataChannel 已经真正建立,而不只是 signaling 被触发。
|
||||
|
||||
## WebUI 判定
|
||||
|
||||
验证页面:
|
||||
|
||||
- `Dashboard`
|
||||
- 能看到 Node P2P 会话明细
|
||||
- 能看到最近节点调度记录,包括 `used_transport` 和 `fallback_from`
|
||||
- `Subagents`
|
||||
- 远端 node branch 的卡片/tooltip 能显示:
|
||||
- P2P transport
|
||||
- session status
|
||||
- retry count
|
||||
- last ready
|
||||
- last error
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. gateway 端口上已经有旧实例
|
||||
|
||||
现象:
|
||||
|
||||
- 新配置明明改了,但 `/webui/api/version` 或 `/webui/api/nodes` 仍表现出旧行为
|
||||
|
||||
处理:
|
||||
|
||||
- 先确认端口上实际监听的是哪一个 `clawgo` 进程
|
||||
- 再启动测试实例
|
||||
|
||||
### 2. chat 路由干扰 node 工具验证
|
||||
|
||||
现象:
|
||||
|
||||
- 普通聊天请求被 router 或 skill 行为分流
|
||||
- 没有真正命中 `nodes` 数据面
|
||||
|
||||
处理:
|
||||
|
||||
- 直接用 `/webui/api/subagents_runtime` 的 `dispatch_and_wait`
|
||||
- 让任务明确走 `node.<id>.main`
|
||||
|
||||
### 3. webrtc 一直停在 connecting
|
||||
|
||||
优先检查:
|
||||
|
||||
- `stun_servers` 是否可达
|
||||
- 两端机器是否允许 UDP 出站
|
||||
- 是否需要 `turn:` / `turns:` 服务器
|
||||
|
||||
### 4. 任务成功但 UI 没显示会话
|
||||
|
||||
优先检查:
|
||||
|
||||
- 是否真的运行在 `webrtc` 配置下
|
||||
- `/webui/api/nodes` 返回的 `p2p` 是否含 `active_sessions`
|
||||
- 前端是否已经更新到包含 node P2P runtime 展示的版本
|
||||
|
||||
## 回归建议
|
||||
|
||||
每次改动以下模块后,至少回归一次本流程:
|
||||
|
||||
- `pkg/nodes/webrtc.go`
|
||||
- `pkg/nodes/transport.go`
|
||||
- `pkg/agent/loop.go`
|
||||
- `pkg/api/server.go`
|
||||
- `cmd/clawgo/cmd_node.go`
|
||||
- `cmd/clawgo/cmd_gateway.go`
|
||||
@@ -1075,12 +1075,45 @@ func (s *Server) webUINodesPayload(ctx context.Context) map[string]interface{} {
|
||||
p2p = s.nodeP2PStatus()
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"nodes": list,
|
||||
"trees": s.buildNodeAgentTrees(ctx, list),
|
||||
"p2p": p2p,
|
||||
"nodes": list,
|
||||
"trees": s.buildNodeAgentTrees(ctx, list),
|
||||
"p2p": p2p,
|
||||
"dispatches": s.webUINodesDispatchPayload(12),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) webUINodesDispatchPayload(limit int) []map[string]interface{} {
|
||||
workspace := strings.TrimSpace(s.workspacePath)
|
||||
if workspace == "" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
path := filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) == 1 && strings.TrimSpace(lines[0]) == "" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, limit)
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
row := map[string]interface{}{}
|
||||
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) webUISessionsPayload() map[string]interface{} {
|
||||
sessionsDir := filepath.Join(filepath.Dir(s.workspacePath), "agents", "main", "sessions")
|
||||
_ = os.MkdirAll(sessionsDir, 0755)
|
||||
@@ -1520,48 +1553,9 @@ func (s *Server) handleWebUINodes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
list := []nodes.NodeInfo{}
|
||||
if s.mgr != nil {
|
||||
list = s.mgr.List()
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
local := nodes.NodeInfo{ID: "local", Name: "local", Endpoint: "gateway", Version: gatewayBuildVersion(), LastSeenAt: time.Now(), Online: true}
|
||||
if strings.TrimSpace(host) != "" {
|
||||
local.Name = host
|
||||
}
|
||||
if ip := detectLocalIP(); ip != "" {
|
||||
local.Endpoint = ip
|
||||
}
|
||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||
matched := false
|
||||
for i := range list {
|
||||
id := strings.ToLower(strings.TrimSpace(list[i].ID))
|
||||
name := strings.ToLower(strings.TrimSpace(list[i].Name))
|
||||
if id == "local" || name == "local" || (hostLower != "" && name == hostLower) {
|
||||
// Always keep local node green/alive with latest ip+version
|
||||
list[i].ID = "local"
|
||||
list[i].Online = true
|
||||
list[i].Version = local.Version
|
||||
if strings.TrimSpace(local.Endpoint) != "" {
|
||||
list[i].Endpoint = local.Endpoint
|
||||
}
|
||||
if strings.TrimSpace(local.Name) != "" {
|
||||
list[i].Name = local.Name
|
||||
}
|
||||
list[i].LastSeenAt = time.Now()
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
list = append([]nodes.NodeInfo{local}, list...)
|
||||
}
|
||||
trees := s.buildNodeAgentTrees(r.Context(), list)
|
||||
p2p := map[string]interface{}{}
|
||||
if s.nodeP2PStatus != nil {
|
||||
p2p = s.nodeP2PStatus()
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "nodes": list, "trees": trees, "p2p": p2p})
|
||||
payload := s.webUINodesPayload(r.Context())
|
||||
payload["ok"] = true
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
|
||||
@@ -433,6 +433,14 @@ func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := NewServer("127.0.0.1", 0, "", nodes.NewManager())
|
||||
workspace := t.TempDir()
|
||||
srv.SetWorkspacePath(workspace)
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "memory"), 0755); err != nil {
|
||||
t.Fatalf("mkdir memory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte("{\"node\":\"edge-b\",\"used_transport\":\"webrtc\",\"fallback_from\":\"\",\"duration_ms\":12}\n"), 0644); err != nil {
|
||||
t.Fatalf("write audit: %v", err)
|
||||
}
|
||||
srv.SetNodeP2PStatusHandler(func() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"enabled": true,
|
||||
@@ -455,4 +463,8 @@ func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) {
|
||||
if p2p == nil || p2p["transport"] != "webrtc" {
|
||||
t.Fatalf("expected p2p summary, got %+v", body)
|
||||
}
|
||||
dispatches, _ := body["dispatches"].([]interface{})
|
||||
if len(dispatches) != 1 {
|
||||
t.Fatalf("expected dispatch audit rows, got %+v", body["dispatches"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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