safesight-control/internal/web/ui.go
tian 7eca9454b0 feat: DB-agent feature sync with conflict detection
- ConsoleFeature 增加 Available/Conflict 字段
- pageConsole 显示 agent 全部能力(含不可用的),不可用项 disabled
- DB 已启用但 agent 不可用时标记为冲突 ⚠
- 不可用功能 hover 显示原因
2026-05-12 13:57:03 +08:00

4756 lines
179 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"
"mime"
"mime/multipart"
"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
alarmCollector *service.AlarmCollector
autoConfig *service.AutoConfigService
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
AlarmRecords []service.AlarmRecord
TodayAlarmCount int
DeviceMetrics []DeviceMetric
ConsoleDevices []ConsoleDeviceData
ConsoleVideoSources []service.ConfigVideoSourceAsset
ConsoleAllVideoSources []service.ConfigVideoSourceAsset
ConsoleUnassignedUnits []service.RecognitionUnitAsset
FaceGalleryPersons []storage.PersonRecord
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
TemplateCloneName string
TemplateCloneDesc string
TemplateCreateMode string
OverlayDraftJSON string
IntegrationConfigDraft 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 "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 ""
}
},
"sub": func(a, b int) int {
return a - b
},
"loop": func(count int) []int {
var r []int
for i := 0; i < count; i++ {
r = append(r, i)
}
return r
},
"mul": func(a, b float64) float64 {
return a * b
},
"div": func(a, b float64) float64 {
if b == 0 {
return 0
}
return a / b
},
}).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 (u *UI) SetAlarmCollector(ac *service.AlarmCollector) {
if u == nil {
return
}
u.alarmCollector = ac
}
func (u *UI) SetAutoConfig(ac *service.AutoConfigService) {
if u == nil {
return
}
u.autoConfig = ac
}
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("/console", u.pageConsole)
r.Post("/console", u.actionConsoleSave)
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}/clone", u.actionAssetOverlayClone)
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("/alarms", u.pageAlarms)
r.Get("/face-gallery", u.pageFaceGallery)
r.Get("/face-photo/*", u.serveFacePhoto)
r.Post("/face-gallery/import", u.actionFaceGalleryImport)
r.Post("/face-gallery/build", u.actionFaceGalleryBuild)
r.Post("/face-gallery/add", u.actionFaceGalleryAdd)
r.Post("/face-gallery/delete", u.actionFaceGalleryDelete)
r.Post("/face-gallery/rename", u.actionFaceGalleryRename)
r.Post("/face-gallery/add-photo", u.actionFaceGalleryAddPhoto)
r.Post("/face-gallery/delete-photo", u.actionFaceGalleryDeletePhoto)
r.Get("/monitor", u.pageMonitor)
r.Get("/hls/*", u.proxyHLS)
r.Get("/api/monitor/channels", u.apiMonitorChannels)
r.Get("/api/device-metrics", u.apiDeviceMetrics)
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 = "总览"
if u.alarmCollector != nil {
data.AlarmRecords = u.alarmCollector.GetRecent(5)
all := u.alarmCollector.GetRecent(9999)
today := time.Now().Format("2006-01-02")
for _, a := range all {
if strings.HasPrefix(a.Timestamp, today) {
data.TodayAlarmCount++
}
}
}
data.Tasks = nil
if u.tasks != nil {
data.Tasks = u.tasks.ListTasks()
}
data.AttentionDevices = nil
// Load device metrics
if u.agent != nil {
for _, dev := range data.Devices {
if dev == nil || !dev.Online { continue }
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil)
if err != nil || code != 200 { continue }
var m struct {
CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"`
Memory struct{ TotalKB uint64 `json:"total_kb"`; AvailableKB uint64 `json:"available_kb"` } `json:"memory"`
NPU struct {
UsagePct float64 `json:"usage_pct"`
Cores map[string]float64 `json:"cores"`
} `json:"npu"`
Disk struct{ UsedPct float64 `json:"used_pct"` } `json:"disk"`
Temperature struct{ Celsius float64 `json:"celsius"` } `json:"temperature"`
}
json.Unmarshal(body, &m)
var mem float64
if m.Memory.TotalKB > 0 { mem = float64(m.Memory.TotalKB-m.Memory.AvailableKB) / float64(m.Memory.TotalKB) * 100 }
npuVal := m.NPU.UsagePct
if len(m.NPU.Cores) > 0 {
for _, v := range m.NPU.Cores {
if v > npuVal {
npuVal = v
}
}
}
data.DeviceMetrics = append(data.DeviceMetrics, DeviceMetric{
Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: mem, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius,
})
}
}
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) pageConsole(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
data := PageData{Title: "管控台"}
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
devices := u.registry.GetDevices()
data.Devices = devices
// Load video sources
var allSources []service.ConfigVideoSourceAsset
if u.preview != nil {
if items, err := u.preview.ListVideoSources(); err == nil {
allSources = items
}
}
// Load recognition units and device assignments
var units []service.RecognitionUnitAsset
var assignments []service.DeviceAssignmentAsset
if u.preview != nil {
if items, err := u.preview.ListRecognitionUnits(); err == nil {
units = items
}
if items, err := u.preview.ListDeviceAssignments(); err == nil {
assignments = items
}
}
// Build device->unit mapping
assignedUnits := map[string][]service.RecognitionUnitAsset{}
for _, a := range assignments {
for _, ref := range a.RecognitionUnits {
for _, u := range units {
if u.Ref == ref {
assignedUnits[a.DeviceID] = append(assignedUnits[a.DeviceID], u)
break
}
}
}
}
// Track used video sources
usedSources := map[string]bool{}
for _, unitList := range assignedUnits {
for _, u := range unitList {
if u.VideoSourceRef != "" {
usedSources[u.VideoSourceRef] = true
}
}
}
// Build source name lookup
sourceNames := map[string]string{}
for _, s := range allSources {
sourceNames[s.Name] = s.Name
}
// Query each device's capabilities from agent to filter available features.
deviceAvailableFeatures := map[string][]ConsoleFeature{}
if u.agent != nil {
for _, dev := range devices {
if dev == nil || !dev.Online || dev.DeviceID == "" {
continue
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/capabilities", nil)
if err != nil || code != 200 {
continue
}
var capsResp struct {
Capabilities []struct {
Key string `json:"key"`
Label string `json:"label"`
Available bool `json:"available"`
Description string `json:"description"`
} `json:"capabilities"`
}
if json.Unmarshal(body, &capsResp) != nil {
continue
}
features := make([]ConsoleFeature, 0, len(capsResp.Capabilities))
for _, c := range capsResp.Capabilities {
features = append(features, ConsoleFeature{
Key: c.Key, Label: c.Label, Description: c.Description,
Available: c.Available, Enabled: false,
})
}
deviceAvailableFeatures[dev.DeviceID] = features
}
}
// Load persisted device features as the source of truth.
// This represents what capabilities the device is configured for,
// not just what the last deployment used.
persistedFeatures := map[string]map[string]bool{}
if u.preview != nil {
for _, dev := range devices {
if dev == nil || dev.DeviceID == "" {
continue
}
if feats, err := u.preview.GetDeviceFeatures(dev.DeviceID); err == nil && len(feats) > 0 {
m := map[string]bool{}
for _, f := range feats {
m[f] = true
}
persistedFeatures[dev.DeviceID] = m
}
}
}
// Load device metrics (NPU, etc)
metrics := map[string]DeviceMetric{}
if u.agent != nil {
for _, dev := range devices {
if dev == nil || !dev.Online {
continue
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil)
if err != nil || code != 200 {
continue
}
var m struct {
CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"`
Memory struct {
TotalKB float64 `json:"total_kb"`
AvailableKB float64 `json:"available_kb"`
} `json:"memory"`
NPU struct {
UsagePct float64 `json:"usage_pct"`
Cores map[string]float64 `json:"cores"`
} `json:"npu"`
Disk struct{ UsedPct float64 `json:"used_pct"` } `json:"disk"`
Temperature struct{ Celsius float64 `json:"celsius"` } `json:"temperature"`
}
json.Unmarshal(body, &m)
npuVal := m.NPU.UsagePct
// Pick max core if per-core data available
if len(m.NPU.Cores) > 0 {
for _, v := range m.NPU.Cores {
if v > npuVal {
npuVal = v
}
}
}
memPct := 0.0
if m.Memory.TotalKB > 0 {
memPct = (m.Memory.TotalKB - m.Memory.AvailableKB) / m.Memory.TotalKB * 100
}
metrics[dev.DeviceID] = DeviceMetric{
Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: memPct, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius,
}
}
}
// Build console device data
var consoleDevices []ConsoleDeviceData
for _, dev := range devices {
if dev == nil {
continue
}
maxUnits := 4
// Gather channels (one per assigned recognition unit)
var channels []ConsoleChannel
var cameras []string
for _, u := range assignedUnits[dev.DeviceID] {
// HLS channel name matches recognition unit Name (used in publish path)
chName := u.Name
displayName := u.Name
if u.DisplayName != "" {
displayName = u.DisplayName
}
srcName := u.VideoSourceRef
if srcName == "" {
srcName = displayName
}
channels = append(channels, ConsoleChannel{Name: chName, Display: displayName, SourceName: srcName})
if u.VideoSourceRef != "" {
cameras = append(cameras, u.VideoSourceRef)
}
}
// Determine which features are enabled from persisted device features.
activeKeys := persistedFeatures[dev.DeviceID]
if activeKeys == nil {
activeKeys = map[string]bool{}
}
// Features from device capabilities (from agent /v1/capabilities).
// Overlay persisted DB state for checkmarks and conflict detection.
available := deviceAvailableFeatures[dev.DeviceID]
var features []ConsoleFeature
if len(available) > 0 {
for _, f := range available {
f.Enabled = activeKeys[f.Key]
// Conflict: user enabled a feature that the device no longer supports.
if f.Enabled && !f.Available {
f.Conflict = true
}
features = append(features, f)
}
// Show persisted features that the agent didn't report (e.g. new features
// added to registry but old agent doesn't know them).
seen := map[string]bool{}
for _, f := range features {
seen[f.Key] = true
}
for key := range activeKeys {
if !seen[key] {
features = append(features, ConsoleFeature{
Key: key, Label: key, Enabled: true, Available: false, Conflict: true,
})
}
}
} else {
// Old agent: show persisted keys.
for key := range activeKeys {
features = append(features, ConsoleFeature{Key: key, Label: key, Enabled: true, Available: true})
}
}
npu, diskUsage, temp, cpuUsage, memUsage := 0.0, 0.0, 0.0, 0.0, 0.0
if m, ok := metrics[dev.DeviceID]; ok {
cpuUsage = m.CPU
memUsage = m.Mem
npu = m.NPU
diskUsage = m.Disk
temp = m.Temperature
}
consoleDevices = append(consoleDevices, ConsoleDeviceData{
Device: dev,
CPUUsage: cpuUsage,
MemUsage: memUsage,
NPUUsage: npu,
DiskUsage: diskUsage,
Temperature: temp,
MaxUnits: maxUnits,
Channels: channels,
Features: features,
Cameras: cameras,
})
}
// Unassigned video sources
var unassigned []service.ConfigVideoSourceAsset
for _, s := range allSources {
if !usedSources[s.Name] {
unassigned = append(unassigned, s)
}
}
data.ConsoleDevices = consoleDevices
data.ConsoleVideoSources = unassigned
data.ConsoleAllVideoSources = allSources
u.render(w, r, "console", data)
}
func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if u.autoConfig == nil || u.preview == nil {
http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound)
return
}
u.ensureDevicesLoaded()
devices := u.registry.GetDevices()
// Step 1: persist feature selections to DB (source of truth).
for _, dev := range devices {
if dev == nil || dev.DeviceID == "" {
continue
}
features := cleanFormList(r.Form["device_"+dev.DeviceID+"_feature"])
if err := u.preview.SaveDeviceFeatures(dev.DeviceID, features); err != nil {
http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("保存功能配置失败: "+err.Error()), http.StatusFound)
return
}
}
// Step 2: build requests from DB only.
// Features come from device_features table.
// Video sources come from existing device assignments and recognition units.
var requests []service.AutoConfigRequest
for _, dev := range devices {
if dev == nil || dev.DeviceID == "" {
continue
}
features, err := u.preview.GetDeviceFeatures(dev.DeviceID)
if err != nil {
continue
}
var sources []string
if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil && assignment != nil {
for _, ref := range assignment.RecognitionUnits {
if unit, err := u.preview.GetRecognitionUnit(ref); err == nil && unit != nil {
if unit.VideoSourceRef != "" {
sources = append(sources, unit.VideoSourceRef)
}
}
}
}
if len(features) == 0 && len(sources) == 0 {
continue
}
requests = append(requests, service.AutoConfigRequest{
DeviceID: dev.DeviceID,
Features: features,
SourceNames: sources,
})
}
if len(requests) == 0 {
http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("未选择检测功能或未分配视频源"), http.StatusFound)
return
}
results, _ := u.autoConfig.BuildPipelineBatch(requests, true)
// Collect task IDs for redirect.
taskIDs := make([]string, 0)
var errs []string
for _, res := range results {
if res.TaskID != "" {
taskIDs = append(taskIDs, res.TaskID)
}
if res.Error != "" {
errs = append(errs, res.DeviceID+": "+res.Error)
}
}
if len(taskIDs) == 1 {
http.Redirect(w, r, "/ui/tasks/"+taskIDs[0], http.StatusFound)
return
}
if len(taskIDs) > 1 {
// Multiple devices: redirect to tasks list.
http.Redirect(w, r, "/ui/tasks?msg="+url.QueryEscape(fmt.Sprintf("已为 %d 台设备创建配置下发任务", len(taskIDs))), http.StatusFound)
return
}
if len(errs) > 0 {
http.Redirect(w, r, "/ui/console?error="+url.QueryEscape(strings.Join(errs, "; ")), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("配置已保存"), http.StatusFound)
}
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) {
u.ensureDevicesLoaded()
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) pageAlarms(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
data := PageData{Title: "告警中心", Devices: u.registry.GetDevices()}
if u.alarmCollector != nil {
data.AlarmRecords = u.alarmCollector.GetRecent(100)
}
u.render(w, r, "alarms", 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"
cloneSource := strings.TrimSpace(r.URL.Query().Get("clone_source"))
cloneName := strings.TrimSpace(r.URL.Query().Get("clone_name"))
cloneDesc := strings.TrimSpace(r.URL.Query().Get("clone_desc"))
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()
}
}
if cloneSource != "" && cloneName != "" {
data.TemplateCloneSource = cloneSource
data.TemplateCloneName = cloneName
data.TemplateCloneDesc = cloneDesc
if source, err := u.preview.GetTemplateAsset(cloneSource); err == nil {
body, _ := json.Marshal(source.Raw)
doc := map[string]any{}
if json.Unmarshal(body, &doc) == nil {
desc := cloneDesc
if desc == "" {
desc = source.Description
}
doc["name"] = cloneName
doc["description"] = desc
data.AssetTemplate = &service.ConfigTemplateAsset{
Name: cloneName,
Description: desc,
Raw: doc,
ReadOnly: false,
}
}
}
}
data.AssetTemplateEditing = (editMode || cloneSource != "") && 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"))
if cloneSource == "" {
http.Error(w, "create template requires clone_source", http.StatusBadRequest)
return
}
var doc map[string]any
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
}
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?name="+url.PathEscape(name)+"&edit=1&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
}
cloneSource := url.QueryEscape(sourceName)
cloneName := url.QueryEscape(targetName)
cloneDesc := url.QueryEscape(item.Description)
http.Redirect(w, r, "/ui/assets/templates?clone_source="+cloneSource+"&clone_name="+cloneName+"&clone_desc="+cloneDesc, 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")
cloneSource := strings.TrimSpace(r.URL.Query().Get("clone_source"))
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
}
if cloneSource != "" {
source, err := u.preview.GetTemplateAsset(cloneSource)
if err != nil {
http.NotFound(w, r)
return
}
body, _ := json.Marshal(source.Raw)
doc := map[string]any{}
if json.Unmarshal(body, &doc) != nil {
http.NotFound(w, r)
return
}
doc["name"] = name
doc["description"] = source.Description
data.Title = "模板可视化编辑(从 " + cloneSource + " 克隆)"
data.AssetTemplate = &service.ConfigTemplateAsset{
Name: name,
Description: source.Description,
Raw: doc,
ReadOnly: false,
}
data.TemplateCloneSource = cloneSource
} else {
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(data.AssetTemplate.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")
cloneSource := strings.TrimSpace(r.FormValue("clone_source"))
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 cloneSource != "" || targetName != name {
if cloneSource != "" {
err = u.preview.SaveTemplateAsset(targetName, description, string(body)+"\n")
} else {
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
}
if data.AssetIntegration != nil && data.AssetIntegration.Type == "custom" {
if configRaw, ok := data.AssetIntegration.Raw["config"]; ok {
if s, err := compactJSON(configRaw); err == nil {
data.IntegrationConfigDraft = s
}
}
if data.IntegrationConfigDraft == "" {
data.IntegrationConfigDraft = "{}"
}
}
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")
serviceType := strings.TrimSpace(r.FormValue("type"))
asset := service.ConfigIntegrationServiceAsset{
Name: strings.TrimSpace(r.FormValue("name")),
Type: serviceType,
Description: strings.TrimSpace(r.FormValue("description")),
Enabled: enabled,
ObjectStorage: &service.ObjectStorageConfig{},
AlarmService: &service.AlarmServiceConfig{},
}
if serviceType == "custom" {
configRaw := strings.TrimSpace(r.FormValue("config_json"))
if configRaw == "" {
configRaw = "{}"
}
var raw map[string]any
if err := json.Unmarshal([]byte(configRaw), &raw); err != nil {
http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape("自定义配置 JSON 格式不正确:"+err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)
return
}
asset.Raw = raw
} else {
asset.ObjectStorage.Endpoint = strings.TrimSpace(r.FormValue("endpoint"))
asset.ObjectStorage.Bucket = strings.TrimSpace(r.FormValue("bucket"))
asset.ObjectStorage.AccessKey = strings.TrimSpace(r.FormValue("access_key"))
asset.ObjectStorage.SecretKey = strings.TrimSpace(r.FormValue("secret_key"))
asset.AlarmService.GetTokenURL = strings.TrimSpace(r.FormValue("get_token_url"))
asset.AlarmService.PutMessageURL = strings.TrimSpace(r.FormValue("put_message_url"))
asset.AlarmService.TenantCode = strings.TrimSpace(r.FormValue("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"
cloneSource := strings.TrimSpace(r.URL.Query().Get("clone_source"))
cloneName := strings.TrimSpace(r.URL.Query().Get("clone_name"))
if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" && !newMode {
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 && cloneSource != "" {
if item, err := u.preview.GetOverlayAsset(cloneSource); err == nil {
rawJSON, err := json.Marshal(item.Raw)
if err == nil {
doc := map[string]any{}
if err := json.Unmarshal(rawJSON, &doc); err == nil {
doc["name"] = cloneName
doc["description"] = item.Description
data.AssetOverlay = &service.ConfigOverlayAsset{
Name: cloneName,
Description: item.Description,
Raw: doc,
}
}
}
}
if data.AssetOverlay == nil {
newMode = false
data.Error = "无法加载源调试参数"
}
}
if newMode {
data.AssetOverlayEditing = data.AssetOverlay != nil
} else {
data.AssetOverlayEditing = editMode && data.AssetOverlay != nil && !data.AssetOverlay.ReadOnly
}
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) actionAssetOverlayClone(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.GetOverlayAsset(sourceName)
if err != nil || item == nil {
http.NotFound(w, r)
return
}
if !item.ReadOnly {
http.Redirect(w, r, "/ui/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准调试参数复制创建"), http.StatusFound)
return
}
targetName, err := u.nextOverlayCloneName(item.Name)
if err != nil {
http.Redirect(w, r, "/ui/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
cloneSource := url.QueryEscape(sourceName)
cloneName := url.QueryEscape(targetName)
http.Redirect(w, r, "/ui/assets/overlays?new=1&clone_source="+cloneSource+"&clone_name="+cloneName, http.StatusFound)
}
func (u *UI) nextOverlayCloneName(sourceName string) (string, error) {
base := strings.TrimSpace(sourceName)
if base == "" {
return "", fmt.Errorf("调试参数名称不能为空")
}
if strings.HasPrefix(base, "std_") {
base = strings.TrimPrefix(base, "std_")
}
candidate := base + "_copy"
if item, err := u.preview.GetOverlayAsset(candidate); err == nil && item != nil {
for i := 2; i < 1000; i++ {
name := fmt.Sprintf("%s_copy_%d", base, i)
item, err := u.preview.GetOverlayAsset(name)
if err != nil || item == nil {
return name, nil
}
}
return "", fmt.Errorf("无法生成可用的调试参数副本名称")
}
return candidate, nil
}
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)
}
func (u *UI) pageMonitor(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
u.render(w, r, "monitor", PageData{Title: "视频监控", Devices: u.registry.GetDevices()})
}
func (u *UI) apiMonitorChannels(w http.ResponseWriter, r *http.Request) {
deviceID := r.URL.Query().Get("device_id")
if deviceID == "" {
http.Error(w, "missing device_id", http.StatusBadRequest)
return
}
dev, ok := u.findDevice(deviceID)
if !ok {
http.Error(w, "device not found", http.StatusNotFound)
return
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/preview/channels", nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(body)
}
func (u *UI) proxyHLS(w http.ResponseWriter, r *http.Request) {
// URL format: /hls/{deviceID}/{hls_path}
path := strings.TrimPrefix(r.URL.Path, "/hls/")
idx := strings.Index(path, "/")
if idx < 0 {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
deviceID := path[:idx]
hlsPath := path[idx+1:]
dev, ok := u.findDevice(deviceID)
if !ok {
http.Error(w, "device not found", http.StatusNotFound)
return
}
body, code, err := u.agent.Do("GET", dev.IP, dev.MediaPort, "/"+hlsPath, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
// Set HLS-friendly headers
if strings.HasSuffix(hlsPath, ".m3u8") {
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Access-Control-Allow-Origin", "*")
} else if strings.HasSuffix(hlsPath, ".ts") {
w.Header().Set("Content-Type", "video/mp2t")
w.Header().Set("Access-Control-Allow-Origin", "*")
}
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(code)
w.Write(body)
}
func (u *UI) actionFaceGalleryAdd(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
// Save photo to dataset directory
datasetDir := filepath.Join("dataset")
personDir := filepath.Join(datasetDir, name)
if err := os.MkdirAll(personDir, 0o755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
file, hdr, err := r.FormFile("photo")
if err != nil {
http.Error(w, "photo required", http.StatusBadRequest)
return
}
defer file.Close()
dst := filepath.Join(personDir, hdr.Filename)
f, err := os.Create(dst)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
io.Copy(f, file)
http.Redirect(w, r, "/ui/face-gallery?msg=ok", http.StatusFound)
}
func (u *UI) actionFaceGalleryBuild(w http.ResponseWriter, r *http.Request) {
addFaceDir := filepath.Join("..", "AddFaceTo3588")
pythonExe := filepath.Join(addFaceDir, ".venv", "Scripts", "python.exe")
// Fallback to system python if venv not available
if _, err := os.Stat(pythonExe); os.IsNotExist(err) {
pythonExe = "python"
}
builder := &service.FaceGalleryBuilder{
PythonExe: pythonExe,
BuildScript: filepath.Join("tools", "build_gallery.py"),
DetModel: filepath.Join("tools", "models", "RetinaFace_mobile320.onnx"),
RecogModel: filepath.Join("tools", "models", "mobilefacenet_arcface_prenorm.onnx"),
DetOutputsConfig: filepath.Join("tools", "models", "retinaface_mobile320_config.json"),
DatasetDir: filepath.Join("dataset"),
OutputDB: filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db"),
}
output, err := builder.Build()
if err != nil {
msg := fmt.Sprintf("生成失败: %v", err)
http.Redirect(w, r, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound)
return
}
// Parse key stats from output for a clean message
persons := "0"
photos := "0"
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "enrolled_persons:") {
persons = strings.TrimSpace(strings.TrimPrefix(line, "enrolled_persons:"))
}
if strings.HasPrefix(line, "ok_images:") {
photos = strings.TrimSpace(strings.TrimPrefix(line, "ok_images:"))
}
}
msg := fmt.Sprintf("生成成功,共 %s 人,%s 张照片", persons, photos)
http.Redirect(w, r, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound)
}
func (u *UI) pageFaceGallery(w http.ResponseWriter, r *http.Request) {
data := PageData{Title: "人脸库管理"}
data.Message = r.URL.Query().Get("msg")
if strings.TrimSpace(u.dbPath) != "" {
if repo := storage.NewFaceGalleryRepo(u.dbPath); repo != nil {
if persons, err := repo.ListPersons(); err == nil {
data.FaceGalleryPersons = persons
}
}
}
u.render(w, r, "face_gallery", data)
}
func (u *UI) actionFaceGalleryDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.FormValue("id"))
if id <= 0 {
http.Error(w, "invalid", http.StatusBadRequest)
return
}
if strings.TrimSpace(u.dbPath) != "" { repo := storage.NewFaceGalleryRepo(u.dbPath)
repo.DeletePerson(id)
}
http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
}
func (u *UI) actionFaceGalleryImport(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(100 << 20); err != nil {
http.Error(w, "form too large", http.StatusBadRequest)
return
}
datasetDir := filepath.Join("dataset")
count := 0
for _, files := range r.MultipartForm.File {
for _, hdr := range files {
fp := fullFilename(hdr)
personName := personNameFromPath(fp)
fileName := filepath.Base(fp)
if personName == "" || fileName == "" {
continue
}
personDir := filepath.Join(datasetDir, personName)
os.MkdirAll(personDir, 0o755)
dst := filepath.Join(personDir, fileName)
// Skip if file already exists
if _, err := os.Stat(dst); err == nil {
continue
}
file, err := hdr.Open()
if err != nil { continue }
f, err := os.Create(dst)
if err != nil { file.Close(); continue }
io.Copy(f, file)
f.Close()
file.Close()
count++
// Register in DB
if strings.TrimSpace(u.dbPath) != "" {
repo := storage.NewFaceGalleryRepo(u.dbPath)
pid, _ := repo.FindOrCreatePerson(personName)
repo.AddPhoto(pid, personName+"/"+fileName)
}
}
}
http.Redirect(w, r, fmt.Sprintf("/ui/face-gallery?msg=已导入 %d 张照片", count), http.StatusFound)
}
func (u *UI) rebuildFaceGallery() {
builder := &service.FaceGalleryBuilder{
PythonExe: "python",
BuildScript: filepath.Join("tools", "build_gallery.py"),
DetModel: filepath.Join("tools", "models", "RetinaFace_mobile320.onnx"),
RecogModel: filepath.Join("tools", "models", "mobilefacenet_arcface_prenorm.onnx"),
DetOutputsConfig: filepath.Join("tools", "models", "retinaface_mobile320_config.json"),
DatasetDir: filepath.Join("dataset"),
OutputDB: filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db"),
}
builder.Build()
}
// fullFilename extracts the full relative path from a webkitdirectory file upload,
// bypassing Go 1.20+ sanitization that strips directory components.
func fullFilename(hdr *multipart.FileHeader) string {
cd := hdr.Header.Get("Content-Disposition")
_, params, err := mime.ParseMediaType(cd)
if err == nil {
if name := params["filename"]; name != "" {
return name
}
}
return hdr.Filename
}
// personNameFromPath extracts the person name from a webkitdirectory path like "李清/photo.jpg".
func personNameFromPath(path string) string {
path = strings.ReplaceAll(path, "\\", "/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
return ""
}
return strings.TrimSpace(parts[len(parts)-2])
}
func (u *UI) serveFacePhoto(w http.ResponseWriter, r *http.Request) {
path := chi.URLParam(r, "*")
if path == "" || strings.Contains(path, "..") {
http.Error(w, "invalid", http.StatusBadRequest)
return
}
fullPath := filepath.Join("dataset", path)
http.ServeFile(w, r, fullPath)
}
func (u *UI) actionFaceGalleryRename(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.FormValue("id"))
name := strings.TrimSpace(r.FormValue("name"))
if id <= 0 || name == "" {
http.Error(w, "invalid", http.StatusBadRequest)
return
}
if strings.TrimSpace(u.dbPath) != "" {
storage.NewFaceGalleryRepo(u.dbPath).RenamePerson(id, name)
}
http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
}
func (u *UI) actionFaceGalleryAddPhoto(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "form error", http.StatusBadRequest); return }
id, _ := strconv.Atoi(r.FormValue("id"))
if id <= 0 { http.Error(w, "invalid id", http.StatusBadRequest); return }
file, hdr, err := r.FormFile("photo")
if err != nil { http.Error(w, "photo required", http.StatusBadRequest); return }
defer file.Close()
// Get person name for directory
if strings.TrimSpace(u.dbPath) == "" { http.Redirect(w, r, "/ui/face-gallery", http.StatusFound); return }
repo := storage.NewFaceGalleryRepo(u.dbPath)
persons, _ := repo.ListPersons()
var personName string
for _, p := range persons { if p.ID == id { personName = p.Name; break } }
if personName == "" { http.Error(w, "person not found", http.StatusNotFound); return }
personDir := filepath.Join("dataset", personName)
os.MkdirAll(personDir, 0o755)
dst := filepath.Join(personDir, hdr.Filename)
f, err := os.Create(dst)
if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return }
defer f.Close()
io.Copy(f, file)
repo.AddPhoto(id, personName+"/"+hdr.Filename)
http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
}
func (u *UI) actionFaceGalleryDeletePhoto(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.FormValue("id"))
if id > 0 && strings.TrimSpace(u.dbPath) != "" {
storage.NewFaceGalleryRepo(u.dbPath).DeletePhoto(id)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
func (u *UI) apiDeviceMetrics(w http.ResponseWriter, r *http.Request) {
deviceID := r.URL.Query().Get("device_id")
if deviceID == "" { http.Error(w, "missing device_id", http.StatusBadRequest); return }
dev, ok := u.findDevice(deviceID)
if !ok { http.Error(w, "device not found", http.StatusNotFound); return }
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil)
if err != nil { http.Error(w, err.Error(), http.StatusBadGateway); return }
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(body)
}
type DeviceMetric struct {
Name string `json:"name"`
CPU float64 `json:"cpu"`
Mem float64 `json:"mem"`
NPU float64 `json:"npu"`
Disk float64 `json:"disk"`
Temperature float64 `json:"temperature"`
}
type ConsoleDeviceData struct {
Device *models.Device
CPUUsage float64
MemUsage float64
NPUUsage float64
DiskUsage float64
Temperature float64
MaxUnits int
Channels []ConsoleChannel
Features []ConsoleFeature
Cameras []string
Busy bool
BusyMsg string
}
type ConsoleChannel struct {
Name string
Display string
SourceName string
URL string
}
type ConsoleFeature struct {
Key string
Label string
Description string
Enabled bool
Available bool // device actually supports this (from agent /v1/capabilities)
Conflict bool // enabled in DB but unavailable on device
}