增加上传配置文件功能

This commit is contained in:
sladro 2026-01-16 15:58:35 +08:00
parent 3e4373ef25
commit debfb95e35
9 changed files with 650 additions and 57 deletions

View File

@ -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` 后缀则自动追加)
Bodymedia-server 配置 JSON
Response 200
```json
{
"ok": true,
"name": "cam1.json",
"path": "/opt/rk3588sys/configs/cam1.json",
"size": 1234,
"mtime_ms": 1730000000000
}
```
失败:
- 400name 非法 / Content-Type 非 application/json / JSON 无效 / 空 body
- 401unauthorized
- 413超过 `max_upload_mb`
- 501`configs_dir` 未配置
- 500写盘失败
## 6. 主程序进程控制agent 对外)
> 说明:该能力用于“启动/重启/关闭主程序media-server并选择加载哪个配置文件”。

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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))
}
}

View File

@ -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 {

View File

@ -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>

View File

@ -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;

View File

@ -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
View 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)
}
}