2258 lines
73 KiB
Go
2258 lines
73 KiB
Go
package web
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html/template"
|
||
"io"
|
||
"io/fs"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"3588AdminBackend/internal/models"
|
||
"3588AdminBackend/internal/service"
|
||
"3588AdminBackend/internal/storage"
|
||
"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
|
||
stateRepo *storage.DeviceConfigStateRepo
|
||
auditRepo *storage.AuditLogsRepo
|
||
dbPath string
|
||
|
||
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
|
||
DeviceRows []DeviceOverviewRow
|
||
AttentionDevices []*models.Device
|
||
Found []*models.Device
|
||
Device *models.Device
|
||
ConfigStatus *ConfigStatusView
|
||
ConfigStatusText string
|
||
ConfigStatusErr string
|
||
ConfigSources service.ConfigPreviewSources
|
||
ConfigPreview *service.ConfigPreviewResult
|
||
ResultTitle string
|
||
SelectedTemplate string
|
||
SelectedProfile string
|
||
SelectedOverlays []string
|
||
SelectedConfigID string
|
||
SelectedVersion string
|
||
Tasks []models.Task
|
||
Task *models.Task
|
||
TaskDeviceRows []TaskDeviceRow
|
||
Templates []service.Template
|
||
Template *service.Template
|
||
AssetTab string
|
||
AssetTemplates []service.ConfigTemplateAsset
|
||
AssetTemplate *service.ConfigTemplateAsset
|
||
AssetProfiles []service.ConfigProfileAsset
|
||
AssetProfile *service.ConfigProfileAsset
|
||
AssetProfileEditor *service.ConfigProfileEditor
|
||
AssetOverlays []service.ConfigOverlayAsset
|
||
AssetOverlay *service.ConfigOverlayAsset
|
||
AssetInstanceCount int
|
||
SelectedDeviceIDs []string
|
||
SelectedDevices []*models.Device
|
||
SelectedQuery string
|
||
SelectedDevicesURL string
|
||
BatchConfigURL string
|
||
ReloadSummary string
|
||
RollbackSummary string
|
||
AuditEntries []storage.AuditLogRecord
|
||
PersistedConfig *storage.DeviceConfigStateRecord
|
||
DBPath string
|
||
|
||
RawJSON string
|
||
RawText string
|
||
SchemaJSON string
|
||
StateJSON string
|
||
FaceGalleryJSON string
|
||
TaskID string
|
||
DeviceIDs string
|
||
RunningTaskCount int
|
||
FailedTaskCount int
|
||
SuccessTaskCount int
|
||
}
|
||
|
||
type DeviceOverviewRow struct {
|
||
Device *models.Device
|
||
ConfigStatus *ConfigStatusView
|
||
ConfigStatusErr string
|
||
}
|
||
|
||
type TaskDeviceRow struct {
|
||
Device *models.Device
|
||
Status models.TaskStatus
|
||
Progress float64
|
||
Error string
|
||
}
|
||
|
||
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"`
|
||
Candidate *ConfigStatusLastGoodFile `json:"candidate"`
|
||
MediaServer ConfigStatusMediaServer `json:"media_server"`
|
||
PreviousConfig *ConfigStatusLastGoodFile `json:"previous_config"`
|
||
PreviousConfigPath string `json:"previous_config_path"`
|
||
}
|
||
|
||
type ConfigStatusMetadata struct {
|
||
ConfigID string `json:"config_id"`
|
||
ConfigVersion string `json:"config_version"`
|
||
BusinessName string `json:"business_name"`
|
||
Template string `json:"template"`
|
||
Profile string `json:"profile"`
|
||
Overlays []string `json:"overlays"`
|
||
RenderedAt string `json:"rendered_at"`
|
||
RenderedBy string `json:"rendered_by"`
|
||
InstanceName string `json:"instance_name"`
|
||
InstanceDisplayName string `json:"instance_display_name"`
|
||
}
|
||
|
||
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, preview ...*service.ConfigPreviewService) (*UI, error) {
|
||
tpl, err := template.New("layout").Funcs(template.FuncMap{
|
||
"json": func(v any) string {
|
||
b, _ := json.MarshalIndent(v, "", " ")
|
||
return string(b)
|
||
},
|
||
"hasString": func(items []string, want string) bool {
|
||
for _, item := range items {
|
||
if item == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
},
|
||
"shortHash": func(v string) string {
|
||
v = strings.TrimSpace(v)
|
||
if len(v) > 8 {
|
||
return v[:8]
|
||
}
|
||
return v
|
||
},
|
||
"displayDeviceName": func(dev *models.Device, status *ConfigStatusView) string {
|
||
if dev == nil {
|
||
return "-"
|
||
}
|
||
return dev.DisplayName()
|
||
},
|
||
"displayDeviceTechnicalName": func(dev *models.Device) string {
|
||
if dev == nil {
|
||
return ""
|
||
}
|
||
if v := strings.TrimSpace(dev.TechnicalName()); v != "" {
|
||
return v
|
||
}
|
||
return ""
|
||
},
|
||
"taskGroupLabel": func(v any) string {
|
||
switch fmt.Sprint(v) {
|
||
case "config_apply":
|
||
return "批量配置"
|
||
case "media_start", "media_restart", "media_stop":
|
||
return "批量服务"
|
||
case "reload", "rollback":
|
||
return "设备操作"
|
||
default:
|
||
return "其他任务"
|
||
}
|
||
},
|
||
"taskActionLabel": func(v any) string {
|
||
switch fmt.Sprint(v) {
|
||
case "config_apply":
|
||
return "下发识别配置"
|
||
case "reload":
|
||
return "重载识别服务"
|
||
case "rollback":
|
||
return "回滚识别配置"
|
||
case "media_start":
|
||
return "启动视频分析服务"
|
||
case "media_restart":
|
||
return "重启视频分析服务"
|
||
case "media_stop":
|
||
return "停止视频分析服务"
|
||
default:
|
||
return fmt.Sprint(v)
|
||
}
|
||
},
|
||
"taskGroupClass": func(v any) string {
|
||
switch fmt.Sprint(v) {
|
||
case "config_apply":
|
||
return "pill run"
|
||
case "media_start", "media_restart", "media_stop":
|
||
return "pill ok"
|
||
case "reload", "rollback":
|
||
return "pill warn"
|
||
default:
|
||
return "pill"
|
||
}
|
||
},
|
||
"taskStatusLabel": func(v any) string {
|
||
switch fmt.Sprint(v) {
|
||
case "success":
|
||
return "成功"
|
||
case "failed":
|
||
return "失败"
|
||
case "running":
|
||
return "执行中"
|
||
default:
|
||
return "待执行"
|
||
}
|
||
},
|
||
"taskStatusClass": func(v any) string {
|
||
switch fmt.Sprint(v) {
|
||
case "success":
|
||
return "pill ok"
|
||
case "failed":
|
||
return "pill bad"
|
||
case "running":
|
||
return "pill run"
|
||
default:
|
||
return "pill"
|
||
}
|
||
},
|
||
"auditField": func(details string, key string) string {
|
||
var m map[string]any
|
||
if err := json.Unmarshal([]byte(details), &m); err != nil {
|
||
return ""
|
||
}
|
||
if v, ok := m[key].(string); ok {
|
||
return strings.TrimSpace(v)
|
||
}
|
||
return ""
|
||
},
|
||
"auditActionLabel": func(v string) string {
|
||
switch strings.TrimSpace(v) {
|
||
case "config_apply":
|
||
return "下发业务配置"
|
||
case "reload":
|
||
return "重载配置"
|
||
case "rollback":
|
||
return "回滚配置"
|
||
case "media_start":
|
||
return "启动服务"
|
||
case "media_restart":
|
||
return "重启服务"
|
||
case "media_stop":
|
||
return "停止服务"
|
||
default:
|
||
return strings.TrimSpace(v)
|
||
}
|
||
},
|
||
"auditStatusLabel": func(v string) string {
|
||
switch strings.TrimSpace(v) {
|
||
case "success":
|
||
return "成功"
|
||
case "failed":
|
||
return "失败"
|
||
case "running":
|
||
return "执行中"
|
||
case "pending":
|
||
return "待执行"
|
||
default:
|
||
return strings.TrimSpace(v)
|
||
}
|
||
},
|
||
"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)
|
||
}
|
||
},
|
||
"icon": func(name string) template.HTML {
|
||
return template.HTML(tablerIconSVG(name))
|
||
},
|
||
}).ParseFS(uiFS, "ui/templates/*.html")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
previewSvc := service.NewConfigPreviewService(nil)
|
||
if len(preview) > 0 && preview[0] != nil {
|
||
previewSvc = preview[0]
|
||
}
|
||
return &UI{
|
||
discovery: discovery,
|
||
registry: registry,
|
||
agent: agent,
|
||
tasks: tasks,
|
||
templates: templates,
|
||
preview: previewSvc,
|
||
tpl: tpl,
|
||
}, nil
|
||
}
|
||
|
||
func (u *UI) SetStateRepo(repo *storage.DeviceConfigStateRepo) {
|
||
if u == nil {
|
||
return
|
||
}
|
||
u.stateRepo = repo
|
||
}
|
||
|
||
func (u *UI) SetAuditRepo(repo *storage.AuditLogsRepo) {
|
||
if u == nil {
|
||
return
|
||
}
|
||
u.auditRepo = repo
|
||
}
|
||
|
||
func (u *UI) SetDBPath(path string) {
|
||
if u == nil {
|
||
return
|
||
}
|
||
u.dbPath = strings.TrimSpace(path)
|
||
}
|
||
|
||
func tablerIconSVG(name string) string {
|
||
icons := map[string]string{
|
||
"devices": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="3" y="4" width="18" height="12" rx="1"/><path d="M7 20h10"/><path d="M9 16v4"/><path d="M15 16v4"/></svg>`,
|
||
"assets": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l4 -14"/><path d="M16 5l4 14"/><path d="M12 5v14"/><path d="M6 15h12"/></svg>`,
|
||
"audit": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 8l0 4l2 2"/><path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5"/></svg>`,
|
||
"system": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c.996 .608 2.296 .07 2.572 -1.065z"/><circle cx="12" cy="12" r="3"/></svg>`,
|
||
"online": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5"/></svg>`,
|
||
"detail": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 12h.01"/><path d="M12 12h.01"/><path d="M9 12h.01"/><path d="M5 12a7 7 0 1 0 14 0a7 7 0 0 0 -14 0"/></svg>`,
|
||
"control": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 4v16l13 -8z"/></svg>`,
|
||
"device": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M11 4h2"/><path d="M12 17v.01"/></svg>`,
|
||
"status": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4l3 8l4 -16l3 8h4"/></svg>`,
|
||
"config": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 6l16 0"/><path d="M4 12l16 0"/><path d="M4 18l16 0"/><path d="M8 6l0 .01"/><path d="M8 12l0 .01"/><path d="M8 18l0 .01"/></svg>`,
|
||
"overview": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4h6v8h-6z"/><path d="M14 4h6v5h-6z"/><path d="M14 13h6v7h-6z"/><path d="M4 16h6v4h-6z"/></svg>`,
|
||
"tech": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 8l-4 4l4 4"/><path d="M17 8l4 4l-4 4"/><path d="M14 4l-4 16"/></svg>`,
|
||
"preview": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5c-7.633 0 -9 7 -9 7s1.367 7 9 7s9 -7 9 -7s-1.367 -7 -9 -7"/><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/></svg>`,
|
||
"apply": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 12l3 3l7 -7"/><path d="M21 12c0 4.97 -4.03 9 -9 9s-9 -4.03 -9 -9s4.03 -9 9 -9"/></svg>`,
|
||
"service": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 13h5"/><path d="M12 16v5"/><path d="M16 4l0 5"/><path d="M20 8h-5"/><path d="M4 9h1a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-1"/><path d="M9 4h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M15 15h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M9 13h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-1"/><path d="M15 4h1a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-1"/></svg>`,
|
||
"task": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 11l3 3l8 -8"/><path d="M20 12v7a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1v-14a1 1 0 0 1 1 -1h9"/></svg>`,
|
||
"result": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l16 0"/><path d="M4 15l4 -6l4 2l4 -5l4 9"/></svg>`,
|
||
"logs": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l16 0"/><path d="M4 15l4 -6l4 2l4 -5l4 9"/></svg>`,
|
||
"meta": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M3 17m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 17m0 2a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M7 6h10"/><path d="M5 8v9"/><path d="M7 19h10"/><path d="M17 8v9"/></svg>`,
|
||
"template": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"/><path d="M9 8h6"/><path d="M9 12h6"/><path d="M9 16h4"/></svg>`,
|
||
"profile": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M12 3c2.755 0 5.455 .638 7.407 1.758a2 2 0 0 1 1.002 1.737v11.01a2 2 0 0 1 -1.002 1.737c-1.952 1.12 -4.652 1.758 -7.407 1.758s-5.455 -.638 -7.407 -1.758a2 2 0 0 1 -1.002 -1.737v-11.01a2 2 0 0 1 1.002 -1.737c1.952 -1.12 4.652 -1.758 7.407 -1.758z"/></svg>`,
|
||
"overlay": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 3.34l10 5.66v10l-10 -5.66z"/><path d="M17 9l4 -2.26l-10 -5.74l-4 2.26z"/><path d="M7 13l-4 -2.26v-6.74l4 -2.26"/></svg>`,
|
||
"release": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 7h10v10h-10z"/><path d="M3 7l3 0"/><path d="M18 7l3 0"/><path d="M7 3l0 3"/><path d="M7 18l0 3"/><path d="M17 18l0 3"/><path d="M17 3l0 3"/></svg>`,
|
||
"discovery": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4"/><path d="M17 12h4"/><path d="M12 3v4"/><path d="M12 17v4"/><circle cx="12" cy="12" r="3"/><path d="M5.636 5.636l2.828 2.828"/><path d="M15.536 15.536l2.828 2.828"/><path d="M5.636 18.364l2.828 -2.828"/><path d="M15.536 8.464l2.828 -2.828"/></svg>`,
|
||
"shield": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 3l8 4v5c0 5 -3.5 9.5 -8 11c-4.5 -1.5 -8 -6 -8 -11v-5l8 -4"/></svg>`,
|
||
"heartbeat": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4l2 -3l4 6l2 -3h6"/></svg>`,
|
||
}
|
||
if svg, ok := icons[name]; ok {
|
||
return svg
|
||
}
|
||
return ""
|
||
}
|
||
|
||
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("/devices/{id}/control", u.pageDeviceControl)
|
||
r.Get("/assets", u.pageAssets)
|
||
r.Post("/assets/import", u.actionAssetsImport)
|
||
r.Get("/assets/templates", u.pageAssetTemplates)
|
||
r.Get("/assets/templates/{name}", u.pageAssetTemplate)
|
||
r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport)
|
||
r.Get("/assets/profiles", u.pageAssetProfiles)
|
||
r.Get("/assets/profiles/{name}", u.pageAssetProfile)
|
||
r.Post("/assets/profiles/{name}", u.actionAssetProfileSave)
|
||
r.Get("/assets/profiles/{name}/export", u.pageAssetProfileExport)
|
||
r.Get("/assets/overlays", u.pageAssetOverlays)
|
||
r.Get("/assets/overlays/{name}", u.pageAssetOverlay)
|
||
r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport)
|
||
r.Get("/audit", u.pageAudit)
|
||
r.Get("/system", u.pageSystem)
|
||
r.Get("/system/db-backup", u.pageSystemDBBackup)
|
||
r.Post("/system/db-restore", u.actionSystemDBRestore)
|
||
r.Get("/device-config", u.pageDeviceConfig)
|
||
r.Get("/device-config/{id}", u.pageDeviceConfigDetail)
|
||
r.Get("/devices-add", u.pageDeviceAdd)
|
||
r.Post("/devices-add", u.actionDeviceAdd)
|
||
r.Post("/devices/batch-action", u.actionDevicesBatchAction)
|
||
r.Get("/devices/batch-config", u.pageDeviceBatchConfig)
|
||
r.Post("/devices/batch-config", u.actionDeviceBatchConfig)
|
||
r.Post("/discovery/search", u.actionDiscoverySearch)
|
||
r.Get("/devices/{id}", u.pageDevice)
|
||
r.Post("/devices/{id}/alias", u.actionDeviceAliasSave)
|
||
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-candidate/apply", u.actionDeviceConfigCandidateApply)
|
||
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) {
|
||
u.ensureDevicesLoaded()
|
||
devices := u.registry.GetDevices()
|
||
for _, d := range devices {
|
||
if d.DeviceID == id {
|
||
return d, true
|
||
}
|
||
}
|
||
if u.discovery != nil {
|
||
_, _ = u.discovery.SearchDefault()
|
||
devices = u.registry.GetDevices()
|
||
for _, d := range devices {
|
||
if d.DeviceID == id {
|
||
return d, true
|
||
}
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func (u *UI) ensureDevicesLoaded() {
|
||
if u.registry == nil || u.discovery == nil {
|
||
return
|
||
}
|
||
if len(u.registry.GetDevices()) > 0 {
|
||
return
|
||
}
|
||
_, _ = u.discovery.SearchDefault()
|
||
}
|
||
|
||
func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) {
|
||
data := u.deviceOverviewPageData(r, nil, "")
|
||
if u.tasks != nil {
|
||
for _, task := range u.tasks.ListTasks() {
|
||
switch task.Status {
|
||
case models.TaskRunning:
|
||
data.RunningTaskCount++
|
||
case models.TaskFailed:
|
||
data.FailedTaskCount++
|
||
case models.TaskSuccess:
|
||
data.SuccessTaskCount++
|
||
}
|
||
}
|
||
}
|
||
data.Title = "总览"
|
||
data.Tasks = nil
|
||
if u.tasks != nil {
|
||
data.Tasks = u.tasks.ListTasks()
|
||
}
|
||
data.AttentionDevices = nil
|
||
for _, dev := range data.Devices {
|
||
if dev != nil && !dev.Online {
|
||
data.AttentionDevices = append(data.AttentionDevices, dev)
|
||
}
|
||
}
|
||
u.render(w, r, "dashboard", data)
|
||
}
|
||
|
||
func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) {
|
||
u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, ""))
|
||
}
|
||
|
||
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.ensureDevicesLoaded()
|
||
u.render(w, r, "device_config", PageData{
|
||
Title: "设备配置入口",
|
||
Devices: u.registry.GetDevices(),
|
||
})
|
||
}
|
||
|
||
func (u *UI) pageDeviceConfigDetail(w http.ResponseWriter, r *http.Request) {
|
||
id := chi.URLParam(r, "id")
|
||
http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound)
|
||
}
|
||
|
||
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 := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
|
||
if len(deviceIDs) == 0 {
|
||
u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "请先选择设备"))
|
||
return
|
||
}
|
||
|
||
typeStr := ""
|
||
switch action {
|
||
case "media_start", "media_restart", "media_stop", "reload", "rollback":
|
||
typeStr = action
|
||
default:
|
||
u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, "不支持的操作: "+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 {
|
||
u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, err.Error()))
|
||
return
|
||
}
|
||
|
||
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
|
||
}
|
||
|
||
func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
|
||
data := u.deviceBatchConfigPageData(r, selectedIDsFromQuery(r.URL.Query()["selected"]))
|
||
u.render(w, r, "device_batch_config", data)
|
||
}
|
||
|
||
func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
|
||
req := service.ConfigPreviewRequest{Profile: strings.TrimSpace(r.FormValue("profile"))}
|
||
data := u.deviceBatchConfigPageData(r, selectedIDs)
|
||
if req.Profile != "" {
|
||
data.SelectedProfile = req.Profile
|
||
}
|
||
for i := range data.AssetProfiles {
|
||
if strings.TrimSpace(data.AssetProfiles[i].Name) == data.SelectedProfile {
|
||
data.AssetProfile = &data.AssetProfiles[i]
|
||
data.SelectedTemplate = profileAssetTemplate(&data.AssetProfiles[i])
|
||
break
|
||
}
|
||
}
|
||
|
||
if len(selectedIDs) == 0 {
|
||
data.Error = "请先选择需要下发配置的设备"
|
||
u.render(w, r, "device_batch_config", data)
|
||
return
|
||
}
|
||
if req.Profile == "" {
|
||
req.Profile = data.SelectedProfile
|
||
}
|
||
if req.Profile == "" {
|
||
data.Error = "请先选择业务配置"
|
||
u.render(w, r, "device_batch_config", data)
|
||
return
|
||
}
|
||
if data.SelectedTemplate == "" {
|
||
data.Error = "所选业务配置缺少可用模板,无法生成下发内容"
|
||
u.render(w, r, "device_batch_config", data)
|
||
return
|
||
}
|
||
req.Template = data.SelectedTemplate
|
||
if u.tasks == nil {
|
||
data.Error = "task service not initialized"
|
||
u.render(w, r, "device_batch_config", data)
|
||
return
|
||
}
|
||
|
||
preview, err := u.preview.Render(req)
|
||
data.ConfigPreview = preview
|
||
if err != nil {
|
||
data.Error = err.Error()
|
||
u.render(w, r, "device_batch_config", data)
|
||
return
|
||
}
|
||
|
||
var configDoc any
|
||
if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil {
|
||
data.Error = "生成配置 JSON 无效: " + err.Error()
|
||
u.render(w, r, "device_batch_config", data)
|
||
return
|
||
}
|
||
|
||
task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"config": configDoc})
|
||
if err != nil {
|
||
data.Error = err.Error()
|
||
u.render(w, r, "device_batch_config", data)
|
||
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
|
||
}
|
||
u.render(w, r, "device", u.deviceDetailPageData(dev))
|
||
}
|
||
|
||
func (u *UI) deviceDetailPageData(dev *models.Device) PageData {
|
||
data := u.deviceControlPageData(dev)
|
||
data.Title = "设备详情"
|
||
if data.ConfigStatus == nil && u.stateRepo != nil && dev != nil {
|
||
if state, err := u.stateRepo.Get(dev.DeviceID); err == nil && state != nil {
|
||
data.PersistedConfig = state
|
||
}
|
||
}
|
||
return data
|
||
}
|
||
|
||
func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) {
|
||
id := chi.URLParam(r, "id")
|
||
http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound)
|
||
}
|
||
|
||
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)
|
||
returnTo := strings.TrimSpace(r.FormValue("return_to"))
|
||
if returnTo == "control" || returnTo == "config" {
|
||
data := u.deviceDetailPageData(dev)
|
||
data.Message = msg
|
||
data.RawText = string(body)
|
||
data.ResultTitle = "执行结果摘要"
|
||
if err != nil {
|
||
data.Error = err.Error()
|
||
}
|
||
u.render(w, r, "device", data)
|
||
return
|
||
}
|
||
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) actionDeviceAliasSave(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()
|
||
alias := strings.TrimSpace(r.FormValue("device_alias"))
|
||
data := u.deviceDetailPageData(dev)
|
||
if err := u.registry.SetDeviceAlias(id, alias); err != nil {
|
||
data.Error = err.Error()
|
||
u.render(w, r, "device", data)
|
||
return
|
||
}
|
||
dev.DeviceAlias = alias
|
||
data.Device = dev
|
||
data.Message = "设备别名已保存"
|
||
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.ensureDevicesLoaded()
|
||
devices := u.registry.GetDevices()
|
||
selectedIDs := filterSelectedDeviceIDs(devices, selectedIDsFromQuery(r.URL.Query()["selected"]))
|
||
data := PageData{
|
||
Title: "任务",
|
||
Tasks: u.tasks.ListTasks(),
|
||
Devices: devices,
|
||
SelectedDeviceIDs: selectedIDs,
|
||
SelectedDevices: selectedDevicesFromIDs(devices, selectedIDs),
|
||
DeviceIDs: strings.Join(selectedIDs, ","),
|
||
}
|
||
u.render(w, r, "tasks", data)
|
||
}
|
||
|
||
func (u *UI) taskPageData(task *models.Task) PageData {
|
||
data := PageData{Title: "任务详情", Task: task}
|
||
if task == nil {
|
||
return data
|
||
}
|
||
|
||
devices := make(map[string]*models.Device)
|
||
if u.registry != nil {
|
||
for _, dev := range u.registry.GetDevices() {
|
||
if dev == nil {
|
||
continue
|
||
}
|
||
devices[dev.DeviceID] = dev
|
||
}
|
||
}
|
||
|
||
rows := make([]TaskDeviceRow, 0, len(task.DeviceIDs))
|
||
for _, did := range task.DeviceIDs {
|
||
row := TaskDeviceRow{}
|
||
if dev := devices[did]; dev != nil {
|
||
row.Device = dev
|
||
} else {
|
||
row.Device = &models.Device{DeviceID: did}
|
||
}
|
||
if ds := task.Devices[did]; ds != nil {
|
||
row.Status = ds.Status
|
||
row.Progress = ds.Progress
|
||
row.Error = ds.Error
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
data.TaskDeviceRows = rows
|
||
return data
|
||
}
|
||
|
||
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
|
||
if ids != "" {
|
||
for _, p := range strings.Split(ids, ",") {
|
||
p = strings.TrimSpace(p)
|
||
if p != "" {
|
||
deviceIDs = append(deviceIDs, p)
|
||
}
|
||
}
|
||
} else {
|
||
deviceIDs = filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
|
||
ids = strings.Join(deviceIDs, ",")
|
||
}
|
||
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
|
||
}
|
||
data := u.taskPageData(task)
|
||
data.TaskID = id
|
||
u.render(w, r, "task", data)
|
||
}
|
||
|
||
func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) {
|
||
http.Redirect(w, r, "/ui/assets/templates", http.StatusFound)
|
||
}
|
||
|
||
func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) {
|
||
http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound)
|
||
}
|
||
|
||
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) {
|
||
http.Redirect(w, r, "/ui/assets", http.StatusFound)
|
||
}
|
||
|
||
func (u *UI) pageLogs(w http.ResponseWriter, r *http.Request) {
|
||
http.Redirect(w, r, "/ui/diagnostics", http.StatusFound)
|
||
}
|
||
|
||
func (u *UI) pageAPIConsole(w http.ResponseWriter, r *http.Request) {
|
||
u.render(w, r, "api", PageData{Title: "高级调试"})
|
||
}
|
||
|
||
func (u *UI) pageAssets(w http.ResponseWriter, r *http.Request) {
|
||
data := u.assetPageData("overview")
|
||
u.render(w, r, "assets", data)
|
||
}
|
||
|
||
func (u *UI) actionAssetsImport(w http.ResponseWriter, r *http.Request) {
|
||
data := u.assetPageData("overview")
|
||
if u.preview == nil {
|
||
data.Error = "配置资产服务未初始化"
|
||
u.render(w, r, "assets", data)
|
||
return
|
||
}
|
||
result, err := u.preview.ImportAssetsFromMediaRepo()
|
||
if err != nil {
|
||
data.Error = err.Error()
|
||
u.render(w, r, "assets", data)
|
||
return
|
||
}
|
||
data = u.assetPageData("overview")
|
||
data.Message = fmt.Sprintf("已导入 %d 个模板、%d 个业务配置、%d 个叠加项", result.Templates, result.Profiles, result.Overlays)
|
||
u.render(w, r, "assets", data)
|
||
}
|
||
|
||
func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) {
|
||
data := u.assetPageData("templates")
|
||
if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
|
||
if item, err := u.preview.GetTemplateAsset(name); err == nil {
|
||
data.AssetTemplate = item
|
||
} else if data.Error == "" {
|
||
data.Error = err.Error()
|
||
}
|
||
} else if len(data.AssetTemplates) > 0 {
|
||
if item, err := u.preview.GetTemplateAsset(data.AssetTemplates[0].Name); err == nil {
|
||
data.AssetTemplate = item
|
||
} else if data.Error == "" {
|
||
data.Error = err.Error()
|
||
}
|
||
}
|
||
u.render(w, r, "asset_templates", data)
|
||
}
|
||
|
||
func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) {
|
||
name := chi.URLParam(r, "name")
|
||
data := u.assetPageData("templates")
|
||
item, err := u.preview.GetTemplateAsset(name)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
data.AssetTemplate = item
|
||
u.render(w, r, "asset_templates", data)
|
||
}
|
||
|
||
func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) {
|
||
u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name"))
|
||
}
|
||
|
||
func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) {
|
||
data := u.assetPageData("profiles")
|
||
selected := strings.TrimSpace(r.URL.Query().Get("name"))
|
||
if selected == "" && len(data.AssetProfiles) > 0 {
|
||
selected = data.AssetProfiles[0].Name
|
||
}
|
||
if selected != "" {
|
||
editor, err := u.preview.GetProfileEditor(selected)
|
||
if err == nil {
|
||
data.AssetProfileEditor = editor
|
||
data.SelectedProfile = editor.Name
|
||
if len(editor.Instances) > 0 && editor.Instances[0].Template != "" {
|
||
data.SelectedTemplate = editor.Instances[0].Template
|
||
}
|
||
} else if data.Error == "" {
|
||
data.Error = err.Error()
|
||
}
|
||
}
|
||
u.render(w, r, "asset_profiles", data)
|
||
}
|
||
|
||
func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) {
|
||
name := chi.URLParam(r, "name")
|
||
data, err := u.profileEditorPageData(name)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
data.Title = "识别配置"
|
||
u.render(w, r, "asset_profiles", data)
|
||
}
|
||
|
||
func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) {
|
||
u.exportAssetJSON(w, r, "profiles", chi.URLParam(r, "name"))
|
||
}
|
||
|
||
func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
|
||
name := chi.URLParam(r, "name")
|
||
editor, data, err := u.profileEditorActionData(r, name)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if err := u.preview.SaveProfileEditor(editor); err != nil {
|
||
data.Error = err.Error()
|
||
u.render(w, r, "asset_profiles", data)
|
||
return
|
||
}
|
||
if editor.Name != name {
|
||
data.Message = "业务配置已保存,名称已更新"
|
||
} else {
|
||
data.Message = "业务配置已保存"
|
||
}
|
||
u.render(w, r, "asset_profiles", data)
|
||
}
|
||
|
||
func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) {
|
||
data := u.assetPageData("overlays")
|
||
if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
|
||
if item, err := u.preview.GetOverlayAsset(name); err == nil {
|
||
data.AssetOverlay = item
|
||
} else if data.Error == "" {
|
||
data.Error = err.Error()
|
||
}
|
||
} else if len(data.AssetOverlays) > 0 {
|
||
if item, err := u.preview.GetOverlayAsset(data.AssetOverlays[0].Name); err == nil {
|
||
data.AssetOverlay = item
|
||
} else if data.Error == "" {
|
||
data.Error = err.Error()
|
||
}
|
||
}
|
||
u.render(w, r, "asset_overlays", data)
|
||
}
|
||
|
||
func (u *UI) pageAssetOverlay(w http.ResponseWriter, r *http.Request) {
|
||
name := chi.URLParam(r, "name")
|
||
data := u.assetPageData("overlays")
|
||
item, err := u.preview.GetOverlayAsset(name)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
data.AssetOverlay = item
|
||
u.render(w, r, "asset_overlays", data)
|
||
}
|
||
|
||
func (u *UI) pageAssetOverlayExport(w http.ResponseWriter, r *http.Request) {
|
||
u.exportAssetJSON(w, r, "overlays", chi.URLParam(r, "name"))
|
||
}
|
||
|
||
func (u *UI) assetPageData(tab string) PageData {
|
||
data := PageData{
|
||
Title: "识别配置",
|
||
AssetTab: tab,
|
||
}
|
||
if u.preview == nil {
|
||
data.Error = "配置资产服务未初始化"
|
||
return data
|
||
}
|
||
sources, err := u.preview.ListSources()
|
||
data.ConfigSources = sources
|
||
if err != nil {
|
||
data.Error = err.Error()
|
||
}
|
||
if items, listErr := u.preview.ListTemplateAssets(); listErr == nil {
|
||
data.AssetTemplates = items
|
||
} else if data.Error == "" {
|
||
data.Error = listErr.Error()
|
||
}
|
||
if items, listErr := u.preview.ListProfileAssets(); listErr == nil {
|
||
data.AssetProfiles = items
|
||
for _, item := range items {
|
||
data.AssetInstanceCount += len(item.Instances)
|
||
}
|
||
} else if data.Error == "" {
|
||
data.Error = listErr.Error()
|
||
}
|
||
if items, listErr := u.preview.ListOverlayAssets(); listErr == nil {
|
||
data.AssetOverlays = items
|
||
} else if data.Error == "" {
|
||
data.Error = listErr.Error()
|
||
}
|
||
return data
|
||
}
|
||
|
||
func (u *UI) profileEditorPageData(name string) (PageData, error) {
|
||
data := u.assetPageData("profiles")
|
||
if u.preview == nil {
|
||
return data, fmt.Errorf("preview service not initialized")
|
||
}
|
||
editor, err := u.preview.GetProfileEditor(name)
|
||
if err != nil {
|
||
return data, err
|
||
}
|
||
data.AssetProfileEditor = editor
|
||
data.SelectedProfile = editor.Name
|
||
if len(editor.Instances) > 0 && editor.Instances[0].Template != "" {
|
||
data.SelectedTemplate = editor.Instances[0].Template
|
||
} else {
|
||
data.SelectedTemplate = "workshop_face_shoe_alarm"
|
||
}
|
||
return data, nil
|
||
}
|
||
|
||
func (u *UI) profileEditorActionData(r *http.Request, name string) (service.ConfigProfileEditor, PageData, error) {
|
||
data, err := u.profileEditorPageData(name)
|
||
if err != nil {
|
||
return service.ConfigProfileEditor{}, data, err
|
||
}
|
||
_ = r.ParseForm()
|
||
editor := service.ConfigProfileEditor{
|
||
Name: strings.TrimSpace(r.FormValue("profile_name")),
|
||
BusinessName: strings.TrimSpace(r.FormValue("business_name")),
|
||
Description: strings.TrimSpace(r.FormValue("description")),
|
||
SiteName: strings.TrimSpace(r.FormValue("site_name")),
|
||
Queue: service.ConfigProfileQueueEditor{
|
||
Size: strings.TrimSpace(r.FormValue("queue_size")),
|
||
Strategy: strings.TrimSpace(r.FormValue("queue_strategy")),
|
||
},
|
||
Instances: parseProfileInstanceForm(r.Form),
|
||
}
|
||
if editor.Name == "" && data.AssetProfileEditor != nil {
|
||
editor.Name = data.AssetProfileEditor.Name
|
||
}
|
||
if editor.DeviceCode == "" && data.AssetProfileEditor != nil {
|
||
editor.DeviceCode = data.AssetProfileEditor.DeviceCode
|
||
}
|
||
if len(editor.Instances) == 0 && data.AssetProfileEditor != nil {
|
||
editor.Instances = append([]service.ConfigProfileInstanceEditor(nil), data.AssetProfileEditor.Instances...)
|
||
}
|
||
data.AssetProfileEditor = &editor
|
||
data.SelectedProfile = editor.Name
|
||
return editor, data, nil
|
||
}
|
||
|
||
func (u *UI) exportAssetJSON(w http.ResponseWriter, r *http.Request, kind string, name string) {
|
||
if u.preview == nil {
|
||
http.Error(w, "preview service not initialized", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
body, filename, err := u.preview.ExportAssetJSON(kind, name)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||
_, _ = w.Write(body)
|
||
}
|
||
|
||
func (u *UI) pageAudit(w http.ResponseWriter, r *http.Request) {
|
||
data := PageData{Title: "审计记录"}
|
||
if u.auditRepo != nil {
|
||
items, err := u.auditRepo.List()
|
||
if err != nil {
|
||
data.Error = err.Error()
|
||
} else {
|
||
data.AuditEntries = items
|
||
}
|
||
}
|
||
if len(data.AuditEntries) == 0 && u.tasks != nil {
|
||
data.Tasks = u.tasks.ListTasks()
|
||
}
|
||
u.render(w, r, "audit", data)
|
||
}
|
||
|
||
func (u *UI) pageSystem(w http.ResponseWriter, r *http.Request) {
|
||
u.renderSystemPage(
|
||
w,
|
||
r,
|
||
http.StatusOK,
|
||
strings.TrimSpace(r.URL.Query().Get("msg")),
|
||
strings.TrimSpace(r.URL.Query().Get("error")),
|
||
)
|
||
}
|
||
|
||
func (u *UI) pageSystemDBBackup(w http.ResponseWriter, r *http.Request) {
|
||
if strings.TrimSpace(u.dbPath) == "" {
|
||
http.Error(w, "database path is not configured", http.StatusNotFound)
|
||
return
|
||
}
|
||
body, err := os.ReadFile(u.dbPath)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
filename := "app-" + time.Now().Format("20060102-150405") + ".db"
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||
_, _ = w.Write(body)
|
||
}
|
||
|
||
func (u *UI) renderSystemPage(w http.ResponseWriter, r *http.Request, status int, message string, errText string) {
|
||
w.WriteHeader(status)
|
||
u.render(w, r, "system", PageData{
|
||
Title: "系统状态",
|
||
Devices: u.registry.GetDevices(),
|
||
DBPath: u.dbPath,
|
||
Message: message,
|
||
Error: errText,
|
||
})
|
||
}
|
||
|
||
func (u *UI) actionSystemDBRestore(w http.ResponseWriter, r *http.Request) {
|
||
if strings.TrimSpace(u.dbPath) == "" {
|
||
http.Error(w, "database path is not configured", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err := r.ParseMultipartForm(50 << 20); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
file, _, err := r.FormFile("file")
|
||
if err != nil {
|
||
u.renderSystemPage(w, r, http.StatusBadRequest, "", "请先选择数据库备份文件")
|
||
return
|
||
}
|
||
defer file.Close()
|
||
body, err := io.ReadAll(file)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if err := os.WriteFile(u.dbPath, body, 0o644); err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/ui/system?msg="+urlQueryEscape("数据库恢复完成"), http.StatusFound)
|
||
}
|
||
|
||
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 {
|
||
dev.Online = false
|
||
return nil, raw, err
|
||
}
|
||
if code < 200 || code >= 300 {
|
||
dev.Online = false
|
||
return nil, raw, fmt.Errorf("GET /v1/config/status -> %d", code)
|
||
}
|
||
dev.Online = true
|
||
dev.LastSeenMs = time.Now().UnixMilli()
|
||
var status ConfigStatusView
|
||
if err := json.Unmarshal(body, &status); err != nil {
|
||
return nil, raw, err
|
||
}
|
||
if v := strings.TrimSpace(status.Metadata.InstanceName); v != "" {
|
||
dev.InstanceName = v
|
||
}
|
||
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
|
||
data.SelectedTemplate = req.Template
|
||
data.SelectedProfile = req.Profile
|
||
data.SelectedOverlays = append([]string(nil), req.Overlays...)
|
||
data.SelectedConfigID = req.ConfigID
|
||
data.SelectedVersion = req.ConfigVersion
|
||
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
|
||
}
|
||
data.ConfigPreview = previewResultFromJSON(raw)
|
||
populateSelectionsFromPreview(&data)
|
||
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)
|
||
data.ResultTitle = "候选配置结果"
|
||
if err != nil {
|
||
data.Error = err.Error()
|
||
}
|
||
u.render(w, r, "config_preview", data)
|
||
}
|
||
|
||
func (u *UI) actionDeviceConfigCandidateApply(w http.ResponseWriter, r *http.Request) {
|
||
id := chi.URLParam(r, "id")
|
||
dev, ok := u.findDevice(id)
|
||
if !ok {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
returnTo := strings.TrimSpace(r.FormValue("return_to"))
|
||
var data PageData
|
||
if returnTo == "control" || returnTo == "config" {
|
||
data = u.deviceDetailPageData(dev)
|
||
} else {
|
||
data = u.configPreviewPageData(dev)
|
||
}
|
||
raw := strings.TrimSpace(r.FormValue("json"))
|
||
if raw != "" {
|
||
data.ConfigPreview = previewResultFromJSON(raw)
|
||
populateSelectionsFromPreview(&data)
|
||
}
|
||
body, code, err := u.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/config/candidate/apply", []byte(`{}`))
|
||
data.Message = fmt.Sprintf("POST /v1/config/candidate/apply -> %d", code)
|
||
data.RawText = prettyJSON(body)
|
||
data.ResultTitle = "应用候选配置结果"
|
||
if err != nil {
|
||
data.Error = err.Error()
|
||
} else {
|
||
status, _, statusErr := u.loadConfigStatus(dev)
|
||
data.ConfigStatus = status
|
||
if statusErr != nil {
|
||
data.ConfigStatusErr = statusErr.Error()
|
||
} else {
|
||
data.ConfigStatusErr = ""
|
||
}
|
||
}
|
||
if returnTo == "control" || returnTo == "config" {
|
||
u.render(w, r, "device", data)
|
||
return
|
||
}
|
||
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,
|
||
SelectedTemplate: "workshop_face_shoe_alarm",
|
||
SelectedProfile: "local_3588_test",
|
||
SelectedOverlays: []string{"face_debug"},
|
||
SelectedConfigID: "preview_" + dev.DeviceID,
|
||
}
|
||
if err != nil {
|
||
data.Error = err.Error()
|
||
}
|
||
status, _, statusErr := u.loadConfigStatus(dev)
|
||
data.ConfigStatus = status
|
||
if statusErr != nil {
|
||
data.ConfigStatusErr = statusErr.Error()
|
||
}
|
||
return data
|
||
}
|
||
|
||
func (u *UI) deviceControlPageData(dev *models.Device) PageData {
|
||
data := PageData{
|
||
Title: "设备控制",
|
||
Device: dev,
|
||
}
|
||
status, raw, statusErr := u.loadConfigStatus(dev)
|
||
data.ConfigStatus = status
|
||
data.ConfigStatusText = raw
|
||
if statusErr != nil {
|
||
data.ConfigStatusErr = statusErr.Error()
|
||
}
|
||
return data
|
||
}
|
||
|
||
func (u *UI) deviceConfigWorkspacePageData(dev *models.Device) PageData {
|
||
data := u.deviceControlPageData(dev)
|
||
data.Title = "配置管理"
|
||
return data
|
||
}
|
||
|
||
func (u *UI) listTemplatesSafe() ([]service.Template, error) {
|
||
if u.templates == nil {
|
||
return nil, nil
|
||
}
|
||
return u.templates.ListTemplates()
|
||
}
|
||
|
||
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 parseAdvancedParams(raw string) map[string]any {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
var out map[string]any
|
||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||
return map[string]any{}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEditor {
|
||
indices := make([]int, 0)
|
||
seen := map[int]struct{}{}
|
||
for key := range form {
|
||
if !strings.HasPrefix(key, "instances[") {
|
||
continue
|
||
}
|
||
rest := strings.TrimPrefix(key, "instances[")
|
||
end := strings.Index(rest, "]")
|
||
if end <= 0 {
|
||
continue
|
||
}
|
||
idx, err := strconv.Atoi(rest[:end])
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if _, ok := seen[idx]; ok {
|
||
continue
|
||
}
|
||
seen[idx] = struct{}{}
|
||
indices = append(indices, idx)
|
||
}
|
||
sort.Ints(indices)
|
||
out := make([]service.ConfigProfileInstanceEditor, 0, len(indices))
|
||
for _, idx := range indices {
|
||
prefix := fmt.Sprintf("instances[%d].", idx)
|
||
inst := service.ConfigProfileInstanceEditor{
|
||
Name: strings.TrimSpace(form.Get(prefix + "name")),
|
||
Template: strings.TrimSpace(form.Get(prefix + "template")),
|
||
DisplayName: strings.TrimSpace(form.Get(prefix + "display_name")),
|
||
RTSPURL: strings.TrimSpace(form.Get(prefix + "rtsp_url")),
|
||
PublishHLSPath: strings.TrimSpace(form.Get(prefix + "publish_hls_path")),
|
||
PublishRTSPPort: strings.TrimSpace(form.Get(prefix + "publish_rtsp_port")),
|
||
PublishRTSPPath: strings.TrimSpace(form.Get(prefix + "publish_rtsp_path")),
|
||
ChannelNo: strings.TrimSpace(form.Get(prefix + "channel_no")),
|
||
AdvancedParams: parseAdvancedParams(strings.TrimSpace(form.Get(prefix + "advanced_params"))),
|
||
Delete: strings.TrimSpace(form.Get(prefix+"delete")) == "1",
|
||
}
|
||
if inst.Name != "" || inst.RTSPURL != "" || inst.Delete {
|
||
out = append(out, inst)
|
||
}
|
||
}
|
||
if strings.TrimSpace(form.Get("add_instance")) == "1" {
|
||
templateName := "workshop_face_shoe_alarm"
|
||
if len(out) > 0 && strings.TrimSpace(out[0].Template) != "" {
|
||
templateName = strings.TrimSpace(out[0].Template)
|
||
}
|
||
out = append(out, service.ConfigProfileInstanceEditor{
|
||
Template: templateName,
|
||
PublishRTSPPort: "8555",
|
||
})
|
||
}
|
||
if len(out) > 0 {
|
||
fallbackTemplate := strings.TrimSpace(out[0].Template)
|
||
if fallbackTemplate == "" {
|
||
fallbackTemplate = "workshop_face_shoe_alarm"
|
||
}
|
||
for i := range out {
|
||
if strings.TrimSpace(out[i].Template) == "" {
|
||
out[i].Template = fallbackTemplate
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func selectedIDsFromQuery(values []string) []string {
|
||
values = cleanFormList(values)
|
||
if len(values) == 0 {
|
||
return nil
|
||
}
|
||
seen := make(map[string]struct{}, len(values))
|
||
out := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
if _, ok := seen[value]; ok {
|
||
continue
|
||
}
|
||
seen[value] = struct{}{}
|
||
out = append(out, value)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func filterSelectedDeviceIDs(devices []*models.Device, candidates []string) []string {
|
||
if len(candidates) == 0 || len(devices) == 0 {
|
||
return nil
|
||
}
|
||
known := make(map[string]struct{}, len(devices))
|
||
for _, dev := range devices {
|
||
if dev == nil {
|
||
continue
|
||
}
|
||
id := strings.TrimSpace(dev.DeviceID)
|
||
if id != "" {
|
||
known[id] = struct{}{}
|
||
}
|
||
}
|
||
seen := make(map[string]struct{}, len(candidates))
|
||
out := make([]string, 0, len(candidates))
|
||
for _, id := range candidates {
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
continue
|
||
}
|
||
if _, ok := known[id]; !ok {
|
||
continue
|
||
}
|
||
if _, ok := seen[id]; ok {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
out = append(out, id)
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
func selectedQueryString(ids []string) string {
|
||
if len(ids) == 0 {
|
||
return ""
|
||
}
|
||
values := url.Values{}
|
||
for _, id := range ids {
|
||
values.Add("selected", id)
|
||
}
|
||
return values.Encode()
|
||
}
|
||
|
||
func selectedURL(path string, ids []string) string {
|
||
query := selectedQueryString(ids)
|
||
if query == "" {
|
||
return path
|
||
}
|
||
return path + "?" + query
|
||
}
|
||
|
||
func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMsg string) PageData {
|
||
u.ensureDevicesLoaded()
|
||
devices := u.registry.GetDevices()
|
||
rows := make([]DeviceOverviewRow, 0, len(devices))
|
||
for _, dev := range devices {
|
||
row := DeviceOverviewRow{Device: dev}
|
||
status, _, err := u.loadConfigStatus(dev)
|
||
row.ConfigStatus = status
|
||
if err != nil {
|
||
row.ConfigStatusErr = err.Error()
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
online := 0
|
||
attention := 0
|
||
for _, d := range devices {
|
||
if d.Online {
|
||
online++
|
||
} else {
|
||
attention++
|
||
}
|
||
}
|
||
failedTasks := 0
|
||
if u.tasks != nil {
|
||
for _, t := range u.tasks.ListTasks() {
|
||
if t.Status == models.TaskFailed {
|
||
failedTasks++
|
||
}
|
||
}
|
||
}
|
||
if selectedIDs == nil {
|
||
selectedIDs = selectedIDsFromQuery(r.URL.Query()["selected"])
|
||
}
|
||
selectedIDs = filterSelectedDeviceIDs(devices, selectedIDs)
|
||
data := PageData{
|
||
Title: "设备",
|
||
Devices: devices,
|
||
DeviceRows: rows,
|
||
DeviceCount: len(devices),
|
||
OnlineCount: online,
|
||
OfflineCount: len(devices) - online,
|
||
RunningTaskCount: 0,
|
||
FailedTaskCount: failedTasks,
|
||
FoundCount: attention,
|
||
SelectedDeviceIDs: selectedIDs,
|
||
SelectedQuery: selectedQueryString(selectedIDs),
|
||
SelectedDevicesURL: selectedURL("/ui/devices", selectedIDs),
|
||
BatchConfigURL: selectedURL("/ui/devices/batch-config", selectedIDs),
|
||
ReloadSummary: batchActionSummary(rows, selectedIDs, "reload"),
|
||
RollbackSummary: batchActionSummary(rows, selectedIDs, "rollback"),
|
||
}
|
||
if errMsg != "" {
|
||
data.Error = errMsg
|
||
}
|
||
return data
|
||
}
|
||
|
||
func (u *UI) deviceBatchConfigPageData(r *http.Request, selectedIDs []string) PageData {
|
||
data := u.deviceOverviewPageData(r, selectedIDs, "")
|
||
sources, err := u.preview.ListSources()
|
||
data.Title = "下发业务配置"
|
||
data.ConfigSources = sources
|
||
data.SelectedDevices = selectedDevicesFromIDs(data.Devices, data.SelectedDeviceIDs)
|
||
profiles, profileErr := u.preview.ListProfileAssets()
|
||
data.AssetProfiles = profiles
|
||
selectedProfile := strings.TrimSpace(r.URL.Query().Get("profile"))
|
||
if selectedProfile == "" {
|
||
selectedProfile = "local_3588_test"
|
||
}
|
||
for i := range profiles {
|
||
if strings.TrimSpace(profiles[i].Name) == selectedProfile {
|
||
data.AssetProfile = &profiles[i]
|
||
data.SelectedProfile = profiles[i].Name
|
||
data.SelectedTemplate = profileAssetTemplate(&profiles[i])
|
||
break
|
||
}
|
||
}
|
||
if data.AssetProfile == nil && len(profiles) > 0 {
|
||
data.AssetProfile = &profiles[0]
|
||
data.SelectedProfile = profiles[0].Name
|
||
data.SelectedTemplate = profileAssetTemplate(&profiles[0])
|
||
}
|
||
if err != nil {
|
||
data.Error = err.Error()
|
||
} else if profileErr != nil {
|
||
data.Error = profileErr.Error()
|
||
}
|
||
return data
|
||
}
|
||
|
||
func selectedDevicesFromIDs(devices []*models.Device, ids []string) []*models.Device {
|
||
if len(devices) == 0 || len(ids) == 0 {
|
||
return nil
|
||
}
|
||
byID := make(map[string]*models.Device, len(devices))
|
||
for _, dev := range devices {
|
||
if dev == nil {
|
||
continue
|
||
}
|
||
byID[strings.TrimSpace(dev.DeviceID)] = dev
|
||
}
|
||
selected := make([]*models.Device, 0, len(ids))
|
||
for _, id := range ids {
|
||
if dev := byID[strings.TrimSpace(id)]; dev != nil {
|
||
selected = append(selected, dev)
|
||
}
|
||
}
|
||
return selected
|
||
}
|
||
|
||
func previewResultFromJSON(raw string) *service.ConfigPreviewResult {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
var doc map[string]any
|
||
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
|
||
return nil
|
||
}
|
||
metadata, _ := doc["metadata"].(map[string]any)
|
||
return &service.ConfigPreviewResult{
|
||
JSON: raw,
|
||
Metadata: metadata,
|
||
Size: len([]byte(raw)),
|
||
}
|
||
}
|
||
|
||
func populateSelectionsFromPreview(data *PageData) {
|
||
if data == nil || data.ConfigPreview == nil {
|
||
return
|
||
}
|
||
if metadata := data.ConfigPreview.Metadata; metadata != nil {
|
||
if v, _ := metadata["template"].(string); strings.TrimSpace(v) != "" {
|
||
data.SelectedTemplate = v
|
||
}
|
||
if v, _ := metadata["profile"].(string); strings.TrimSpace(v) != "" {
|
||
data.SelectedProfile = v
|
||
}
|
||
if v, _ := metadata["config_id"].(string); strings.TrimSpace(v) != "" {
|
||
data.SelectedConfigID = v
|
||
}
|
||
if v, _ := metadata["config_version"].(string); strings.TrimSpace(v) != "" {
|
||
data.SelectedVersion = v
|
||
}
|
||
if items, ok := metadata["overlays"].([]any); ok {
|
||
overlays := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
|
||
overlays = append(overlays, s)
|
||
}
|
||
}
|
||
if len(overlays) > 0 {
|
||
data.SelectedOverlays = overlays
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func profileAssetTemplate(asset *service.ConfigProfileAsset) string {
|
||
if asset == nil {
|
||
return ""
|
||
}
|
||
for _, item := range asset.Instances {
|
||
if v := strings.TrimSpace(item.Template); v != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func profileAssetBusinessName(asset *service.ConfigProfileAsset) string {
|
||
if asset == nil {
|
||
return ""
|
||
}
|
||
if v := strings.TrimSpace(asset.BusinessName); v != "" {
|
||
return v
|
||
}
|
||
return strings.TrimSpace(asset.Name)
|
||
}
|
||
|
||
func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action string) string {
|
||
if len(selectedIDs) == 0 {
|
||
return ""
|
||
}
|
||
rowByID := make(map[string]DeviceOverviewRow, len(rows))
|
||
for _, row := range rows {
|
||
if row.Device == nil {
|
||
continue
|
||
}
|
||
rowByID[strings.TrimSpace(row.Device.DeviceID)] = row
|
||
}
|
||
lines := make([]string, 0, len(selectedIDs))
|
||
for _, id := range selectedIDs {
|
||
row, ok := rowByID[strings.TrimSpace(id)]
|
||
if !ok || row.Device == nil {
|
||
continue
|
||
}
|
||
label := row.Device.DisplayName()
|
||
switch action {
|
||
case "reload":
|
||
summary := "未取到当前业务配置"
|
||
if row.ConfigStatus != nil {
|
||
meta := row.ConfigStatus.Metadata
|
||
if name := strings.TrimSpace(meta.BusinessName); name != "" {
|
||
summary = name
|
||
if profile := strings.TrimSpace(meta.Profile); profile != "" {
|
||
summary += " (" + profile + ")"
|
||
}
|
||
} else if profile := strings.TrimSpace(meta.Profile); profile != "" {
|
||
summary = profile
|
||
} else if configID := strings.TrimSpace(meta.ConfigID); configID != "" {
|
||
summary = configID
|
||
}
|
||
}
|
||
lines = append(lines, label+" -> "+summary)
|
||
case "rollback":
|
||
summary := "未取到可回滚业务配置"
|
||
if row.ConfigStatus != nil && row.ConfigStatus.PreviousConfig != nil {
|
||
meta := row.ConfigStatus.PreviousConfig.Metadata
|
||
if name := strings.TrimSpace(meta.BusinessName); name != "" {
|
||
summary = name
|
||
if profile := strings.TrimSpace(meta.Profile); profile != "" {
|
||
summary += " (" + profile + ")"
|
||
}
|
||
} else if profile := strings.TrimSpace(meta.Profile); profile != "" {
|
||
summary = profile
|
||
} else if configID := strings.TrimSpace(meta.ConfigID); configID != "" {
|
||
summary = configID
|
||
}
|
||
}
|
||
lines = append(lines, label+" -> "+summary)
|
||
}
|
||
}
|
||
return strings.Join(lines, ";")
|
||
}
|
||
|
||
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)
|
||
}
|