feat: expand node artifact operations and retention

This commit is contained in:
lpf
2026-03-09 10:46:22 +08:00
parent be2e025fe5
commit ba3be33c91
22 changed files with 2724 additions and 151 deletions

View File

@@ -217,6 +217,9 @@ func gatewayCmd() {
registryServer.SetSubagentHandler(func(cctx context.Context, action string, args map[string]interface{}) (interface{}, error) {
return agentLoop.HandleSubagentRuntime(cctx, action, args)
})
registryServer.SetNodeDispatchHandler(func(cctx context.Context, req nodes.Request, mode string) (nodes.Response, error) {
return agentLoop.DispatchNodeRequest(cctx, req, mode)
})
registryServer.SetToolsCatalogHandler(func() interface{} {
return agentLoop.GetToolCatalog()
})

View File

@@ -43,6 +43,7 @@ type nodeRegisterOptions struct {
Version string
Actions []string
Models []string
Tags []string
Agents []nodes.AgentInfo
Capabilities nodes.Capabilities
Watch bool
@@ -124,6 +125,7 @@ func printNodeHelp() {
fmt.Println(" --version <value> Reported node version (default: current clawgo version)")
fmt.Println(" --actions <csv> Supported actions, e.g. run,agent_task")
fmt.Println(" --models <csv> Supported models, e.g. gpt-4o-mini")
fmt.Println(" --tags <csv> Node tags for dispatch policy, e.g. gpu,vision,build")
fmt.Println(" --capabilities <csv> Capability flags: run,invoke,model,camera,screen,location,canvas")
fmt.Println(" --watch Keep a websocket connection open and send heartbeats")
fmt.Println(" --heartbeat-sec <n> Heartbeat interval in seconds when --watch is set (default: 30)")
@@ -270,6 +272,12 @@ func parseNodeRegisterArgs(args []string, cfg *config.Config) (nodeRegisterOptio
return opts, err
}
opts.Models = splitCSV(v)
case "--tags":
v, err := next()
if err != nil {
return opts, err
}
opts.Tags = splitCSV(v)
case "--capabilities":
v, err := next()
if err != nil {
@@ -359,6 +367,7 @@ func buildNodeInfo(opts nodeRegisterOptions) nodes.NodeInfo {
return nodes.NodeInfo{
ID: strings.TrimSpace(opts.ID),
Name: strings.TrimSpace(opts.Name),
Tags: append([]string(nil), opts.Tags...),
OS: strings.TrimSpace(opts.OS),
Arch: strings.TrimSpace(opts.Arch),
Version: strings.TrimSpace(opts.Version),

View File

@@ -55,6 +55,19 @@ func TestParseNodeRegisterArgsDefaults(t *testing.T) {
}
}
func TestParseNodeRegisterArgsTags(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
opts, err := parseNodeRegisterArgs([]string{"--id", "edge-dev", "--tags", "vision,gpu"}, cfg)
if err != nil {
t.Fatalf("parseNodeRegisterArgs failed: %v", err)
}
if len(opts.Tags) != 2 || opts.Tags[0] != "vision" || opts.Tags[1] != "gpu" {
t.Fatalf("unexpected tags: %+v", opts.Tags)
}
}
func TestPostNodeRegisterSendsNodeInfo(t *testing.T) {
t.Parallel()

View File

@@ -86,6 +86,31 @@ func (al *AgentLoop) SetNodeP2PTransport(t nodes.Transport) {
al.nodeRouter.P2P = t
}
func (al *AgentLoop) DispatchNodeRequest(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error) {
if al == nil || al.tools == nil {
return nodes.Response{}, fmt.Errorf("agent loop not ready")
}
args := map[string]interface{}{
"action": req.Action,
"node": req.Node,
"mode": mode,
"task": req.Task,
"model": req.Model,
}
if len(req.Args) > 0 {
args["args"] = req.Args
}
out, err := al.tools.Execute(ctx, "nodes", args)
if err != nil {
return nodes.Response{}, err
}
var resp nodes.Response
if err := json.Unmarshal([]byte(out), &resp); err != nil {
return nodes.Response{}, err
}
return resp, nil
}
// StartupCompactionReport provides startup memory/session maintenance stats.
type StartupCompactionReport struct {
TotalSessions int `json:"total_sessions"`
@@ -149,6 +174,18 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
return nodes.Response{OK: false, Code: "unsupported_action", Node: "local", Action: req.Action, Error: "unsupported local simulated action"}
}
})
nodeDispatchPolicy := nodes.DispatchPolicy{
PreferLocal: cfg.Gateway.Nodes.Dispatch.PreferLocal,
PreferP2P: cfg.Gateway.Nodes.Dispatch.PreferP2P,
AllowRelayFallback: cfg.Gateway.Nodes.Dispatch.AllowRelayFallback,
ActionTags: cfg.Gateway.Nodes.Dispatch.ActionTags,
AgentTags: cfg.Gateway.Nodes.Dispatch.AgentTags,
AllowActions: cfg.Gateway.Nodes.Dispatch.AllowActions,
DenyActions: cfg.Gateway.Nodes.Dispatch.DenyActions,
AllowAgents: cfg.Gateway.Nodes.Dispatch.AllowAgents,
DenyAgents: cfg.Gateway.Nodes.Dispatch.DenyAgents,
}
nodesManager.SetDispatchPolicy(nodeDispatchPolicy)
var nodeP2P nodes.Transport
if cfg.Gateway.Nodes.P2P.Enabled {
switch strings.ToLower(strings.TrimSpace(cfg.Gateway.Nodes.P2P.Transport)) {
@@ -159,7 +196,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
nodeP2P = &nodes.WebsocketP2PTransport{Manager: nodesManager}
}
}
nodesRouter := &nodes.Router{P2P: nodeP2P, Relay: &nodes.HTTPRelayTransport{Manager: nodesManager}}
nodesRouter := &nodes.Router{P2P: nodeP2P, Relay: &nodes.HTTPRelayTransport{Manager: nodesManager}, Policy: nodesManager.DispatchPolicy()}
toolsRegistry.Register(tools.NewNodesTool(nodesManager, nodesRouter, filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl")))
if cs != nil {

View File

@@ -7,6 +7,8 @@ import (
"bytes"
"compress/gzip"
"context"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -33,32 +35,35 @@ import (
)
type Server struct {
addr string
token string
mgr *nodes.Manager
server *http.Server
nodeConnMu sync.Mutex
nodeConnIDs map[string]string
nodeSockets map[string]*nodeSocketConn
nodeWebRTC *nodes.WebRTCTransport
nodeP2PStatus func() map[string]interface{}
gatewayVersion string
webuiVersion string
configPath string
workspacePath string
logFilePath string
onChat func(ctx context.Context, sessionKey, content string) (string, error)
onChatHistory func(sessionKey string) []map[string]interface{}
onConfigAfter func()
onCron func(action string, args map[string]interface{}) (interface{}, error)
onSubagents func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error)
onToolsCatalog func() interface{}
webUIDir string
ekgCacheMu sync.Mutex
ekgCachePath string
ekgCacheStamp time.Time
ekgCacheSize int64
ekgCacheRows []map[string]interface{}
addr string
token string
mgr *nodes.Manager
server *http.Server
nodeConnMu sync.Mutex
nodeConnIDs map[string]string
nodeSockets map[string]*nodeSocketConn
nodeWebRTC *nodes.WebRTCTransport
nodeP2PStatus func() map[string]interface{}
artifactStatsMu sync.Mutex
artifactStats map[string]interface{}
gatewayVersion string
webuiVersion string
configPath string
workspacePath string
logFilePath string
onChat func(ctx context.Context, sessionKey, content string) (string, error)
onChatHistory func(sessionKey string) []map[string]interface{}
onConfigAfter func()
onCron func(action string, args map[string]interface{}) (interface{}, error)
onSubagents func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error)
onNodeDispatch func(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error)
onToolsCatalog func() interface{}
webUIDir string
ekgCacheMu sync.Mutex
ekgCachePath string
ekgCacheStamp time.Time
ekgCacheSize int64
ekgCacheRows []map[string]interface{}
}
var nodesWebsocketUpgrader = websocket.Upgrader{
@@ -74,11 +79,12 @@ func NewServer(host string, port int, token string, mgr *nodes.Manager) *Server
port = 7788
}
return &Server{
addr: fmt.Sprintf("%s:%d", addr, port),
token: strings.TrimSpace(token),
mgr: mgr,
nodeConnIDs: map[string]string{},
nodeSockets: map[string]*nodeSocketConn{},
addr: fmt.Sprintf("%s:%d", addr, port),
token: strings.TrimSpace(token),
mgr: mgr,
nodeConnIDs: map[string]string{},
nodeSockets: map[string]*nodeSocketConn{},
artifactStats: map[string]interface{}{},
}
}
@@ -114,6 +120,9 @@ func (s *Server) SetCronHandler(fn func(action string, args map[string]interface
func (s *Server) SetSubagentHandler(fn func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error)) {
s.onSubagents = fn
}
func (s *Server) SetNodeDispatchHandler(fn func(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error)) {
s.onNodeDispatch = fn
}
func (s *Server) SetToolsCatalogHandler(fn func() interface{}) { s.onToolsCatalog = fn }
func (s *Server) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) }
func (s *Server) SetGatewayVersion(v string) { s.gatewayVersion = strings.TrimSpace(v) }
@@ -227,6 +236,12 @@ func (s *Server) Start(ctx context.Context) error {
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/node_dispatches/replay", s.handleWebUINodeDispatchReplay)
mux.HandleFunc("/webui/api/node_artifacts", s.handleWebUINodeArtifacts)
mux.HandleFunc("/webui/api/node_artifacts/export", s.handleWebUINodeArtifactsExport)
mux.HandleFunc("/webui/api/node_artifacts/download", s.handleWebUINodeArtifactDownload)
mux.HandleFunc("/webui/api/node_artifacts/delete", s.handleWebUINodeArtifactDelete)
mux.HandleFunc("/webui/api/node_artifacts/prune", s.handleWebUINodeArtifactPrune)
mux.HandleFunc("/webui/api/cron", s.handleWebUICron)
mux.HandleFunc("/webui/api/skills", s.handleWebUISkills)
mux.HandleFunc("/webui/api/sessions", s.handleWebUISessions)
@@ -1075,14 +1090,121 @@ func (s *Server) webUINodesPayload(ctx context.Context) map[string]interface{} {
if s.nodeP2PStatus != nil {
p2p = s.nodeP2PStatus()
}
dispatches := s.webUINodesDispatchPayload(12)
return map[string]interface{}{
"nodes": list,
"trees": s.buildNodeAgentTrees(ctx, list),
"p2p": p2p,
"dispatches": s.webUINodesDispatchPayload(12),
"nodes": list,
"trees": s.buildNodeAgentTrees(ctx, list),
"p2p": p2p,
"dispatches": dispatches,
"alerts": s.webUINodeAlertsPayload(list, p2p, dispatches),
"artifact_retention": s.artifactStatsSnapshot(),
}
}
func (s *Server) webUINodeAlertsPayload(nodeList []nodes.NodeInfo, p2p map[string]interface{}, dispatches []map[string]interface{}) []map[string]interface{} {
alerts := make([]map[string]interface{}, 0)
for _, node := range nodeList {
nodeID := strings.TrimSpace(node.ID)
if nodeID == "" || nodeID == "local" {
continue
}
if !node.Online {
alerts = append(alerts, map[string]interface{}{
"severity": "critical",
"kind": "node_offline",
"node": nodeID,
"title": "Node offline",
"detail": fmt.Sprintf("node %s is offline", nodeID),
})
}
}
if sessions, ok := p2p["nodes"].([]map[string]interface{}); ok {
for _, session := range sessions {
appendNodeSessionAlert(&alerts, session)
}
} else if sessions, ok := p2p["nodes"].([]interface{}); ok {
for _, raw := range sessions {
if session, ok := raw.(map[string]interface{}); ok {
appendNodeSessionAlert(&alerts, session)
}
}
}
failuresByNode := map[string]int{}
for _, row := range dispatches {
nodeID := strings.TrimSpace(fmt.Sprint(row["node"]))
if nodeID == "" {
continue
}
if ok, _ := row["ok"].(bool); ok {
continue
}
failuresByNode[nodeID]++
}
for nodeID, count := range failuresByNode {
if count < 2 {
continue
}
alerts = append(alerts, map[string]interface{}{
"severity": "warning",
"kind": "dispatch_failures",
"node": nodeID,
"title": "Repeated dispatch failures",
"detail": fmt.Sprintf("node %s has %d recent failed dispatches", nodeID, count),
"count": count,
})
}
return alerts
}
func appendNodeSessionAlert(alerts *[]map[string]interface{}, session map[string]interface{}) {
nodeID := strings.TrimSpace(fmt.Sprint(session["node"]))
if nodeID == "" {
return
}
status := strings.ToLower(strings.TrimSpace(fmt.Sprint(session["status"])))
retryCount := int(int64Value(session["retry_count"]))
lastError := strings.TrimSpace(fmt.Sprint(session["last_error"]))
switch {
case status == "failed" || status == "closed":
*alerts = append(*alerts, map[string]interface{}{
"severity": "critical",
"kind": "p2p_session_down",
"node": nodeID,
"title": "P2P session down",
"detail": firstNonEmptyString(lastError, fmt.Sprintf("node %s p2p session is %s", nodeID, status)),
})
case retryCount >= 3 || (status == "connecting" && retryCount >= 2):
*alerts = append(*alerts, map[string]interface{}{
"severity": "warning",
"kind": "p2p_session_unstable",
"node": nodeID,
"title": "P2P session unstable",
"detail": firstNonEmptyString(lastError, fmt.Sprintf("node %s p2p session retry_count=%d", nodeID, retryCount)),
"count": retryCount,
})
}
}
func int64Value(v interface{}) int64 {
switch value := v.(type) {
case int:
return int64(value)
case int32:
return int64(value)
case int64:
return value
case float32:
return int64(value)
case float64:
return int64(value)
case json.Number:
if n, err := value.Int64(); err == nil {
return n
}
}
return 0
}
func (s *Server) webUINodesDispatchPayload(limit int) []map[string]interface{} {
workspace := strings.TrimSpace(s.workspacePath)
if workspace == "" {
@@ -1115,6 +1237,381 @@ func (s *Server) webUINodesDispatchPayload(limit int) []map[string]interface{} {
return out
}
func (s *Server) webUINodeArtifactsPayload(limit int) []map[string]interface{} {
return s.webUINodeArtifactsPayloadFiltered("", "", "", limit)
}
func (s *Server) webUINodeArtifactsPayloadFiltered(nodeFilter, actionFilter, kindFilter string, limit int) []map[string]interface{} {
nodeFilter = strings.TrimSpace(nodeFilter)
actionFilter = strings.TrimSpace(actionFilter)
kindFilter = strings.TrimSpace(kindFilter)
rows, _ := s.readNodeDispatchAuditRows()
if len(rows) == 0 {
return []map[string]interface{}{}
}
out := make([]map[string]interface{}, 0, limit)
for rowIndex := len(rows) - 1; rowIndex >= 0; rowIndex-- {
row := rows[rowIndex]
artifacts, _ := row["artifacts"].([]interface{})
for artifactIndex, raw := range artifacts {
artifact, ok := raw.(map[string]interface{})
if !ok {
continue
}
item := map[string]interface{}{
"id": buildNodeArtifactID(row, artifact, artifactIndex),
"time": row["time"],
"node": row["node"],
"action": row["action"],
"used_transport": row["used_transport"],
"ok": row["ok"],
"error": row["error"],
}
for _, key := range []string{"name", "kind", "mime_type", "storage", "path", "url", "content_text", "content_base64", "source_path", "size_bytes"} {
if value, ok := artifact[key]; ok {
item[key] = value
}
}
if nodeFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["node"])), nodeFilter) {
continue
}
if actionFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["action"])), actionFilter) {
continue
}
if kindFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["kind"])), kindFilter) {
continue
}
out = append(out, item)
if limit > 0 && len(out) >= limit {
return out
}
}
}
return out
}
func (s *Server) readNodeDispatchAuditRows() ([]map[string]interface{}, string) {
workspace := strings.TrimSpace(s.workspacePath)
if workspace == "" {
return nil, ""
}
path := filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl")
data, err := os.ReadFile(path)
if err != nil {
return nil, path
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
rows := make([]map[string]interface{}, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
row := map[string]interface{}{}
if err := json.Unmarshal([]byte(line), &row); err != nil {
continue
}
rows = append(rows, row)
}
return rows, path
}
func buildNodeArtifactID(row, artifact map[string]interface{}, artifactIndex int) string {
seed := fmt.Sprintf("%v|%v|%v|%d|%v|%v|%v",
row["time"], row["node"], row["action"], artifactIndex,
artifact["name"], artifact["source_path"], artifact["path"],
)
sum := sha1.Sum([]byte(seed))
return fmt.Sprintf("%x", sum[:8])
}
func sanitizeZipEntryName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "artifact.bin"
}
name = strings.ReplaceAll(name, "\\", "/")
name = filepath.Base(name)
name = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r
case r >= 'A' && r <= 'Z':
return r
case r >= '0' && r <= '9':
return r
case r == '.', r == '-', r == '_':
return r
default:
return '_'
}
}, name)
if strings.Trim(name, "._") == "" {
return "artifact.bin"
}
return name
}
func (s *Server) findNodeArtifactByID(id string) (map[string]interface{}, bool) {
for _, item := range s.webUINodeArtifactsPayload(10000) {
if strings.TrimSpace(fmt.Sprint(item["id"])) == id {
return item, true
}
}
return nil, false
}
func resolveArtifactPath(workspace, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if filepath.IsAbs(raw) {
clean := filepath.Clean(raw)
if info, err := os.Stat(clean); err == nil && !info.IsDir() {
return clean
}
return ""
}
root := strings.TrimSpace(workspace)
if root == "" {
return ""
}
clean := filepath.Clean(filepath.Join(root, raw))
if rel, err := filepath.Rel(root, clean); err != nil || strings.HasPrefix(rel, "..") {
return ""
}
if info, err := os.Stat(clean); err == nil && !info.IsDir() {
return clean
}
return ""
}
func readArtifactBytes(workspace string, item map[string]interface{}) ([]byte, string, error) {
if content := strings.TrimSpace(fmt.Sprint(item["content_base64"])); content != "" {
raw, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return nil, "", err
}
return raw, strings.TrimSpace(fmt.Sprint(item["mime_type"])), nil
}
for _, rawPath := range []string{fmt.Sprint(item["source_path"]), fmt.Sprint(item["path"])} {
if path := resolveArtifactPath(workspace, rawPath); path != "" {
b, err := os.ReadFile(path)
if err != nil {
return nil, "", err
}
return b, strings.TrimSpace(fmt.Sprint(item["mime_type"])), nil
}
}
if contentText := fmt.Sprint(item["content_text"]); strings.TrimSpace(contentText) != "" {
return []byte(contentText), "text/plain; charset=utf-8", nil
}
return nil, "", fmt.Errorf("artifact content unavailable")
}
func (s *Server) filteredNodeDispatches(nodeFilter, actionFilter string, limit int) []map[string]interface{} {
items := s.webUINodesDispatchPayload(limit)
if nodeFilter == "" && actionFilter == "" {
return items
}
out := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
if nodeFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["node"])), nodeFilter) {
continue
}
if actionFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["action"])), actionFilter) {
continue
}
out = append(out, item)
}
return out
}
func filteredNodeAlerts(alerts []map[string]interface{}, nodeFilter string) []map[string]interface{} {
if nodeFilter == "" {
return alerts
}
out := make([]map[string]interface{}, 0, len(alerts))
for _, item := range alerts {
if strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["node"])), nodeFilter) {
out = append(out, item)
}
}
return out
}
func (s *Server) setArtifactStats(summary map[string]interface{}) {
s.artifactStatsMu.Lock()
defer s.artifactStatsMu.Unlock()
if summary == nil {
s.artifactStats = map[string]interface{}{}
return
}
copySummary := make(map[string]interface{}, len(summary))
for k, v := range summary {
copySummary[k] = v
}
s.artifactStats = copySummary
}
func (s *Server) artifactStatsSnapshot() map[string]interface{} {
s.artifactStatsMu.Lock()
defer s.artifactStatsMu.Unlock()
out := make(map[string]interface{}, len(s.artifactStats))
for k, v := range s.artifactStats {
out[k] = v
}
return out
}
func (s *Server) nodeArtifactRetentionConfig() cfgpkg.GatewayNodesArtifactsConfig {
cfg := cfgpkg.DefaultConfig()
if strings.TrimSpace(s.configPath) != "" {
if loaded, err := cfgpkg.LoadConfig(s.configPath); err == nil && loaded != nil {
cfg = loaded
}
}
return cfg.Gateway.Nodes.Artifacts
}
func (s *Server) applyNodeArtifactRetention() map[string]interface{} {
retention := s.nodeArtifactRetentionConfig()
if !retention.Enabled || !retention.PruneOnRead || retention.KeepLatest <= 0 {
summary := map[string]interface{}{
"enabled": retention.Enabled,
"keep_latest": retention.KeepLatest,
"retain_days": retention.RetainDays,
"prune_on_read": retention.PruneOnRead,
"pruned": 0,
"last_run_at": time.Now().UTC().Format(time.RFC3339),
}
s.setArtifactStats(summary)
return summary
}
items := s.webUINodeArtifactsPayload(0)
cutoff := time.Time{}
if retention.RetainDays > 0 {
cutoff = time.Now().UTC().Add(-time.Duration(retention.RetainDays) * 24 * time.Hour)
}
pruned := 0
prunedByAge := 0
prunedByCount := 0
for index, item := range items {
drop := false
dropByAge := false
if !cutoff.IsZero() {
if tm, err := time.Parse(time.RFC3339, strings.TrimSpace(fmt.Sprint(item["time"]))); err == nil && tm.Before(cutoff) {
drop = true
dropByAge = true
}
}
if !drop && index >= retention.KeepLatest {
drop = true
}
if !drop {
continue
}
_, deletedAudit, _ := s.deleteNodeArtifact(strings.TrimSpace(fmt.Sprint(item["id"])))
if deletedAudit {
pruned++
if dropByAge {
prunedByAge++
} else {
prunedByCount++
}
}
}
summary := map[string]interface{}{
"enabled": true,
"keep_latest": retention.KeepLatest,
"retain_days": retention.RetainDays,
"prune_on_read": retention.PruneOnRead,
"pruned": pruned,
"pruned_by_age": prunedByAge,
"pruned_by_count": prunedByCount,
"remaining": len(s.webUINodeArtifactsPayload(0)),
"last_run_at": time.Now().UTC().Format(time.RFC3339),
}
s.setArtifactStats(summary)
return summary
}
func (s *Server) deleteNodeArtifact(id string) (bool, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return false, false, fmt.Errorf("id is required")
}
rows, auditPath := s.readNodeDispatchAuditRows()
if len(rows) == 0 || auditPath == "" {
return false, false, fmt.Errorf("artifact audit is empty")
}
deletedFile := false
deletedAudit := false
for rowIndex, row := range rows {
artifacts, _ := row["artifacts"].([]interface{})
if len(artifacts) == 0 {
continue
}
nextArtifacts := make([]interface{}, 0, len(artifacts))
for artifactIndex, raw := range artifacts {
artifact, ok := raw.(map[string]interface{})
if !ok {
nextArtifacts = append(nextArtifacts, raw)
continue
}
if buildNodeArtifactID(row, artifact, artifactIndex) != id {
nextArtifacts = append(nextArtifacts, artifact)
continue
}
for _, rawPath := range []string{fmt.Sprint(artifact["source_path"]), fmt.Sprint(artifact["path"])} {
if path := resolveArtifactPath(s.workspacePath, rawPath); path != "" {
if err := os.Remove(path); err == nil {
deletedFile = true
break
}
}
}
deletedAudit = true
}
if deletedAudit {
row["artifacts"] = nextArtifacts
row["artifact_count"] = len(nextArtifacts)
kinds := make([]string, 0, len(nextArtifacts))
for _, raw := range nextArtifacts {
if artifact, ok := raw.(map[string]interface{}); ok {
if kind := strings.TrimSpace(fmt.Sprint(artifact["kind"])); kind != "" {
kinds = append(kinds, kind)
}
}
}
if len(kinds) > 0 {
row["artifact_kinds"] = kinds
} else {
delete(row, "artifact_kinds")
}
rows[rowIndex] = row
break
}
}
if !deletedAudit {
return false, false, fmt.Errorf("artifact not found")
}
var buf bytes.Buffer
for _, row := range rows {
encoded, err := json.Marshal(row)
if err != nil {
continue
}
buf.Write(encoded)
buf.WriteByte('\n')
}
if err := os.WriteFile(auditPath, buf.Bytes(), 0644); err != nil {
return deletedFile, false, err
}
return deletedFile, true, nil
}
func (s *Server) webUISessionsPayload() map[string]interface{} {
sessionsDir := filepath.Join(filepath.Dir(s.workspacePath), "agents", "main", "sessions")
_ = os.MkdirAll(sessionsDir, 0755)
@@ -1607,6 +2104,325 @@ func (s *Server) handleWebUINodeDispatches(w http.ResponseWriter, r *http.Reques
})
}
func (s *Server) handleWebUINodeDispatchReplay(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.onNodeDispatch == nil {
http.Error(w, "node dispatch handler not configured", http.StatusServiceUnavailable)
return
}
var body struct {
Node string `json:"node"`
Action string `json:"action"`
Mode string `json:"mode"`
Task string `json:"task"`
Model string `json:"model"`
Args map[string]interface{} `json:"args"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
req := nodes.Request{
Node: strings.TrimSpace(body.Node),
Action: strings.TrimSpace(body.Action),
Task: body.Task,
Model: body.Model,
Args: body.Args,
}
if req.Node == "" || req.Action == "" {
http.Error(w, "node and action are required", http.StatusBadRequest)
return
}
resp, err := s.onNodeDispatch(r.Context(), req, strings.TrimSpace(body.Mode))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"result": resp,
})
}
func (s *Server) handleWebUINodeArtifacts(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 := 200
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
if n > 1000 {
n = 1000
}
limit = n
}
}
retentionSummary := s.applyNodeArtifactRetention()
nodeFilter := strings.TrimSpace(r.URL.Query().Get("node"))
actionFilter := strings.TrimSpace(r.URL.Query().Get("action"))
kindFilter := strings.TrimSpace(r.URL.Query().Get("kind"))
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"items": s.webUINodeArtifactsPayloadFiltered(nodeFilter, actionFilter, kindFilter, limit),
"artifact_retention": retentionSummary,
})
}
func (s *Server) handleWebUINodeArtifactsExport(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
}
retentionSummary := s.applyNodeArtifactRetention()
limit := 200
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
if n > 1000 {
n = 1000
}
limit = n
}
}
nodeFilter := strings.TrimSpace(r.URL.Query().Get("node"))
actionFilter := strings.TrimSpace(r.URL.Query().Get("action"))
kindFilter := strings.TrimSpace(r.URL.Query().Get("kind"))
artifacts := s.webUINodeArtifactsPayloadFiltered(nodeFilter, actionFilter, kindFilter, limit)
dispatches := s.filteredNodeDispatches(nodeFilter, actionFilter, limit)
payload := s.webUINodesPayload(r.Context())
nodeList, _ := payload["nodes"].([]nodes.NodeInfo)
p2p, _ := payload["p2p"].(map[string]interface{})
alerts := filteredNodeAlerts(s.webUINodeAlertsPayload(nodeList, p2p, dispatches), nodeFilter)
var archive bytes.Buffer
zw := zip.NewWriter(&archive)
writeJSON := func(name string, value interface{}) error {
entry, err := zw.Create(name)
if err != nil {
return err
}
enc := json.NewEncoder(entry)
enc.SetIndent("", " ")
return enc.Encode(value)
}
manifest := map[string]interface{}{
"generated_at": time.Now().UTC().Format(time.RFC3339),
"filters": map[string]interface{}{
"node": nodeFilter,
"action": actionFilter,
"kind": kindFilter,
"limit": limit,
},
"artifact_count": len(artifacts),
"dispatch_count": len(dispatches),
"alert_count": len(alerts),
"retention": retentionSummary,
}
if err := writeJSON("manifest.json", manifest); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := writeJSON("dispatches.json", dispatches); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := writeJSON("alerts.json", alerts); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := writeJSON("artifacts.json", artifacts); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, item := range artifacts {
name := sanitizeZipEntryName(firstNonEmptyString(
fmt.Sprint(item["name"]),
fmt.Sprint(item["source_path"]),
fmt.Sprint(item["path"]),
fmt.Sprintf("%s.bin", fmt.Sprint(item["id"])),
))
raw, _, err := readArtifactBytes(s.workspacePath, item)
entryName := filepath.ToSlash(filepath.Join("files", fmt.Sprintf("%s-%s", fmt.Sprint(item["id"]), name)))
if err != nil || len(raw) == 0 {
entryName = filepath.ToSlash(filepath.Join("files", fmt.Sprintf("%s-metadata.json", fmt.Sprint(item["id"]))))
raw, err = json.MarshalIndent(item, "", " ")
if err != nil {
continue
}
}
entry, err := zw.Create(entryName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := entry.Write(raw); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if err := zw.Close(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
filename := "node-artifacts-export.zip"
if nodeFilter != "" {
filename = fmt.Sprintf("node-artifacts-%s.zip", sanitizeZipEntryName(nodeFilter))
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(archive.Bytes())
}
func (s *Server) handleWebUINodeArtifactDownload(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
}
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
item, ok := s.findNodeArtifactByID(id)
if !ok {
http.Error(w, "artifact not found", http.StatusNotFound)
return
}
name := strings.TrimSpace(fmt.Sprint(item["name"]))
if name == "" {
name = "artifact"
}
mimeType := strings.TrimSpace(fmt.Sprint(item["mime_type"]))
if mimeType == "" {
mimeType = "application/octet-stream"
}
if contentB64 := strings.TrimSpace(fmt.Sprint(item["content_base64"])); contentB64 != "" {
payload, err := base64.StdEncoding.DecodeString(contentB64)
if err != nil {
http.Error(w, "invalid inline artifact payload", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
_, _ = w.Write(payload)
return
}
for _, rawPath := range []string{fmt.Sprint(item["source_path"]), fmt.Sprint(item["path"])} {
if path := resolveArtifactPath(s.workspacePath, rawPath); path != "" {
http.ServeFile(w, r, path)
return
}
}
if contentText := fmt.Sprint(item["content_text"]); strings.TrimSpace(contentText) != "" {
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
_, _ = w.Write([]byte(contentText))
return
}
http.Error(w, "artifact content unavailable", http.StatusNotFound)
}
func (s *Server) handleWebUINodeArtifactDelete(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
deletedFile, deletedAudit, err := s.deleteNodeArtifact(strings.TrimSpace(body.ID))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"id": strings.TrimSpace(body.ID),
"deleted_file": deletedFile,
"deleted_audit": deletedAudit,
})
}
func (s *Server) handleWebUINodeArtifactPrune(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Node string `json:"node"`
Action string `json:"action"`
Kind string `json:"kind"`
KeepLatest int `json:"keep_latest"`
Limit int `json:"limit"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
limit := body.Limit
if limit <= 0 || limit > 5000 {
limit = 5000
}
keepLatest := body.KeepLatest
if keepLatest < 0 {
keepLatest = 0
}
items := s.webUINodeArtifactsPayloadFiltered(strings.TrimSpace(body.Node), strings.TrimSpace(body.Action), strings.TrimSpace(body.Kind), limit)
pruned := 0
deletedFiles := 0
for index, item := range items {
if index < keepLatest {
continue
}
deletedFile, deletedAudit, err := s.deleteNodeArtifact(strings.TrimSpace(fmt.Sprint(item["id"])))
if err != nil || !deletedAudit {
continue
}
pruned++
if deletedFile {
deletedFiles++
}
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"pruned": pruned,
"deleted_files": deletedFiles,
"kept": keepLatest,
})
}
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)

View File

@@ -1,10 +1,12 @@
package api
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -432,7 +434,10 @@ func TestHandleWebUILogsLive(t *testing.T) {
func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) {
t.Parallel()
srv := NewServer("127.0.0.1", 0, "", nodes.NewManager())
mgr := nodes.NewManager()
mgr.Upsert(nodes.NodeInfo{ID: "edge-b", Name: "Edge B"})
mgr.MarkOffline("edge-b")
srv := NewServer("127.0.0.1", 0, "", mgr)
workspace := t.TempDir()
srv.SetWorkspacePath(workspace)
if err := os.MkdirAll(filepath.Join(workspace, "memory"), 0755); err != nil {
@@ -446,6 +451,9 @@ func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) {
"enabled": true,
"transport": "webrtc",
"active_sessions": 2,
"nodes": []map[string]interface{}{
{"node": "edge-b", "status": "connecting", "retry_count": 3, "last_error": "signal timeout"},
},
}
})
@@ -463,6 +471,10 @@ func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) {
if p2p == nil || p2p["transport"] != "webrtc" {
t.Fatalf("expected p2p summary, got %+v", body)
}
alerts, _ := body["alerts"].([]interface{})
if len(alerts) == 0 {
t.Fatalf("expected node alerts, got %+v", body)
}
dispatches, _ := body["dispatches"].([]interface{})
if len(dispatches) != 1 {
t.Fatalf("expected dispatch audit rows, got %+v", body["dispatches"])
@@ -473,3 +485,273 @@ func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) {
t.Fatalf("expected artifact previews in dispatch row, got %+v", first)
}
}
func TestHandleWebUINodeDispatchReplay(t *testing.T) {
t.Parallel()
srv := NewServer("127.0.0.1", 0, "", nodes.NewManager())
srv.SetNodeDispatchHandler(func(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error) {
if req.Node != "edge-a" || req.Action != "screen_snapshot" || mode != "auto" {
t.Fatalf("unexpected replay request: %+v mode=%s", req, mode)
}
if fmt.Sprint(req.Args["quality"]) != "high" {
t.Fatalf("unexpected args: %+v", req.Args)
}
return nodes.Response{
OK: true,
Node: req.Node,
Action: req.Action,
Payload: map[string]interface{}{
"used_transport": "webrtc",
},
}, nil
})
body := `{"node":"edge-a","action":"screen_snapshot","mode":"auto","args":{"quality":"high"}}`
req := httptest.NewRequest(http.MethodPost, "/webui/api/node_dispatches/replay", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.handleWebUINodeDispatchReplay(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"used_transport":"webrtc"`) {
t.Fatalf("expected replay result body, got: %s", rec.Body.String())
}
}
func TestHandleWebUINodeArtifactsListAndDelete(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"), 0o755); err != nil {
t.Fatalf("mkdir memory: %v", err)
}
artifactPath := filepath.Join(workspace, "artifact.txt")
if err := os.WriteFile(artifactPath, []byte("artifact-body"), 0o644); err != nil {
t.Fatalf("write artifact: %v", err)
}
auditLine := fmt.Sprintf("{\"time\":\"2026-03-09T00:00:00Z\",\"node\":\"edge-a\",\"action\":\"run\",\"artifacts\":[{\"name\":\"artifact.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"source_path\":\"%s\",\"size_bytes\":13}]}\n", artifactPath)
if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLine), 0o644); err != nil {
t.Fatalf("write audit: %v", err)
}
listReq := httptest.NewRequest(http.MethodGet, "/webui/api/node_artifacts", nil)
listRec := httptest.NewRecorder()
srv.handleWebUINodeArtifacts(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", listRec.Code)
}
var listBody map[string]interface{}
if err := json.Unmarshal(listRec.Body.Bytes(), &listBody); err != nil {
t.Fatalf("decode list body: %v", err)
}
items, _ := listBody["items"].([]interface{})
if len(items) != 1 {
t.Fatalf("expected 1 artifact, got %+v", listBody)
}
item, _ := items[0].(map[string]interface{})
artifactID := strings.TrimSpace(fmt.Sprint(item["id"]))
if artifactID == "" {
t.Fatalf("expected artifact id, got %+v", item)
}
deleteReq := httptest.NewRequest(http.MethodPost, "/webui/api/node_artifacts/delete", strings.NewReader(fmt.Sprintf(`{"id":"%s"}`, artifactID)))
deleteReq.Header.Set("Content-Type", "application/json")
deleteRec := httptest.NewRecorder()
srv.handleWebUINodeArtifactDelete(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", deleteRec.Code, deleteRec.Body.String())
}
if _, err := os.Stat(artifactPath); !os.IsNotExist(err) {
t.Fatalf("expected artifact file removed, stat err=%v", err)
}
}
func TestHandleWebUINodeArtifactsExport(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"), 0o755); err != nil {
t.Fatalf("mkdir memory: %v", err)
}
auditLine := "{\"time\":\"2026-03-09T00:00:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"shot.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"Y2FwdHVyZQ==\",\"size_bytes\":7}]}\n"
if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLine), 0o644); err != nil {
t.Fatalf("write audit: %v", err)
}
srv.mgr.Upsert(nodes.NodeInfo{ID: "edge-a", Name: "Edge A", Online: true})
req := httptest.NewRequest(http.MethodGet, "/webui/api/node_artifacts/export?node=edge-a&action=screen_snapshot&kind=text", nil)
rec := httptest.NewRecorder()
srv.handleWebUINodeArtifactsExport(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/zip") {
t.Fatalf("expected zip response, got %q", got)
}
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
if err != nil {
t.Fatalf("open zip: %v", err)
}
seen := map[string]bool{}
for _, file := range zr.File {
seen[file.Name] = true
}
for _, required := range []string{"manifest.json", "dispatches.json", "alerts.json", "artifacts.json"} {
if !seen[required] {
t.Fatalf("missing zip entry %q in %+v", required, seen)
}
}
foundFile := false
for _, file := range zr.File {
if !strings.HasPrefix(file.Name, "files/") {
continue
}
foundFile = true
rc, err := file.Open()
if err != nil {
t.Fatalf("open artifact file: %v", err)
}
body, _ := io.ReadAll(rc)
_ = rc.Close()
if string(body) != "capture" {
t.Fatalf("unexpected artifact payload %q", string(body))
}
}
if !foundFile {
t.Fatalf("expected exported artifact file in zip")
}
}
func TestHandleWebUINodeArtifactsPrune(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"), 0o755); err != nil {
t.Fatalf("mkdir memory: %v", err)
}
auditLines := strings.Join([]string{
"{\"time\":\"2026-03-09T00:00:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"one.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"b25l\"}]}",
"{\"time\":\"2026-03-09T00:01:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"two.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"dHdv\"}]}",
"{\"time\":\"2026-03-09T00:02:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"three.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"dGhyZWU=\"}]}",
}, "\n") + "\n"
if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLines), 0o644); err != nil {
t.Fatalf("write audit: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/webui/api/node_artifacts/prune", strings.NewReader(`{"node":"edge-a","action":"screen_snapshot","kind":"text","keep_latest":1}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.handleWebUINodeArtifactPrune(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
items := srv.webUINodeArtifactsPayloadFiltered("edge-a", "screen_snapshot", "text", 10)
if len(items) != 1 {
t.Fatalf("expected 1 remaining artifact, got %d", len(items))
}
if got := fmt.Sprint(items[0]["name"]); got != "three.txt" {
t.Fatalf("expected newest artifact to remain, got %q", got)
}
}
func TestHandleWebUINodeArtifactsAppliesRetentionConfig(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"), 0o755); err != nil {
t.Fatalf("mkdir memory: %v", err)
}
cfg := cfgpkg.DefaultConfig()
cfg.Gateway.Nodes.Artifacts.Enabled = true
cfg.Gateway.Nodes.Artifacts.KeepLatest = 1
cfg.Gateway.Nodes.Artifacts.PruneOnRead = true
cfgPath := filepath.Join(workspace, "config.json")
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
srv.SetConfigPath(cfgPath)
auditLines := strings.Join([]string{
"{\"time\":\"2026-03-09T00:00:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"one.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"b25l\"}]}",
"{\"time\":\"2026-03-09T00:01:00Z\",\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"two.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"dHdv\"}]}",
}, "\n") + "\n"
if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLines), 0o644); err != nil {
t.Fatalf("write audit: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/webui/api/node_artifacts", nil)
rec := httptest.NewRecorder()
srv.handleWebUINodeArtifacts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
items := srv.webUINodeArtifactsPayload(10)
if len(items) != 1 {
t.Fatalf("expected retention to keep 1 artifact, got %d", len(items))
}
if got := fmt.Sprint(items[0]["name"]); got != "two.txt" {
t.Fatalf("expected newest artifact to remain, got %q", got)
}
stats := srv.artifactStatsSnapshot()
if fmt.Sprint(stats["pruned"]) == "" || fmt.Sprint(stats["pruned"]) == "0" {
t.Fatalf("expected retention stats to record pruned artifacts, got %+v", stats)
}
if fmt.Sprint(stats["keep_latest"]) != "1" {
t.Fatalf("expected keep_latest in stats, got %+v", stats)
}
}
func TestHandleWebUINodeArtifactsAppliesRetentionDays(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"), 0o755); err != nil {
t.Fatalf("mkdir memory: %v", err)
}
cfg := cfgpkg.DefaultConfig()
cfg.Gateway.Nodes.Artifacts.Enabled = true
cfg.Gateway.Nodes.Artifacts.KeepLatest = 10
cfg.Gateway.Nodes.Artifacts.RetainDays = 1
cfg.Gateway.Nodes.Artifacts.PruneOnRead = true
cfgPath := filepath.Join(workspace, "config.json")
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
srv.SetConfigPath(cfgPath)
oldTime := time.Now().UTC().Add(-48 * time.Hour).Format(time.RFC3339)
newTime := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339)
auditLines := strings.Join([]string{
fmt.Sprintf("{\"time\":%q,\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"old.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"b2xk\"}]}", oldTime),
fmt.Sprintf("{\"time\":%q,\"node\":\"edge-a\",\"action\":\"screen_snapshot\",\"ok\":true,\"artifacts\":[{\"name\":\"fresh.txt\",\"kind\":\"text\",\"mime_type\":\"text/plain\",\"content_base64\":\"ZnJlc2g=\"}]}", newTime),
}, "\n") + "\n"
if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte(auditLines), 0o644); err != nil {
t.Fatalf("write audit: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/webui/api/node_artifacts", nil)
rec := httptest.NewRecorder()
srv.handleWebUINodeArtifacts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
items := srv.webUINodeArtifactsPayload(10)
if len(items) != 1 {
t.Fatalf("expected retention days to keep 1 artifact, got %d", len(items))
}
if got := fmt.Sprint(items[0]["name"]); got != "fresh.txt" {
t.Fatalf("expected fresh artifact to remain, got %q", got)
}
}

View File

@@ -295,7 +295,9 @@ type GatewayConfig struct {
}
type GatewayNodesConfig struct {
P2P GatewayNodesP2PConfig `json:"p2p,omitempty"`
P2P GatewayNodesP2PConfig `json:"p2p,omitempty"`
Dispatch GatewayNodesDispatchConfig `json:"dispatch,omitempty"`
Artifacts GatewayNodesArtifactsConfig `json:"artifacts,omitempty"`
}
type GatewayICEConfig struct {
@@ -311,6 +313,25 @@ type GatewayNodesP2PConfig struct {
ICEServers []GatewayICEConfig `json:"ice_servers,omitempty"`
}
type GatewayNodesDispatchConfig struct {
PreferLocal bool `json:"prefer_local,omitempty"`
PreferP2P bool `json:"prefer_p2p,omitempty"`
AllowRelayFallback bool `json:"allow_relay_fallback,omitempty"`
ActionTags map[string][]string `json:"action_tags,omitempty"`
AgentTags map[string][]string `json:"agent_tags,omitempty"`
AllowActions map[string][]string `json:"allow_actions,omitempty"`
DenyActions map[string][]string `json:"deny_actions,omitempty"`
AllowAgents map[string][]string `json:"allow_agents,omitempty"`
DenyAgents map[string][]string `json:"deny_agents,omitempty"`
}
type GatewayNodesArtifactsConfig struct {
Enabled bool `json:"enabled,omitempty"`
KeepLatest int `json:"keep_latest,omitempty"`
RetainDays int `json:"retain_days,omitempty"`
PruneOnRead bool `json:"prune_on_read,omitempty"`
}
type CronConfig struct {
MinSleepSec int `json:"min_sleep_sec" env:"CLAWGO_CRON_MIN_SLEEP_SEC"`
MaxSleepSec int `json:"max_sleep_sec" env:"CLAWGO_CRON_MAX_SLEEP_SEC"`
@@ -559,6 +580,23 @@ func DefaultConfig() *Config {
STUNServers: []string{},
ICEServers: []GatewayICEConfig{},
},
Dispatch: GatewayNodesDispatchConfig{
PreferLocal: false,
PreferP2P: true,
AllowRelayFallback: true,
ActionTags: map[string][]string{},
AgentTags: map[string][]string{},
AllowActions: map[string][]string{},
DenyActions: map[string][]string{},
AllowAgents: map[string][]string{},
DenyAgents: map[string][]string{},
},
Artifacts: GatewayNodesArtifactsConfig{
Enabled: false,
KeepLatest: 500,
RetainDays: 7,
PruneOnRead: true,
},
},
},
Cron: CronConfig{

View File

@@ -145,6 +145,21 @@ func Validate(cfg *Config) []error {
}
}
}
errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.action_tags", cfg.Gateway.Nodes.Dispatch.ActionTags)...)
errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.agent_tags", cfg.Gateway.Nodes.Dispatch.AgentTags)...)
errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.allow_actions", cfg.Gateway.Nodes.Dispatch.AllowActions)...)
errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.deny_actions", cfg.Gateway.Nodes.Dispatch.DenyActions)...)
errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.allow_agents", cfg.Gateway.Nodes.Dispatch.AllowAgents)...)
errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.deny_agents", cfg.Gateway.Nodes.Dispatch.DenyAgents)...)
if cfg.Gateway.Nodes.Artifacts.Enabled && cfg.Gateway.Nodes.Artifacts.KeepLatest <= 0 {
errs = append(errs, fmt.Errorf("gateway.nodes.artifacts.keep_latest must be > 0 when enabled=true"))
}
if cfg.Gateway.Nodes.Artifacts.KeepLatest < 0 {
errs = append(errs, fmt.Errorf("gateway.nodes.artifacts.keep_latest must be >= 0"))
}
if cfg.Gateway.Nodes.Artifacts.RetainDays < 0 {
errs = append(errs, fmt.Errorf("gateway.nodes.artifacts.retain_days must be >= 0"))
}
if cfg.Cron.MinSleepSec <= 0 {
errs = append(errs, fmt.Errorf("cron.min_sleep_sec must be > 0"))
}
@@ -239,6 +254,21 @@ func Validate(cfg *Config) []error {
return errs
}
func validateDispatchTagMap(prefix string, mapping map[string][]string) []error {
if len(mapping) == 0 {
return nil
}
errs := make([]error, 0)
for key, tags := range mapping {
if strings.TrimSpace(key) == "" {
errs = append(errs, fmt.Errorf("%s contains empty key", prefix))
continue
}
errs = append(errs, validateNonEmptyStringList(fmt.Sprintf("%s.%s", prefix, key), tags)...)
}
return errs
}
func validateMCPTools(cfg *Config) []error {
var errs []error
mcp := cfg.Tools.MCP

View File

@@ -177,3 +177,70 @@ func TestValidateGatewayNodeP2PIceServersRequireTurnCredentials(t *testing.T) {
t.Fatalf("expected validation errors")
}
}
func TestValidateGatewayNodeDispatchRejectsEmptyTagKey(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Gateway.Nodes.Dispatch.ActionTags = map[string][]string{
"": {"vision"},
}
if errs := Validate(cfg); len(errs) == 0 {
t.Fatalf("expected validation errors")
}
}
func TestValidateGatewayNodeDispatchRejectsEmptyAllowNodeKey(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Gateway.Nodes.Dispatch.AllowActions = map[string][]string{
"": {"screen_snapshot"},
}
if errs := Validate(cfg); len(errs) == 0 {
t.Fatalf("expected validation errors")
}
}
func TestDefaultConfigSetsNodeArtifactRetentionDefaults(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
if cfg.Gateway.Nodes.Artifacts.Enabled {
t.Fatalf("expected node artifact retention disabled by default")
}
if cfg.Gateway.Nodes.Artifacts.KeepLatest != 500 {
t.Fatalf("unexpected default keep_latest: %d", cfg.Gateway.Nodes.Artifacts.KeepLatest)
}
if cfg.Gateway.Nodes.Artifacts.RetainDays != 7 {
t.Fatalf("unexpected default retain_days: %d", cfg.Gateway.Nodes.Artifacts.RetainDays)
}
if !cfg.Gateway.Nodes.Artifacts.PruneOnRead {
t.Fatalf("expected prune_on_read enabled by default")
}
}
func TestValidateNodeArtifactRetentionRequiresPositiveKeepLatestWhenEnabled(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Gateway.Nodes.Artifacts.Enabled = true
cfg.Gateway.Nodes.Artifacts.KeepLatest = 0
if errs := Validate(cfg); len(errs) == 0 {
t.Fatalf("expected validation errors")
}
}
func TestValidateNodeArtifactRetentionRejectsNegativeRetainDays(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Gateway.Nodes.Artifacts.RetainDays = -1
if errs := Validate(cfg); len(errs) == 0 {
t.Fatalf("expected validation errors")
}
}

View File

@@ -30,6 +30,19 @@ type Manager struct {
ttl time.Duration
auditPath string
statePath string
policy DispatchPolicy
}
type DispatchPolicy struct {
PreferLocal bool
PreferP2P bool
AllowRelayFallback bool
ActionTags map[string][]string
AgentTags map[string][]string
AllowActions map[string][]string
DenyActions map[string][]string
AllowAgents map[string][]string
DenyAgents map[string][]string
}
var defaultManager = NewManager()
@@ -43,6 +56,16 @@ func NewManager() *Manager {
senders: map[string]WireSender{},
pending: map[string]chan WireMessage{},
ttl: defaultNodeTTL,
policy: DispatchPolicy{
PreferP2P: true,
AllowRelayFallback: true,
ActionTags: map[string][]string{},
AgentTags: map[string][]string{},
AllowActions: map[string][]string{},
DenyActions: map[string][]string{},
AllowAgents: map[string][]string{},
DenyAgents: map[string][]string{},
},
}
go m.reaperLoop()
return m
@@ -61,6 +84,18 @@ func (m *Manager) SetStatePath(path string) {
m.loadState()
}
func (m *Manager) SetDispatchPolicy(policy DispatchPolicy) {
m.mu.Lock()
defer m.mu.Unlock()
m.policy = normalizeDispatchPolicy(policy)
}
func (m *Manager) DispatchPolicy() DispatchPolicy {
m.mu.RLock()
defer m.mu.RUnlock()
return cloneDispatchPolicy(m.policy)
}
func (m *Manager) Upsert(info NodeInfo) {
m.mu.Lock()
now := time.Now().UTC()
@@ -71,6 +106,9 @@ func (m *Manager) Upsert(info NodeInfo) {
if info.RegisteredAt.IsZero() {
info.RegisteredAt = old.RegisteredAt
}
if len(info.Tags) == 0 && len(old.Tags) > 0 {
info.Tags = append([]string(nil), old.Tags...)
}
if strings.TrimSpace(info.Endpoint) == "" {
info.Endpoint = old.Endpoint
}
@@ -303,8 +341,9 @@ func (m *Manager) PickRequest(req Request, mode string) (NodeInfo, bool) {
defer m.mu.RUnlock()
bestScore := -1
bestNode := NodeInfo{}
policy := normalizeDispatchPolicy(m.policy)
for _, n := range m.nodes {
score, ok := scoreNodeCandidate(n, req, mode, m.senders[strings.TrimSpace(n.ID)] != nil)
score, ok := scoreNodeCandidate(n, req, mode, m.senders[strings.TrimSpace(n.ID)] != nil, policy)
if !ok {
continue
}
@@ -319,23 +358,39 @@ func (m *Manager) PickRequest(req Request, mode string) (NodeInfo, bool) {
return bestNode, true
}
func scoreNodeCandidate(n NodeInfo, req Request, mode string, hasWireSender bool) (int, bool) {
func scoreNodeCandidate(n NodeInfo, req Request, mode string, hasWireSender bool, policy DispatchPolicy) (int, bool) {
if !n.Online {
return 0, false
}
if !nodeSupportsRequest(n, req) {
return 0, false
}
if !matchesDispatchPolicy(n, req, policy) {
return 0, false
}
mode = strings.ToLower(strings.TrimSpace(mode))
if mode == "p2p" && !hasWireSender {
return 0, false
}
if !policy.AllowRelayFallback && strings.TrimSpace(n.ID) != "local" && !hasWireSender {
return 0, false
}
score := 100
if hasWireSender {
score += 30
}
if policy.PreferP2P {
if hasWireSender {
score += 35
} else {
score -= 10
}
}
if policy.PreferLocal && strings.EqualFold(strings.TrimSpace(n.ID), "local") {
score += 60
}
if prefersRealtimeTransport(req.Action) && hasWireSender {
score += 40
}
@@ -370,6 +425,151 @@ func scoreNodeCandidate(n NodeInfo, req Request, mode string, hasWireSender bool
return score, true
}
func normalizeDispatchPolicy(policy DispatchPolicy) DispatchPolicy {
normalized := DispatchPolicy{
PreferLocal: policy.PreferLocal,
PreferP2P: policy.PreferP2P,
AllowRelayFallback: policy.AllowRelayFallback,
ActionTags: map[string][]string{},
AgentTags: map[string][]string{},
AllowActions: map[string][]string{},
DenyActions: map[string][]string{},
AllowAgents: map[string][]string{},
DenyAgents: map[string][]string{},
}
for key, tags := range policy.ActionTags {
trimmed := normalizeStringList(tags)
if len(trimmed) > 0 {
normalized.ActionTags[strings.ToLower(strings.TrimSpace(key))] = trimmed
}
}
for key, tags := range policy.AgentTags {
trimmed := normalizeStringList(tags)
if len(trimmed) > 0 {
normalized.AgentTags[strings.ToLower(strings.TrimSpace(key))] = trimmed
}
}
for key, tags := range policy.AllowActions {
trimmed := normalizeStringList(tags)
if len(trimmed) > 0 {
normalized.AllowActions[strings.ToLower(strings.TrimSpace(key))] = trimmed
}
}
for key, tags := range policy.DenyActions {
trimmed := normalizeStringList(tags)
if len(trimmed) > 0 {
normalized.DenyActions[strings.ToLower(strings.TrimSpace(key))] = trimmed
}
}
for key, tags := range policy.AllowAgents {
trimmed := normalizeStringList(tags)
if len(trimmed) > 0 {
normalized.AllowAgents[strings.ToLower(strings.TrimSpace(key))] = trimmed
}
}
for key, tags := range policy.DenyAgents {
trimmed := normalizeStringList(tags)
if len(trimmed) > 0 {
normalized.DenyAgents[strings.ToLower(strings.TrimSpace(key))] = trimmed
}
}
return normalized
}
func cloneDispatchPolicy(policy DispatchPolicy) DispatchPolicy {
return normalizeDispatchPolicy(policy)
}
func normalizeStringList(values []string) []string {
if len(values) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, raw := range values {
trimmed := strings.ToLower(strings.TrimSpace(raw))
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}
func matchesDispatchPolicy(n NodeInfo, req Request, policy DispatchPolicy) bool {
if !isNodePermittedByPolicy(n, req, policy) {
return false
}
if tags := policy.ActionTags[strings.ToLower(strings.TrimSpace(req.Action))]; len(tags) > 0 && !nodeMatchesAnyTag(n, tags) {
return false
}
remoteAgentID := requestedRemoteAgentID(req.Args)
if remoteAgentID != "" {
if tags := policy.AgentTags[remoteAgentID]; len(tags) > 0 && !nodeMatchesAnyTag(n, tags) {
return false
}
}
return true
}
func isNodePermittedByPolicy(n NodeInfo, req Request, policy DispatchPolicy) bool {
nodeID := strings.ToLower(strings.TrimSpace(n.ID))
action := strings.ToLower(strings.TrimSpace(req.Action))
remoteAgentID := requestedRemoteAgentID(req.Args)
if remoteAgentID == "" && action == "agent_task" {
remoteAgentID = "main"
}
if deny := policy.DenyActions[nodeID]; len(deny) > 0 && containsNormalized(deny, action) {
return false
}
if allow := policy.AllowActions[nodeID]; len(allow) > 0 && !containsNormalized(allow, action) {
return false
}
if remoteAgentID != "" {
if deny := policy.DenyAgents[nodeID]; len(deny) > 0 && containsNormalized(deny, remoteAgentID) {
return false
}
if allow := policy.AllowAgents[nodeID]; len(allow) > 0 && !containsNormalized(allow, remoteAgentID) {
return false
}
}
return true
}
func containsNormalized(items []string, target string) bool {
target = strings.ToLower(strings.TrimSpace(target))
for _, item := range items {
if strings.ToLower(strings.TrimSpace(item)) == target {
return true
}
}
return false
}
func nodeMatchesAnyTag(n NodeInfo, tags []string) bool {
if len(tags) == 0 {
return true
}
nodeTags := normalizeStringList(n.Tags)
if len(nodeTags) == 0 {
return false
}
seen := map[string]struct{}{}
for _, tag := range nodeTags {
seen[tag] = struct{}{}
}
for _, tag := range tags {
if _, ok := seen[strings.ToLower(strings.TrimSpace(tag))]; ok {
return true
}
}
return false
}
func requestedRemoteAgentID(args map[string]interface{}) string {
if len(args) == 0 {
return ""

View File

@@ -74,3 +74,139 @@ func TestPickRequestPrefersRealtimeCapableNodeForScreenActions(t *testing.T) {
t.Fatalf("expected p2p-ready, got %+v", picked)
}
}
func TestPickRequestHonorsActionTagsPolicy(t *testing.T) {
t.Parallel()
manager := NewManager()
manager.SetDispatchPolicy(DispatchPolicy{
PreferP2P: true,
AllowRelayFallback: true,
ActionTags: map[string][]string{
"screen_snapshot": {"vision"},
},
})
now := time.Now().UTC()
manager.Upsert(NodeInfo{
ID: "build-node",
Tags: []string{"build"},
Online: true,
LastSeenAt: now,
Capabilities: Capabilities{
Screen: true,
},
Actions: []string{"screen_snapshot"},
})
manager.Upsert(NodeInfo{
ID: "vision-node",
Tags: []string{"vision"},
Online: true,
LastSeenAt: now,
Capabilities: Capabilities{
Screen: true,
},
Actions: []string{"screen_snapshot"},
})
picked, ok := manager.PickRequest(Request{Action: "screen_snapshot"}, "auto")
if !ok {
t.Fatalf("expected node pick")
}
if picked.ID != "vision-node" {
t.Fatalf("expected vision-node, got %+v", picked)
}
}
func TestPickRequestHonorsPreferLocalPolicy(t *testing.T) {
t.Parallel()
manager := NewManager()
manager.SetDispatchPolicy(DispatchPolicy{
PreferLocal: true,
PreferP2P: false,
AllowRelayFallback: true,
})
now := time.Now().UTC()
manager.Upsert(NodeInfo{
ID: "local",
Online: true,
LastSeenAt: now.Add(-1 * time.Minute),
Capabilities: Capabilities{
Run: true,
},
Actions: []string{"run"},
})
manager.Upsert(NodeInfo{
ID: "remote",
Online: true,
LastSeenAt: now,
Capabilities: Capabilities{
Run: true,
},
Actions: []string{"run"},
})
picked, ok := manager.PickRequest(Request{Action: "run"}, "auto")
if !ok {
t.Fatalf("expected node pick")
}
if picked.ID != "local" {
t.Fatalf("expected local, got %+v", picked)
}
}
func TestPickRequestHonorsNodeAllowActionsPolicy(t *testing.T) {
t.Parallel()
manager := NewManager()
manager.SetDispatchPolicy(DispatchPolicy{
AllowRelayFallback: true,
AllowActions: map[string][]string{
"camera-node": {"camera_snap"},
},
})
now := time.Now().UTC()
manager.Upsert(NodeInfo{
ID: "camera-node",
Online: true,
LastSeenAt: now,
Capabilities: Capabilities{
Camera: true,
Screen: true,
},
Actions: []string{"camera_snap", "screen_snapshot"},
})
if _, ok := manager.PickRequest(Request{Action: "screen_snapshot"}, "auto"); ok {
t.Fatalf("expected screen_snapshot to be blocked by allow_actions")
}
if _, ok := manager.PickRequest(Request{Action: "camera_snap"}, "auto"); !ok {
t.Fatalf("expected camera_snap to remain allowed")
}
}
func TestPickRequestHonorsNodeDenyAgentsPolicy(t *testing.T) {
t.Parallel()
manager := NewManager()
manager.SetDispatchPolicy(DispatchPolicy{
AllowRelayFallback: true,
DenyAgents: map[string][]string{
"edge-a": {"coder"},
},
})
now := time.Now().UTC()
manager.Upsert(NodeInfo{
ID: "edge-a",
Online: true,
LastSeenAt: now,
Capabilities: Capabilities{
Model: true,
},
Agents: []AgentInfo{{ID: "main"}, {ID: "coder"}},
})
if _, ok := manager.PickRequest(Request{Action: "agent_task", Args: map[string]interface{}{"remote_agent_id": "coder"}}, "auto"); ok {
t.Fatalf("expected coder agent_task to be denied by policy")
}
}

View File

@@ -20,8 +20,9 @@ type Transport interface {
// Router prefers p2p transport and falls back to relay.
type Router struct {
P2P Transport
Relay Transport
P2P Transport
Relay Transport
Policy DispatchPolicy
}
func (r *Router) Dispatch(ctx context.Context, req Request, mode string) (Response, error) {
@@ -43,14 +44,25 @@ func (r *Router) Dispatch(ctx context.Context, req Request, mode string) (Respon
resp, err := r.Relay.Send(ctx, req)
return annotateTransport(resp, "relay", r.Relay.Name(), ""), err
default: // auto
if r.P2P != nil {
preferP2P := r.Policy.PreferP2P || r.Relay == nil
if preferP2P && r.P2P != nil {
if resp, err := r.P2P.Send(ctx, req); err == nil && resp.OK {
return annotateTransport(resp, "auto", r.P2P.Name(), ""), nil
} else if !r.Policy.AllowRelayFallback {
return annotateTransport(resp, "auto", r.P2P.Name(), ""), err
}
}
if r.Relay != nil {
resp, err := r.Relay.Send(ctx, req)
return annotateTransport(resp, "auto", r.Relay.Name(), "p2p"), err
fallback := ""
if preferP2P && r.P2P != nil {
fallback = "p2p"
}
return annotateTransport(resp, "auto", r.Relay.Name(), fallback), err
}
if !preferP2P && r.P2P != nil {
resp, err := r.P2P.Send(ctx, req)
return annotateTransport(resp, "auto", r.P2P.Name(), "relay"), err
}
return Response{}, fmt.Errorf("no transport available")
}

View File

@@ -42,6 +42,7 @@ type Artifact struct {
type NodeInfo struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Tags []string `json:"tags,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
Version string `json:"version,omitempty"`

View File

@@ -150,6 +150,9 @@ func (t *NodesTool) writeAudit(req nodes.Request, resp nodes.Response, mode stri
"error": resp.Error,
"duration_ms": durationMs,
}
if len(req.Args) > 0 {
row["request_args"] = req.Args
}
if used, _ := resp.Payload["used_transport"].(string); strings.TrimSpace(used) != "" {
row["used_transport"] = strings.TrimSpace(used)
}

View File

@@ -13,6 +13,7 @@ const Skills = lazy(() => import('./pages/Skills'));
const MCP = lazy(() => import('./pages/MCP'));
const Memory = lazy(() => import('./pages/Memory'));
const Nodes = lazy(() => import('./pages/Nodes'));
const NodeArtifacts = lazy(() => import('./pages/NodeArtifacts'));
const TaskAudit = lazy(() => import('./pages/TaskAudit'));
const EKG = lazy(() => import('./pages/EKG'));
const LogCodes = lazy(() => import('./pages/LogCodes'));
@@ -43,6 +44,7 @@ export default function App() {
<Route path="cron" element={<Cron />} />
<Route path="memory" element={<Memory />} />
<Route path="nodes" element={<Nodes />} />
<Route path="node-artifacts" element={<NodeArtifacts />} />
<Route path="task-audit" element={<TaskAudit />} />
<Route path="ekg" element={<EKG />} />
<Route path="subagent-profiles" element={<SubagentProfiles />} />

View File

@@ -21,6 +21,7 @@ const Sidebar: React.FC = () => {
title: t('sidebarRuntime'),
items: [
{ icon: <Terminal className="w-5 h-5" />, label: t('nodes'), to: '/nodes' },
{ icon: <FolderOpen className="w-5 h-5" />, label: t('nodeArtifacts'), to: '/node-artifacts' },
{ icon: <ClipboardList className="w-5 h-5" />, label: t('taskAudit'), to: '/task-audit' },
{ icon: <Terminal className="w-5 h-5" />, label: t('logs'), to: '/logs' },
{ icon: <BrainCircuit className="w-5 h-5" />, label: t('ekg'), to: '/ekg' },

View File

@@ -11,6 +11,8 @@ type RuntimeSnapshot = {
trees?: any[];
p2p?: Record<string, any>;
dispatches?: any[];
alerts?: any[];
artifact_retention?: Record<string, any>;
};
sessions?: {
sessions?: Array<{ key: string; title?: string; channel?: string }>;
@@ -49,6 +51,10 @@ interface AppContextType {
setNodeP2P: React.Dispatch<React.SetStateAction<Record<string, any>>>;
nodeDispatchItems: any[];
setNodeDispatchItems: React.Dispatch<React.SetStateAction<any[]>>;
nodeAlerts: any[];
setNodeAlerts: React.Dispatch<React.SetStateAction<any[]>>;
nodeArtifactRetention: Record<string, any>;
setNodeArtifactRetention: React.Dispatch<React.SetStateAction<Record<string, any>>>;
cron: CronJob[];
setCron: (cron: CronJob[]) => void;
skills: Skill[];
@@ -111,6 +117,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [nodeTrees, setNodeTrees] = useState('[]');
const [nodeP2P, setNodeP2P] = useState<Record<string, any>>({});
const [nodeDispatchItems, setNodeDispatchItems] = useState<any[]>([]);
const [nodeAlerts, setNodeAlerts] = useState<any[]>([]);
const [nodeArtifactRetention, setNodeArtifactRetention] = useState<Record<string, any>>({});
const [cron, setCron] = useState<CronJob[]>([]);
const [skills, setSkills] = useState<Skill[]>([]);
const [clawhubInstalled, setClawhubInstalled] = useState(false);
@@ -171,6 +179,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
setNodeTrees(JSON.stringify(j.trees || [], null, 2));
setNodeP2P(j.p2p || {});
setNodeDispatchItems(Array.isArray(j.dispatches) ? j.dispatches : []);
setNodeAlerts(Array.isArray(j.alerts) ? j.alerts : []);
setNodeArtifactRetention(j.artifact_retention || {});
setIsGatewayOnline(true);
} catch (e) {
setIsGatewayOnline(false);
@@ -277,6 +287,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
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 : []);
setNodeAlerts(Array.isArray(snapshot.nodes.alerts) ? snapshot.nodes.alerts : []);
setNodeArtifactRetention(snapshot.nodes.artifact_retention || {});
}
if (snapshot.sessions) {
const arr = Array.isArray(snapshot.sessions.sessions) ? snapshot.sessions.sessions : [];
@@ -355,7 +367,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, nodeDispatchItems, setNodeDispatchItems,
cfg, setCfg, cfgRaw, setCfgRaw, configEditing, setConfigEditing, nodes, setNodes, nodeTrees, setNodeTrees, nodeP2P, setNodeP2P, nodeDispatchItems, setNodeDispatchItems, nodeAlerts, setNodeAlerts, nodeArtifactRetention, setNodeArtifactRetention,
cron, setCron, skills, setSkills, clawhubInstalled, clawhubPath,
sessions, setSessions,
taskQueueItems, setTaskQueueItems, ekgSummary, setEkgSummary,

View File

@@ -12,7 +12,24 @@ const resources = {
mcpServicesHint: 'Manage MCP servers, install packages, and inspect discovered remote tools.',
cronJobs: 'Cron Jobs',
nodes: 'Nodes',
nodeArtifacts: 'Node Artifacts',
nodeArtifactsHint: 'Browse, download, and delete files or media returned from node dispatches.',
nodeArtifactsEmpty: 'No node artifacts found.',
nodeArtifactsPrune: 'Prune',
nodeArtifactsKeepLatest: 'Keep latest N',
nodeArtifactsRetention: 'Artifact Retention',
nodeArtifactsRetentionHint: 'Latest auto-prune result from node artifact retention.',
nodeArtifactsRetentionKeepLatest: 'Keep latest',
nodeArtifactsRetentionRetainDays: 'Retain days',
nodeArtifactsRetentionPruned: 'Pruned this run',
nodeArtifactsRetentionRemaining: 'Remaining',
nodeArtifactDetail: 'Artifact Detail',
nodeArtifactPreviewUnavailable: 'Preview unavailable for this artifact.',
allNodes: 'All Nodes',
allKinds: 'All Kinds',
size: 'Size',
nodesDetailHint: 'Inspect node capabilities, mirrored remote agents, recent dispatches, and returned artifacts.',
nodesFilterPlaceholder: 'Filter by node id, name, or tag',
agentTree: 'Agent Tree',
noAgentTree: 'No agent tree available.',
readonlyMirror: 'Read-only mirror',
@@ -26,6 +43,16 @@ const resources = {
subagentProfiles: 'Subagent Profiles',
subagentsRuntime: 'Agents',
nodeP2P: 'Node P2P',
nodeAlerts: 'Node Alerts',
nodeAlertsEmpty: 'No active node alerts.',
nodeTags: 'Node Tags',
nodeDispatchDetail: 'Dispatch Detail',
replayDispatch: 'Replay Dispatch',
replaying: 'Replaying...',
resetReplayDraft: 'Reset Draft',
nodeReplayRequest: 'Replay Request',
nodeReplayResult: 'Replay Result',
nodeReplayInvalidArgs: 'Replay args must be valid JSON.',
agentTopology: 'Agent Topology',
agentTopologyHint: 'Unified graph for local agents, registered nodes, and mirrored remote agent branches.',
runningTasks: 'running',
@@ -146,6 +173,28 @@ const resources = {
dashboardNodeDispatchError: 'Error',
configNodeP2P: 'Node P2P',
configNodeP2PHint: 'Configure websocket tunnel or WebRTC transport for remote nodes.',
configNodeDispatch: 'Node Dispatch Policy',
configNodeDispatchHint: 'Bias node selection and bind actions or agents to tagged nodes.',
configNodeDispatchPreferLocal: 'Prefer local node',
configNodeDispatchPreferP2P: 'Prefer P2P transport',
configNodeDispatchAllowRelay: 'Allow relay fallback',
configNodeDispatchActionTags: 'Action tag rules',
configNodeDispatchAgentTags: 'Agent tag rules',
configNodeDispatchAllowActions: 'Node allow actions',
configNodeDispatchDenyActions: 'Node deny actions',
configNodeDispatchAllowAgents: 'Node allow agents',
configNodeDispatchDenyAgents: 'Node deny agents',
configNodeDispatchActionTagsPlaceholder: 'screen_snapshot=vision\ncamera_snap=vision,gpu',
configNodeDispatchAgentTagsPlaceholder: 'coder=build\nvision=vision,gpu',
configNodeDispatchAllowActionsPlaceholder: 'edge-a=camera_snap,screen_snapshot',
configNodeDispatchDenyActionsPlaceholder: 'edge-b=screen_record',
configNodeDispatchAllowAgentsPlaceholder: 'edge-a=main,coder',
configNodeDispatchDenyAgentsPlaceholder: 'edge-b=vision',
configNodeArtifacts: 'Node Artifact Retention',
configNodeArtifactsHint: 'Auto-prune old node artifacts so audit and media storage stay bounded.',
configNodeArtifactsKeepLatest: 'Keep latest artifacts',
configNodeArtifactsRetainDays: 'Retain days (0 to disable)',
configNodeArtifactsPruneOnRead: 'Apply retention on list/export',
configNodeP2PStunPlaceholder: 'Comma-separated STUN URLs',
configNodeP2PIceServers: 'ICE Servers',
configNodeP2PIceServersEmpty: 'No structured ICE servers configured.',
@@ -156,6 +205,8 @@ const resources = {
paused: 'Paused',
noCronJobs: 'No cron jobs found',
noNodes: 'No nodes available',
allActions: 'All Actions',
allTransports: 'All Transports',
sessions: 'Sessions',
mainChat: 'Main Chat',
internalStream: 'Internal Stream',
@@ -551,6 +602,12 @@ const resources = {
proxies: 'Proxies',
cross_session_call_id: 'Cross-session Call ID',
supports_responses_compact: 'Supports Responses Compact',
dispatch: 'Dispatch',
prefer_local: 'Prefer Local',
prefer_p2p: 'Prefer P2P',
allow_relay_fallback: 'Allow Relay Fallback',
action_tags: 'Action Tags',
agent_tags: 'Agent Tags',
min_sleep_sec: 'Min Sleep (Seconds)',
max_sleep_sec: 'Max Sleep (Seconds)',
retry_backoff_base_sec: 'Retry Backoff Base (Seconds)',
@@ -573,7 +630,24 @@ const resources = {
mcpServicesHint: '管理 MCP 服务、安装服务包,并查看已发现的远端工具。',
cronJobs: '定时任务',
nodes: '节点',
nodeArtifacts: '节点工件',
nodeArtifactsHint: '查看、下载和删除节点调度返回的文件或媒体工件。',
nodeArtifactsEmpty: '当前没有节点工件。',
nodeArtifactsPrune: '清理',
nodeArtifactsKeepLatest: '保留最近 N 条',
nodeArtifactsRetention: '工件保留统计',
nodeArtifactsRetentionHint: '显示最近一次节点工件自动清理的结果。',
nodeArtifactsRetentionKeepLatest: '保留最近数量',
nodeArtifactsRetentionRetainDays: '保留天数',
nodeArtifactsRetentionPruned: '本次清理数量',
nodeArtifactsRetentionRemaining: '剩余数量',
nodeArtifactDetail: '工件详情',
nodeArtifactPreviewUnavailable: '该工件暂不支持预览。',
allNodes: '全部节点',
allKinds: '全部类型',
size: '大小',
nodesDetailHint: '查看节点能力、远端镜像 agent、最近调度记录以及返回的工件。',
nodesFilterPlaceholder: '按节点 ID、名称或标签筛选',
agentTree: '代理树',
noAgentTree: '当前没有可用的代理树。',
readonlyMirror: '只读镜像',
@@ -587,6 +661,16 @@ const resources = {
subagentProfiles: '子代理档案',
subagentsRuntime: 'Agents',
nodeP2P: '节点 P2P',
nodeAlerts: '节点告警',
nodeAlertsEmpty: '当前没有活跃节点告警。',
nodeTags: '节点标签',
nodeDispatchDetail: '调度详情',
replayDispatch: '重放调度',
replaying: '重放中...',
resetReplayDraft: '重置草稿',
nodeReplayRequest: '重放请求',
nodeReplayResult: '重放结果',
nodeReplayInvalidArgs: '重放参数必须是合法 JSON。',
agentTopology: 'Agent 拓扑',
agentTopologyHint: '统一展示本地 agent、注册 node 以及远端镜像 agent 分支的关系图。',
runningTasks: '运行中',
@@ -707,6 +791,28 @@ const resources = {
dashboardNodeDispatchError: '错误',
configNodeP2P: '节点 P2P',
configNodeP2PHint: '为远端节点配置 websocket tunnel 或 WebRTC 传输。',
configNodeDispatch: '节点调度策略',
configNodeDispatchHint: '控制节点选择偏好,并把动作或 agent 绑定到带标签的节点。',
configNodeDispatchPreferLocal: '优先本地节点',
configNodeDispatchPreferP2P: '优先 P2P 传输',
configNodeDispatchAllowRelay: '允许 relay 回退',
configNodeDispatchActionTags: '动作标签规则',
configNodeDispatchAgentTags: 'Agent 标签规则',
configNodeDispatchAllowActions: '节点允许动作',
configNodeDispatchDenyActions: '节点禁止动作',
configNodeDispatchAllowAgents: '节点允许 Agents',
configNodeDispatchDenyAgents: '节点禁止 Agents',
configNodeDispatchActionTagsPlaceholder: 'screen_snapshot=vision\ncamera_snap=vision,gpu',
configNodeDispatchAgentTagsPlaceholder: 'coder=build\nvision=vision,gpu',
configNodeDispatchAllowActionsPlaceholder: 'edge-a=camera_snap,screen_snapshot',
configNodeDispatchDenyActionsPlaceholder: 'edge-b=screen_record',
configNodeDispatchAllowAgentsPlaceholder: 'edge-a=main,coder',
configNodeDispatchDenyAgentsPlaceholder: 'edge-b=vision',
configNodeArtifacts: '节点工件保留策略',
configNodeArtifactsHint: '自动清理旧节点工件,避免审计和媒体存储无限增长。',
configNodeArtifactsKeepLatest: '保留最近工件数',
configNodeArtifactsRetainDays: '保留天数0 表示关闭)',
configNodeArtifactsPruneOnRead: '在列表/导出时执行保留策略',
configNodeP2PStunPlaceholder: '逗号分隔的 STUN URL',
configNodeP2PIceServers: 'ICE 服务器',
configNodeP2PIceServersEmpty: '当前没有结构化 ICE 服务器配置。',
@@ -717,6 +823,8 @@ const resources = {
paused: '已暂停',
noCronJobs: '未找到定时任务',
noNodes: '无可用节点',
allActions: '全部动作',
allTransports: '全部传输',
sessions: '会话',
mainChat: '主对话',
internalStream: '内部流',
@@ -1112,6 +1220,12 @@ const resources = {
proxies: '代理集合',
cross_session_call_id: '跨会话调用 ID',
supports_responses_compact: '支持紧凑 responses',
dispatch: '调度',
prefer_local: '优先本地',
prefer_p2p: '优先 P2P',
allow_relay_fallback: '允许 Relay 回退',
action_tags: '动作标签',
agent_tags: 'Agent 标签',
min_sleep_sec: '最小休眠(秒)',
max_sleep_sec: '最大休眠(秒)',
retry_backoff_base_sec: '重试退避基准(秒)',

View File

@@ -18,6 +18,29 @@ function setPath(obj: any, path: string, value: any) {
return next;
}
function parseTagRuleText(raw: string) {
const out: Record<string, string[]> = {};
for (const line of String(raw || '').split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const idx = trimmed.indexOf('=');
if (idx <= 0) continue;
const key = trimmed.slice(0, idx).trim();
const tags = trimmed.slice(idx + 1).split(',').map((item) => item.trim()).filter(Boolean);
if (!key || tags.length === 0) continue;
out[key] = tags;
}
return out;
}
function formatTagRuleText(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
return Object.entries(value as Record<string, any>)
.map(([key, tags]) => `${key}=${Array.isArray(tags) ? tags.join(',') : ''}`)
.filter((line) => line !== '=')
.join('\n');
}
const Config: React.FC = () => {
const { t } = useTranslation();
const ui = useUI();
@@ -111,6 +134,14 @@ const Config: React.FC = () => {
setCfg((v) => setPath(v, `gateway.nodes.p2p.${field}`, value));
}
function updateGatewayDispatchField(field: string, value: any) {
setCfg((v) => setPath(v, `gateway.nodes.dispatch.${field}`, value));
}
function updateGatewayArtifactsField(field: string, value: any) {
setCfg((v) => setPath(v, `gateway.nodes.artifacts.${field}`, value));
}
function updateGatewayIceServer(index: number, field: string, value: any) {
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
@@ -402,6 +433,144 @@ const Config: React.FC = () => {
<div className="text-xs text-zinc-500">{t('configNodeP2PIceServersEmpty')}</div>
)}
</div>
<div className="border-t border-zinc-800/70 pt-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configNodeDispatch')}</div>
<div className="text-xs text-zinc-500">{t('configNodeDispatchHint')}</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeDispatchPreferLocal')}</div>
<input
type="checkbox"
checked={Boolean((cfg as any)?.gateway?.nodes?.dispatch?.prefer_local)}
onChange={(e) => updateGatewayDispatchField('prefer_local', e.target.checked)}
/>
</label>
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeDispatchPreferP2P')}</div>
<input
type="checkbox"
checked={Boolean((cfg as any)?.gateway?.nodes?.dispatch?.prefer_p2p ?? true)}
onChange={(e) => updateGatewayDispatchField('prefer_p2p', e.target.checked)}
/>
</label>
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeDispatchAllowRelay')}</div>
<input
type="checkbox"
checked={Boolean((cfg as any)?.gateway?.nodes?.dispatch?.allow_relay_fallback ?? true)}
onChange={(e) => updateGatewayDispatchField('allow_relay_fallback', e.target.checked)}
/>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeDispatchActionTags')}</div>
<textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.action_tags)}
onChange={(e) => updateGatewayDispatchField('action_tags', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchActionTagsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</label>
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeDispatchAgentTags')}</div>
<textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.agent_tags)}
onChange={(e) => updateGatewayDispatchField('agent_tags', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchAgentTagsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeDispatchAllowActions')}</div>
<textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.allow_actions)}
onChange={(e) => updateGatewayDispatchField('allow_actions', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchAllowActionsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</label>
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeDispatchDenyActions')}</div>
<textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.deny_actions)}
onChange={(e) => updateGatewayDispatchField('deny_actions', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchDenyActionsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeDispatchAllowAgents')}</div>
<textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.allow_agents)}
onChange={(e) => updateGatewayDispatchField('allow_agents', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchAllowAgentsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</label>
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeDispatchDenyAgents')}</div>
<textarea
value={formatTagRuleText((cfg as any)?.gateway?.nodes?.dispatch?.deny_agents)}
onChange={(e) => updateGatewayDispatchField('deny_agents', parseTagRuleText(e.target.value))}
placeholder={t('configNodeDispatchDenyAgentsPlaceholder')}
className="min-h-28 w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2"
/>
</label>
</div>
</div>
<div className="border-t border-zinc-800/70 pt-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configNodeArtifacts')}</div>
<div className="text-xs text-zinc-500">{t('configNodeArtifactsHint')}</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('enable')}</div>
<input
type="checkbox"
checked={Boolean((cfg as any)?.gateway?.nodes?.artifacts?.enabled)}
onChange={(e) => updateGatewayArtifactsField('enabled', e.target.checked)}
/>
</label>
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeArtifactsKeepLatest')}</div>
<input
type="number"
min={1}
value={Number((cfg as any)?.gateway?.nodes?.artifacts?.keep_latest || 500)}
onChange={(e) => updateGatewayArtifactsField('keep_latest', Math.max(1, Number.parseInt(e.target.value || '0', 10) || 1))}
className="w-full px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
</label>
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeArtifactsPruneOnRead')}</div>
<input
type="checkbox"
checked={Boolean((cfg as any)?.gateway?.nodes?.artifacts?.prune_on_read ?? true)}
onChange={(e) => updateGatewayArtifactsField('prune_on_read', e.target.checked)}
/>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
<div className="text-zinc-300">{t('configNodeArtifactsRetainDays')}</div>
<input
type="number"
min={0}
value={Number((cfg as any)?.gateway?.nodes?.artifacts?.retain_days ?? 7)}
onChange={(e) => updateGatewayArtifactsField('retain_days', Math.max(0, Number.parseInt(e.target.value || '0', 10) || 0))}
className="w-full px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
/>
</label>
</div>
</div>
</div>
)}
{activeTop && activeTop !== hotReloadTabKey ? (

View File

@@ -39,6 +39,8 @@ const Dashboard: React.FC = () => {
cfg,
nodeP2P,
nodeDispatchItems,
nodeAlerts,
nodeArtifactRetention,
taskQueueItems,
ekgSummary,
} = useAppContext();
@@ -83,6 +85,10 @@ const Dashboard: React.FC = () => {
}))
.sort((a, b) => a.node.localeCompare(b.node));
}, [nodeP2P]);
const artifactRetentionEnabled = Boolean(nodeArtifactRetention?.enabled);
const artifactRetentionPruned = Number(nodeArtifactRetention?.pruned || 0);
const artifactRetentionRemaining = Number(nodeArtifactRetention?.remaining || 0);
const artifactRetentionLastRun = formatRuntimeTime(nodeArtifactRetention?.last_run_at);
const recentNodeDispatches = useMemo(() => {
return [...nodeDispatchItems]
.slice(0, 8)
@@ -101,6 +107,9 @@ const Dashboard: React.FC = () => {
error: String(item?.error || '').trim(),
}));
}, [nodeDispatchItems]);
const topNodeAlerts = useMemo(() => {
return [...(Array.isArray(nodeAlerts) ? nodeAlerts : [])].slice(0, 6);
}, [nodeAlerts]);
return (
<div className="p-4 md:p-6 xl:p-8 w-full space-y-6 xl:space-y-8">
@@ -158,6 +167,69 @@ const Dashboard: React.FC = () => {
</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('nodeArtifactsRetention')}</h2>
</div>
<div className="text-xs text-zinc-500 mt-1">{t('nodeArtifactsRetentionHint')}</div>
</div>
<div className={`rounded-full px-2.5 py-1 text-[11px] font-medium ${artifactRetentionEnabled ? 'bg-emerald-500/10 text-emerald-300' : 'bg-zinc-800 text-zinc-400'}`}>
{artifactRetentionEnabled ? t('enabled') : t('disabled')}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 text-sm">
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
<div className="text-zinc-400 text-xs">{t('nodeArtifactsRetentionPruned')}</div>
<div className="mt-2 text-xl font-semibold text-zinc-100">{artifactRetentionPruned}</div>
</div>
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
<div className="text-zinc-400 text-xs">{t('nodeArtifactsRetentionRemaining')}</div>
<div className="mt-2 text-xl font-semibold text-zinc-100">{artifactRetentionRemaining}</div>
</div>
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
<div className="text-zinc-400 text-xs">{t('nodeArtifactsRetentionKeepLatest')}</div>
<div className="mt-2 text-xl font-semibold text-zinc-100">{Number(nodeArtifactRetention?.keep_latest || 0) || '-'}</div>
</div>
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
<div className="text-zinc-400 text-xs">{t('time')}</div>
<div className="mt-2 text-sm font-medium text-zinc-100">{artifactRetentionLastRun}</div>
</div>
</div>
</div>
<div className="brand-card rounded-[30px] border border-zinc-800/80 p-6">
<div className="flex items-center gap-2 mb-5 text-zinc-200">
<AlertTriangle className="w-5 h-5 text-amber-400" />
<h2 className="text-lg font-medium">{t('nodeAlerts')}</h2>
</div>
{topNodeAlerts.length === 0 ? (
<div className="text-sm text-zinc-500 text-center py-8">{t('nodeAlertsEmpty')}</div>
) : (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
{topNodeAlerts.map((alert: any, index: number) => {
const severity = String(alert?.severity || 'warning');
return (
<div key={`${alert?.node || 'node'}-${alert?.kind || index}-${index}`} 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">{String(alert?.title || '-')}</div>
<div className="text-xs text-zinc-500 mt-1 truncate">{String(alert?.node || '-')} · {String(alert?.kind || '-')}</div>
</div>
<div className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${severity === 'critical' ? 'bg-rose-500/10 text-rose-300' : 'bg-amber-500/10 text-amber-300'}`}>
{severity}
</div>
</div>
<div className="mt-3 text-xs text-zinc-300 whitespace-pre-wrap break-words">{String(alert?.detail || '-')}</div>
</div>
);
})}
</div>
)}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6 items-stretch">
<div className="brand-card rounded-[30px] border border-zinc-800/80 p-6 min-h-[340px] h-full">
<div className="flex items-center gap-2 mb-5 text-zinc-200">

View File

@@ -0,0 +1,275 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { useAppContext } from '../context/AppContext';
import { formatLocalDateTime } from '../utils/time';
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 NodeArtifacts: React.FC = () => {
const { t } = useTranslation();
const { q } = useAppContext();
const [searchParams, setSearchParams] = useSearchParams();
const [items, setItems] = useState<any[]>([]);
const [retentionSummary, setRetentionSummary] = useState<Record<string, any>>({});
const [selectedID, setSelectedID] = useState('');
const [loading, setLoading] = useState(false);
const [prunePending, setPrunePending] = useState(false);
const [nodeFilter, setNodeFilter] = useState(searchParams.get('node') || 'all');
const [actionFilter, setActionFilter] = useState(searchParams.get('action') || 'all');
const [kindFilter, setKindFilter] = useState(searchParams.get('kind') || 'all');
const [keepLatest, setKeepLatest] = useState('20');
const apiQuery = useMemo(() => {
const params = new URLSearchParams();
params.set('limit', '400');
if (nodeFilter !== 'all') params.set('node', nodeFilter);
if (actionFilter !== 'all') params.set('action', actionFilter);
if (kindFilter !== 'all') params.set('kind', kindFilter);
return params.toString();
}, [nodeFilter, actionFilter, kindFilter]);
useEffect(() => {
const next = new URLSearchParams();
if (nodeFilter !== 'all') next.set('node', nodeFilter);
if (actionFilter !== 'all') next.set('action', actionFilter);
if (kindFilter !== 'all') next.set('kind', kindFilter);
setSearchParams(next, { replace: true });
}, [nodeFilter, actionFilter, kindFilter, setSearchParams]);
const loadArtifacts = async () => {
setLoading(true);
try {
const query = q ? `${q}&${apiQuery}` : `?${apiQuery}`;
const r = await fetch(`/webui/api/node_artifacts${query}`);
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
const next = Array.isArray(j.items) ? j.items : [];
setRetentionSummary(j.artifact_retention && typeof j.artifact_retention === 'object' ? j.artifact_retention : {});
setItems(next);
if (next.length === 0) {
setSelectedID('');
} else if (!next.some((item: any) => String(item?.id || '') === selectedID)) {
setSelectedID(String(next[0]?.id || ''));
}
} catch (err) {
console.error(err);
setItems([]);
setSelectedID('');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadArtifacts();
}, [q, apiQuery]);
const nodes = useMemo(() => Array.from(new Set(items.map((item) => String(item?.node || '')).filter(Boolean))).sort(), [items]);
const actions = useMemo(() => Array.from(new Set(items.map((item) => String(item?.action || '')).filter(Boolean))).sort(), [items]);
const kinds = useMemo(() => Array.from(new Set(items.map((item) => String(item?.kind || '')).filter(Boolean))).sort(), [items]);
const filteredItems = items;
const selected = useMemo(() => {
return filteredItems.find((item) => String(item?.id || '') === selectedID) || filteredItems[0] || null;
}, [filteredItems, selectedID]);
async function deleteArtifact(id: string) {
const r = await fetch(`/webui/api/node_artifacts/delete${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (!r.ok) throw new Error(await r.text());
await loadArtifacts();
}
async function pruneArtifacts() {
setPrunePending(true);
try {
const keep = Math.max(0, Number.parseInt(keepLatest || '0', 10) || 0);
const r = await fetch(`/webui/api/node_artifacts/prune${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node: nodeFilter === 'all' ? '' : nodeFilter,
action: actionFilter === 'all' ? '' : actionFilter,
kind: kindFilter === 'all' ? '' : kindFilter,
keep_latest: keep,
limit: 1000,
}),
});
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
setRetentionSummary(j && typeof j === 'object' ? { ...retentionSummary, manual_pruned: j.pruned, manual_deleted_files: j.deleted_files, last_run_at: new Date().toISOString() } : retentionSummary);
await loadArtifacts();
} catch (err) {
console.error(err);
} finally {
setPrunePending(false);
}
}
function downloadURL(id: string) {
return `/webui/api/node_artifacts/download${q ? `${q}&id=${encodeURIComponent(id)}` : `?id=${encodeURIComponent(id)}`}`;
}
function exportURL() {
const query = q ? `${q}&${apiQuery}` : `?${apiQuery}`;
return `/webui/api/node_artifacts/export${query}`;
}
return (
<div className="h-full p-4 md:p-6 xl:p-8 flex flex-col gap-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-xl md:text-2xl font-semibold">{t('nodeArtifacts')}</h1>
<div className="text-sm text-zinc-500 mt-1">{t('nodeArtifactsHint')}</div>
</div>
<div className="flex items-center gap-2">
<a href={exportURL()} className="rounded-xl border border-zinc-700 px-3 py-1.5 text-sm text-zinc-200">
{t('export')}
</a>
<button onClick={loadArtifacts} className="brand-button px-3 py-1.5 rounded-xl text-sm text-white">
{loading ? t('loading') : t('refresh')}
</button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
<div className="brand-card rounded-[28px] border border-zinc-800 overflow-hidden flex flex-col min-h-0">
<div className="p-3 border-b border-zinc-800 space-y-2">
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/60 p-3 text-xs text-zinc-300 space-y-1">
<div className="font-medium text-zinc-100">{t('nodeArtifactsRetention')}</div>
<div>{t('nodeArtifactsRetentionKeepLatest')}: {Number(retentionSummary?.keep_latest || 0) || '-'}</div>
<div>{t('nodeArtifactsRetentionRetainDays')}: {Number(retentionSummary?.retain_days || 0)}</div>
<div>{t('nodeArtifactsRetentionPruned')}: {Number(retentionSummary?.pruned || retentionSummary?.manual_pruned || 0)}</div>
<div>{t('nodeArtifactsRetentionRemaining')}: {Number(retentionSummary?.remaining || filteredItems.length || 0)}</div>
</div>
<div className="grid grid-cols-1 gap-2">
<select value={nodeFilter} onChange={(e) => setNodeFilter(e.target.value)} className="rounded-xl bg-zinc-950/70 border border-zinc-800 px-2 py-2 text-xs">
<option value="all">{t('allNodes')}</option>
{nodes.map((node) => <option key={node} value={node}>{node}</option>)}
</select>
<select value={actionFilter} onChange={(e) => setActionFilter(e.target.value)} className="rounded-xl bg-zinc-950/70 border border-zinc-800 px-2 py-2 text-xs">
<option value="all">{t('allActions')}</option>
{actions.map((action) => <option key={action} value={action}>{action}</option>)}
</select>
<select value={kindFilter} onChange={(e) => setKindFilter(e.target.value)} className="rounded-xl bg-zinc-950/70 border border-zinc-800 px-2 py-2 text-xs">
<option value="all">{t('allKinds')}</option>
{kinds.map((kind) => <option key={kind} value={kind}>{kind}</option>)}
</select>
</div>
<div className="grid grid-cols-[1fr_auto] gap-2">
<input
value={keepLatest}
onChange={(e) => setKeepLatest(e.target.value)}
inputMode="numeric"
placeholder={t('nodeArtifactsKeepLatest')}
className="rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2 text-xs"
/>
<button
onClick={pruneArtifacts}
disabled={prunePending}
className="rounded-xl border border-zinc-700 px-3 py-2 text-xs text-zinc-200 disabled:opacity-60"
>
{prunePending ? t('loading') : t('nodeArtifactsPrune')}
</button>
</div>
</div>
<div className="overflow-y-auto min-h-0">
{filteredItems.length === 0 ? (
<div className="p-4 text-sm text-zinc-500">{t('nodeArtifactsEmpty')}</div>
) : filteredItems.map((item, index) => {
const active = String(selected?.id || '') === String(item?.id || '');
return (
<button
key={String(item?.id || index)}
onClick={() => setSelectedID(String(item?.id || ''))}
className={`w-full text-left px-3 py-3 border-b border-zinc-800/60 hover:bg-zinc-800/20 ${active ? 'bg-indigo-500/15' : ''}`}
>
<div className="text-sm font-medium text-zinc-100 truncate">{String(item?.name || item?.source_path || `artifact-${index + 1}`)}</div>
<div className="text-xs text-zinc-400 truncate">{String(item?.node || '-')} · {String(item?.action || '-')} · {String(item?.kind || '-')}</div>
<div className="text-[11px] text-zinc-500 truncate">{formatLocalDateTime(item?.time)}</div>
</button>
);
})}
</div>
</div>
<div className="brand-card rounded-[28px] border border-zinc-800 overflow-hidden flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-zinc-800 text-xs text-zinc-400 uppercase tracking-wider">{t('nodeArtifactDetail')}</div>
<div className="p-4 overflow-y-auto min-h-0 space-y-4 text-sm">
{!selected ? (
<div className="text-zinc-500">{t('nodeArtifactsEmpty')}</div>
) : (
<>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-lg font-medium text-zinc-100">{String(selected?.name || selected?.source_path || 'artifact')}</div>
<div className="text-xs text-zinc-500 mt-1">{String(selected?.node || '-')} · {String(selected?.action || '-')} · {formatLocalDateTime(selected?.time)}</div>
</div>
<div className="flex items-center gap-2">
<a href={downloadURL(String(selected?.id || ''))} className="rounded-xl border border-zinc-700 px-3 py-1.5 text-xs text-zinc-200">
{t('download')}
</a>
<button onClick={() => deleteArtifact(String(selected?.id || ''))} className="rounded-xl bg-red-900/60 px-3 py-1.5 text-xs text-red-100">
{t('delete')}
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div><div className="text-zinc-500 text-xs">{t('node')}</div><div>{String(selected?.node || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">{t('action')}</div><div>{String(selected?.action || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">{t('kind')}</div><div>{String(selected?.kind || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">{t('size')}</div><div>{formatBytes(selected?.size_bytes)}</div></div>
</div>
<div className="text-xs text-zinc-500 break-all">
{String(selected?.source_path || selected?.path || selected?.url || '-')}
</div>
{(() => {
const kind = String(selected?.kind || '').trim().toLowerCase();
const mime = String(selected?.mime_type || '').trim().toLowerCase();
const isImage = kind === 'image' || mime.startsWith('image/');
const isVideo = kind === 'video' || mime.startsWith('video/');
const dataUrl = dataUrlForArtifact(selected);
if (isImage && dataUrl) {
return <img src={dataUrl} alt={String(selected?.name || 'artifact')} className="max-h-[420px] rounded-2xl border border-zinc-800 object-contain bg-black/30" />;
}
if (isVideo && dataUrl) {
return <video src={dataUrl} controls className="max-h-[420px] w-full rounded-2xl border border-zinc-800 bg-black/30" />;
}
if (String(selected?.content_text || '').trim() !== '') {
return <pre className="rounded-2xl border border-zinc-800 bg-black/20 p-3 text-[12px] text-zinc-300 whitespace-pre-wrap overflow-auto max-h-[420px]">{String(selected?.content_text || '')}</pre>;
}
return <div className="text-zinc-500">{t('nodeArtifactPreviewUnavailable')}</div>;
})()}
<pre className="rounded-2xl border border-zinc-800 bg-zinc-950/60 p-3 text-xs overflow-auto">{JSON.stringify(selected, null, 2)}</pre>
</>
)}
</div>
</div>
</div>
</div>
);
};
export default NodeArtifacts;

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useAppContext } from '../context/AppContext';
import { formatLocalDateTime } from '../utils/time';
@@ -20,10 +21,23 @@ function formatBytes(value: unknown) {
const Nodes: React.FC = () => {
const { t } = useTranslation();
const { q, nodes, nodeTrees, nodeP2P, refreshNodes } = useAppContext();
const { q, nodes, nodeTrees, nodeP2P, nodeAlerts, refreshNodes } = useAppContext();
const [selectedNodeID, setSelectedNodeID] = useState('');
const [dispatches, setDispatches] = useState<any[]>([]);
const [selectedDispatchKey, setSelectedDispatchKey] = useState('');
const [loading, setLoading] = useState(false);
const [reloadTick, setReloadTick] = useState(0);
const [nodeFilter, setNodeFilter] = useState('');
const [dispatchActionFilter, setDispatchActionFilter] = useState('all');
const [dispatchTransportFilter, setDispatchTransportFilter] = useState('all');
const [dispatchStatusFilter, setDispatchStatusFilter] = useState('all');
const [replayPending, setReplayPending] = useState(false);
const [replayResult, setReplayResult] = useState<any>(null);
const [replayError, setReplayError] = useState('');
const [replayModeDraft, setReplayModeDraft] = useState('auto');
const [replayTaskDraft, setReplayTaskDraft] = useState('');
const [replayModelDraft, setReplayModelDraft] = useState('');
const [replayArgsDraft, setReplayArgsDraft] = useState('{}');
const nodeItems = useMemo(() => {
try {
@@ -54,11 +68,16 @@ const Nodes: React.FC = () => {
const fetchDispatches = async () => {
setLoading(true);
try {
const r = await fetch(`/webui/api/node_dispatches${q ? `${q}&limit=200` : '?limit=200'}`);
const r = await fetch(`/webui/api/node_dispatches${q ? `${q}&limit=300` : '?limit=300'}`);
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
if (!cancelled) {
setDispatches(Array.isArray(j.items) ? j.items : []);
const items = Array.isArray(j.items) ? j.items : [];
setDispatches(items);
if (items.length > 0 && !selectedDispatchKey) {
const first = items[0];
setSelectedDispatchKey(`${first?.time || ''}:${first?.node || ''}:${first?.action || ''}`);
}
}
} catch (err) {
console.error(err);
@@ -69,11 +88,28 @@ const Nodes: React.FC = () => {
};
fetchDispatches();
return () => { cancelled = true; };
}, [q]);
}, [q, reloadTick]);
const filteredNodes = useMemo(() => {
const keyword = nodeFilter.trim().toLowerCase();
if (!keyword) return nodeItems;
return nodeItems.filter((item: any) => {
const tags = Array.isArray(item?.tags) ? item.tags.join(' ') : '';
const haystack = [
item?.id,
item?.name,
item?.os,
item?.arch,
item?.version,
tags,
].join(' ').toLowerCase();
return haystack.includes(keyword);
});
}, [nodeItems, nodeFilter]);
const selectedNode = useMemo(() => {
return nodeItems.find((item) => String(item?.id || '') === selectedNodeID) || nodeItems[0] || null;
}, [nodeItems, selectedNodeID]);
return nodeItems.find((item) => String(item?.id || '') === selectedNodeID) || filteredNodes[0] || nodeItems[0] || null;
}, [nodeItems, filteredNodes, selectedNodeID]);
const selectedTree = useMemo(() => {
const nodeID = String(selectedNode?.id || '');
@@ -85,11 +121,97 @@ const Nodes: React.FC = () => {
const sessions = Array.isArray(nodeP2P?.nodes) ? nodeP2P.nodes : [];
return sessions.find((item: any) => String(item?.node || '') === nodeID) || null;
}, [nodeP2P, selectedNode]);
const selectedNodeAlerts = useMemo(() => {
const nodeID = String(selectedNode?.id || '');
return (Array.isArray(nodeAlerts) ? nodeAlerts : []).filter((item: any) => String(item?.node || '') === nodeID);
}, [nodeAlerts, selectedNode]);
const filteredDispatches = useMemo(() => {
const nodeID = String(selectedNode?.id || '');
return dispatches.filter((item) => String(item?.node || '') === nodeID);
}, [dispatches, selectedNode]);
return dispatches.filter((item) => {
if (String(item?.node || '') !== nodeID) return false;
if (dispatchActionFilter !== 'all' && String(item?.action || '') !== dispatchActionFilter) return false;
if (dispatchTransportFilter !== 'all' && String(item?.used_transport || '-') !== dispatchTransportFilter) return false;
if (dispatchStatusFilter === 'ok' && !item?.ok) return false;
if (dispatchStatusFilter === 'error' && item?.ok) return false;
return true;
});
}, [dispatches, selectedNode, dispatchActionFilter, dispatchTransportFilter, dispatchStatusFilter]);
const dispatchActions = useMemo(() => {
return Array.from(new Set(dispatches.map((item) => String(item?.action || '').trim()).filter(Boolean))).sort();
}, [dispatches]);
const dispatchTransports = useMemo(() => {
return Array.from(new Set(dispatches.map((item) => String(item?.used_transport || '').trim()).filter(Boolean))).sort();
}, [dispatches]);
const selectedDispatch = useMemo(() => {
if (!selectedDispatchKey) return filteredDispatches[0] || null;
return filteredDispatches.find((item) => `${item?.time || ''}:${item?.node || ''}:${item?.action || ''}` === selectedDispatchKey) || filteredDispatches[0] || null;
}, [filteredDispatches, selectedDispatchKey]);
const selectedDispatchPretty = useMemo(() => selectedDispatch ? JSON.stringify(selectedDispatch, null, 2) : '', [selectedDispatch]);
useEffect(() => {
if (!selectedDispatch) {
setReplayModeDraft('auto');
setReplayTaskDraft('');
setReplayModelDraft('');
setReplayArgsDraft('{}');
return;
}
setReplayModeDraft(String(selectedDispatch.mode || 'auto'));
setReplayTaskDraft(String(selectedDispatch.task || ''));
setReplayModelDraft(String(selectedDispatch.model || ''));
setReplayArgsDraft(JSON.stringify(selectedDispatch.request_args || {}, null, 2));
}, [selectedDispatch]);
function resetReplayDraft() {
if (!selectedDispatch) return;
setReplayModeDraft(String(selectedDispatch.mode || 'auto'));
setReplayTaskDraft(String(selectedDispatch.task || ''));
setReplayModelDraft(String(selectedDispatch.model || ''));
setReplayArgsDraft(JSON.stringify(selectedDispatch.request_args || {}, null, 2));
setReplayError('');
}
async function replayDispatch() {
if (!selectedDispatch) return;
setReplayPending(true);
setReplayError('');
setReplayResult(null);
try {
let parsedArgs: Record<string, any> = {};
try {
parsedArgs = replayArgsDraft.trim() ? JSON.parse(replayArgsDraft) : {};
} catch {
throw new Error(t('nodeReplayInvalidArgs'));
}
const r = await fetch(`/webui/api/node_dispatches/replay${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node: selectedDispatch.node,
action: selectedDispatch.action,
mode: replayModeDraft || 'auto',
task: replayTaskDraft,
model: replayModelDraft,
args: parsedArgs,
}),
});
const text = await r.text();
if (!r.ok) throw new Error(text || 'replay failed');
const json = text ? JSON.parse(text) : {};
setReplayResult(json.result || null);
setReloadTick((value) => value + 1);
refreshNodes();
} catch (err: any) {
setReplayError(String(err?.message || err || 'replay failed'));
} finally {
setReplayPending(false);
}
}
return (
<div className="h-full p-4 md:p-6 xl:p-8 flex flex-col gap-4">
@@ -98,138 +220,290 @@ const Nodes: React.FC = () => {
<h1 className="text-xl md:text-2xl font-semibold">{t('nodes')}</h1>
<div className="text-sm text-zinc-500 mt-1">{t('nodesDetailHint')}</div>
</div>
<button onClick={() => { refreshNodes(); }} className="brand-button px-3 py-1.5 rounded-xl text-sm text-white">
<button onClick={() => { refreshNodes(); setReloadTick((value) => value + 1); }} className="brand-button px-3 py-1.5 rounded-xl text-sm text-white">
{loading ? t('loading') : t('refresh')}
</button>
</div>
<div className="flex-1 min-h-0 grid grid-cols-1 xl:grid-cols-[320px_1fr] gap-4">
<div className="grid grid-cols-1 xl:grid-cols-[300px_1fr_1.1fr] gap-4 flex-1 min-h-0">
<div className="brand-card rounded-[28px] border border-zinc-800 overflow-hidden flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-zinc-800 text-xs text-zinc-400 uppercase tracking-wider">{t('nodes')}</div>
<div className="px-3 py-2 border-b border-zinc-800 space-y-2">
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('nodes')}</div>
<input
value={nodeFilter}
onChange={(e) => setNodeFilter(e.target.value)}
placeholder={t('nodesFilterPlaceholder')}
className="w-full rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2 text-sm"
/>
</div>
<div className="overflow-y-auto min-h-0">
{nodeItems.length === 0 ? (
{filteredNodes.length === 0 ? (
<div className="p-4 text-sm text-zinc-500">{t('noNodes')}</div>
) : nodeItems.map((node: any, index: number) => {
) : filteredNodes.map((node: any, index: number) => {
const nodeID = String(node?.id || `node-${index}`);
const active = String(selectedNode?.id || '') === nodeID;
const tags = Array.isArray(node?.tags) ? node.tags : [];
return (
<button
key={nodeID}
onClick={() => setSelectedNodeID(nodeID)}
className={`w-full text-left px-3 py-2 border-b border-zinc-800/60 hover:bg-zinc-800/20 ${active ? 'bg-indigo-500/15' : ''}`}
className={`w-full text-left px-3 py-3 border-b border-zinc-800/60 hover:bg-zinc-800/20 ${active ? 'bg-indigo-500/15' : ''}`}
>
<div className="text-sm font-medium text-zinc-100 truncate">{String(node?.name || nodeID)}</div>
<div className="text-xs text-zinc-400 truncate">{nodeID} · {String(node?.os || '-')} / {String(node?.arch || '-')}</div>
<div className="text-[11px] text-zinc-500 truncate">{String(node?.online ? t('online') : t('offline'))} · {String(node?.version || '-')}</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{tags.slice(0, 4).map((tag: string) => (
<span key={`${nodeID}-${tag}`} className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[10px] text-zinc-300">
{tag}
</span>
))}
</div>
)}
</button>
);
})}
</div>
</div>
<div className="grid grid-cols-1 2xl:grid-cols-[1.1fr_1fr] gap-4 min-h-0">
<div className="brand-card rounded-[28px] border border-zinc-800 overflow-hidden flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-zinc-800 text-xs text-zinc-400 uppercase tracking-wider">{t('nodeDetails')}</div>
<div className="p-4 overflow-y-auto min-h-0 space-y-4 text-sm">
{!selectedNode ? (
<div className="text-zinc-500">{t('noNodes')}</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<div><div className="text-zinc-500 text-xs">{t('status')}</div><div>{selectedNode.online ? t('online') : t('offline')}</div></div>
<div><div className="text-zinc-500 text-xs">{t('time')}</div><div>{formatLocalDateTime(selectedNode.last_seen_at)}</div></div>
<div><div className="text-zinc-500 text-xs">{t('version')}</div><div>{String(selectedNode.version || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">OS</div><div>{String(selectedNode.os || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">Arch</div><div>{String(selectedNode.arch || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">Endpoint</div><div className="break-all">{String(selectedNode.endpoint || '-')}</div></div>
</div>
<div className="brand-card rounded-[28px] border border-zinc-800 overflow-hidden flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-zinc-800 text-xs text-zinc-400 uppercase tracking-wider">{t('nodeDetails')}</div>
<div className="p-4 overflow-y-auto min-h-0 space-y-4 text-sm">
{!selectedNode ? (
<div className="text-zinc-500">{t('noNodes')}</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<div><div className="text-zinc-500 text-xs">{t('status')}</div><div>{selectedNode.online ? t('online') : t('offline')}</div></div>
<div><div className="text-zinc-500 text-xs">{t('time')}</div><div>{formatLocalDateTime(selectedNode.last_seen_at)}</div></div>
<div><div className="text-zinc-500 text-xs">{t('version')}</div><div>{String(selectedNode.version || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">OS</div><div>{String(selectedNode.os || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">Arch</div><div>{String(selectedNode.arch || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">Endpoint</div><div className="break-all">{String(selectedNode.endpoint || '-')}</div></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeCapabilities')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200 break-all">
{Object.entries(selectedNode.capabilities || {}).filter(([, enabled]) => Boolean(enabled)).map(([key]) => key).join(', ') || '-'}
</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeActions')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200 break-all">
{Array.isArray(selectedNode.actions) && selectedNode.actions.length > 0 ? selectedNode.actions.join(', ') : '-'}
</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeModels')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200 break-all">
{Array.isArray(selectedNode.models) && selectedNode.models.length > 0 ? selectedNode.models.join(', ') : '-'}
</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeAgents')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200 break-all">
{Array.isArray(selectedNode.agents) && selectedNode.agents.length > 0 ? selectedNode.agents.map((item: any) => String(item?.id || '-')).join(', ') : '-'}
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Link
to={`/node-artifacts?node=${encodeURIComponent(String(selectedNode.id || ''))}`}
className="rounded-xl border border-zinc-700 px-3 py-1.5 text-xs text-zinc-200"
>
{t('nodeArtifacts')}
</Link>
<a
href={`/webui/api/node_artifacts/export${q ? `${q}&node=${encodeURIComponent(String(selectedNode.id || ''))}` : `?node=${encodeURIComponent(String(selectedNode.id || ''))}`}`}
className="rounded-xl border border-zinc-700 px-3 py-1.5 text-xs text-zinc-200"
>
{t('export')}
</a>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeTags')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200 break-all">
{Array.isArray(selectedNode.tags) && selectedNode.tags.length > 0 ? selectedNode.tags.join(', ') : '-'}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeP2P')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200">
{selectedSession ? (
<div className="grid grid-cols-2 gap-3 text-xs">
<div><div className="text-zinc-500">{t('status')}</div><div>{String(selectedSession.status || 'unknown')}</div></div>
<div><div className="text-zinc-500">{t('dashboardNodeP2PSessionRetries')}</div><div>{Number(selectedSession.retry_count || 0)}</div></div>
<div><div className="text-zinc-500">{t('dashboardNodeP2PSessionReady')}</div><div>{formatLocalDateTime(selectedSession.last_ready_at)}</div></div>
<div><div className="text-zinc-500">{t('dashboardNodeP2PSessionError')}</div><div className="break-all">{String(selectedSession.last_error || '-')}</div></div>
</div>
) : (
<div className="text-zinc-500">{t('dashboardNodeP2PSessionsEmpty')}</div>
)}
<div className="text-zinc-500 text-xs mb-1">{t('nodeCapabilities')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200 break-all">
{Object.entries(selectedNode.capabilities || {}).filter(([, enabled]) => Boolean(enabled)).map(([key]) => key).join(', ') || '-'}
</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('agentTree')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200 space-y-2">
{Array.isArray(selectedTree?.items) && selectedTree.items.length > 0 ? selectedTree.items.map((item: any, index: number) => (
<div key={`${item?.agent_id || index}`} className="rounded-xl border border-zinc-800/80 bg-black/20 p-3">
<div className="text-sm font-medium text-zinc-100">{String(item?.display_name || item?.agent_id || '-')}</div>
<div className="text-xs text-zinc-500 mt-1">{String(item?.agent_id || '-')} · {String(item?.transport || '-')} · {String(item?.role || '-')}</div>
</div>
)) : (
<div className="text-zinc-500">{t('noAgentTree')}</div>
)}
<div className="text-zinc-500 text-xs mb-1">{t('nodeActions')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200 break-all">
{Array.isArray(selectedNode.actions) && selectedNode.actions.length > 0 ? selectedNode.actions.join(', ') : '-'}
</div>
</div>
</>
)}
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeModels')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200 break-all">
{Array.isArray(selectedNode.models) && selectedNode.models.length > 0 ? selectedNode.models.join(', ') : '-'}
</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeAgents')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200 break-all">
{Array.isArray(selectedNode.agents) && selectedNode.agents.length > 0 ? selectedNode.agents.map((item: any) => String(item?.id || '-')).join(', ') : '-'}
</div>
</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeAlerts')}</div>
<div className="space-y-2">
{selectedNodeAlerts.length > 0 ? selectedNodeAlerts.map((alert: any, index: number) => {
const severity = String(alert?.severity || 'warning');
return (
<div key={`${alert?.kind || index}-${index}`} className={`rounded-2xl border p-3 ${severity === 'critical' ? 'border-rose-900/60 bg-rose-950/20' : 'border-amber-900/60 bg-amber-950/20'}`}>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-zinc-100">{String(alert?.title || '-')}</div>
<div className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${severity === 'critical' ? 'bg-rose-500/10 text-rose-300' : 'bg-amber-500/10 text-amber-300'}`}>{severity}</div>
</div>
<div className="mt-2 text-xs text-zinc-300 whitespace-pre-wrap break-words">{String(alert?.detail || '-')}</div>
</div>
);
}) : (
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-500">{t('nodeAlertsEmpty')}</div>
)}
</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeP2P')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200">
{selectedSession ? (
<div className="grid grid-cols-2 gap-3 text-xs">
<div><div className="text-zinc-500">{t('status')}</div><div>{String(selectedSession.status || 'unknown')}</div></div>
<div><div className="text-zinc-500">{t('dashboardNodeP2PSessionRetries')}</div><div>{Number(selectedSession.retry_count || 0)}</div></div>
<div><div className="text-zinc-500">{t('dashboardNodeP2PSessionReady')}</div><div>{formatLocalDateTime(selectedSession.last_ready_at)}</div></div>
<div><div className="text-zinc-500">{t('dashboardNodeP2PSessionError')}</div><div className="break-all">{String(selectedSession.last_error || '-')}</div></div>
</div>
) : (
<div className="text-zinc-500">{t('dashboardNodeP2PSessionsEmpty')}</div>
)}
</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('agentTree')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 text-zinc-200 space-y-2">
{Array.isArray(selectedTree?.items) && selectedTree.items.length > 0 ? selectedTree.items.map((item: any, index: number) => (
<div key={`${item?.agent_id || index}`} className="rounded-xl border border-zinc-800/80 bg-black/20 p-3">
<div className="text-sm font-medium text-zinc-100">{String(item?.display_name || item?.agent_id || '-')}</div>
<div className="text-xs text-zinc-500 mt-1">{String(item?.agent_id || '-')} · {String(item?.transport || '-')} · {String(item?.role || '-')}</div>
</div>
)) : (
<div className="text-zinc-500">{t('noAgentTree')}</div>
)}
</div>
</div>
</>
)}
</div>
</div>
<div className="brand-card rounded-[28px] border border-zinc-800 overflow-hidden flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-zinc-800 space-y-2">
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('nodeDispatchDetail')}</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<select value={dispatchActionFilter} onChange={(e) => setDispatchActionFilter(e.target.value)} className="rounded-xl bg-zinc-950/70 border border-zinc-800 px-2 py-2 text-xs">
<option value="all">{t('allActions')}</option>
{dispatchActions.map((action) => <option key={action} value={action}>{action}</option>)}
</select>
<select value={dispatchTransportFilter} onChange={(e) => setDispatchTransportFilter(e.target.value)} className="rounded-xl bg-zinc-950/70 border border-zinc-800 px-2 py-2 text-xs">
<option value="all">{t('allTransports')}</option>
{dispatchTransports.map((transport) => <option key={transport} value={transport}>{transport}</option>)}
</select>
<select value={dispatchStatusFilter} onChange={(e) => setDispatchStatusFilter(e.target.value)} className="rounded-xl bg-zinc-950/70 border border-zinc-800 px-2 py-2 text-xs">
<option value="all">{t('allStatus')}</option>
<option value="ok">ok</option>
<option value="error">error</option>
</select>
</div>
</div>
<div className="brand-card rounded-[28px] border border-zinc-800 overflow-hidden flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-zinc-800 text-xs text-zinc-400 uppercase tracking-wider">{t('dashboardNodeDispatches')}</div>
<div className="p-4 overflow-y-auto min-h-0 space-y-3 text-sm">
<div className="grid grid-rows-[220px_1fr] min-h-0 flex-1">
<div className="overflow-y-auto min-h-0 border-b border-zinc-800/60">
{filteredDispatches.length === 0 ? (
<div className="p-4 text-sm text-zinc-500">{t('dashboardNodeDispatchesEmpty')}</div>
) : filteredDispatches.map((item: any, index: number) => {
const key = `${item?.time || ''}:${item?.node || ''}:${item?.action || ''}`;
const active = `${selectedDispatch?.time || ''}:${selectedDispatch?.node || ''}:${selectedDispatch?.action || ''}` === key;
return (
<button
key={key || `dispatch-${index}`}
onClick={() => setSelectedDispatchKey(key)}
className={`w-full text-left px-3 py-2 border-b border-zinc-800/60 hover:bg-zinc-800/20 ${active ? 'bg-indigo-500/15' : ''}`}
>
<div className="text-sm font-medium text-zinc-100 truncate">{`${item?.action || '-'} · ${item?.used_transport || '-'}`}</div>
<div className="text-xs text-zinc-400 truncate">{formatLocalDateTime(item?.time)} · {Number(item?.duration_ms || 0)}ms · {Number(item?.artifact_count || 0)} {t('dashboardNodeDispatchArtifacts')}</div>
</button>
);
})}
</div>
<div className="p-4 overflow-y-auto min-h-0 space-y-3 text-sm">
{!selectedDispatch ? (
<div className="text-zinc-500">{t('dashboardNodeDispatchesEmpty')}</div>
) : filteredDispatches.map((item: any, index: number) => (
<div key={`${item?.time || index}-${index}`} className="rounded-2xl border border-zinc-800 bg-zinc-950/40 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-zinc-100 truncate">{String(item?.action || '-')}</div>
<div className="text-xs text-zinc-500 mt-1">{formatLocalDateTime(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 className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-zinc-200">{t('nodeDispatchDetail')}</div>
<div className="flex items-center gap-2">
<button
onClick={resetReplayDraft}
disabled={replayPending}
className="rounded-xl border border-zinc-700 px-3 py-1.5 text-xs text-zinc-200 disabled:opacity-60"
>
{t('resetReplayDraft')}
</button>
<button
onClick={replayDispatch}
disabled={replayPending}
className="brand-button px-3 py-1.5 rounded-xl text-xs text-white disabled:opacity-60"
>
{replayPending ? t('replaying') : t('replayDispatch')}
</button>
</div>
</div>
<div className="grid 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">{String(item?.used_transport || '-')}</div></div>
<div><div className="text-zinc-400">{t('dashboardNodeDispatchFallback')}</div><div className="text-zinc-200 mt-1">{String(item?.fallback_from || '-')}</div></div>
<div><div className="text-zinc-400">{t('duration')}</div><div className="text-zinc-200 mt-1">{Number(item?.duration_ms || 0)}ms</div></div>
<div><div className="text-zinc-400">{t('dashboardNodeDispatchArtifacts')}</div><div className="text-zinc-200 mt-1">{Number(item?.artifact_count || 0)}</div></div>
<div className="grid grid-cols-2 gap-3">
<div><div className="text-zinc-500 text-xs">{t('node')}</div><div>{selectedDispatch.node || '-'}</div></div>
<div><div className="text-zinc-500 text-xs">{t('action')}</div><div>{selectedDispatch.action || '-'}</div></div>
<div><div className="text-zinc-500 text-xs">{t('dashboardNodeDispatchTransport')}</div><div>{selectedDispatch.used_transport || '-'}</div></div>
<div><div className="text-zinc-500 text-xs">{t('dashboardNodeDispatchFallback')}</div><div>{selectedDispatch.fallback_from || '-'}</div></div>
<div><div className="text-zinc-500 text-xs">{t('duration')}</div><div>{Number(selectedDispatch.duration_ms || 0)}ms</div></div>
<div><div className="text-zinc-500 text-xs">{t('status')}</div><div>{selectedDispatch.ok ? 'ok' : 'error'}</div></div>
</div>
{Array.isArray(item?.artifacts) && item.artifacts.length > 0 && (
<div className="mt-4 space-y-3">
{item.artifacts.slice(0, 3).map((artifact: any, artifactIndex: number) => {
<div>
<div className="text-zinc-500 text-xs mb-1">{t('error')}</div>
<div className="p-3 rounded-2xl bg-zinc-950/60 border border-zinc-800 whitespace-pre-wrap text-zinc-200">{selectedDispatch.error || '-'}</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeReplayRequest')}</div>
<div className="space-y-2">
<div className="grid grid-cols-3 gap-2">
<label className="space-y-1">
<div className="text-zinc-500 text-[11px]">{t('mode')}</div>
<select value={replayModeDraft} onChange={(e) => setReplayModeDraft(e.target.value)} className="w-full rounded-xl border border-zinc-800 bg-zinc-950/60 px-2 py-2 text-xs">
<option value="auto">auto</option>
<option value="p2p">p2p</option>
<option value="relay">relay</option>
</select>
</label>
<label className="space-y-1 col-span-2">
<div className="text-zinc-500 text-[11px]">{t('model')}</div>
<input value={replayModelDraft} onChange={(e) => setReplayModelDraft(e.target.value)} className="w-full rounded-xl border border-zinc-800 bg-zinc-950/60 px-3 py-2 text-xs" />
</label>
</div>
<label className="space-y-1 block">
<div className="text-zinc-500 text-[11px]">{t('task')}</div>
<textarea value={replayTaskDraft} onChange={(e) => setReplayTaskDraft(e.target.value)} className="min-h-24 w-full rounded-2xl border border-zinc-800 bg-zinc-950/60 p-3 text-xs" />
</label>
<label className="space-y-1 block">
<div className="text-zinc-500 text-[11px]">{t('args')}</div>
<textarea value={replayArgsDraft} onChange={(e) => setReplayArgsDraft(e.target.value)} className="min-h-40 w-full rounded-2xl border border-zinc-800 bg-zinc-950/60 p-3 text-xs font-mono" />
</label>
</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('nodeReplayResult')}</div>
{replayError ? (
<div className="rounded-2xl border border-red-900/50 bg-red-950/20 p-3 text-xs whitespace-pre-wrap text-red-300">{replayError}</div>
) : (
<pre className="rounded-2xl border border-zinc-800 bg-zinc-950/60 p-3 text-xs overflow-auto">{replayResult ? JSON.stringify(replayResult, null, 2) : '-'}</pre>
)}
</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('dashboardNodeDispatchArtifactPreview')}</div>
<div className="space-y-3">
{Array.isArray(selectedDispatch.artifacts) && selectedDispatch.artifacts.length > 0 ? selectedDispatch.artifacts.map((artifact: any, artifactIndex: number) => {
const kind = String(artifact?.kind || '').trim().toLowerCase();
const mime = String(artifact?.mime_type || '').trim().toLowerCase();
const isImage = kind === 'image' || mime.startsWith('image/');
@@ -250,11 +524,18 @@ const Nodes: React.FC = () => {
</div>
</div>
);
})}
}) : (
<div className="text-zinc-500">{t('dashboardNodeDispatchesEmpty')}</div>
)}
</div>
)}
</div>
))}
</div>
<div>
<div className="text-zinc-500 text-xs mb-1">{t('rawJson')}</div>
<pre className="rounded-2xl border border-zinc-800 bg-zinc-950/60 p-3 text-xs overflow-auto">{selectedDispatchPretty}</pre>
</div>
</>
)}
</div>
</div>
</div>