3588AdminBackend/internal/web/ui.go

3846 lines
128 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
resourcesRepo *storage.ResourcesRepo
tpl *template.Template
}
const (
deviceAssignmentPreviewDevicePrefix = "demo-edge-"
deviceAssignmentPreviewDeviceCount = 8
)
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
SelectedRecognitionUnit string
SelectedAssignmentDevice string
SelectedOverlays []string
SelectedConfigID string
SelectedVersion string
Tasks []models.Task
Task *models.Task
TaskDeviceRows []TaskDeviceRow
StandardModels []storage.StandardModelRecord
ModelStatusBoard *service.ModelStatusBoard
StandardResources []storage.StandardResourceRecord
ResourceStatusBoard *service.ResourceStatusBoard
Templates []service.Template
Template *service.Template
AssetTab string
AssetTemplates []service.ConfigTemplateAsset
AssetTemplate *service.ConfigTemplateAsset
AssetTemplateEditing bool
AssetProfiles []service.ConfigProfileAsset
AssetProfile *service.ConfigProfileAsset
AssetProfileEditor *service.ConfigProfileEditor
AssetProfileEditing bool
AssetProfileFormAction string
ActiveInstanceIndex int
RecognitionUnits []service.RecognitionUnitAsset
RecognitionUnit *service.RecognitionUnitAsset
RecognitionUnitEditing bool
DeviceAssignments []service.DeviceAssignmentAsset
DeviceAssignment *service.DeviceAssignmentAsset
DeviceAssignmentEditing bool
DeviceAssignmentBoard *service.DeviceAssignmentBoard
DeviceAssignmentBoardJSON template.JS
MaxUnitsPerDevice int
AssetTemplateMap map[string]service.ConfigTemplateAsset
AssetVideoSources []service.ConfigVideoSourceAsset
AssetVideoSource *service.ConfigVideoSourceAsset
AssetVideoSourceEditing bool
AssetIntegrations []service.ConfigIntegrationServiceAsset
AssetIntegration *service.ConfigIntegrationServiceAsset
AssetIntegrationEditing bool
AssetOverlays []service.ConfigOverlayAsset
AssetOverlay *service.ConfigOverlayAsset
AssetOverlayEditing bool
AssetInstanceCount int
SelectedDeviceIDs []string
SelectedDevices []*models.Device
SelectedQuery string
SelectedDevicesURL string
BatchConfigURL string
ReloadSummary string
RollbackSummary string
TemplateDraftName string
TemplateDraftDescription string
TemplateCloneSource string
TemplateCreateMode string
OverlayDraftJSON 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)
},
"rawHTML": func(v string) template.HTML {
return template.HTML(v)
},
"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
},
"modelTypeLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "face_detection":
return "人脸检测"
case "face_recognition":
return "人脸识别"
case "object_detection":
return "通用检测"
case "ppe_detection":
return "PPE检测"
case "shoe_detection":
return "工鞋检测"
case "other":
return "其他"
default:
return "-"
}
},
"resourceTypeLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "face_gallery":
return "人脸库"
case "dataset":
return "数据集"
case "calibration":
return "标定文件"
default:
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 "设备操作"
case "resource_sync_one", "resource_sync_all":
return "资源同步"
default:
return "其他任务"
}
},
"taskActionLabel": func(v any) string {
switch fmt.Sprint(v) {
case "config_apply":
return "下发设备分配"
case "model_sync_one":
return "更新单个模型"
case "model_sync_all":
return "更新全部模型"
case "resource_sync_one":
return "同步单个资源"
case "resource_sync_all":
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 "model_sync_one", "model_sync_all":
return "pill warn"
case "resource_sync_one", "resource_sync_all":
return "pill warn"
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))
},
"slotTypeLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "video_source":
return "视频源"
case "object_storage":
return "对象存储"
case "token_service":
return "认证服务"
case "alarm_service":
return "告警服务"
case "stream_publish":
return "视频输出"
default:
return strings.TrimSpace(v)
}
},
"inputBindingRef": func(bindings map[string]service.InputBindingEditor, slot string) string {
if len(bindings) == 0 {
return ""
}
return strings.TrimSpace(bindings[slot].VideoSourceRef)
},
"serviceBindingRef": func(bindings map[string]service.ServiceBindingEditor, slot string) string {
if len(bindings) == 0 {
return ""
}
return strings.TrimSpace(bindings[slot].ServiceRef)
},
"outputBindingValue": func(bindings map[string]service.OutputBindingEditor, slot string, field string) string {
if len(bindings) == 0 {
return ""
}
item, ok := bindings[slot]
if !ok {
return ""
}
switch strings.TrimSpace(field) {
case "publish_hls_path":
return strings.TrimSpace(item.PublishHLSPath)
case "publish_rtsp_port":
return strings.TrimSpace(item.PublishRTSPPort)
case "publish_rtsp_path":
return strings.TrimSpace(item.PublishRTSPPath)
case "channel_no":
return strings.TrimSpace(item.ChannelNo)
default:
return ""
}
},
}).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 (u *UI) SetResourcesRepo(repo *storage.ResourcesRepo) {
if u == nil {
return
}
u.resourcesRepo = repo
}
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>`,
"theme": `<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 3a9 9 0 1 0 9 9a4.5 4.5 0 0 1 -9 -9"/></svg>`,
"bell": `<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 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3l2 3h-16l2 -3v-3a7 7 0 0 1 4 -6"/><path d="M9 17v1a3 3 0 0 0 6 0v-1"/></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>`,
"edit": `<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 6l3 3"/><path d="M7 18l3.5 -.5l8 -8a2.121 2.121 0 0 0 -3 -3l-8 8l-.5 3.5"/></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
w.Header().Set("Cache-Control", "no-store, max-age=0")
w.Header().Set("Pragma", "no-cache")
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("/plans", u.redirectPlansToSceneTemplates)
r.Get("/plans/{name}", u.redirectPlanToSceneTemplate)
r.Get("/scene-templates", u.pagePlans)
r.Get("/scene-templates/{name}", u.pagePlan)
r.Post("/scene-templates/create", u.actionPlanCreate)
r.Post("/scene-templates/{name}", u.actionPlanSave)
r.Post("/scene-templates/{name}/delete", u.actionPlanDelete)
r.Get("/scene-templates/{name}/export", u.pagePlanExport)
r.Get("/recognition-units", u.pageRecognitionUnits)
r.Post("/recognition-units", u.actionRecognitionUnitSave)
r.Post("/recognition-units/delete", u.actionRecognitionUnitDelete)
r.Get("/device-assignments", u.pageDeviceAssignments)
r.Post("/device-assignments", u.actionDeviceAssignmentSave)
r.Post("/device-assignments/{id}/delete", u.actionDeviceAssignmentDelete)
r.Get("/assets", u.pageAssets)
r.Get("/assets/video-sources", u.pageAssetVideoSources)
r.Post("/assets/video-sources", u.actionAssetVideoSourceSave)
r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete)
r.Get("/assets/templates", u.pageAssetTemplates)
r.Post("/assets/templates/create", u.actionAssetTemplateCreate)
r.Post("/assets/templates/{name}/clone", u.actionAssetTemplateClone)
r.Get("/assets/templates/{name}", u.pageAssetTemplate)
r.Post("/assets/templates/{name}/rename", u.actionAssetTemplateRename)
r.Post("/assets/templates/{name}/delete", u.actionAssetTemplateDelete)
r.Get("/assets/templates/{name}/graph", u.pageAssetTemplateGraph)
r.Post("/assets/templates/{name}/graph", u.actionAssetTemplateGraphSave)
r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport)
r.Get("/assets/profiles", u.redirectAssetProfilesToPlans)
r.Get("/assets/profiles/{name}", u.redirectAssetProfileToPlan)
r.Post("/assets/profiles/{name}", u.actionPlanSave)
r.Get("/assets/profiles/{name}/export", u.pagePlanExport)
r.Get("/assets/integrations", u.pageAssetIntegrations)
r.Post("/assets/integrations", u.actionAssetIntegrationSave)
r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete)
r.Get("/assets/overlays", u.pageAssetOverlays)
r.Post("/assets/overlays", u.actionAssetOverlaySave)
r.Post("/assets/overlays/{name}/delete", u.actionAssetOverlayDelete)
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.Get("/resources", u.pageResources)
r.Post("/system/db-restore", u.actionSystemDBRestore)
r.Get("/api/graph-node-types", u.apiGraphNodeTypes)
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.Post("/devices/{id}/plan-apply", u.actionDevicePlanApply)
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.Post("/models/sync", u.actionModelSync)
r.Post("/resources/sync", u.actionResourceSync)
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")
w.Header().Set("Cache-Control", "no-store, max-age=0")
w.Header().Set("Pragma", "no-cache")
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
}
devices := u.registry.GetDevices()
if len(devices) > 0 {
// Check if any device is online; if not, try discovery to refresh
hasOnline := false
for _, d := range devices {
if d.Online {
hasOnline = true
break
}
}
if hasOnline {
return
}
}
_, _ = u.discovery.SearchDefault()
if len(u.registry.GetDevices()) == 0 {
_, _ = 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"])
data := u.deviceBatchConfigPageData(r, selectedIDs)
if len(selectedIDs) == 0 {
data.Error = "请先选择需要下发分配的设备"
u.render(w, r, "device_batch_config", data)
return
}
if u.tasks == nil {
data.Error = "task service not initialized"
u.render(w, r, "device_batch_config", data)
return
}
configs := make(map[string]any, len(selectedIDs))
for _, deviceID := range selectedIDs {
preview, err := u.preview.RenderDeviceAssignment(deviceID)
if err != nil {
data.Error = err.Error()
u.render(w, r, "device_batch_config", data)
return
}
if data.ConfigPreview == nil {
data.ConfigPreview = preview
}
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
}
configs[deviceID] = configDoc
}
task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"configs": configs})
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
}
}
if u.preview != nil {
if profiles, err := u.preview.ListProfileAssets(); err == nil {
data.AssetProfiles = profiles
selectedProfile := ""
if data.ConfigStatus != nil && strings.TrimSpace(data.ConfigStatus.Metadata.Profile) != "" {
selectedProfile = strings.TrimSpace(data.ConfigStatus.Metadata.Profile)
} else if data.PersistedConfig != nil && strings.TrimSpace(data.PersistedConfig.ProfileName) != "" {
selectedProfile = strings.TrimSpace(data.PersistedConfig.ProfileName)
}
if selectedProfile == "" && len(profiles) > 0 {
selectedProfile = profiles[0].Name
}
data.SelectedProfile = selectedProfile
for i := range profiles {
if strings.TrimSpace(profiles[i].Name) == selectedProfile {
data.AssetProfile = &profiles[i]
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])
}
} else if data.Error == "" {
data.Error = err.Error()
}
if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil {
data.DeviceAssignment = assignment
data.SelectedAssignmentDevice = assignment.DeviceID
data.SelectedProfile = assignment.ProfileName
}
}
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.ensureDevicesLoaded()
data := PageData{Title: "模型管理", Devices: u.registry.GetDevices()}
for _, dev := range data.Devices {
if dev == nil {
continue
}
if dev.Online {
data.OnlineCount++
}
}
board := service.ModelStatusBoard{}
if strings.TrimSpace(u.dbPath) != "" {
if store, err := storage.OpenSQLite(u.dbPath); err == nil {
modelsRepo := storage.NewModelsRepo(store.DB())
if items, err := modelsRepo.List(); err == nil {
data.StandardModels = items
installed := map[string][]service.InstalledModelStatus{}
for _, device := range data.Devices {
if device == nil || !device.Online {
continue
}
items, err := service.FetchInstalledModelStatuses(u.agent, device)
if err == nil {
installed[device.DeviceID] = items
}
}
board = service.BuildModelStatusBoard(items, data.Devices, installed)
}
_ = store.Close()
}
}
data.ModelStatusBoard = &board
u.render(w, r, "models", data)
}
func (u *UI) actionModelSync(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
action := strings.TrimSpace(r.FormValue("action"))
if action == "" {
action = "model_sync_all"
}
if action != "model_sync_one" && action != "model_sync_all" {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
deviceIDs := make([]string, 0)
for _, id := range r.Form["device_id"] {
id = strings.TrimSpace(id)
if id != "" {
deviceIDs = append(deviceIDs, id)
}
}
if len(deviceIDs) == 0 {
http.Error(w, "missing device_id", http.StatusBadRequest)
return
}
payload := map[string]any{}
if modelName := strings.TrimSpace(r.FormValue("model_name")); modelName != "" {
payload["model_name"] = modelName
}
task, err := u.tasks.CreateTask(action, deviceIDs, payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
data := PageData{Title: "日志审计", Devices: u.registry.GetDevices()}
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, "diagnostics", data)
}
func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
data := PageData{Title: "资源管理", Devices: u.registry.GetDevices()}
for _, dev := range data.Devices {
if dev == nil {
continue
}
if dev.Online {
data.OnlineCount++
}
}
board := service.ResourceStatusBoard{}
if strings.TrimSpace(u.dbPath) != "" {
if store, err := storage.OpenSQLite(u.dbPath); err == nil {
resourcesRepo := storage.NewResourcesRepo(store.DB())
if items, err := resourcesRepo.List(); err == nil {
data.StandardResources = items
board = service.FetchAndBuildResourceBoard(u.agent, data.Devices, items)
}
_ = store.Close()
}
}
data.ResourceStatusBoard = &board
u.render(w, r, "resources", data)
}
func (u *UI) actionResourceSync(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
action := strings.TrimSpace(r.FormValue("action"))
if action == "" {
action = "resource_sync_all"
}
if action != "resource_sync_one" && action != "resource_sync_all" {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
deviceIDs := make([]string, 0)
for _, id := range r.Form["device_id"] {
id = strings.TrimSpace(id)
if id != "" {
deviceIDs = append(deviceIDs, id)
}
}
if len(deviceIDs) == 0 {
http.Error(w, "missing device_id", http.StatusBadRequest)
return
}
payload := map[string]any{}
if resourceName := strings.TrimSpace(r.FormValue("resource_name")); resourceName != "" {
payload["resource_name"] = resourceName
}
task, err := u.tasks.CreateTask(action, deviceIDs, payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
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) pageAssetTemplates(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("templates")
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
}
editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
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()
}
}
data.AssetTemplateEditing = editMode && data.AssetTemplate != nil && !data.AssetTemplate.ReadOnly
u.render(w, r, "asset_templates", data)
}
func (u *UI) actionAssetTemplateCreate(w http.ResponseWriter, r *http.Request) {
if u.preview == nil {
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
return
}
name, err := normalizeConfigName(r.FormValue("name"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name = strings.TrimSuffix(name, ".json")
description := strings.TrimSpace(r.FormValue("description"))
cloneSource := strings.TrimSpace(r.FormValue("clone_source"))
var doc map[string]any
if cloneSource != "" {
item, err := u.preview.GetTemplateAsset(cloneSource)
if err != nil || item == nil {
http.Error(w, "clone source template not found", http.StatusNotFound)
return
}
body, err := json.Marshal(item.Raw)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.Unmarshal(body, &doc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
doc = map[string]any{
"name": name,
"description": description,
"params": map[string]any{},
"template": map[string]any{
"nodes": []any{},
"edges": []any{},
},
}
}
doc["name"] = name
doc["description"] = description
body, err := json.MarshalIndent(doc, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := u.preview.SaveTemplateAsset(name, description, string(body)+"\n"); err != nil {
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") {
status = http.StatusBadRequest
}
http.Error(w, err.Error(), status)
return
}
http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(name)+"/graph?msg="+urlQueryEscape("模板已创建"), http.StatusFound)
}
func (u *UI) actionAssetTemplateClone(w http.ResponseWriter, r *http.Request) {
if u.preview == nil {
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
return
}
sourceName := chi.URLParam(r, "name")
item, err := u.preview.GetTemplateAsset(sourceName)
if err != nil || item == nil {
http.NotFound(w, r)
return
}
if !item.ReadOnly {
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准模板复制创建"), http.StatusFound)
return
}
targetName, err := u.nextTemplateCloneName(item.Name)
if err != nil {
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
body, err := json.Marshal(item.Raw)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
doc := map[string]any{}
if err := json.Unmarshal(body, &doc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
doc["name"] = targetName
doc["description"] = item.Description
pretty, err := json.MarshalIndent(doc, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := u.preview.SaveTemplateAsset(targetName, item.Description, string(pretty)+"\n"); err != nil {
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&edit=1&msg="+urlQueryEscape("标准模板已复制,请继续编辑"), http.StatusFound)
}
func (u *UI) nextTemplateCloneName(sourceName string) (string, error) {
base := strings.TrimSpace(sourceName)
if base == "" {
return "", fmt.Errorf("template name is required")
}
if strings.HasPrefix(base, "std_") {
base = strings.TrimPrefix(base, "std_")
}
candidate := base + "_copy"
if item, err := u.preview.GetTemplateAsset(candidate); err == nil && item != nil {
for i := 2; i < 1000; i++ {
name := fmt.Sprintf("%s_copy_%d", base, i)
item, err := u.preview.GetTemplateAsset(name)
if err != nil || item == nil {
return name, nil
}
}
return "", fmt.Errorf("无法生成可用的模板副本名称")
}
return candidate, nil
}
func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
data := u.assetPageData("templates")
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
}
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) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
data := u.assetPageData("templates")
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
}
if u.preview == nil {
data.Error = "配置中心服务未初始化"
u.render(w, r, "asset_templates", data)
return
}
item, err := u.preview.GetTemplateAsset(name)
if err != nil {
http.NotFound(w, r)
return
}
data.Title = "模板可视化编辑"
if item.ReadOnly {
data.Title = "标准模板可视化预览"
}
data.AssetTemplate = item
raw, err := compactJSON(item.Raw)
if err != nil {
data.Error = err.Error()
u.render(w, r, "asset_templates", data)
return
}
data.RawJSON = raw
u.render(w, r, "asset_template_graph", data)
}
func (u *UI) actionAssetTemplateGraphSave(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if u.preview == nil {
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
return
}
raw := strings.TrimSpace(r.FormValue("json"))
if raw == "" {
http.Error(w, "template json is required", http.StatusBadRequest)
return
}
var doc map[string]any
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
http.Error(w, "invalid template json: "+err.Error(), http.StatusBadRequest)
return
}
if err := validateTemplateGraphDocument(doc); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
targetName := name
if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" {
normalized, err := normalizeConfigName(rawName)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
targetName = strings.TrimSuffix(normalized, ".json")
}
description := strings.TrimSpace(r.FormValue("description"))
if description == "" {
description, _ = doc["description"].(string)
}
doc["name"] = targetName
doc["description"] = description
body, err := json.MarshalIndent(doc, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if targetName != name {
err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n")
} else {
err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n")
}
if err != nil {
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "read-only") || strings.Contains(err.Error(), "copy it before editing") {
status = http.StatusBadRequest
}
http.Error(w, err.Error(), status)
return
}
message := "模板已保存"
if targetName != name {
message = "模板已保存,名称已更新"
}
http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(targetName)+"/graph?msg="+urlQueryEscape(message), http.StatusFound)
}
func (u *UI) actionAssetTemplateRename(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if u.preview == nil {
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
return
}
item, err := u.preview.GetTemplateAsset(name)
if err != nil || item == nil {
http.NotFound(w, r)
return
}
targetName := name
if rawName := strings.TrimSpace(r.FormValue("name")); rawName != "" {
normalized, err := normalizeConfigName(rawName)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
targetName = strings.TrimSuffix(normalized, ".json")
}
description := strings.TrimSpace(r.FormValue("description"))
if description == "" {
description = item.Description
}
body, err := json.Marshal(item.Raw)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
doc := map[string]any{}
if err := json.Unmarshal(body, &doc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
doc["name"] = targetName
doc["description"] = description
body, err = json.MarshalIndent(doc, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if targetName != name {
err = u.preview.RenameTemplateAsset(name, targetName, description, string(body)+"\n")
} else {
err = u.preview.SaveTemplateAsset(name, description, string(body)+"\n")
}
if err != nil {
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&msg="+urlQueryEscape("模板已保存,名称已更新"), http.StatusFound)
}
func (u *UI) actionAssetTemplateDelete(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if u.preview == nil {
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
return
}
if err := u.preview.DeleteTemplateAsset(name); err != nil {
http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/templates?msg="+urlQueryEscape("用户模板已删除"), http.StatusFound)
}
func (u *UI) apiGraphNodeTypes(w http.ResponseWriter, r *http.Request) {
if body, ok := u.loadAgentGraphNodeTypes(); ok {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(body)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"items": graphNodeTypesCatalog()})
}
func (u *UI) loadAgentGraphNodeTypes() ([]byte, bool) {
if u.agent == nil || u.registry == nil {
return nil, false
}
u.ensureDevicesLoaded()
for _, dev := range u.registry.GetDevices() {
if dev == nil || strings.TrimSpace(dev.IP) == "" || dev.AgentPort <= 0 {
continue
}
body, code, err := u.agent.Do(http.MethodGet, dev.IP, dev.AgentPort, "/v1/graph-node-types", nil)
if err != nil || code < 200 || code >= 300 {
continue
}
var payload struct {
Items []graphNodeTypeInfo `json:"items"`
}
if err := json.Unmarshal(body, &payload); err != nil || len(payload.Items) == 0 {
continue
}
return body, true
}
return nil, false
}
func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) {
u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name"))
}
func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("")
data.Title = "场景"
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
}
newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
selected := strings.TrimSpace(r.URL.Query().Get("name"))
if selected == "" && !newMode && len(data.AssetProfiles) > 0 {
selected = data.AssetProfiles[0].Name
}
if newMode {
editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()}
if len(data.AssetTemplates) > 0 {
editor.PrimaryTemplateName = data.AssetTemplates[0].Name
}
data.AssetProfileEditor = editor
data.AssetProfileEditing = true
data.AssetProfileFormAction = "/ui/scene-templates/create"
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0)
u.render(w, r, "scene_templates", data)
return
}
if selected != "" {
editor, err := u.preview.GetProfileEditor(selected)
if err == nil {
data.AssetProfileEditor = editor
data.SelectedProfile = editor.Name
data.AssetProfileEditing = editMode
data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(editor.Name)
if len(editor.Instances) > 0 && editor.Instances[0].Template != "" {
data.SelectedTemplate = editor.Instances[0].Template
}
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.URL.Query()))
} else if data.Error == "" {
data.Error = err.Error()
}
} else if len(data.AssetProfiles) == 0 {
editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()}
if len(data.AssetTemplates) > 0 {
editor.PrimaryTemplateName = data.AssetTemplates[0].Name
}
data.AssetProfileEditor = editor
data.AssetProfileEditing = true
data.AssetProfileFormAction = "/ui/scene-templates/create"
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0)
}
u.render(w, r, "scene_templates", data)
}
func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) {
u.pagePlans(w, r)
}
func (u *UI) pagePlan(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 = "场景"
data.AssetProfileEditing = strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name)
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query()))
u.render(w, r, "scene_templates", data)
}
func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) {
u.pagePlan(w, r)
}
func (u *UI) pagePlanExport(w http.ResponseWriter, r *http.Request) {
u.exportAssetJSON(w, r, "profiles", chi.URLParam(r, "name"))
}
func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) {
u.pagePlanExport(w, r)
}
func (u *UI) actionPlanSave(w http.ResponseWriter, r *http.Request) {
u.actionPlanSaveWithName(w, r, chi.URLParam(r, "name"))
}
func (u *UI) actionPlanCreate(w http.ResponseWriter, r *http.Request) {
u.actionPlanSaveWithName(w, r, "")
}
func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name string) {
var (
editor service.ConfigProfileEditor
data PageData
err error
)
if strings.TrimSpace(name) == "" {
data = u.assetPageData("")
data.Title = "场景"
_ = r.ParseForm()
editor = service.ConfigProfileEditor{
Name: strings.TrimSpace(r.FormValue("profile_name")),
PrimaryTemplateName: strings.TrimSpace(r.FormValue("primary_template_name")),
BusinessName: strings.TrimSpace(r.FormValue("business_name")),
Description: strings.TrimSpace(r.FormValue("description")),
OverlayName: strings.TrimSpace(r.FormValue("overlay_name")),
SiteName: strings.TrimSpace(r.FormValue("site_name")),
Queue: service.DefaultConfigProfileQueue(),
Instances: parseProfileInstanceForm(r.Form),
}
data.AssetProfileEditor = &editor
data.AssetProfileEditing = true
data.AssetProfileFormAction = "/ui/scene-templates/create"
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form))
} else {
editor, data, err = u.profileEditorActionData(r, name)
if err != nil {
http.NotFound(w, r)
return
}
data.AssetProfileEditing = true
data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name)
}
if err := u.preview.SaveProfileEditor(editor); err != nil {
data.Error = err.Error()
data.Title = "场景"
data.AssetProfileEditing = true
u.render(w, r, "scene_templates", data)
return
}
msg := "场景已保存"
if strings.TrimSpace(name) != "" && editor.Name != name {
msg = "场景已保存,名称已更新"
}
http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape(msg)+"&name="+url.PathEscape(editor.Name), http.StatusFound)
}
func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(chi.URLParam(r, "name"))
if name == "" {
http.NotFound(w, r)
return
}
if u.stateRepo != nil {
items, err := u.stateRepo.ListByProfileName(name)
if err != nil {
http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
if len(items) > 0 {
deviceIDs := make([]string, 0, len(items))
for _, item := range items {
deviceIDs = append(deviceIDs, item.DeviceID)
}
msg := fmt.Sprintf("场景 %q 正被以下设备使用,无法删除:%s", name, strings.Join(deviceIDs, "、"))
http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(msg), http.StatusFound)
return
}
}
if err := u.preview.DeleteProfileAsset(name); err != nil {
http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape("场景已删除"), http.StatusFound)
}
func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) {
u.actionPlanSave(w, r)
}
func (u *UI) redirectAssetProfilesToPlans(w http.ResponseWriter, r *http.Request) {
target := "/ui/scene-templates"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusFound)
}
func (u *UI) redirectAssetProfileToPlan(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/scene-templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound)
}
func (u *UI) redirectPlansToSceneTemplates(w http.ResponseWriter, r *http.Request) {
target := "/ui/scene-templates"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusFound)
}
func (u *UI) redirectPlanToSceneTemplate(w http.ResponseWriter, r *http.Request) {
target := "/ui/scene-templates/" + url.PathEscape(chi.URLParam(r, "name"))
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusFound)
}
func (u *UI) pageRecognitionUnits(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("")
data.Title = "视频通道"
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
}
newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
selected := strings.TrimSpace(r.URL.Query().Get("ref"))
if selected == "" && !newMode && len(data.RecognitionUnits) > 0 {
selected = data.RecognitionUnits[0].Ref
}
data.SelectedRecognitionUnit = selected
if newMode {
defaultTemplate := ""
if len(data.AssetProfiles) > 0 {
defaultTemplate = data.AssetProfiles[0].Name
}
data.RecognitionUnit = &service.RecognitionUnitAsset{
SceneTemplateName: defaultTemplate,
OutputChannel: "cam1",
RTSPPort: "8555",
}
data.RecognitionUnitEditing = true
} else if selected != "" {
if item, err := u.preview.GetRecognitionUnit(selected); err == nil {
data.RecognitionUnit = item
data.RecognitionUnitEditing = editMode
} else if data.Error == "" {
data.Error = err.Error()
}
}
u.render(w, r, "recognition_units", data)
}
func (u *UI) actionRecognitionUnitSave(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
asset := service.RecognitionUnitAsset{
SceneTemplateName: strings.TrimSpace(r.FormValue("scene_template_name")),
Name: strings.TrimSpace(r.FormValue("name")),
DisplayName: strings.TrimSpace(r.FormValue("display_name")),
SiteName: strings.TrimSpace(r.FormValue("site_name")),
VideoSourceRef: strings.TrimSpace(r.FormValue("video_source_ref")),
OutputChannel: strings.TrimSpace(r.FormValue("output_channel")),
RTSPPort: strings.TrimSpace(r.FormValue("rtsp_port")),
}
originalRef := strings.TrimSpace(r.FormValue("original_ref"))
if err := u.preview.SaveRecognitionUnit(asset, originalRef); err != nil {
data := u.assetPageData("")
data.Title = "视频通道"
data.Error = err.Error()
data.RecognitionUnit = &asset
data.RecognitionUnitEditing = true
data.SelectedRecognitionUnit = originalRef
u.render(w, r, "recognition_units", data)
return
}
ref := serviceRecognitionUnitRef(asset.SceneTemplateName, asset.Name)
http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound)
}
func serviceRecognitionUnitRef(profileName string, unitName string) string {
return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName)
}
func (u *UI) actionRecognitionUnitDelete(w http.ResponseWriter, r *http.Request) {
ref := strings.TrimSpace(r.FormValue("ref"))
if err := u.preview.DeleteRecognitionUnit(ref); err != nil {
http.Redirect(w, r, "/ui/recognition-units?error="+urlQueryEscape(err.Error())+"&ref="+url.QueryEscape(ref), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("视频通道已删除"), http.StatusFound)
}
func (u *UI) pageDeviceAssignments(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("")
data.Title = "通道部署"
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
}
u.ensureDevicesLoaded()
data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices())
maxUnits := service.DefaultMaxUnitsPerDevice()
if value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("max_units_per_device"))); err == nil && value > 0 {
maxUnits = value
}
data.MaxUnitsPerDevice = maxUnits
if u.preview != nil {
if board, err := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); err == nil {
data.DeviceAssignmentBoard = board
if payload, marshalErr := json.Marshal(board); marshalErr == nil {
data.DeviceAssignmentBoardJSON = template.JS(payload)
}
} else if data.Error == "" {
data.Error = err.Error()
}
}
u.render(w, r, "device_assignments", data)
}
func (u *UI) actionDeviceAssignmentSave(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
assignments, err := parseDeviceAssignmentBoardState(strings.TrimSpace(r.FormValue("board_state_json")))
if err != nil {
data := u.assetPageData("")
data.Title = "通道部署"
u.ensureDevicesLoaded()
data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices())
data.Error = err.Error()
maxUnits := service.DefaultMaxUnitsPerDevice()
if value, convErr := strconv.Atoi(strings.TrimSpace(r.FormValue("max_units_per_device"))); convErr == nil && value > 0 {
maxUnits = value
}
data.MaxUnitsPerDevice = maxUnits
if u.preview != nil {
if board, buildErr := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); buildErr == nil {
data.DeviceAssignmentBoard = board
if payload, marshalErr := json.Marshal(board); marshalErr == nil {
data.DeviceAssignmentBoardJSON = template.JS(payload)
}
}
}
u.render(w, r, "device_assignments", data)
return
}
if err := u.preview.SaveDeviceAssignmentBoard(filterPreviewDeviceAssignments(assignments)); err != nil {
http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已保存"), http.StatusFound)
}
func deviceAssignmentBoardDevices(devices []*models.Device) []*models.Device {
out := make([]*models.Device, 0, len(devices))
seen := make(map[string]struct{}, len(devices))
for _, dev := range devices {
if dev == nil || strings.TrimSpace(dev.DeviceID) == "" {
continue
}
deviceID := strings.TrimSpace(dev.DeviceID)
if _, ok := seen[deviceID]; ok {
continue
}
seen[deviceID] = struct{}{}
out = append(out, dev)
}
for len(out) < deviceAssignmentPreviewDeviceCount {
index := len(out) + 1
deviceID := fmt.Sprintf("%s%02d", deviceAssignmentPreviewDevicePrefix, index)
out = append(out, &models.Device{
DeviceID: deviceID,
DeviceName: fmt.Sprintf("测试设备 %02d", index),
Online: true,
})
}
return out
}
func filterPreviewDeviceAssignments(assignments map[string][]string) map[string][]string {
if len(assignments) == 0 {
return assignments
}
filtered := make(map[string][]string, len(assignments))
for deviceID, refs := range assignments {
if strings.HasPrefix(strings.TrimSpace(deviceID), deviceAssignmentPreviewDevicePrefix) {
continue
}
filtered[deviceID] = refs
}
return filtered
}
func (u *UI) actionDeviceAssignmentDelete(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(chi.URLParam(r, "id"))
if err := u.preview.DeleteDeviceAssignment(deviceID); err != nil {
http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error())+"&device_id="+url.PathEscape(deviceID), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("通道部署已删除"), http.StatusFound)
}
func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("video-sources")
data.Title = "配置中心"
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
}
newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
selected := strings.TrimSpace(r.URL.Query().Get("name"))
if selected != "" {
if item, err := u.preview.GetVideoSource(selected); err == nil {
data.AssetVideoSource = item
} else if data.Error == "" {
data.Error = err.Error()
}
} else if !newMode && len(data.AssetVideoSources) > 0 {
if item, err := u.preview.GetVideoSource(data.AssetVideoSources[0].Name); err == nil {
data.AssetVideoSource = item
}
}
if data.AssetVideoSource == nil {
data.AssetVideoSource = &service.ConfigVideoSourceAsset{
SourceType: "rtsp",
SourceTypeLabel: "RTSP",
}
data.AssetVideoSourceEditing = true
} else {
data.AssetVideoSourceEditing = newMode || editMode
}
u.render(w, r, "assets", data)
}
func (u *UI) actionAssetVideoSourceSave(w http.ResponseWriter, r *http.Request) {
if u.preview == nil {
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
return
}
_ = r.ParseForm()
asset := service.ConfigVideoSourceAsset{
Name: strings.TrimSpace(r.FormValue("name")),
SourceType: strings.TrimSpace(r.FormValue("source_type")),
Area: strings.TrimSpace(r.FormValue("area")),
Description: strings.TrimSpace(r.FormValue("description")),
Config: service.VideoSourceConfig{
URL: strings.TrimSpace(r.FormValue("url")),
Resolution: strings.TrimSpace(r.FormValue("resolution")),
FrameSize: strings.TrimSpace(r.FormValue("frame_size")),
FPS: strings.TrimSpace(r.FormValue("fps")),
VideoFormat: strings.TrimSpace(r.FormValue("video_format")),
FocalLength: strings.TrimSpace(r.FormValue("focal_length")),
MountHeight: strings.TrimSpace(r.FormValue("mount_height")),
MountAngle: strings.TrimSpace(r.FormValue("mount_angle")),
},
}
if err := u.preview.SaveVideoSourceAsset(asset); err != nil {
data := u.assetPageData("video-sources")
data.Title = "配置中心"
data.Error = err.Error()
data.AssetVideoSource = &asset
data.AssetVideoSource.SourceTypeLabel = serviceVideoSourceTypeLabel(asset.SourceType)
data.AssetVideoSourceEditing = true
u.render(w, r, "assets", data)
return
}
http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound)
}
func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request) {
if u.preview == nil {
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
return
}
name := chi.URLParam(r, "name")
if err := u.preview.DeleteVideoSource(name); err != nil {
http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound)
}
func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("integrations")
data.Title = "配置中心"
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
}
newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
selected := strings.TrimSpace(r.URL.Query().Get("name"))
if selected != "" {
if item, err := u.preview.GetIntegrationService(selected); err == nil {
data.AssetIntegration = item
} else if data.Error == "" {
data.Error = err.Error()
}
} else if !newMode && len(data.AssetIntegrations) > 0 {
if item, err := u.preview.GetIntegrationService(data.AssetIntegrations[0].Name); err == nil {
data.AssetIntegration = item
}
}
if data.AssetIntegration == nil {
data.AssetIntegration = &service.ConfigIntegrationServiceAsset{
Type: "object_storage",
TypeLabel: "对象存储",
Enabled: true,
ObjectStorage: &service.ObjectStorageConfig{},
}
data.AssetIntegrationEditing = true
} else {
data.AssetIntegrationEditing = newMode || editMode
}
u.render(w, r, "assets", data)
}
func (u *UI) actionAssetIntegrationSave(w http.ResponseWriter, r *http.Request) {
if u.preview == nil {
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
return
}
_ = r.ParseForm()
enabled := strings.TrimSpace(r.FormValue("enabled")) == "1" || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "true") || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "on")
asset := service.ConfigIntegrationServiceAsset{
Name: strings.TrimSpace(r.FormValue("name")),
Type: strings.TrimSpace(r.FormValue("type")),
Description: strings.TrimSpace(r.FormValue("description")),
Enabled: enabled,
ObjectStorage: &service.ObjectStorageConfig{
Endpoint: strings.TrimSpace(r.FormValue("endpoint")),
Bucket: strings.TrimSpace(r.FormValue("bucket")),
AccessKey: strings.TrimSpace(r.FormValue("access_key")),
SecretKey: strings.TrimSpace(r.FormValue("secret_key")),
},
TokenService: &service.TokenServiceConfig{
GetTokenURL: strings.TrimSpace(r.FormValue("get_token_url")),
Username: strings.TrimSpace(r.FormValue("username")),
Password: strings.TrimSpace(r.FormValue("password")),
TenantCode: strings.TrimSpace(r.FormValue("tenant_code")),
},
AlarmService: &service.AlarmServiceConfig{
PutMessageURL: strings.TrimSpace(r.FormValue("put_message_url")),
Username: strings.TrimSpace(r.FormValue("alarm_username")),
Password: strings.TrimSpace(r.FormValue("alarm_password")),
TenantCode: strings.TrimSpace(r.FormValue("alarm_tenant_code")),
},
}
if err := u.preview.SaveIntegrationServiceAsset(asset); err != nil {
http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound)
}
func (u *UI) actionAssetIntegrationDelete(w http.ResponseWriter, r *http.Request) {
if u.preview == nil {
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
return
}
name := chi.URLParam(r, "name")
if err := u.preview.DeleteIntegrationService(name); err != nil {
http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已删除"), http.StatusFound)
}
func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) {
data := u.assetPageData("overlays")
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
if data.Error == "" {
data.Error = strings.TrimSpace(r.URL.Query().Get("error"))
}
newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1"
editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1"
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 !newMode && 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()
}
}
if newMode {
data.AssetOverlay = &service.ConfigOverlayAsset{
Name: "",
Description: "",
Raw: map[string]any{
"name": "",
"description": "",
"instance_overrides": map[string]any{"*": map[string]any{"override": map[string]any{}}},
},
}
data.AssetOverlayEditing = true
} else {
data.AssetOverlayEditing = editMode && data.AssetOverlay != nil
}
if data.AssetOverlay != nil {
rawJSON, err := compactJSON(data.AssetOverlay.Raw)
if err == nil {
data.OverlayDraftJSON = rawJSON
}
}
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) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) {
if u.preview == nil {
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
return
}
_ = r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
description := strings.TrimSpace(r.FormValue("description"))
rawText := strings.TrimSpace(r.FormValue("json"))
if rawText == "" {
rawText = "{}"
}
raw := map[string]any{}
if err := json.Unmarshal([]byte(rawText), &raw); err != nil {
data := u.assetPageData("overlays")
data.Title = "配置中心"
data.Error = "调试参数 JSON 格式不正确:" + err.Error()
data.AssetOverlayEditing = true
data.AssetOverlay = &service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw}
data.OverlayDraftJSON = rawText
u.render(w, r, "asset_overlays", data)
return
}
asset := service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw}
if err := u.preview.SaveOverlayAsset(asset, raw); err != nil {
data := u.assetPageData("overlays")
data.Title = "配置中心"
data.Error = err.Error()
data.AssetOverlayEditing = true
data.AssetOverlay = &asset
data.OverlayDraftJSON = rawText
u.render(w, r, "asset_overlays", data)
return
}
http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已保存")+"&name="+url.PathEscape(name), http.StatusFound)
}
func (u *UI) actionAssetOverlayDelete(w http.ResponseWriter, r *http.Request) {
if u.preview == nil {
http.Error(w, "config preview service is not configured", http.StatusInternalServerError)
return
}
name := chi.URLParam(r, "name")
if err := u.preview.DeleteOverlayAsset(name); err != nil {
http.Redirect(w, r, "/ui/assets/overlays?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已删除"), http.StatusFound)
}
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
data.AssetTemplateMap = make(map[string]service.ConfigTemplateAsset, len(items))
for _, item := range items {
data.AssetTemplateMap[item.Name] = item
}
} 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()
}
if items, listErr := u.preview.ListVideoSources(); listErr == nil {
data.AssetVideoSources = items
} else if data.Error == "" {
data.Error = listErr.Error()
}
if items, listErr := u.preview.ListIntegrationServices(); listErr == nil {
data.AssetIntegrations = items
} else if data.Error == "" {
data.Error = listErr.Error()
}
if items, listErr := u.preview.ListRecognitionUnits(); listErr == nil {
data.RecognitionUnits = items
} else if data.Error == "" {
data.Error = listErr.Error()
}
if items, listErr := u.preview.ListDeviceAssignments(); listErr == nil {
data.DeviceAssignments = 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 = "std_workshop_face_recognition_shoe_alarm"
}
return data, nil
}
func clampActiveInstanceIndex(count int, preferred int) int {
if count <= 0 {
return 0
}
if preferred < 0 {
return 0
}
if preferred >= count {
return count - 1
}
return preferred
}
func activeInstanceIndexFromValues(values url.Values) int {
raw := strings.TrimSpace(values.Get("active_instance"))
if raw == "" {
return 0
}
idx, err := strconv.Atoi(raw)
if err != nil {
return 0
}
return idx
}
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")),
PrimaryTemplateName: strings.TrimSpace(r.FormValue("primary_template_name")),
BusinessName: strings.TrimSpace(r.FormValue("business_name")),
Description: strings.TrimSpace(r.FormValue("description")),
OverlayName: strings.TrimSpace(r.FormValue("overlay_name")),
SiteName: strings.TrimSpace(r.FormValue("site_name")),
Queue: service.DefaultConfigProfileQueue(),
Instances: parseProfileInstanceForm(r.Form),
}
if editor.Name == "" && data.AssetProfileEditor != nil {
editor.Name = data.AssetProfileEditor.Name
}
if editor.PrimaryTemplateName == "" && data.AssetProfileEditor != nil {
editor.PrimaryTemplateName = data.AssetProfileEditor.PrimaryTemplateName
}
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
data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form))
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 serviceVideoSourceTypeLabel(v string) string {
switch strings.TrimSpace(v) {
case "rtsp":
return "RTSP"
case "rtmp":
return "RTMP"
case "file":
return "文件"
case "usb_camera":
return "USB 摄像头"
default:
return strings.TrimSpace(v)
}
}
func compactJSON(v any) (string, error) {
body, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(body), nil
}
func validateTemplateGraphDocument(doc map[string]any) error {
knownTypes := knownGraphNodeTypes()
templateMap, ok := doc["template"].(map[string]any)
if !ok {
return fmt.Errorf("template must be an object")
}
nodes, ok := templateMap["nodes"].([]any)
if !ok {
return fmt.Errorf("template.nodes must be an array")
}
edges, ok := templateMap["edges"].([]any)
if !ok {
return fmt.Errorf("template.edges must be an array")
}
seen := map[string]bool{}
for _, item := range nodes {
node, ok := item.(map[string]any)
if !ok {
return fmt.Errorf("template node must be an object")
}
id := strings.TrimSpace(fmt.Sprint(node["id"]))
if id == "" {
return fmt.Errorf("template node id is required")
}
if seen[id] {
return fmt.Errorf("duplicate node id: %s", id)
}
seen[id] = true
nodeType := strings.TrimSpace(fmt.Sprint(node["type"]))
if nodeType == "" {
return fmt.Errorf("template node type is required: %s", id)
}
if !knownTypes[nodeType] {
return fmt.Errorf("unknown node type: %s", nodeType)
}
}
for _, item := range edges {
var from, to string
if edge, ok := item.([]any); ok {
if len(edge) < 2 {
return fmt.Errorf("edge must have from and to")
}
from = strings.TrimSpace(fmt.Sprint(edge[0]))
to = strings.TrimSpace(fmt.Sprint(edge[1]))
} else if edge, ok := item.(map[string]any); ok {
from = strings.TrimSpace(fmt.Sprint(edge["from"]))
to = strings.TrimSpace(fmt.Sprint(edge["to"]))
} else {
return fmt.Errorf("edge must be an array or object")
}
if from == "" || to == "" {
return fmt.Errorf("edge has empty endpoint")
}
if !seen[from] || !seen[to] {
return fmt.Errorf("edge references unknown node: %s -> %s", from, to)
}
}
return 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
}
preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID)
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
}
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,
}
if err != nil {
data.Error = err.Error()
}
if dev != nil {
if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil {
data.DeviceAssignment = assignment
data.SelectedAssignmentDevice = assignment.DeviceID
data.SelectedProfile = assignment.ProfileName
}
}
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 parseDeviceAssignmentBoardState(raw string) (map[string][]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("设备分配数据为空")
}
var payload struct {
Devices map[string][]string `json:"devices"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, fmt.Errorf("设备分配数据格式错误")
}
if payload.Devices == nil {
return map[string][]string{}, nil
}
out := make(map[string][]string, len(payload.Devices))
for deviceID, refs := range payload.Devices {
deviceID = strings.TrimSpace(deviceID)
if deviceID == "" {
continue
}
out[deviceID] = cleanFormList(refs)
}
return out, nil
}
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 defaultProfileEditorDraft(templates []service.ConfigTemplateAsset) *service.ConfigProfileEditor {
templateName := "std_workshop_face_recognition_shoe_alarm"
if len(templates) > 0 && strings.TrimSpace(templates[0].Name) != "" {
templateName = strings.TrimSpace(templates[0].Name)
}
inst := newProfileInstanceDraft(templateName, "cam1")
return &service.ConfigProfileEditor{
Name: "",
BusinessName: "",
Description: "",
OverlayName: "",
Queue: service.DefaultConfigProfileQueue(),
Instances: []service.ConfigProfileInstanceEditor{inst},
}
}
func (u *UI) actionDevicePlanApply(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.deviceDetailPageData(dev)
if data.DeviceAssignment == nil {
data.Error = "请先到通道部署中为该设备指定视频通道"
u.render(w, r, "device", data)
return
}
if u.tasks == nil {
data.Error = "task service not initialized"
u.render(w, r, "device", data)
return
}
preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID)
data.ConfigPreview = preview
if err != nil {
data.Error = err.Error()
u.render(w, r, "device", 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", data)
return
}
task, err := u.tasks.CreateTask("config_apply", []string{dev.DeviceID}, map[string]any{"config": configDoc})
if err != nil {
data.Error = err.Error()
u.render(w, r, "device", data)
return
}
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
}
func nextProfileInstanceName(instances []service.ConfigProfileInstanceEditor) string {
used := make(map[string]struct{}, len(instances))
for _, inst := range instances {
name := strings.TrimSpace(inst.Name)
if name == "" {
continue
}
used[name] = struct{}{}
}
for i := 1; ; i++ {
candidate := fmt.Sprintf("cam%d", i)
if _, ok := used[candidate]; !ok {
return candidate
}
}
}
func newProfileInstanceDraft(templateName string, channelName string) service.ConfigProfileInstanceEditor {
outputs := map[string]service.OutputBindingEditor{
"stream_output_main": {
PublishHLSPath: "./web/hls/" + channelName + "/index.m3u8",
PublishRTSPPort: "8555",
PublishRTSPPath: "/live/" + channelName,
ChannelNo: channelName,
},
}
return service.ConfigProfileInstanceEditor{
Name: channelName,
Template: templateName,
PublishHLSPath: outputs["stream_output_main"].PublishHLSPath,
PublishRTSPPort: outputs["stream_output_main"].PublishRTSPPort,
PublishRTSPPath: outputs["stream_output_main"].PublishRTSPPath,
ChannelNo: outputs["stream_output_main"].ChannelNo,
OutputBindings: outputs,
}
}
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)
inputBindings := parseInputBindingForm(form, prefix)
serviceBindings := parseServiceBindingForm(form, prefix)
outputBindings := parseOutputBindingForm(form, prefix)
inst := service.ConfigProfileInstanceEditor{
Name: strings.TrimSpace(form.Get(prefix + "name")),
Template: strings.TrimSpace(form.Get(prefix + "template")),
VideoSourceRef: firstString(inputBindingValue(inputBindings, "video_input_main"), strings.TrimSpace(form.Get(prefix+"video_source_ref"))),
DisplayName: strings.TrimSpace(form.Get(prefix + "display_name")),
PublishHLSPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_hls_path"), strings.TrimSpace(form.Get(prefix+"publish_hls_path"))),
PublishRTSPPort: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_port"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_port"))),
PublishRTSPPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_path"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_path"))),
ChannelNo: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "channel_no"), strings.TrimSpace(form.Get(prefix+"channel_no"))),
InputBindings: inputBindings,
ServiceBindings: serviceBindings,
OutputBindings: outputBindings,
AdvancedParams: parseAdvancedParams(strings.TrimSpace(form.Get(prefix + "advanced_params"))),
Delete: strings.TrimSpace(form.Get(prefix+"delete")) == "1",
}
if inst.Name != "" || inst.VideoSourceRef != "" || len(inst.ServiceBindings) > 0 || len(inst.OutputBindings) > 0 || inst.Delete {
out = append(out, inst)
}
}
if strings.TrimSpace(form.Get("add_instance")) == "1" {
templateName := "std_workshop_face_recognition_shoe_alarm"
if len(out) > 0 && strings.TrimSpace(out[0].Template) != "" {
templateName = strings.TrimSpace(out[0].Template)
}
out = append(out, newProfileInstanceDraft(templateName, nextProfileInstanceName(out)))
}
if len(out) > 0 {
fallbackTemplate := strings.TrimSpace(out[0].Template)
if fallbackTemplate == "" {
fallbackTemplate = "std_workshop_face_recognition_shoe_alarm"
}
for i := range out {
if strings.TrimSpace(out[i].Template) == "" {
out[i].Template = fallbackTemplate
}
}
}
return out
}
func parseInputBindingForm(form url.Values, prefix string) map[string]service.InputBindingEditor {
bindings := map[string]service.InputBindingEditor{}
for key := range form {
if !strings.HasPrefix(key, prefix+"input_bindings.") || !strings.HasSuffix(key, ".video_source_ref") {
continue
}
slot := strings.TrimPrefix(key, prefix+"input_bindings.")
slot = strings.TrimSuffix(slot, ".video_source_ref")
slot = strings.TrimSpace(slot)
if slot == "" {
continue
}
value := strings.TrimSpace(form.Get(key))
if value == "" {
continue
}
bindings[slot] = service.InputBindingEditor{VideoSourceRef: value}
}
if len(bindings) == 0 {
return nil
}
return bindings
}
func parseServiceBindingForm(form url.Values, prefix string) map[string]service.ServiceBindingEditor {
bindings := map[string]service.ServiceBindingEditor{}
for key := range form {
if !strings.HasPrefix(key, prefix+"service_bindings.") || !strings.HasSuffix(key, ".service_ref") {
continue
}
slot := strings.TrimPrefix(key, prefix+"service_bindings.")
slot = strings.TrimSuffix(slot, ".service_ref")
slot = strings.TrimSpace(slot)
if slot == "" {
continue
}
value := strings.TrimSpace(form.Get(key))
if value == "" {
continue
}
bindings[slot] = service.ServiceBindingEditor{ServiceRef: value}
}
if len(bindings) == 0 {
return nil
}
return bindings
}
func parseOutputBindingForm(form url.Values, prefix string) map[string]service.OutputBindingEditor {
bindings := map[string]service.OutputBindingEditor{}
for key := range form {
if !strings.HasPrefix(key, prefix+"output_bindings.") {
continue
}
slotField := strings.TrimPrefix(key, prefix+"output_bindings.")
dot := strings.LastIndex(slotField, ".")
if dot <= 0 {
continue
}
slot := strings.TrimSpace(slotField[:dot])
field := strings.TrimSpace(slotField[dot+1:])
if slot == "" || field == "" {
continue
}
value := strings.TrimSpace(form.Get(key))
entry := bindings[slot]
switch field {
case "publish_hls_path":
entry.PublishHLSPath = value
case "publish_rtsp_port":
entry.PublishRTSPPort = value
case "publish_rtsp_path":
entry.PublishRTSPPath = value
case "channel_no":
entry.ChannelNo = value
default:
continue
}
if strings.TrimSpace(entry.PublishHLSPath) == "" && strings.TrimSpace(entry.PublishRTSPPort) == "" &&
strings.TrimSpace(entry.PublishRTSPPath) == "" && strings.TrimSpace(entry.ChannelNo) == "" {
continue
}
bindings[slot] = entry
}
if len(bindings) == 0 {
return nil
}
return bindings
}
func inputBindingValue(bindings map[string]service.InputBindingEditor, slot string) string {
if len(bindings) == 0 {
return ""
}
return strings.TrimSpace(bindings[slot].VideoSourceRef)
}
func outputBindingFormValue(bindings map[string]service.OutputBindingEditor, slot string, field string) string {
if len(bindings) == 0 {
return ""
}
item, ok := bindings[slot]
if !ok {
return ""
}
switch field {
case "publish_hls_path":
return strings.TrimSpace(item.PublishHLSPath)
case "publish_rtsp_port":
return strings.TrimSpace(item.PublishRTSPPort)
case "publish_rtsp_path":
return strings.TrimSpace(item.PublishRTSPPath)
case "channel_no":
return strings.TrimSpace(item.ChannelNo)
default:
return ""
}
}
func firstString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
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)
assignments, assignErr := u.preview.ListDeviceAssignments()
filteredAssignments := make([]service.DeviceAssignmentAsset, 0, len(assignments))
selectedSet := make(map[string]struct{}, len(data.SelectedDeviceIDs))
for _, id := range data.SelectedDeviceIDs {
selectedSet[id] = struct{}{}
}
for _, item := range assignments {
if _, ok := selectedSet[item.DeviceID]; ok {
filteredAssignments = append(filteredAssignments, item)
}
}
data.DeviceAssignments = filteredAssignments
if err != nil {
data.Error = err.Error()
} else if assignErr != nil {
data.Error = assignErr.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)
}