增加上传配置文件功能
This commit is contained in:
parent
3e4373ef25
commit
debfb95e35
@ -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)并选择加载哪个配置文件”。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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": <rootConfig>}, 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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -75,6 +75,30 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>上传 Media 配置</h2>
|
||||
<div class="muted small">转发到 agent:<code class="mono">PUT /v1/media-server/configs/{name}</code>(自动补 .json)</div>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/media-server/configs/upload" enctype="multipart/form-data" class="row">
|
||||
<div>
|
||||
<div class="muted small">name</div>
|
||||
<input name="name" placeholder="cam1" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">file</div>
|
||||
<input type="file" name="file" accept="application/json,.json" />
|
||||
</div>
|
||||
<div style="align-self:end"><button type="submit">上传</button></div>
|
||||
</form>
|
||||
<div class="muted small" style="margin-top:10px">批量上传:按文件名作为 name(每个文件单独上传)</div>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/media-server/configs/upload-batch" enctype="multipart/form-data" class="row">
|
||||
<div>
|
||||
<div class="muted small">files</div>
|
||||
<input type="file" name="files" multiple accept="application/json,.json" />
|
||||
</div>
|
||||
<div style="align-self:end"><button type="submit">批量上传</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>设备信息(JSON)</h2>
|
||||
<pre>{{json .Device}}</pre>
|
||||
|
||||
@ -57,38 +57,64 @@
|
||||
<div class="muted small" style="align-self:end">由 discovery 与轮询自动更新</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
<table id="devices-table">
|
||||
<thead>
|
||||
<tr><th>设备ID</th><th>名称</th><th>地址</th><th>状态</th><th>主程序</th><th>最后在线</th><th>版本</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Devices}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/devices/{{.DeviceID}}">{{.DeviceID}}</a></td>
|
||||
<td>{{.DeviceName}}</td>
|
||||
<td class="mono">
|
||||
{{.IP}}:{{.AgentPort}}
|
||||
<div class="muted small">media: {{.MediaPort}}</div>
|
||||
</td>
|
||||
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
|
||||
<td>
|
||||
{{if .Online}}
|
||||
<span class="pill warn" data-media-status="{{.DeviceID}}">待查询</span>
|
||||
{{else}}
|
||||
<span class="muted">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ago .LastSeenMs}}</div>
|
||||
<div class="muted small mono">{{.LastSeenMs}}</div>
|
||||
</td>
|
||||
<td class="mono">{{.Version}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form method="post" action="/ui/devices/batch-action" style="margin-top:10px">
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="muted small">批量操作</div>
|
||||
<select name="action">
|
||||
<option value="media_start">启动 Media</option>
|
||||
<option value="media_restart">重启 Media</option>
|
||||
<option value="media_stop">停止 Media</option>
|
||||
<option value="reload">重载</option>
|
||||
<option value="rollback">回滚</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted small">config(可选,仅 start/restart 生效)</div>
|
||||
<input name="config" placeholder="cam1" />
|
||||
</div>
|
||||
<div style="align-self:end">
|
||||
<button type="submit" onclick="return confirm('确认对所选设备执行批量操作?')">执行(已选 <span id="selectedCount">0</span>)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
<table id="devices-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:48px"><input id="selectAll" type="checkbox" title="全选(仅作用于当前筛选可见行)" /></th>
|
||||
<th>设备ID</th><th>名称</th><th>地址</th><th>状态</th><th>主程序</th><th>最后在线</th><th>版本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Devices}}
|
||||
<tr>
|
||||
<td><input class="dev-check" type="checkbox" name="device_id" value="{{.DeviceID}}" /></td>
|
||||
<td><a class="mono" href="/ui/devices/{{.DeviceID}}">{{.DeviceID}}</a></td>
|
||||
<td>{{.DeviceName}}</td>
|
||||
<td class="mono">
|
||||
{{.IP}}:{{.AgentPort}}
|
||||
<div class="muted small">media: {{.MediaPort}}</div>
|
||||
</td>
|
||||
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
|
||||
<td>
|
||||
{{if .Online}}
|
||||
<span class="pill warn" data-media-status="{{.DeviceID}}">待查询</span>
|
||||
{{else}}
|
||||
<span class="muted">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ago .LastSeenMs}}</div>
|
||||
<div class="muted small mono">{{.LastSeenMs}}</div>
|
||||
</td>
|
||||
<td class="mono">{{.Version}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@ -106,6 +132,36 @@
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const table = document.getElementById('devices-table');
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const out = document.getElementById('selectedCount');
|
||||
if(!table || !selectAll || !out) return;
|
||||
|
||||
const visibleRows = () => Array.from(table.tBodies[0].rows).filter(tr => tr.style.display !== 'none');
|
||||
const getChecks = (rows) => rows.map(tr => tr.querySelector('input.dev-check')).filter(Boolean);
|
||||
|
||||
const updateCount = () => {
|
||||
const checks = getChecks(Array.from(table.tBodies[0].rows));
|
||||
const n = checks.filter(c => c.checked).length;
|
||||
out.textContent = String(n);
|
||||
};
|
||||
|
||||
selectAll.addEventListener('change', () => {
|
||||
const rows = visibleRows();
|
||||
const checks = getChecks(rows);
|
||||
for(const c of checks) c.checked = selectAll.checked;
|
||||
updateCount();
|
||||
});
|
||||
|
||||
table.addEventListener('change', (e) => {
|
||||
const el = e.target;
|
||||
if(el && el.classList && el.classList.contains('dev-check')) updateCount();
|
||||
});
|
||||
|
||||
updateCount();
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const els = Array.from(document.querySelectorAll('[data-media-status]'));
|
||||
if(!els.length) return;
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
{{define "tasks"}}
|
||||
<div class="card">
|
||||
<h2>创建任务</h2>
|
||||
<div class="muted small">当前仅支持 <code class="mono">config_apply</code></div>
|
||||
<div class="muted small">
|
||||
支持类型:
|
||||
<code class="mono">config_apply</code>、
|
||||
<code class="mono">reload</code>、
|
||||
<code class="mono">rollback</code>、
|
||||
<code class="mono">media_start</code>、
|
||||
<code class="mono">media_restart</code>、
|
||||
<code class="mono">media_stop</code>
|
||||
</div>
|
||||
<form method="post" action="/ui/tasks" style="margin-top:10px">
|
||||
<div class="row">
|
||||
<div>
|
||||
|
||||
45
internal/web/ui_test.go
Normal file
45
internal/web/ui_test.go
Normal file
@ -0,0 +1,45 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/service"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUI_ActionDevicesBatchAction_RedirectsToTask(t *testing.T) {
|
||||
cfg := &config.Config{Concurrency: 1, OfflineAfterMs: 1000000}
|
||||
reg := service.NewRegistryService(cfg, nil)
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "dev1", IP: "127.0.0.1", AgentPort: 9100, Online: true})
|
||||
reg.UpdateDevice(&models.Device{DeviceID: "dev2", IP: "127.0.0.1", AgentPort: 9100, Online: true})
|
||||
tasks := service.NewTaskService(cfg, nil, reg)
|
||||
|
||||
ui, err := NewUI(nil, reg, nil, tasks, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUI: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "media_start")
|
||||
form.Add("device_id", "dev1")
|
||||
form.Add("device_id", "dev2")
|
||||
form.Set("config", "cam1")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/devices/batch-action", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ui.actionDevicesBatchAction(rr, req)
|
||||
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("expected 302, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/ui/tasks/") {
|
||||
t.Fatalf("expected redirect to /ui/tasks/*, got %q", loc)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user