mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-12 08:58:58 +08:00
feat: expand node artifact operations and retention
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '重试退避基准(秒)',
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
275
webui/src/pages/NodeArtifacts.tsx
Normal file
275
webui/src/pages/NodeArtifacts.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user