3588AdminBackend/internal/web/ui.go

1035 lines
29 KiB
Go

package web
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"time"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/service"
"github.com/go-chi/chi/v5"
)
type UI struct {
discovery *service.DiscoveryService
registry *service.RegistryService
agent *service.AgentClient
tasks *service.TaskService
templates *service.TemplateService
preview *service.ConfigPreviewService
tpl *template.Template
}
type PageData struct {
Title string
ContentHTML template.HTML
Message string
Error string
DeviceCount int
OnlineCount int
OfflineCount int
FoundCount int
Devices []*models.Device
AttentionDevices []*models.Device
Found []*models.Device
Device *models.Device
ConfigStatus *ConfigStatusView
ConfigStatusText string
ConfigStatusErr string
ConfigSources service.ConfigPreviewSources
ConfigPreview *service.ConfigPreviewResult
Tasks []models.Task
Task *models.Task
Templates []service.Template
Template *service.Template
RawJSON string
RawText string
SchemaJSON string
StateJSON string
FaceGalleryJSON string
TaskID string
DeviceIDs string
RunningTaskCount int
FailedTaskCount int
SuccessTaskCount int
}
type ConfigStatusView struct {
OK bool `json:"ok"`
ConfigPath string `json:"config_path"`
Exists bool `json:"exists"`
Sha256 string `json:"sha256"`
Size int64 `json:"size"`
Metadata ConfigStatusMetadata `json:"metadata"`
MediaServer ConfigStatusMediaServer `json:"media_server"`
LastGood *ConfigStatusLastGoodFile `json:"last_good"`
}
type ConfigStatusMetadata struct {
ConfigID string `json:"config_id"`
ConfigVersion string `json:"config_version"`
Template string `json:"template"`
Profile string `json:"profile"`
Overlays []string `json:"overlays"`
RenderedAt string `json:"rendered_at"`
RenderedBy string `json:"rendered_by"`
}
type ConfigStatusMediaServer struct {
Running bool `json:"running"`
PID int `json:"pid"`
Version string `json:"version"`
}
type ConfigStatusLastGoodFile struct {
Path string `json:"path"`
Exists bool `json:"exists"`
Sha256 string `json:"sha256"`
Metadata ConfigStatusMetadata `json:"metadata"`
}
func NewUI(discovery *service.DiscoveryService, registry *service.RegistryService, agent *service.AgentClient, tasks *service.TaskService, templates *service.TemplateService) (*UI, error) {
tpl, err := template.New("layout").Funcs(template.FuncMap{
"json": func(v any) string {
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
},
"ago": func(ms int64) string {
if ms <= 0 {
return "-"
}
d := time.Since(time.UnixMilli(ms))
if d < 0 {
d = 0
}
s := int64(d.Seconds())
switch {
case s < 60:
return fmt.Sprintf("%d秒前", s)
case s < 3600:
return fmt.Sprintf("%d分钟前", s/60)
case s < 86400:
return fmt.Sprintf("%d小时前", s/3600)
default:
return fmt.Sprintf("%d天前", s/86400)
}
},
}).ParseFS(uiFS, "ui/templates/*.html")
if err != nil {
return nil, err
}
return &UI{
discovery: discovery,
registry: registry,
agent: agent,
tasks: tasks,
templates: templates,
preview: service.NewConfigPreviewService(nil),
tpl: tpl,
}, nil
}
func (u *UI) Routes() (chi.Router, error) {
r := chi.NewRouter()
assets, err := fs.Sub(uiFS, "ui/assets")
if err != nil {
return nil, err
}
assetHandler := http.StripPrefix("/assets/", http.FileServer(http.FS(assets)))
r.Handle("/assets/*", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
p := req.URL.Path
switch {
case strings.HasSuffix(p, ".css"):
w.Header().Set("Content-Type", "text/css; charset=utf-8")
case strings.HasSuffix(p, ".js"):
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
}
assetHandler.ServeHTTP(w, req)
}))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/dashboard", http.StatusFound)
})
r.Get("/dashboard", u.pageDashboard)
r.Get("/devices", u.pageDevices)
r.Get("/device-config", u.pageDeviceConfig)
r.Get("/devices-add", u.pageDeviceAdd)
r.Post("/devices-add", u.actionDeviceAdd)
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)
r.Get("/devices/{id}/logs", u.pageDeviceLogs)
r.Get("/devices/{id}/graphs", u.pageDeviceGraphs)
r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply)
r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI)
r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly)
r.Get("/devices/{id}/config-preview", u.pageDeviceConfigPreview)
r.Post("/devices/{id}/config-preview", u.actionDeviceConfigPreview)
r.Post("/devices/{id}/config-candidate", u.actionDeviceConfigCandidate)
r.Post("/devices/{id}/config-ui/plan", u.actionDeviceConfigUIPlan)
r.Post("/devices/{id}/config-ui/apply", u.actionDeviceConfigUIApply)
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)
r.Get("/tasks/{id}", u.pageTask)
r.Get("/templates", u.pageTemplates)
r.Get("/templates/{name}", u.pageTemplate)
r.Get("/models", u.pageModels)
r.Get("/diagnostics", u.pageDiagnostics)
r.Get("/recognition", u.pageRecognition)
r.Get("/logs", u.pageLogs)
r.Get("/api", u.pageAPIConsole)
return r, nil
}
func (u *UI) render(w http.ResponseWriter, r *http.Request, content string, data PageData) {
var buf bytes.Buffer
if err := u.tpl.ExecuteTemplate(&buf, content, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.ContentHTML = template.HTML(buf.String())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := u.tpl.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (u *UI) findDevice(id string) (*models.Device, bool) {
devices := u.registry.GetDevices()
for _, d := range devices {
if d.DeviceID == id {
return d, true
}
}
return nil, false
}
func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) {
devices := u.registry.GetDevices()
tasks := u.tasks.ListTasks()
online := 0
attentionDevices := make([]*models.Device, 0)
for _, d := range devices {
if d.Online {
online++
} else {
attentionDevices = append(attentionDevices, d)
}
}
runningTasks := 0
failedTasks := 0
successTasks := 0
for _, t := range tasks {
switch t.Status {
case models.TaskRunning:
runningTasks++
case models.TaskFailed:
failedTasks++
case models.TaskSuccess:
successTasks++
}
}
u.render(w, r, "dashboard", PageData{
Title: "总览",
Devices: devices,
AttentionDevices: attentionDevices,
Tasks: tasks,
DeviceCount: len(devices),
OnlineCount: online,
OfflineCount: len(devices) - online,
RunningTaskCount: runningTasks,
FailedTaskCount: failedTasks,
SuccessTaskCount: successTasks,
})
}
func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) {
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,
})
}
func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "device_add", PageData{Title: "新增设备"})
}
func (u *UI) pageDeviceConfig(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "device_config", PageData{Title: "配置管理", Devices: u.registry.GetDevices()})
}
func (u *UI) actionDeviceAdd(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
deviceID := strings.TrimSpace(r.FormValue("device_id"))
deviceName := strings.TrimSpace(r.FormValue("device_name"))
ip := strings.TrimSpace(r.FormValue("ip"))
agentPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("agent_port")))
mediaPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("media_port")))
if deviceID == "" || ip == "" {
u.render(w, r, "device_add", PageData{Title: "新增设备", Error: "节点标识和 IP 不能为空"})
return
}
if agentPort == 0 {
agentPort = 9100
}
if mediaPort == 0 {
mediaPort = 9000
}
dev := &models.Device{
DeviceID: deviceID,
DeviceName: deviceName,
IP: ip,
AgentPort: agentPort,
MediaPort: mediaPort,
Online: true,
LastSeenMs: time.Now().UnixMilli(),
}
u.registry.UpdateDevice(dev)
http.Redirect(w, r, "/ui/devices", http.StatusFound)
}
func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
timeoutMs, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("timeout_ms")))
if timeoutMs <= 0 {
timeoutMs = 1200
}
found, err := u.discovery.Search(timeoutMs)
devices := u.registry.GetDevices()
online := 0
for _, d := range devices {
if d.Online {
online++
}
}
data := PageData{Title: "设备列表", Devices: devices, Found: found, FoundCount: len(found), DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online}
if err != nil {
data.Error = err.Error()
}
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)
if !ok {
http.NotFound(w, r)
return
}
status, raw, statusErr := u.loadConfigStatus(dev)
data := PageData{Title: "节点详情", Device: dev, ConfigStatus: status, ConfigStatusText: raw}
if statusErr != nil {
data.ConfigStatusErr = statusErr.Error()
}
u.render(w, r, "device", data)
}
func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
action := strings.TrimSpace(r.FormValue("action"))
method := "POST"
path := ""
switch action {
case "reload":
path = "/v1/media-server/reload"
case "rollback":
path = "/v1/media-server/rollback"
case "media_start":
path = "/v1/media-server/start"
case "media_restart":
path = "/v1/media-server/restart"
case "media_stop":
path = "/v1/media-server/stop"
case "media_status":
method = "GET"
path = "/v1/media-server/status"
case "info":
method = "GET"
path = "/v1/info"
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
body, code, err := u.agent.Do(method, dev.IP, dev.AgentPort, path, nil)
msg := fmt.Sprintf("%s %s -> %d", method, path, code)
data := PageData{Title: "节点详情", Device: dev, Message: msg, RawText: string(body)}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "device", data)
}
func (u *UI) pageDeviceLogs(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
limit := strings.TrimSpace(r.URL.Query().Get("limit"))
path := "/v1/logs/recent"
if limit != "" {
path += "?limit=" + urlQueryEscape(limit)
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, path, nil)
data := PageData{Title: "诊断日志", Device: dev, Message: fmt.Sprintf("GET %s -> %d", path, code), RawText: string(body)}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "device_logs", data)
}
func (u *UI) pageDeviceGraphs(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/graphs", nil)
data := PageData{Title: "运行指标", Device: dev, Message: fmt.Sprintf("GET /v1/graphs -> %d", code), RawText: string(body)}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "device_graphs", data)
}
func (u *UI) actionDeviceConfigApply(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
raw := strings.TrimSpace(r.FormValue("json"))
if raw == "" {
raw = `{"config":{}}`
}
body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config", []byte(raw))
data := PageData{Title: "节点详情", Device: dev, Message: fmt.Sprintf("PUT /v1/config -> %d", code), RawText: string(body), RawJSON: raw}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "device", data)
}
func (u *UI) actionDeviceModelUpload(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(100 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
if name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
path := fmt.Sprintf("/v1/models/%s", name)
resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/octet-stream", hdr.Size)
out := PageData{Title: "节点详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)}
if derr != nil {
out.Error = derr.Error()
}
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()})
}
func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
typeStr := strings.TrimSpace(r.FormValue("type"))
if typeStr == "" {
typeStr = "config_apply"
}
ids := strings.TrimSpace(r.FormValue("device_ids"))
var deviceIDs []string
for _, p := range strings.Split(ids, ",") {
p = strings.TrimSpace(p)
if p != "" {
deviceIDs = append(deviceIDs, p)
}
}
raw := strings.TrimSpace(r.FormValue("payload_json"))
if raw == "" {
raw = `{"config":{}}`
}
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids})
return
}
task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
if err != nil {
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids})
return
}
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
}
func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
items := u.tasks.ListTasks()
var task *models.Task
for i := range items {
if items[i].ID == id {
t := items[i]
task = &t
break
}
}
if task == nil {
http.NotFound(w, r)
return
}
u.render(w, r, "task", PageData{Title: "任务详情", Task: task, TaskID: id})
}
func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) {
list, err := u.templates.ListTemplates()
data := PageData{Title: "识别配置", Templates: list}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "templates", data)
}
func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
t, err := u.templates.GetTemplate(name)
if err != nil {
http.NotFound(w, r)
return
}
u.render(w, r, "template", PageData{Title: "配置模板详情", Template: t})
}
func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "models", PageData{Title: "模型管理", Devices: u.registry.GetDevices()})
}
func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "diagnostics", PageData{Title: "日志分析", Devices: u.registry.GetDevices()})
}
func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {
list, err := u.templates.ListTemplates()
data := PageData{Title: "识别配置", Devices: u.registry.GetDevices(), Templates: list}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "recognition", data)
}
func (u *UI) pageLogs(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "logs", PageData{Title: "日志分析", Devices: u.registry.GetDevices()})
}
func (u *UI) pageAPIConsole(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "api", PageData{Title: "高级调试"})
}
func urlQueryEscape(s string) string {
r := strings.NewReplacer("%", "%25", " ", "%20", "+", "%2B", "&", "%26", "=", "%3D", "?", "%3F")
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 {
return string(raw)
}
return out.String()
}
func (u *UI) loadConfigStatus(dev *models.Device) (*ConfigStatusView, string, error) {
if u.agent == nil || dev == nil {
return nil, "", nil
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil)
raw := fmt.Sprintf("GET /v1/config/status -> %d\n%s", code, prettyJSON(body))
if err != nil {
return nil, raw, err
}
if code < 200 || code >= 300 {
return nil, raw, fmt.Errorf("GET /v1/config/status -> %d", code)
}
var status ConfigStatusView
if err := json.Unmarshal(body, &status); err != nil {
return nil, raw, err
}
return &status, raw, nil
}
func (u *UI) loadConfigUIData(dev *models.Device) PageData {
schemaBody, schemaCode, schemaErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/ui/schema", nil)
stateBody, stateCode, stateErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/ui/state", nil)
faceBody, faceCode, faceErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/face-gallery", nil)
data := PageData{
Title: "高级识别配置",
Device: dev,
SchemaJSON: fmt.Sprintf("GET /v1/config/ui/schema -> %d\n%s", schemaCode, prettyJSON(schemaBody)),
StateJSON: fmt.Sprintf("GET /v1/config/ui/state -> %d\n%s", stateCode, prettyJSON(stateBody)),
FaceGalleryJSON: fmt.Sprintf("GET /v1/face-gallery -> %d\n%s", faceCode, prettyJSON(faceBody)),
RawJSON: strings.TrimSpace(prettyJSON(stateBody)),
}
if schemaErr != nil {
data.Error = schemaErr.Error()
} else if stateErr != nil {
data.Error = stateErr.Error()
} else if faceErr != nil {
data.Error = faceErr.Error()
}
return data
}
func (u *UI) pageDeviceConfigUI(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
u.render(w, r, "config_ui", u.loadConfigUIData(dev))
}
func (u *UI) pageDeviceConfigFriendly(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
u.render(w, r, "config_friendly", PageData{Title: "识别方案配置", Device: dev})
}
func (u *UI) pageDeviceConfigPreview(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
data := u.configPreviewPageData(dev)
u.render(w, r, "config_preview", data)
}
func (u *UI) actionDeviceConfigPreview(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
req := service.ConfigPreviewRequest{
Template: strings.TrimSpace(r.FormValue("template")),
Profile: strings.TrimSpace(r.FormValue("profile")),
Overlays: cleanFormList(r.Form["overlay"]),
ConfigID: strings.TrimSpace(r.FormValue("config_id")),
ConfigVersion: strings.TrimSpace(r.FormValue("config_version")),
}
if req.Template == "" {
req.Template = "workshop_face_shoe_alarm"
}
if req.Profile == "" {
req.Profile = "local_3588_test"
}
preview, err := u.preview.Render(req)
data := u.configPreviewPageData(dev)
data.ConfigPreview = preview
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "config_preview", data)
}
func (u *UI) actionDeviceConfigCandidate(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
raw := strings.TrimSpace(r.FormValue("json"))
data := u.configPreviewPageData(dev)
if raw == "" {
data.Error = "候选配置 JSON 不能为空"
u.render(w, r, "config_preview", data)
return
}
if err := json.Unmarshal([]byte(raw), new(any)); err != nil {
data.Error = "候选配置 JSON 无效: " + err.Error()
u.render(w, r, "config_preview", data)
return
}
body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config/candidate", []byte(raw))
data.Message = fmt.Sprintf("PUT /v1/config/candidate -> %d", code)
data.RawText = prettyJSON(body)
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "config_preview", data)
}
func (u *UI) configPreviewPageData(dev *models.Device) PageData {
sources, err := u.preview.ListSources()
data := PageData{Title: "配置预览", Device: dev, ConfigSources: sources}
if err != nil {
data.Error = err.Error()
}
return data
}
func cleanFormList(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
out = append(out, value)
}
}
return out
}
func (u *UI) actionDeviceConfigUIPlan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
raw := strings.TrimSpace(r.FormValue("json"))
if raw == "" {
raw = `{"instances":[]}`
}
if err := json.Unmarshal([]byte(raw), new(any)); err != nil {
data := u.loadConfigUIData(dev)
data.Error = "json 无效: " + err.Error()
data.RawJSON = raw
u.render(w, r, "config_ui", data)
return
}
body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/ui/plan", []byte(raw))
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("POST /v1/config/ui/plan -> %d", code)
data.RawText = prettyJSON(body)
data.RawJSON = raw
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "config_ui", data)
}
func (u *UI) actionDeviceConfigUIApply(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
raw := strings.TrimSpace(r.FormValue("json"))
if raw == "" {
raw = `{"instances":[]}`
}
if err := json.Unmarshal([]byte(raw), new(any)); err != nil {
data := u.loadConfigUIData(dev)
data.Error = "json 无效: " + err.Error()
data.RawJSON = raw
u.render(w, r, "config_ui", data)
return
}
body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/ui/apply", []byte(raw))
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("POST /v1/config/ui/apply -> %d", code)
data.RawText = prettyJSON(body)
data.RawJSON = raw
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "config_ui", data)
}
func (u *UI) actionDeviceFaceGalleryUpload(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(500 << 20); 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()
resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, "/v1/face-gallery", file, "application/octet-stream", hdr.Size)
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("PUT /v1/face-gallery -> %d", code)
data.RawText = prettyJSON(resp)
if derr != nil {
data.Error = derr.Error()
}
u.render(w, r, "config_ui", data)
}
func (u *UI) actionDeviceFaceGalleryReload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
resp, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/face-gallery/reload", nil)
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("POST /v1/face-gallery/reload -> %d", code)
data.RawText = prettyJSON(resp)
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "config_ui", data)
}