diff --git a/API_Device_RemoteMgmt_InterfaceTable.md b/API_Device_RemoteMgmt_InterfaceTable.md index 19e3252..d5037db 100644 --- a/API_Device_RemoteMgmt_InterfaceTable.md +++ b/API_Device_RemoteMgmt_InterfaceTable.md @@ -236,6 +236,40 @@ Response 200:`{"ok":true}` 失败:500 + `{"error":"..."}` +### 5.3 `PUT /v1/media-server/configs/{name}` +用途:上传 media-server 配置文件到 `agent.media_server_process.configs_dir`。 + +批量上传:对不同文件名重复调用该接口即可。 + +**Auth**:必须(401) + +Headers: +- `Content-Type: application/json` +- `X-RK-Token: ...` + +Path params: +- `name`: string(仅允许 `[A-Za-z0-9._-]`,禁止 `/`、`\\`、`..`;若无 `.json` 后缀则自动追加) + +Body:media-server 配置 JSON + +Response 200: +```json +{ + "ok": true, + "name": "cam1.json", + "path": "/opt/rk3588sys/configs/cam1.json", + "size": 1234, + "mtime_ms": 1730000000000 +} +``` + +失败: +- 400:name 非法 / Content-Type 非 application/json / JSON 无效 / 空 body +- 401:unauthorized +- 413:超过 `max_upload_mb` +- 501:`configs_dir` 未配置 +- 500:写盘失败 + ## 6. 主程序进程控制(agent 对外) > 说明:该能力用于“启动/重启/关闭主程序(media-server)并选择加载哪个配置文件”。 diff --git a/internal/service/registry.go b/internal/service/registry.go index a8de622..2e30782 100644 --- a/internal/service/registry.go +++ b/internal/service/registry.go @@ -10,10 +10,10 @@ import ( ) type RegistryService struct { - cfg *config.Config - agent *AgentClient - mu sync.RWMutex - devices map[string]*models.Device + cfg *config.Config + agent *AgentClient + mu sync.RWMutex + devices map[string]*models.Device } func NewRegistryService(cfg *config.Config, agent *AgentClient) *RegistryService { @@ -23,7 +23,9 @@ func NewRegistryService(cfg *config.Config, agent *AgentClient) *RegistryService devices: make(map[string]*models.Device), } go s.startPruning() - go s.startGraphPolling() + if agent != nil { + go s.startGraphPolling() + } return s } diff --git a/internal/service/task.go b/internal/service/task.go index 14febda..3a071f6 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -1,8 +1,10 @@ package service import ( + "bytes" "encoding/json" "fmt" + "io" "sync" "3588AdminBackend/internal/config" @@ -65,7 +67,7 @@ func (s *TaskService) ListTasks() []models.Task { func (s *TaskService) CreateTask(tType string, deviceIDs []string, payload interface{}) (*models.Task, error) { id := uuid.New().String() task := models.NewTask(id, tType, deviceIDs, payload) - + s.mu.Lock() s.tasks[id] = task s.mu.Unlock() @@ -80,7 +82,11 @@ func (s *TaskService) runTask(task *models.Task) { task.Mu.Unlock() // Concurrency control - sem := make(chan struct{}, s.cfg.Concurrency) + concurrency := s.cfg.Concurrency + if concurrency <= 0 { + concurrency = 5 + } + sem := make(chan struct{}, concurrency) var wg sync.WaitGroup for _, did := range task.DeviceIDs { @@ -95,13 +101,68 @@ func (s *TaskService) runTask(task *models.Task) { } wg.Wait() + + // Overall status: success only if all devices succeed. task.Mu.Lock() - task.Status = models.TaskSuccess // Simple logic: overall success if finished + overallOK := true + for _, ds := range task.Devices { + if ds == nil || ds.Status != models.TaskSuccess { + overallOK = false + break + } + } + if overallOK { + task.Status = models.TaskSuccess + } else { + task.Status = models.TaskFailed + } task.Mu.Unlock() } +func extractConfigPayload(payload any) (any, error) { + if payload == nil { + return nil, fmt.Errorf("payload is required") + } + // Backward-compatible: if payload is {"config": }, use payload.config. + if m, ok := payload.(map[string]any); ok { + if v, exists := m["config"]; exists { + return v, nil + } + } + return payload, nil +} + +func optionalConfigRequestBody(payload any) (io.Reader, int64, error) { + if payload == nil { + return nil, 0, nil + } + // Accept payload as either {"config":"cam1"} or any map that contains a string config. + m, ok := payload.(map[string]any) + if !ok { + return nil, 0, nil + } + v, exists := m["config"] + if !exists { + return nil, 0, nil + } + configStr, ok := v.(string) + if !ok || configStr == "" { + // Ignore invalid shapes (e.g. UI default {"config":{}}) to avoid 400. + return nil, 0, nil + } + b, err := json.Marshal(map[string]any{"config": configStr}) + if err != nil { + return nil, 0, err + } + return bytes.NewReader(b), int64(len(b)), nil +} + func (s *TaskService) executeOnDevice(task *models.Task, did string) { s.updateDeviceStatus(task.ID, did, models.TaskRunning, 0, "") + if s.agent == nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "agent client not initialized") + return + } // Find device devs := s.registry.GetDevices() @@ -123,18 +184,100 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) { return } - // For now, only config_apply is implemented in PRD - if task.Type == "config_apply" { - body, _ := json.Marshal(task.Payload) + switch task.Type { + case "config_apply": + cfgPayload, err := extractConfigPayload(task.Payload) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + body, err := json.Marshal(cfgPayload) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "invalid payload: "+err.Error()) + return + } _, code, err := s.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config", body) if err != nil { s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) - } else if code >= 400 { - s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) - } else { - s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + return } - } else { + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + + case "reload": + _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/reload", nil, "", 0) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + + case "rollback": + _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/rollback", nil, "", 0) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + + case "media_start": + bodyR, bodyLen, err := optionalConfigRequestBody(task.Payload) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/start", bodyR, "", bodyLen) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + + case "media_restart": + bodyR, bodyLen, err := optionalConfigRequestBody(task.Payload) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/restart", bodyR, "", bodyLen) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + + case "media_stop": + _, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/stop", nil, "", 0) + if err != nil { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error()) + return + } + if code >= 400 { + s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code)) + return + } + s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "") + + default: s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "unsupported task type") } } diff --git a/internal/service/task_test.go b/internal/service/task_test.go index 04237ba..bbe8416 100644 --- a/internal/service/task_test.go +++ b/internal/service/task_test.go @@ -3,24 +3,46 @@ package service import ( "3588AdminBackend/internal/config" "3588AdminBackend/internal/models" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" "testing" "time" ) +func waitForTaskDone(t *testing.T, task *models.Task, timeout time.Duration) models.TaskStatus { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + task.Mu.RLock() + st := task.Status + task.Mu.RUnlock() + if st == models.TaskSuccess || st == models.TaskFailed { + return st + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for task to finish") + return "" +} + func TestTaskService_CreateTask(t *testing.T) { cfg := &config.Config{ Concurrency: 5, } // Mock registry - reg := NewRegistryService(cfg, nil) + agent := NewAgentClient(cfg) + reg := NewRegistryService(cfg, agent) reg.UpdateDevice(&models.Device{ DeviceID: "dev1", IP: "127.0.0.1", AgentPort: 9100, Online: true, }) - - agent := NewAgentClient(cfg) svc := NewTaskService(cfg, agent, reg) task, err := svc.CreateTask("config_apply", []string{"dev1"}, map[string]string{"foo": "bar"}) @@ -37,7 +59,7 @@ func TestTaskService_CreateTask(t *testing.T) { task.Mu.RLock() defer task.Mu.RUnlock() - + if task.Devices["dev1"].Status == models.TaskPending { t.Error("expected task status to change from pending") } @@ -47,18 +69,18 @@ func TestTaskService_Subscribe(t *testing.T) { cfg := &config.Config{ Concurrency: 5, } - svc := NewTaskService(cfg, nil, NewRegistryService(cfg, nil)) - + svc := NewTaskService(cfg, NewAgentClient(cfg), NewRegistryService(cfg, NewAgentClient(cfg))) + taskID := "test-task" svc.tasks[taskID] = models.NewTask(taskID, "test", []string{"dev1"}, nil) ch, cleanup := svc.Subscribe(taskID) defer cleanup() - + go func() { svc.updateDeviceStatus(taskID, "dev1", models.TaskRunning, 0.5, "") }() - + select { case update := <-ch: if update.DeviceID != "dev1" || update.Status != models.TaskRunning { @@ -68,3 +90,90 @@ func TestTaskService_Subscribe(t *testing.T) { t.Error("timed out waiting for event") } } + +func TestTaskService_ConfigApply_UsesPayloadConfigField(t *testing.T) { + var gotBody any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Fatalf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/v1/config" { + t.Fatalf("expected path /v1/config, got %s", r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + u, _ := url.Parse(server.URL) + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatalf("SplitHostPort(%q): %v", u.Host, err) + } + port, _ := strconv.Atoi(portStr) + + cfg := &config.Config{Concurrency: 1} + agent := NewAgentClient(cfg) + reg := NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true}) + svc := NewTaskService(cfg, agent, reg) + + payload := map[string]any{"config": map[string]any{"a": 1}} + task, err := svc.CreateTask("config_apply", []string{"dev1"}, payload) + if err != nil { + t.Fatalf("failed to create task: %v", err) + } + st := waitForTaskDone(t, task, 2*time.Second) + if st != models.TaskSuccess { + t.Fatalf("expected task success, got %s", st) + } + + m, ok := gotBody.(map[string]any) + if !ok || m["a"].(float64) != 1 { + t.Fatalf("expected body {a:1}, got %#v", gotBody) + } +} + +func TestTaskService_MediaStart_IgnoresInvalidConfigShape(t *testing.T) { + var bodyBytes []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/v1/media-server/start" { + t.Fatalf("expected path /v1/media-server/start, got %s", r.URL.Path) + } + bodyBytes, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + u, _ := url.Parse(server.URL) + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatalf("SplitHostPort(%q): %v", u.Host, err) + } + port, _ := strconv.Atoi(portStr) + + cfg := &config.Config{Concurrency: 1} + agent := NewAgentClient(cfg) + reg := NewRegistryService(cfg, agent) + reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: host, AgentPort: port, Online: true}) + svc := NewTaskService(cfg, agent, reg) + + // UI default payload_json is {"config":{}}; this should be ignored for media_start. + payload := map[string]any{"config": map[string]any{}} + task, err := svc.CreateTask("media_start", []string{"dev1"}, payload) + if err != nil { + t.Fatalf("failed to create task: %v", err) + } + st := waitForTaskDone(t, task, 2*time.Second) + if st != models.TaskSuccess { + t.Fatalf("expected task success, got %s", st) + } + if len(bodyBytes) != 0 { + t.Fatalf("expected empty body, got %q", string(bodyBytes)) + } +} diff --git a/internal/web/ui.go b/internal/web/ui.go index 3f2902e..8e3edb2 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -7,6 +7,8 @@ import ( "html/template" "io/fs" "net/http" + "net/url" + "path/filepath" "strconv" "strings" "time" @@ -119,6 +121,7 @@ func (u *UI) Routes() (chi.Router, error) { }) r.Get("/devices", u.pageDevices) + r.Post("/devices/batch-action", u.actionDevicesBatchAction) r.Post("/discovery/search", u.actionDiscoverySearch) r.Get("/devices/{id}", u.pageDevice) r.Post("/devices/{id}/action", u.actionDeviceAction) @@ -132,6 +135,8 @@ func (u *UI) Routes() (chi.Router, error) { r.Post("/devices/{id}/face-gallery/upload", u.actionDeviceFaceGalleryUpload) r.Post("/devices/{id}/face-gallery/reload", u.actionDeviceFaceGalleryReload) r.Post("/devices/{id}/models/upload", u.actionDeviceModelUpload) + r.Post("/devices/{id}/media-server/configs/upload", u.actionDeviceMediaServerConfigUpload) + r.Post("/devices/{id}/media-server/configs/upload-batch", u.actionDeviceMediaServerConfigUploadBatch) r.Get("/tasks", u.pageTasks) r.Post("/tasks", u.actionCreateTask) @@ -209,6 +214,67 @@ func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) { u.render(w, r, "devices", data) } +func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + action := strings.TrimSpace(r.FormValue("action")) + deviceIDs := r.Form["device_id"] + if len(deviceIDs) == 0 { + devices := u.registry.GetDevices() + online := 0 + for _, d := range devices { + if d.Online { + online++ + } + } + u.render(w, r, "devices", PageData{Title: "设备", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: "请先选择设备"}) + return + } + + typeStr := "" + switch action { + case "media_start", "media_restart", "media_stop", "reload", "rollback": + typeStr = action + default: + devices := u.registry.GetDevices() + online := 0 + for _, d := range devices { + if d.Online { + online++ + } + } + u.render(w, r, "devices", PageData{Title: "设备", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: "不支持的操作: " + action}) + return + } + + if u.tasks == nil { + http.Error(w, "task service not initialized", http.StatusInternalServerError) + return + } + + var payload any + if typeStr == "media_start" || typeStr == "media_restart" { + cfgName := strings.TrimSpace(r.FormValue("config")) + if cfgName != "" { + payload = map[string]any{"config": cfgName} + } + } + + task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload) + if err != nil { + devices := u.registry.GetDevices() + online := 0 + for _, d := range devices { + if d.Online { + online++ + } + } + u.render(w, r, "devices", PageData{Title: "设备", Devices: devices, DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online, Error: err.Error()}) + return + } + + http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) +} + func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") dev, ok := u.findDevice(id) @@ -351,6 +417,98 @@ func (u *UI) actionDeviceModelUpload(w http.ResponseWriter, r *http.Request) { u.render(w, r, "device", out) } +func (u *UI) actionDeviceMediaServerConfigUpload(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + + if err := r.ParseMultipartForm(50 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + name, err := normalizeConfigName(r.FormValue("name")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + file, hdr, err := r.FormFile("file") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + + path := "/v1/media-server/configs/" + url.PathEscape(name) + resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size) + data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)} + if derr != nil { + data.Error = derr.Error() + } + u.render(w, r, "device", data) +} + +func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + + if err := r.ParseMultipartForm(200 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if r.MultipartForm == nil || len(r.MultipartForm.File) == 0 { + http.Error(w, "files is required", http.StatusBadRequest) + return + } + files := r.MultipartForm.File["files"] + if len(files) == 0 { + http.Error(w, "files is required", http.StatusBadRequest) + return + } + + var sb strings.Builder + errCount := 0 + for _, hdr := range files { + name, nerr := normalizeConfigName(filepath.Base(hdr.Filename)) + if nerr != nil { + errCount++ + sb.WriteString(fmt.Sprintf("%s -> invalid name: %v\n", hdr.Filename, nerr)) + continue + } + file, err := hdr.Open() + if err != nil { + errCount++ + sb.WriteString(fmt.Sprintf("%s -> open failed: %v\n", name, err)) + continue + } + path := "/v1/media-server/configs/" + url.PathEscape(name) + resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size) + _ = file.Close() + if derr != nil { + errCount++ + sb.WriteString(fmt.Sprintf("%s -> %d error: %v\n", name, code, derr)) + continue + } + if len(resp) > 0 { + sb.WriteString(fmt.Sprintf("%s -> %d %s\n", name, code, strings.TrimSpace(string(resp)))) + } else { + sb.WriteString(fmt.Sprintf("%s -> %d\n", name, code)) + } + } + + data := PageData{Title: "设备详情", Device: dev, Message: "批量上传完成", RawText: sb.String()} + if errCount > 0 { + data.Error = fmt.Sprintf("部分失败: %d/%d", errCount, len(files)) + } + u.render(w, r, "device", data) +} + func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) { u.render(w, r, "tasks", PageData{Title: "任务", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices()}) } @@ -433,6 +591,20 @@ func urlQueryEscape(s string) string { return r.Replace(s) } +func normalizeConfigName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("name is required") + } + if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") { + return "", fmt.Errorf("name contains invalid path") + } + if !strings.HasSuffix(strings.ToLower(name), ".json") { + name += ".json" + } + return name, nil +} + func prettyJSON(raw []byte) string { var out bytes.Buffer if err := json.Indent(&out, raw, "", " "); err != nil { diff --git a/internal/web/ui/templates/device.html b/internal/web/ui/templates/device.html index 4e3829c..61238ec 100644 --- a/internal/web/ui/templates/device.html +++ b/internal/web/ui/templates/device.html @@ -75,6 +75,30 @@ +
+

上传 Media 配置

+
转发到 agent:PUT /v1/media-server/configs/{name}(自动补 .json)
+
+
+
name
+ +
+
+
file
+ +
+
+
+
批量上传:按文件名作为 name(每个文件单独上传)
+
+
+
files
+ +
+
+
+
+

设备信息(JSON)

{{json .Device}}
diff --git a/internal/web/ui/templates/devices.html b/internal/web/ui/templates/devices.html index 8128860..c5f92dc 100644 --- a/internal/web/ui/templates/devices.html +++ b/internal/web/ui/templates/devices.html @@ -57,38 +57,64 @@
由 discovery 与轮询自动更新
-
- - - - - - {{range .Devices}} - - - - - - - - - - {{end}} - -
设备ID名称地址状态主程序最后在线版本
{{.DeviceID}}{{.DeviceName}} - {{.IP}}:{{.AgentPort}} -
media: {{.MediaPort}}
-
{{if .Online}}在线{{else}}离线{{end}} - {{if .Online}} - 待查询 - {{else}} - - - {{end}} - -
{{ago .LastSeenMs}}
-
{{.LastSeenMs}}
-
{{.Version}}
-
+
+
+
+
批量操作
+ +
+
+
config(可选,仅 start/restart 生效)
+ +
+
+ +
+
+ +
+ + + + + + + + + {{range .Devices}} + + + + + + + + + + + {{end}} + +
设备ID名称地址状态主程序最后在线版本
{{.DeviceID}}{{.DeviceName}} + {{.IP}}:{{.AgentPort}} +
media: {{.MediaPort}}
+
{{if .Online}}在线{{else}}离线{{end}} + {{if .Online}} + 待查询 + {{else}} + - + {{end}} + +
{{ago .LastSeenMs}}
+
{{.LastSeenMs}}
+
{{.Version}}
+
+