feat: surface node p2p runtime visibility

This commit is contained in:
lpf
2026-03-08 22:53:03 +08:00
parent daaac53f5a
commit 29729d7c70
10 changed files with 168 additions and 24 deletions

View File

@@ -133,11 +133,25 @@ func gatewayCmd() {
if loop == nil || server == nil || runtimeCfg == nil { if loop == nil || server == nil || runtimeCfg == nil {
return return
} }
server.SetNodeP2PStatusHandler(func() map[string]interface{} {
return map[string]interface{}{
"enabled": runtimeCfg.Gateway.Nodes.P2P.Enabled,
"transport": strings.TrimSpace(runtimeCfg.Gateway.Nodes.P2P.Transport),
"configured_stun": append([]string(nil), runtimeCfg.Gateway.Nodes.P2P.STUNServers...),
}
})
switch { switch {
case runtimeCfg.Gateway.Nodes.P2P.Enabled && strings.EqualFold(strings.TrimSpace(runtimeCfg.Gateway.Nodes.P2P.Transport), "webrtc"): case runtimeCfg.Gateway.Nodes.P2P.Enabled && strings.EqualFold(strings.TrimSpace(runtimeCfg.Gateway.Nodes.P2P.Transport), "webrtc"):
webrtcTransport := nodes.NewWebRTCTransport(runtimeCfg.Gateway.Nodes.P2P.STUNServers) webrtcTransport := nodes.NewWebRTCTransport(runtimeCfg.Gateway.Nodes.P2P.STUNServers)
loop.SetNodeP2PTransport(webrtcTransport) loop.SetNodeP2PTransport(webrtcTransport)
server.SetNodeWebRTCTransport(webrtcTransport) server.SetNodeWebRTCTransport(webrtcTransport)
server.SetNodeP2PStatusHandler(func() map[string]interface{} {
snapshot := webrtcTransport.Snapshot()
snapshot["enabled"] = true
snapshot["transport"] = "webrtc"
snapshot["configured_stun"] = append([]string(nil), runtimeCfg.Gateway.Nodes.P2P.STUNServers...)
return snapshot
})
default: default:
server.SetNodeWebRTCTransport(nil) server.SetNodeWebRTCTransport(nil)
} }

View File

@@ -163,11 +163,18 @@ func statusCmd() {
} }
fmt.Printf("Nodes: total=%d online=%d\n", len(ns), online) fmt.Printf("Nodes: total=%d online=%d\n", len(ns), online)
fmt.Printf("Nodes Capabilities: run=%d model=%d camera=%d screen=%d location=%d canvas=%d\n", caps["run"], caps["model"], caps["camera"], caps["screen"], caps["location"], caps["canvas"]) fmt.Printf("Nodes Capabilities: run=%d model=%d camera=%d screen=%d location=%d canvas=%d\n", caps["run"], caps["model"], caps["camera"], caps["screen"], caps["location"], caps["canvas"])
if total, okCnt, avgMs, actionTop, err := collectNodeDispatchStats(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl")); err == nil && total > 0 { fmt.Printf("Nodes P2P: enabled=%t transport=%s\n", cfg.Gateway.Nodes.P2P.Enabled, strings.TrimSpace(cfg.Gateway.Nodes.P2P.Transport))
if total, okCnt, avgMs, actionTop, transportTop, fallbackCnt, err := collectNodeDispatchStats(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl")); err == nil && total > 0 {
fmt.Printf("Nodes Dispatch: total=%d ok=%d fail=%d avg_ms=%d\n", total, okCnt, total-okCnt, avgMs) fmt.Printf("Nodes Dispatch: total=%d ok=%d fail=%d avg_ms=%d\n", total, okCnt, total-okCnt, avgMs)
if actionTop != "" { if actionTop != "" {
fmt.Printf("Nodes Dispatch Top Action: %s\n", actionTop) fmt.Printf("Nodes Dispatch Top Action: %s\n", actionTop)
} }
if transportTop != "" {
fmt.Printf("Nodes Dispatch Top Transport: %s\n", transportTop)
}
if fallbackCnt > 0 {
fmt.Printf("Nodes Dispatch Fallbacks: %d\n", fallbackCnt)
}
} }
} }
} }
@@ -261,23 +268,26 @@ func collectTriggerErrorCounts(path string) (map[string]int, error) {
return counts, nil return counts, nil
} }
func collectNodeDispatchStats(path string) (int, int, int, string, error) { func collectNodeDispatchStats(path string) (int, int, int, string, string, int, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return 0, 0, 0, "", err return 0, 0, 0, "", "", 0, err
} }
lines := strings.Split(strings.TrimSpace(string(data)), "\n") lines := strings.Split(strings.TrimSpace(string(data)), "\n")
total, okCnt, msSum := 0, 0, 0 total, okCnt, msSum, fallbackCnt := 0, 0, 0, 0
actionCnt := map[string]int{} actionCnt := map[string]int{}
transportCnt := map[string]int{}
for _, line := range lines { for _, line := range lines {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" { if line == "" {
continue continue
} }
var row struct { var row struct {
Action string `json:"action"` Action string `json:"action"`
OK bool `json:"ok"` UsedTransport string `json:"used_transport"`
DurationMS int `json:"duration_ms"` FallbackFrom string `json:"fallback_from"`
OK bool `json:"ok"`
DurationMS int `json:"duration_ms"`
} }
if err := json.Unmarshal([]byte(line), &row); err != nil { if err := json.Unmarshal([]byte(line), &row); err != nil {
continue continue
@@ -294,6 +304,13 @@ func collectNodeDispatchStats(path string) (int, int, int, string, error) {
a = "unknown" a = "unknown"
} }
actionCnt[a]++ actionCnt[a]++
used := strings.TrimSpace(strings.ToLower(row.UsedTransport))
if used != "" {
transportCnt[used]++
}
if strings.TrimSpace(row.FallbackFrom) != "" {
fallbackCnt++
}
} }
avg := 0 avg := 0
if total > 0 { if total > 0 {
@@ -310,7 +327,18 @@ func collectNodeDispatchStats(path string) (int, int, int, string, error) {
if topAction != "" { if topAction != "" {
topAction = fmt.Sprintf("%s(%d)", topAction, topN) topAction = fmt.Sprintf("%s(%d)", topAction, topN)
} }
return total, okCnt, avg, topAction, nil topTransport := ""
topTN := 0
for k, v := range transportCnt {
if v > topTN {
topTN = v
topTransport = k
}
}
if topTransport != "" {
topTransport = fmt.Sprintf("%s(%d)", topTransport, topTN)
}
return total, okCnt, avg, topAction, topTransport, fallbackCnt, nil
} }
func collectSkillExecStats(path string) (int, int, int, float64, string, error) { func collectSkillExecStats(path string) (int, int, int, float64, string, error) {

View File

@@ -41,6 +41,7 @@ type Server struct {
nodeConnIDs map[string]string nodeConnIDs map[string]string
nodeSockets map[string]*nodeSocketConn nodeSockets map[string]*nodeSocketConn
nodeWebRTC *nodes.WebRTCTransport nodeWebRTC *nodes.WebRTCTransport
nodeP2PStatus func() map[string]interface{}
gatewayVersion string gatewayVersion string
webuiVersion string webuiVersion string
configPath string configPath string
@@ -120,6 +121,9 @@ func (s *Server) SetWebUIVersion(v string) { s.webuiVersion
func (s *Server) SetNodeWebRTCTransport(t *nodes.WebRTCTransport) { func (s *Server) SetNodeWebRTCTransport(t *nodes.WebRTCTransport) {
s.nodeWebRTC = t s.nodeWebRTC = t
} }
func (s *Server) SetNodeP2PStatusHandler(fn func() map[string]interface{}) {
s.nodeP2PStatus = fn
}
func (s *Server) rememberNodeConnection(nodeID, connID string) { func (s *Server) rememberNodeConnection(nodeID, connID string) {
nodeID = strings.TrimSpace(nodeID) nodeID = strings.TrimSpace(nodeID)
@@ -1066,9 +1070,14 @@ func (s *Server) webUINodesPayload(ctx context.Context) map[string]interface{} {
if !matched { if !matched {
list = append([]nodes.NodeInfo{local}, list...) list = append([]nodes.NodeInfo{local}, list...)
} }
p2p := map[string]interface{}{}
if s.nodeP2PStatus != nil {
p2p = s.nodeP2PStatus()
}
return map[string]interface{}{ return map[string]interface{}{
"nodes": list, "nodes": list,
"trees": s.buildNodeAgentTrees(ctx, list), "trees": s.buildNodeAgentTrees(ctx, list),
"p2p": p2p,
} }
} }
@@ -1548,7 +1557,11 @@ func (s *Server) handleWebUINodes(w http.ResponseWriter, r *http.Request) {
list = append([]nodes.NodeInfo{local}, list...) list = append([]nodes.NodeInfo{local}, list...)
} }
trees := s.buildNodeAgentTrees(r.Context(), list) trees := s.buildNodeAgentTrees(r.Context(), list)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "nodes": list, "trees": trees}) 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})
case http.MethodPost: case http.MethodPost:
var body struct { var body struct {
Action string `json:"action"` Action string `json:"action"`

View File

@@ -428,3 +428,31 @@ func TestHandleWebUILogsLive(t *testing.T) {
t.Fatalf("expected tail-ok entry, got: %+v", entry) t.Fatalf("expected tail-ok entry, got: %+v", entry)
} }
} }
func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) {
t.Parallel()
srv := NewServer("127.0.0.1", 0, "", nodes.NewManager())
srv.SetNodeP2PStatusHandler(func() map[string]interface{} {
return map[string]interface{}{
"enabled": true,
"transport": "webrtc",
"active_sessions": 2,
}
})
req := httptest.NewRequest(http.MethodGet, "/webui/api/nodes", nil)
rec := httptest.NewRecorder()
srv.handleWebUINodes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
p2p, _ := body["p2p"].(map[string]interface{})
if p2p == nil || p2p["transport"] != "webrtc" {
t.Fatalf("expected p2p summary, got %+v", body)
}
}

View File

@@ -33,25 +33,44 @@ func (r *Router) Dispatch(ctx context.Context, req Request, mode string) (Respon
if r.P2P == nil { if r.P2P == nil {
return Response{OK: false, Node: req.Node, Action: req.Action, Error: "p2p transport unavailable"}, nil return Response{OK: false, Node: req.Node, Action: req.Action, Error: "p2p transport unavailable"}, nil
} }
return r.P2P.Send(ctx, req) resp, err := r.P2P.Send(ctx, req)
return annotateTransport(resp, "p2p", r.P2P.Name(), ""), err
case "relay": case "relay":
if r.Relay == nil { if r.Relay == nil {
return Response{OK: false, Node: req.Node, Action: req.Action, Error: "relay transport unavailable"}, nil return Response{OK: false, Node: req.Node, Action: req.Action, Error: "relay transport unavailable"}, nil
} }
return r.Relay.Send(ctx, req) resp, err := r.Relay.Send(ctx, req)
return annotateTransport(resp, "relay", r.Relay.Name(), ""), err
default: // auto default: // auto
if r.P2P != nil { if r.P2P != nil {
if resp, err := r.P2P.Send(ctx, req); err == nil && resp.OK { if resp, err := r.P2P.Send(ctx, req); err == nil && resp.OK {
return resp, nil return annotateTransport(resp, "auto", r.P2P.Name(), ""), nil
} }
} }
if r.Relay != nil { if r.Relay != nil {
return r.Relay.Send(ctx, req) resp, err := r.Relay.Send(ctx, req)
return annotateTransport(resp, "auto", r.Relay.Name(), "p2p"), err
} }
return Response{}, fmt.Errorf("no transport available") return Response{}, fmt.Errorf("no transport available")
} }
} }
func annotateTransport(resp Response, mode, usedTransport, fallbackFrom string) Response {
if resp.Payload == nil {
resp.Payload = map[string]interface{}{}
}
if strings.TrimSpace(mode) != "" {
resp.Payload["dispatch_mode"] = strings.TrimSpace(mode)
}
if strings.TrimSpace(usedTransport) != "" {
resp.Payload["used_transport"] = strings.TrimSpace(usedTransport)
}
if strings.TrimSpace(fallbackFrom) != "" {
resp.Payload["fallback_from"] = strings.TrimSpace(fallbackFrom)
}
return resp
}
// WebsocketP2PTransport uses the persistent node websocket as a request/response tunnel // WebsocketP2PTransport uses the persistent node websocket as a request/response tunnel
// while the project evolves toward a true peer data channel. // while the project evolves toward a true peer data channel.
type WebsocketP2PTransport struct { type WebsocketP2PTransport struct {

View File

@@ -71,6 +71,29 @@ func NewWebRTCTransport(stunServers []string) *WebRTCTransport {
func (t *WebRTCTransport) Name() string { return "p2p-webrtc" } func (t *WebRTCTransport) Name() string { return "p2p-webrtc" }
func (t *WebRTCTransport) Snapshot() map[string]interface{} {
t.mu.Lock()
defer t.mu.Unlock()
nodes := make([]map[string]interface{}, 0, len(t.sessions))
active := 0
for nodeID, session := range t.sessions {
status := "connecting"
if session != nil && session.dc != nil && session.dc.ReadyState() == webrtc.DataChannelStateOpen {
status = "open"
active++
}
nodes = append(nodes, map[string]interface{}{
"node": nodeID,
"status": status,
})
}
return map[string]interface{}{
"transport": "webrtc",
"active_sessions": active,
"nodes": nodes,
}
}
func (t *WebRTCTransport) BindSignaler(nodeID string, sender WireSender) { func (t *WebRTCTransport) BindSignaler(nodeID string, sender WireSender) {
nodeID = strings.TrimSpace(nodeID) nodeID = strings.TrimSpace(nodeID)
if nodeID == "" { if nodeID == "" {

View File

@@ -22,20 +22,20 @@ type NodesTool struct {
func NewNodesTool(m *nodes.Manager, r *nodes.Router, auditPath string) *NodesTool { func NewNodesTool(m *nodes.Manager, r *nodes.Router, auditPath string) *NodesTool {
return &NodesTool{manager: m, router: r, auditPath: strings.TrimSpace(auditPath)} return &NodesTool{manager: m, router: r, auditPath: strings.TrimSpace(auditPath)}
} }
func (t *NodesTool) Name() string { return "nodes" } func (t *NodesTool) Name() string { return "nodes" }
func (t *NodesTool) Description() string { func (t *NodesTool) Description() string {
return "Manage paired nodes (status/describe/run/invoke/camera/screen/location/canvas)." return "Manage paired nodes (status/describe/run/invoke/camera/screen/location/canvas)."
} }
func (t *NodesTool) Parameters() map[string]interface{} { func (t *NodesTool) Parameters() map[string]interface{} {
return map[string]interface{}{"type": "object", "properties": map[string]interface{}{ return map[string]interface{}{"type": "object", "properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "description": "status|describe|run|invoke|agent_task|camera_snap|camera_clip|screen_record|screen_snapshot|location_get|canvas_snapshot|canvas_action"}, "action": map[string]interface{}{"type": "string", "description": "status|describe|run|invoke|agent_task|camera_snap|camera_clip|screen_record|screen_snapshot|location_get|canvas_snapshot|canvas_action"},
"node": map[string]interface{}{"type": "string", "description": "target node id"}, "node": map[string]interface{}{"type": "string", "description": "target node id"},
"mode": map[string]interface{}{"type": "string", "description": "auto|p2p|relay (default auto)"}, "mode": map[string]interface{}{"type": "string", "description": "auto|p2p|relay (default auto)"},
"args": map[string]interface{}{"type": "object", "description": "action args"}, "args": map[string]interface{}{"type": "object", "description": "action args"},
"task": map[string]interface{}{"type": "string", "description": "agent_task content for child node model"}, "task": map[string]interface{}{"type": "string", "description": "agent_task content for child node model"},
"model": map[string]interface{}{"type": "string", "description": "optional model for agent_task"}, "model": map[string]interface{}{"type": "string", "description": "optional model for agent_task"},
"command": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "run command array shortcut"}, "command": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "run command array shortcut"},
"facing": map[string]interface{}{"type": "string", "description": "camera facing: front|back|both"}, "facing": map[string]interface{}{"type": "string", "description": "camera facing: front|back|both"},
"duration_ms": map[string]interface{}{"type": "integer", "description": "clip/record duration"}, "duration_ms": map[string]interface{}{"type": "integer", "description": "clip/record duration"},
}, "required": []string{"action"}} }, "required": []string{"action"}}
} }
@@ -144,6 +144,12 @@ func (t *NodesTool) writeAudit(req nodes.Request, resp nodes.Response, mode stri
"error": resp.Error, "error": resp.Error,
"duration_ms": durationMs, "duration_ms": durationMs,
} }
if used, _ := resp.Payload["used_transport"].(string); strings.TrimSpace(used) != "" {
row["used_transport"] = strings.TrimSpace(used)
}
if fallback, _ := resp.Payload["fallback_from"].(string); strings.TrimSpace(fallback) != "" {
row["fallback_from"] = strings.TrimSpace(fallback)
}
b, _ := json.Marshal(row) b, _ := json.Marshal(row)
f, err := os.OpenFile(t.auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) f, err := os.OpenFile(t.auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil { if err != nil {

View File

@@ -9,6 +9,7 @@ type RuntimeSnapshot = {
nodes?: { nodes?: {
nodes?: any[]; nodes?: any[];
trees?: any[]; trees?: any[];
p2p?: Record<string, any>;
}; };
sessions?: { sessions?: {
sessions?: Array<{ key: string; title?: string; channel?: string }>; sessions?: Array<{ key: string; title?: string; channel?: string }>;
@@ -43,6 +44,8 @@ interface AppContextType {
setNodes: (nodes: string) => void; setNodes: (nodes: string) => void;
nodeTrees: string; nodeTrees: string;
setNodeTrees: (trees: string) => void; setNodeTrees: (trees: string) => void;
nodeP2P: Record<string, any>;
setNodeP2P: React.Dispatch<React.SetStateAction<Record<string, any>>>;
cron: CronJob[]; cron: CronJob[];
setCron: (cron: CronJob[]) => void; setCron: (cron: CronJob[]) => void;
skills: Skill[]; skills: Skill[];
@@ -103,6 +106,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [configEditing, setConfigEditing] = useState(false); const [configEditing, setConfigEditing] = useState(false);
const [nodes, setNodes] = useState('[]'); const [nodes, setNodes] = useState('[]');
const [nodeTrees, setNodeTrees] = useState('[]'); const [nodeTrees, setNodeTrees] = useState('[]');
const [nodeP2P, setNodeP2P] = useState<Record<string, any>>({});
const [cron, setCron] = useState<CronJob[]>([]); const [cron, setCron] = useState<CronJob[]>([]);
const [skills, setSkills] = useState<Skill[]>([]); const [skills, setSkills] = useState<Skill[]>([]);
const [clawhubInstalled, setClawhubInstalled] = useState(false); const [clawhubInstalled, setClawhubInstalled] = useState(false);
@@ -161,6 +165,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const j = await r.json(); const j = await r.json();
setNodes(JSON.stringify(j.nodes || [], null, 2)); setNodes(JSON.stringify(j.nodes || [], null, 2));
setNodeTrees(JSON.stringify(j.trees || [], null, 2)); setNodeTrees(JSON.stringify(j.trees || [], null, 2));
setNodeP2P(j.p2p || {});
setIsGatewayOnline(true); setIsGatewayOnline(true);
} catch (e) { } catch (e) {
setIsGatewayOnline(false); setIsGatewayOnline(false);
@@ -265,6 +270,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (snapshot.nodes) { if (snapshot.nodes) {
setNodes(JSON.stringify(Array.isArray(snapshot.nodes.nodes) ? snapshot.nodes.nodes : [], null, 2)); 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)); setNodeTrees(JSON.stringify(Array.isArray(snapshot.nodes.trees) ? snapshot.nodes.trees : [], null, 2));
setNodeP2P(snapshot.nodes.p2p || {});
} }
if (snapshot.sessions) { if (snapshot.sessions) {
const arr = Array.isArray(snapshot.sessions.sessions) ? snapshot.sessions.sessions : []; const arr = Array.isArray(snapshot.sessions.sessions) ? snapshot.sessions.sessions : [];
@@ -343,7 +349,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
return ( return (
<AppContext.Provider value={{ <AppContext.Provider value={{
token, setToken, sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, isGatewayOnline, setIsGatewayOnline, token, setToken, sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, isGatewayOnline, setIsGatewayOnline,
cfg, setCfg, cfgRaw, setCfgRaw, configEditing, setConfigEditing, nodes, setNodes, nodeTrees, setNodeTrees, cfg, setCfg, cfgRaw, setCfgRaw, configEditing, setConfigEditing, nodes, setNodes, nodeTrees, setNodeTrees, nodeP2P, setNodeP2P,
cron, setCron, skills, setSkills, clawhubInstalled, clawhubPath, cron, setCron, skills, setSkills, clawhubInstalled, clawhubPath,
sessions, setSessions, sessions, setSessions,
taskQueueItems, setTaskQueueItems, ekgSummary, setEkgSummary, taskQueueItems, setTaskQueueItems, ekgSummary, setEkgSummary,

View File

@@ -24,6 +24,7 @@ const resources = {
tasks: 'Tasks', tasks: 'Tasks',
subagentProfiles: 'Subagent Profiles', subagentProfiles: 'Subagent Profiles',
subagentsRuntime: 'Agents', subagentsRuntime: 'Agents',
nodeP2P: 'Node P2P',
agentTopology: 'Agent Topology', agentTopology: 'Agent Topology',
agentTopologyHint: 'Unified graph for local agents, registered nodes, and mirrored remote agent branches.', agentTopologyHint: 'Unified graph for local agents, registered nodes, and mirrored remote agent branches.',
runningTasks: 'running', runningTasks: 'running',
@@ -549,6 +550,7 @@ const resources = {
tasks: '任务管理', tasks: '任务管理',
subagentProfiles: '子代理档案', subagentProfiles: '子代理档案',
subagentsRuntime: 'Agents', subagentsRuntime: 'Agents',
nodeP2P: '节点 P2P',
agentTopology: 'Agent 拓扑', agentTopology: 'Agent 拓扑',
agentTopologyHint: '统一展示本地 agent、注册 node 以及远端镜像 agent 分支的关系图。', agentTopologyHint: '统一展示本地 agent、注册 node 以及远端镜像 agent 分支的关系图。',
runningTasks: '运行中', runningTasks: '运行中',

View File

@@ -14,6 +14,7 @@ const Dashboard: React.FC = () => {
webuiVersion, webuiVersion,
skills, skills,
cfg, cfg,
nodeP2P,
taskQueueItems, taskQueueItems,
ekgSummary, ekgSummary,
} = useAppContext(); } = useAppContext();
@@ -36,6 +37,9 @@ const Dashboard: React.FC = () => {
const ekgEscalationCount = Number(ekgSummary?.escalation_count || 0); const ekgEscalationCount = Number(ekgSummary?.escalation_count || 0);
const ekgTopProvider = (Array.isArray(ekgSummary?.provider_top_workload) ? ekgSummary.provider_top_workload[0]?.key : '') || '-'; const ekgTopProvider = (Array.isArray(ekgSummary?.provider_top_workload) ? ekgSummary.provider_top_workload[0]?.key : '') || '-';
const ekgTopErrSig = (Array.isArray(ekgSummary?.errsig_top_workload) ? ekgSummary.errsig_top_workload[0]?.key : '') || '-'; const ekgTopErrSig = (Array.isArray(ekgSummary?.errsig_top_workload) ? ekgSummary.errsig_top_workload[0]?.key : '') || '-';
const p2pEnabled = Boolean(nodeP2P?.enabled);
const p2pTransport = String(nodeP2P?.transport || (p2pEnabled ? 'enabled' : 'disabled'));
const p2pSessions = Number(nodeP2P?.active_sessions || 0);
return ( return (
<div className="p-4 md:p-6 xl:p-8 w-full space-y-6 xl:space-y-8"> <div className="p-4 md:p-6 xl:p-8 w-full space-y-6 xl:space-y-8">
@@ -53,12 +57,13 @@ const Dashboard: React.FC = () => {
</button> </button>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-6 gap-4">
<StatCard title={t('gatewayStatus')} value={isGatewayOnline ? t('online') : t('offline')} icon={<Activity className={`w-6 h-6 ${isGatewayOnline ? 'text-emerald-400' : 'text-red-400'}`} />} /> <StatCard title={t('gatewayStatus')} value={isGatewayOnline ? t('online') : t('offline')} icon={<Activity className={`w-6 h-6 ${isGatewayOnline ? 'text-emerald-400' : 'text-red-400'}`} />} />
<StatCard title={t('activeSessions')} value={sessions.length} icon={<MessageSquare className="w-6 h-6 text-blue-400" />} /> <StatCard title={t('activeSessions')} value={sessions.length} icon={<MessageSquare className="w-6 h-6 text-blue-400" />} />
<StatCard title={t('skills')} value={skills.length} icon={<Sparkles className="w-6 h-6 text-pink-400" />} /> <StatCard title={t('skills')} value={skills.length} icon={<Sparkles className="w-6 h-6 text-pink-400" />} />
<StatCard title={t('subagentsRuntime')} value={subagentCount} icon={<Wrench className="w-6 h-6 text-cyan-400" />} /> <StatCard title={t('subagentsRuntime')} value={subagentCount} icon={<Wrench className="w-6 h-6 text-cyan-400" />} />
<StatCard title={t('taskAudit')} value={recentTasks.length} icon={<Activity className="w-6 h-6 text-amber-400" />} /> <StatCard title={t('taskAudit')} value={recentTasks.length} icon={<Activity className="w-6 h-6 text-amber-400" />} />
<StatCard title={t('nodeP2P')} value={p2pEnabled ? `${p2pSessions} · ${p2pTransport}` : t('disabled')} icon={<Workflow className="w-6 h-6 text-violet-400" />} />
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">