package web
import (
"bytes"
"crypto/sha256"
"encoding/csv"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"mime"
"mime/multipart"
"io/fs"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"safesight-control/internal/models"
"safesight-control/internal/service"
"safesight-control/internal/storage"
"github.com/go-chi/chi/v5"
"golang.org/x/text/encoding/simplifiedchinese"
)
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
configVersionsRepo *storage.ConfigVersionsRepo
tpl *template.Template
}
const (
deviceAssignmentPreviewDevicePrefix = "demo-edge-"
deviceAssignmentPreviewDeviceCount = 8
)
const version = "1.0"
type PageData struct {
Title string
Version string
Year int
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
ConfigVersions []storage.ConfigVersionRecord
AlarmRecords []service.AlarmRecord
TotalAlarmCount int
TotalAlarmPages int
PageNumbers []int
CurrentPage int
PerPage int
SelectedFrom string
SelectedTo string
WeekStart string
WeekEnd string
MonthStart string
MonthEnd string
TodayAlarmCount int
UnacknowledgedAlarmCount int
DetectionChannelCount int
AlarmFilterDevices []string
AlarmFilterRuleTypes []string
SelectedAlarmDevice string
SelectedAlarmRuleType string
DeviceMetrics []DeviceMetric
ConsoleDevices []ConsoleDeviceData
ConsoleVideoSources []service.ConfigVideoSourceAsset
ConsoleAllVideoSources []service.ConfigVideoSourceAsset
WizardVideoSourcesJSON template.HTML
ConsoleUnassignedUnits []service.RecognitionUnitAsset
FaceGalleryPersons []storage.PersonRecord
FaceGalleryQuality map[string]string
DeviceModelStatuses []service.InstalledModelStatus
DeviceResourceStatuses []service.InstalledResourceStatus
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
}
},
"ruleLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "unknown_face":
return "陌生人员告警"
case "known_person":
return "已登记人员"
case "non_compliant_workshoe":
return "未穿劳保鞋"
case "non_black_shoe":
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 "资源同步"
case "face_gallery_reload":
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 "face_gallery_reload":
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 "face_gallery_reload":
return "pill ok"
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
},
"shortID": func(v string) string {
if len(v) > 8 {
return v[:8]
}
return v
},
"add": func(a, b int) int {
return a + b
},
"severityClass": func(v string) string {
switch strings.TrimSpace(v) {
case "critical":
return "bad"
case "warning":
return "warn"
case "info":
return "ok"
default:
return ""
}
},
"severityLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "critical":
return "严重"
case "warning":
return "警告"
case "info":
return "信息"
default:
return v
}
},
"statusClass": func(v string) string {
switch strings.TrimSpace(v) {
case "unacknowledged":
return "bad"
case "acknowledged":
return "run"
case "resolved":
return "ok"
default:
return ""
}
},
"statusLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "unacknowledged":
return "未处理"
case "acknowledged":
return "已确认"
case "resolved":
return "已关闭"
default:
return v
}
},
}).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 (u *UI) SetConfigVersionsRepo(repo *storage.ConfigVersionsRepo) {
if u == nil {
return
}
u.configVersionsRepo = repo
}
func tablerIconSVG(name string) string {
icons := map[string]string{
"devices": ``,
"assets": ``,
"audit": ``,
"system": ``,
"theme": ``,
"bell": ``,
"online": ``,
"detail": ``,
"control": ``,
"device": ``,
"status": ``,
"config": ``,
"overview": ``,
"tech": ``,
"preview": ``,
"apply": ``,
"service": ``,
"task": ``,
"result": ``,
"logs": ``,
"meta": ``,
"template": ``,
"edit": ``,
"profile": ``,
"overlay": ``,
"release": ``,
"discovery": ``,
"shield": ``,
"heartbeat": ``,
}
if svg, ok := icons[name]; ok {
return svg
}
return ""
}
func (u *UI) RegisterAlarmRoutes(r chi.Router) {
r.Get("/api/alarms/stream", u.apiAlarmStream)
r.Get("/api/alarms/unacknowledged-count", u.apiAlarmUnacknowledgedCount)
r.Get("/api/alarms/daily-count", u.apiAlarmDailyCount)
r.Get("/api/alarms/export", u.apiAlarmExport)
r.Get("/api/monitor/channels", u.apiMonitorChannels)
r.Get("/api/device-metrics", u.apiDeviceMetrics)
}
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, "/dashboard", http.StatusFound)
})
r.Get("/dashboard", u.pageDashboard)
r.Get("/console", u.pageConsole)
r.Post("/console", u.actionConsoleSave)
r.Get("/wizard", u.pageWizard)
r.Get("/wizard/device-info", u.apiWizardDeviceInfo)
r.Post("/wizard/apply", u.actionWizardApply)
r.Post("/wizard/create-source", u.actionWizardCreateSource)
r.Get("/devices", u.pageDevices)
r.Post("/devices/cleanup-offline", u.actionDevicesCleanupOffline)
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.Get("/assets/video-sources/template", u.actionVideoSourceCSVTemplate)
r.Post("/assets/video-sources/import", u.actionVideoSourceCSVImport)
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.Get("/devices/{id}/config-versions", u.pageDeviceConfigVersions)
r.Get("/devices/{id}/config-versions/{versionID}", u.apiDeviceConfigVersion)
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}/edge-server/configs/upload", u.actionDeviceMediaServerConfigUpload)
r.Post("/devices/{id}/edge-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/upload", u.actionModelUpload)
r.Post("/models/sync", u.actionModelSync)
r.Post("/resources/sync", u.actionResourceSync)
r.Get("/diagnostics", u.pageDiagnostics)
r.Get("/alarms", u.pageAlarms)
r.Post("/alarms/{id}/acknowledge", u.actionAcknowledgeAlarm)
r.Post("/alarms/{id}/resolve", u.actionResolveAlarm)
r.Post("/alarms/{id}/severity", u.actionUpdateAlarmSeverity)
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.Post("/face-gallery/sync", u.actionFaceGallerySync)
r.Get("/monitor", u.pageMonitor)
r.Get("/hls/*", u.proxyHLS)
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) {
data.Version = version
data.Year = time.Now().Year()
if u.alarmCollector != nil {
data.UnacknowledgedAlarmCount = u.alarmCollector.GetUnacknowledgedCount()
}
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 offline; trigger discovery to refresh
hasOffline := false
for _, d := range devices {
if !d.Online {
hasOffline = true
break
}
}
if !hasOffline {
return
}
}
_, _ = u.discovery.SearchDefault()
if len(u.registry.GetDevices()) == 0 {
_, _ = u.discovery.SearchDefault()
}
}
func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) {
// First-time setup: if no recognition units exist, guide to wizard.
if u.preview != nil {
if units, err := u.preview.ListRecognitionUnits(); err == nil && len(units) == 0 {
http.Redirect(w, r, "/wizard", http.StatusFound)
return
}
}
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)
data.UnacknowledgedAlarmCount = u.alarmCollector.GetUnacknowledgedCount()
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
// Count detection channels
if u.preview != nil {
if units, err := u.preview.ListRecognitionUnits(); err == nil {
data.DetectionChannelCount = len(units)
}
}
// 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 and active features from agent.
deviceAvailableFeatures := map[string][]ConsoleFeature{}
deviceActiveFeatures := map[string]map[string]bool{}
if u.agent != nil {
for _, dev := range devices {
if dev == nil || !dev.Online || dev.DeviceID == "" {
continue
}
// Get capabilities
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
// Get active features from config template
body2, code2, err2 := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil)
if err2 == nil && code2 == 200 {
tmpl := jsonGetStr(body2, "template")
if tmpl != "" {
active := featuresFromTemplate(tmpl)
m := map[string]bool{}
for _, f := range active {
m[f] = true
}
deviceActiveFeatures[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 := deviceActiveFeatures[dev.DeviceID]
if activeKeys == nil {
activeKeys = map[string]bool{}
}
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 {
if !f.Available {
continue // skip features the device doesn't support
}
f.Enabled = activeKeys[f.Key]
features = append(features, f)
}
// Only show features the device actually supports.
seen := map[string]bool{}
for _, f := range features {
seen[f.Key] = true
}
} else {
// Old agent without /v1/capabilities: show nothing for now.
}
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, "/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound)
return
}
u.ensureDevicesLoaded()
devices := u.registry.GetDevices()
// Step 1: collect form features per device.
// Only process devices listed in dirty_device_ids (client-side change detection).
dirtyIDs := strings.Split(strings.TrimSpace(r.FormValue("dirty_device_ids")), ",")
dirtySet := map[string]bool{}
for _, id := range dirtyIDs {
if id != "" {
dirtySet[id] = true
}
}
formFeatures := map[string][]string{}
for _, dev := range devices {
if dev == nil || dev.DeviceID == "" {
continue
}
if len(dirtySet) > 0 && !dirtySet[dev.DeviceID] {
continue
}
features := cleanFormList(r.Form["device_"+dev.DeviceID+"_feature"])
if len(features) > 0 {
formFeatures[dev.DeviceID] = features
}
}
// Step 2: build requests only for devices whose features actually changed.
var requests []service.AutoConfigRequest
for _, dev := range devices {
if dev == nil || dev.DeviceID == "" {
continue
}
features := formFeatures[dev.DeviceID]
if len(features) == 0 {
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 {
continue
}
requests = append(requests, service.AutoConfigRequest{
DeviceID: dev.DeviceID,
Features: features,
SourceNames: sources,
})
}
if len(requests) == 0 {
http.Redirect(w, r, "/console?msg="+url.QueryEscape("未选择检测功能"), http.StatusFound)
return
}
// Step 3: save features to DB BEFORE creating tasks (avoids SQLITE_BUSY race).
for _, req := range requests {
_ = u.preview.SaveDeviceFeatures(req.DeviceID, req.Features)
}
// Step 4: run pipeline (creates tasks asynchronously).
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, "/console?msg="+url.QueryEscape("正在下发配置...")+"&task="+taskIDs[0], http.StatusFound)
return
}
if len(taskIDs) > 1 {
http.Redirect(w, r, "/console?msg="+url.QueryEscape(fmt.Sprintf("正在为 %d 台设备下发配置...", len(taskIDs))), http.StatusFound)
return
}
if len(errs) > 0 {
http.Redirect(w, r, "/console?error="+url.QueryEscape(strings.Join(errs, "; ")), http.StatusFound)
return
}
http.Redirect(w, r, "/console?msg="+url.QueryEscape("配置已保存"), http.StatusFound)
}
func (u *UI) pageWizard(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
data := PageData{Title: "部署向导"}
data.Devices = u.registry.GetDevices()
if u.preview != nil {
if sources, err := u.preview.ListVideoSources(); err == nil {
data.ConsoleAllVideoSources = sources
type wizardSource struct {
Name string `json:"name"`
URL string `json:"url"`
}
var list []wizardSource
for _, s := range sources {
list = append(list, wizardSource{Name: s.Name, URL: s.Config.URL})
}
if b, err := json.Marshal(list); err == nil {
data.WizardVideoSourcesJSON = template.HTML(b)
}
}
}
u.render(w, r, "wizard", data)
}
func (u *UI) actionWizardApply(w http.ResponseWriter, r *http.Request) {
if u.autoConfig == nil {
http.Error(w, "not available", http.StatusServiceUnavailable)
return
}
var req struct {
DeviceID string `json:"device_id"`
Features []string `json:"features"`
SourceNames []string `json:"source_names"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if u.preview != nil && len(req.Features) > 0 {
_ = u.preview.SaveDeviceFeatures(req.DeviceID, req.Features)
}
result, err := u.autoConfig.BuildPipeline(service.AutoConfigRequest{
DeviceID: req.DeviceID,
Features: req.Features,
SourceNames: req.SourceNames,
}, true)
if err != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
func (u *UI) actionWizardCreateSource(w http.ResponseWriter, r *http.Request) {
if u.preview == nil {
http.Error(w, "not available", http.StatusServiceUnavailable)
return
}
name := strings.TrimSpace(r.FormValue("name"))
rtspURL := strings.TrimSpace(r.FormValue("url"))
if name == "" || rtspURL == "" {
http.Error(w, "name and url required", http.StatusBadRequest)
return
}
asset := service.ConfigVideoSourceAsset{
Name: name,
SourceType: "rtsp",
Config: service.VideoSourceConfig{
URL: rtspURL,
Resolution: "1920x1080",
FPS: "25",
},
}
if err := u.preview.SaveVideoSourceAsset(asset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"name": name})
}
func (u *UI) apiWizardDeviceInfo(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.URL.Query().Get("device_id"))
if deviceID == "" {
http.Error(w, "device_id required", http.StatusBadRequest)
return
}
var dev *models.Device
for _, d := range u.registry.GetDevices() {
if d != nil && d.DeviceID == deviceID {
dev = d
break
}
}
if dev == nil {
http.Error(w, "device not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"online": dev.Online,
"device_id": dev.DeviceID,
"name": dev.DisplayName(),
}
if u.agent != nil && dev.Online {
// Channels: use management DB for friendly names and RTSP URLs
channels := queryDeviceChannelsFromDB(u.preview, deviceID)
resp["channels"] = channels
caps := queryAgentCapabilities(u.agent, dev)
resp["capabilities"] = caps
// Include currently enabled features from management DB
if u.preview != nil {
if enabled, err := u.preview.GetDeviceFeatures(deviceID); err == nil {
resp["enabled_features"] = enabled
}
}
}
json.NewEncoder(w).Encode(resp)
}
func queryDeviceChannelsFromDB(preview *service.ConfigPreviewService, deviceID string) []map[string]string {
if preview == nil {
return nil
}
assignment, err := preview.GetDeviceAssignment(deviceID)
if err != nil || assignment == nil {
return nil
}
var channels []map[string]string
for _, ref := range assignment.RecognitionUnits {
unit, err := preview.GetRecognitionUnit(ref)
if err != nil || unit == nil {
continue
}
ch := map[string]string{
"name": unit.Name,
}
if unit.DisplayName != "" {
ch["display"] = unit.DisplayName
} else {
ch["display"] = unit.Name
}
if unit.VideoSourceRef != "" {
src, err := preview.GetVideoSource(unit.VideoSourceRef)
if err == nil && src != nil && src.Config.URL != "" {
ch["rtsp_url"] = src.Config.URL
}
}
channels = append(channels, ch)
}
return channels
}
func queryAgentChannels(agent *service.AgentClient, dev *models.Device) []map[string]string {
body, code, err := agent.Do("GET", dev.IP, dev.AgentPort, "/v1/preview/channels", nil)
if err != nil || code != 200 {
return nil
}
var result struct {
Channels []struct {
Name string `json:"name"`
HlsURL string `json:"hls_url"`
} `json:"channels"`
}
if json.Unmarshal(body, &result) != nil {
return nil
}
var channels []map[string]string
for _, ch := range result.Channels {
channels = append(channels, map[string]string{
"name": ch.Name,
"hls_url": ch.HlsURL,
})
}
return channels
}
func queryAgentCapabilities(agent *service.AgentClient, dev *models.Device) []map[string]any {
body, code, err := agent.Do("GET", dev.IP, dev.AgentPort, "/v1/capabilities", nil)
if err != nil || code != 200 {
return nil
}
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 {
return nil
}
var caps []map[string]any
for _, c := range capsResp.Capabilities {
caps = append(caps, map[string]any{
"key": c.Key,
"label": c.Label,
"available": c.Available,
"description": c.Description,
})
}
return caps
}
func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, ""))
}
func (u *UI) actionDevicesCleanupOffline(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
ids := r.Form["device_id"]
if len(ids) == 0 {
http.Redirect(w, r, "/devices?error="+urlQueryEscape("未选择设备"), http.StatusFound)
return
}
u.ensureDevicesLoaded()
devices := u.registry.GetDevices()
deviceMap := map[string]*models.Device{}
for _, dev := range devices {
if dev != nil {
deviceMap[dev.DeviceID] = dev
}
}
deleted := 0
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
dev := deviceMap[id]
if dev != nil && dev.Online {
continue // skip online devices
}
if strings.TrimSpace(u.dbPath) != "" {
if store, err := storage.OpenSQLite(u.dbPath); err == nil {
db := store.DB()
db.Exec("DELETE FROM device_assignments WHERE device_id = ?", id)
db.Exec("DELETE FROM device_config_state WHERE device_id = ?", id)
db.Exec("DELETE FROM device_features WHERE device_id = ?", id)
db.Exec("DELETE FROM config_versions WHERE device_id = ?", id)
db.Exec("DELETE FROM devices WHERE device_id = ?", id)
store.Close()
u.registry.RemoveDevice(id)
deleted++
}
}
}
http.Redirect(w, r, "/devices?msg="+urlQueryEscape(fmt.Sprintf("已清理 %d 台离线设备", deleted)), http.StatusFound)
}
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, "/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, "/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"))
// cleanup_offline handles device IDs directly (may include DB-only offline devices)
if action == "cleanup_offline" {
u.actionDevicesCleanupOffline(w, r)
return
}
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, "/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, "/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
}
}
// Load device model and resource status vs management standards.
if dev.Online && u.agent != nil {
if items, err := service.FetchInstalledModelStatuses(u.agent, dev); err == nil {
data.DeviceModelStatuses = items
}
if items, err := service.FetchInstalledResourceStatuses(u.agent, dev); err == nil {
data.DeviceResourceStatuses = items
}
}
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
}
resourcesRepo := storage.NewResourcesRepo(store.DB())
if items, err := resourcesRepo.List(); err == nil {
data.StandardResources = items
}
store.Close()
}
}
return data
}
func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
http.Redirect(w, r, "/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/edge-server/reload"
case "rollback":
path = "/v1/edge-server/rollback"
case "media_start":
path = "/v1/edge-server/start"
case "media_restart":
path = "/v1/edge-server/restart"
case "media_stop":
path = "/v1/edge-server/stop"
case "media_status":
method = "GET"
path = "/v1/edge-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) pageDeviceConfigVersions(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)
data.Title = "配置历史"
if u.configVersionsRepo != nil {
versions, _ := u.configVersionsRepo.List(id)
data.ConfigVersions = versions
}
u.render(w, r, "config_versions", data)
}
func (u *UI) apiDeviceConfigVersion(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "versionID")
if u.configVersionsRepo == nil {
http.Error(w, "not available", http.StatusServiceUnavailable)
return
}
vid, _ := strconv.Atoi(id)
rec, err := u.configVersionsRepo.GetByID(vid)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rec)
}
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/edge-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/edge-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, "/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, "/assets/templates", http.StatusFound)
}
func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/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{}
var installed map[string][]service.InstalledModelStatus
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, nil)
}
_ = store.Close()
}
}
// Query device capabilities for compatibility matrix.
// Devices without a required capability show "N/A" instead of "缺失".
if u.agent != nil && data.StandardModels != nil {
deviceCapabilities := map[string]map[string]bool{}
for _, dev := range data.Devices {
if dev == nil || !dev.Online {
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"`
Available bool `json:"available"`
} `json:"capabilities"`
}
if json.Unmarshal(body, &capsResp) != nil {
continue
}
caps := map[string]bool{}
for _, c := range capsResp.Capabilities {
if c.Available {
caps[c.Key] = true
}
}
deviceCapabilities[dev.DeviceID] = caps
}
if len(deviceCapabilities) > 0 {
if installed == nil {
installed = map[string][]service.InstalledModelStatus{}
}
board = service.BuildModelStatusBoard(data.StandardModels, data.Devices, installed, deviceCapabilities)
}
}
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, "/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
func (u *UI) actionModelUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(100 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
modelType := strings.TrimSpace(r.FormValue("model_type"))
version := strings.TrimSpace(r.FormValue("version"))
description := strings.TrimSpace(r.FormValue("description"))
autoSync := r.FormValue("auto_sync") == "1"
if name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
if modelType == "" {
modelType = "other"
}
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
if !strings.HasSuffix(strings.ToLower(hdr.Filename), ".rknn") {
http.Error(w, "only .rknn files are supported", http.StatusBadRequest)
return
}
modelsDir := filepath.Join("models", "standard_models")
if err := os.MkdirAll(modelsDir, 0o755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dstPath := filepath.Join(modelsDir, hdr.Filename)
dst, err := os.Create(dstPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer dst.Close()
hasher := sha256.New()
writer := io.MultiWriter(dst, hasher)
size, err := io.Copy(writer, file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sha256sum := hex.EncodeToString(hasher.Sum(nil))
if version == "" {
version = time.Now().Format("20060102-150405")
}
if strings.TrimSpace(u.dbPath) != "" {
if store, err := storage.OpenSQLite(u.dbPath); err == nil {
modelsRepo := storage.NewModelsRepo(store.DB())
modelsRepo.Save(storage.StandardModelRecord{
Name: name, FileName: hdr.Filename,
Version: version, SHA256: sha256sum,
SizeBytes: size, ModelType: modelType,
Description: description,
})
store.Close()
}
}
if autoSync {
u.ensureDevicesLoaded()
deviceIDs := make([]string, 0)
for _, dev := range u.registry.GetDevices() {
if dev != nil && dev.Online {
deviceIDs = append(deviceIDs, dev.DeviceID)
}
}
if len(deviceIDs) > 0 {
payload := map[string]any{"model_name": name}
task, _ := u.tasks.CreateTask("model_sync_all", deviceIDs, payload)
http.Redirect(w, r, "/tasks/"+url.PathEscape(task.ID), http.StatusFound)
return
}
}
http.Redirect(w, r, "/models?msg="+url.QueryEscape("模型 "+name+" 上传成功"), 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()}
// Parse date filter: default to past 7 days.
q := r.URL.Query()
today := time.Now().Format("2006-01-02")
weekAgo := time.Now().AddDate(0, 0, -7).Format("2006-01-02")
from := strings.TrimSpace(q.Get("from"))
to := strings.TrimSpace(q.Get("to"))
if from == "" && to == "" {
from = weekAgo
to = today
}
// Parse page: ?page=N (default 1), ?per_page=N (default 20)
page := 1
if p, err := strconv.Atoi(q.Get("page")); err == nil && p > 0 {
page = p
}
perPage := 20
if pp, err := strconv.Atoi(q.Get("per_page")); err == nil && pp >= 10 && pp <= 200 {
perPage = pp
}
data.SelectedFrom = from
data.SelectedTo = to
data.CurrentPage = page
data.PerPage = perPage
// Compute quick-link ranges.
now := time.Now()
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
mon := now.AddDate(0, 0, -(weekday - 1))
data.WeekStart = mon.Format("2006-01-02")
data.WeekEnd = today
data.MonthStart = now.Format("2006-01-02")[:8] + "01"
data.MonthEnd = today
data.SelectedAlarmDevice = strings.TrimSpace(q.Get("device"))
data.SelectedAlarmRuleType = strings.TrimSpace(q.Get("rule_type"))
if u.alarmCollector != nil {
data.AlarmRecords, data.TotalAlarmCount = u.alarmCollector.GetFiltered(from, to, perPage, (page-1)*perPage, data.SelectedAlarmDevice, data.SelectedAlarmRuleType)
data.AlarmFilterDevices = u.alarmCollector.GetDistinctDeviceIDs()
data.AlarmFilterRuleTypes = u.alarmCollector.GetDistinctRuleTypes()
}
// Calculate total pages and build page list: 1, ..., cur-2, cur-1, cur, cur+1, cur+2, ..., N
if data.TotalAlarmCount > 0 {
data.TotalAlarmPages = (data.TotalAlarmCount + perPage - 1) / perPage
tp := data.TotalAlarmPages
// Always include first and last; include current ±2; fill gaps with -1 (ellipsis).
seen := map[int]bool{}
add := func(p int) {
if p >= 1 && p <= tp && !seen[p] {
seen[p] = true
data.PageNumbers = append(data.PageNumbers, p)
}
}
add(1)
for p := page - 2; p <= page+2; p++ {
add(p)
}
add(tp)
// Insert -1 for gaps > 1.
compact := make([]int, 0, len(data.PageNumbers))
for i, p := range data.PageNumbers {
if i > 0 && p > data.PageNumbers[i-1]+1 {
compact = append(compact, -1)
}
compact = append(compact, p)
}
data.PageNumbers = compact
}
u.render(w, r, "alarms", data)
}
func (u *UI) actionAcknowledgeAlarm(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if u.alarmCollector == nil || id == "" {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := u.alarmCollector.AcknowledgeAlarm(id, "admin"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (u *UI) actionResolveAlarm(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if u.alarmCollector == nil || id == "" {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := u.alarmCollector.ResolveAlarm(id, "admin"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (u *UI) actionUpdateAlarmSeverity(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if u.alarmCollector == nil || id == "" {
http.Error(w, "not found", http.StatusNotFound)
return
}
severity := strings.TrimSpace(r.FormValue("severity"))
if severity == "" {
http.Error(w, "missing severity", http.StatusBadRequest)
return
}
if err := u.alarmCollector.UpdateSeverity(id, severity); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (u *UI) apiAlarmStream(w http.ResponseWriter, r *http.Request) {
if u.alarmCollector == nil {
http.Error(w, "not available", http.StatusServiceUnavailable)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ch := u.alarmCollector.Subscribe()
defer u.alarmCollector.Unsubscribe(ch)
for {
select {
case alarm := <-ch:
data, err := json.Marshal(alarm)
if err != nil {
continue
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
func (u *UI) apiAlarmUnacknowledgedCount(w http.ResponseWriter, r *http.Request) {
count := 0
if u.alarmCollector != nil {
count = u.alarmCollector.GetUnacknowledgedCount()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"count": count})
}
func (u *UI) apiAlarmDailyCount(w http.ResponseWriter, r *http.Request) {
days := 7
if d, err := strconv.Atoi(r.URL.Query().Get("days")); err == nil && d > 0 && d <= 90 {
days = d
}
result := u.alarmCollector.GetDailyCounts(days)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
func (u *UI) apiAlarmExport(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
from := strings.TrimSpace(q.Get("from"))
to := strings.TrimSpace(q.Get("to"))
deviceID := strings.TrimSpace(q.Get("device"))
ruleType := strings.TrimSpace(q.Get("rule_type"))
if from == "" {
from = time.Now().AddDate(0, 0, -7).Format("2006-01-02")
}
if to == "" {
to = time.Now().Format("2006-01-02")
}
records := u.alarmCollector.ExportFiltered(from, to, deviceID, ruleType)
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="alarms_%s_%s.csv"`, from, to))
w.Header().Set("Cache-Control", "no-cache")
w.Write([]byte("\xef\xbb\xbf"))
w.Write([]byte("时间,设备ID,通道,规则,目标,置信度,等级,状态\n"))
for _, a := range records {
fmt.Fprintf(w, "%s,%s,%s,%s,%s,%.2f,%s,%s\n",
escapeCSV(a.Timestamp), escapeCSV(a.DeviceID), escapeCSV(a.Channel),
escapeCSV(a.RuleName), escapeCSV(a.ObjectLabel), a.Confidence,
escapeCSV(a.Severity), escapeCSV(a.Status))
}
}
func escapeCSV(s string) string {
if strings.ContainsAny(s, ",\"\n") {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
return s
}
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, "/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/assets", http.StatusFound)
}
func (u *UI) pageLogs(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/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, "/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, "/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准模板复制创建"), http.StatusFound)
return
}
targetName, err := u.nextTemplateCloneName(item.Name)
if err != nil {
http.Redirect(w, r, "/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, "/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, "/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, "/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/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, "/assets/templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/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 = "/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 = "/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 = "/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 = "/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 = "/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 = "/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, "/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, "/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, "/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(msg), http.StatusFound)
return
}
}
if err := u.preview.DeleteProfileAsset(name); err != nil {
http.Redirect(w, r, "/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/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 := "/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, "/scene-templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound)
}
func (u *UI) redirectPlansToSceneTemplates(w http.ResponseWriter, r *http.Request) {
target := "/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 := "/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, "/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, "/recognition-units?error="+urlQueryEscape(err.Error())+"&ref="+url.QueryEscape(ref), http.StatusFound)
return
}
http.Redirect(w, r, "/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 {
// Enrich board cards with config sync status.
if u.agent != nil && u.configVersionsRepo != nil {
for i, card := range board.Cards {
versions, _ := u.configVersionsRepo.List(card.DeviceID)
if len(versions) == 0 {
continue
}
expectedSHA := versions[0].ConfigID
for _, dev := range data.Devices {
if dev == nil || dev.DeviceID != card.DeviceID || !dev.Online {
continue
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil)
if err != nil || code != 200 {
continue
}
var statusResp struct {
SHA256 string `json:"sha256"`
}
if json.Unmarshal(body, &statusResp) == nil {
board.Cards[i].ConfigSynced = strings.EqualFold(statusResp.SHA256, expectedSHA)
}
break
}
}
}
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, "/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/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, "/device-assignments?error="+urlQueryEscape(err.Error())+"&device_id="+url.PathEscape(deviceID), http.StatusFound)
return
}
http.Redirect(w, r, "/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 {
if newMode {
data.AssetVideoSource = &service.ConfigVideoSourceAsset{
SourceType: "rtsp",
SourceTypeLabel: "RTSP",
}
data.AssetVideoSourceEditing = true
}
// otherwise just leave nil → nothing selected, no editor shown
} 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, "/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, "/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
return
}
http.Redirect(w, r, "/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound)
}
func (u *UI) actionVideoSourceCSVTemplate(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=video_sources_template.csv")
w.Write([]byte("\xef\xbb\xbf")) // BOM for Excel
w.Write([]byte("name,source_type,area,url,resolution,fps,description\n"))
w.Write([]byte("车间东门,rtsp,车间A,rtsp://192.168.1.100:554/stream1,1920x1080,25,\n"))
w.Write([]byte("车间西门,rtsp,车间A,rtsp://192.168.1.101:554/stream1,1920x1080,25,\n"))
}
func (u *UI) actionVideoSourceCSVImport(w http.ResponseWriter, r *http.Request) {
if u.preview == nil {
http.Error(w, "not available", http.StatusServiceUnavailable)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("文件解析失败"), http.StatusFound)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("请选择文件"), http.StatusFound)
return
}
defer file.Close()
// Read raw bytes for encoding detection
rawBytes, err := io.ReadAll(file)
if err != nil {
http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("读取文件失败: "+err.Error()), http.StatusFound)
return
}
// Strip UTF-8 BOM if present
rawBytes = bytes.TrimPrefix(rawBytes, []byte("\xef\xbb\xbf"))
// Try UTF-8 first; if invalid, try GBK
if !utf8.Valid(rawBytes) {
decoder := simplifiedchinese.GBK.NewDecoder()
decoded, err := decoder.Bytes(rawBytes)
if err != nil {
http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("文件编码不支持,请保存为 UTF-8 格式"), http.StatusFound)
return
}
rawBytes = decoded
}
reader := csv.NewReader(bytes.NewReader(rawBytes))
reader.LazyQuotes = true
records, err := reader.ReadAll()
if err != nil {
http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("CSV 解析失败: "+err.Error()), http.StatusFound)
return
}
if len(records) < 2 {
http.Redirect(w, r, "/assets/video-sources?error="+urlQueryEscape("CSV 文件为空或无数据行"), http.StatusFound)
return
}
imported := 0
skipped := 0
for i, row := range records {
if i == 0 {
continue // skip header
}
if len(row) < 4 {
skipped++
continue
}
name := strings.TrimSpace(row[0])
sourceType := strings.TrimSpace(row[1])
area := ""
url := ""
resolution := ""
fps := ""
if len(row) > 2 {
area = strings.TrimSpace(row[2])
}
if len(row) > 3 {
url = strings.TrimSpace(row[3])
}
if len(row) > 4 {
resolution = strings.TrimSpace(row[4])
}
if len(row) > 5 {
fps = strings.TrimSpace(row[5])
}
if name == "" || url == "" {
skipped++
continue
}
if sourceType == "" {
sourceType = "rtsp"
}
if resolution == "" {
resolution = "1920x1080"
}
if fps == "" {
fps = "25"
}
asset := service.ConfigVideoSourceAsset{
Name: name,
SourceType: sourceType,
Area: area,
Config: service.VideoSourceConfig{
URL: url,
Resolution: resolution,
FPS: fps,
},
}
if err := u.preview.SaveVideoSourceAsset(asset); err != nil {
skipped++
continue
}
imported++
}
msg := fmt.Sprintf("导入完成:%d 个视频源,%d 条跳过", imported, skipped)
http.Redirect(w, r, "/assets/video-sources?msg="+urlQueryEscape(msg), 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 {
if newMode {
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, "/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, "/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)
return
}
http.Redirect(w, r, "/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, "/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
return
}
http.Redirect(w, r, "/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, "/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, "/assets/overlays?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准调试参数复制创建"), http.StatusFound)
return
}
targetName, err := u.nextOverlayCloneName(item.Name)
if err != nil {
http.Redirect(w, r, "/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, "/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, "/assets/overlays?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound)
return
}
http.Redirect(w, r, "/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, "/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/schema", nil)
stateBody, stateCode, stateErr := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/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/schema -> %d\n%s", schemaCode, prettyJSON(schemaBody)),
StateJSON: fmt.Sprintf("GET /v1/config/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 featuresFromTemplate(templateName string) []string {
var features []string
if strings.Contains(templateName, "face_recognition") {
features = append(features, "face")
}
if strings.Contains(templateName, "shoe") {
features = append(features, "shoe")
}
return features
}
func jsonGetStr(body []byte, key string) string {
var m map[string]any
if json.Unmarshal(body, &m) == nil {
if s, ok := m[key].(string); ok {
return s
}
}
return ""
}
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 sortedCopy(s []string) []string {
if s == nil {
return nil
}
c := make([]string, len(s))
copy(c, s)
sort.Strings(c)
return c
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
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, "/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 {
// Skip discovery when just toggling selection (device_id in query).
if len(r.URL.Query()["device_id"]) == 0 && len(r.URL.Query()["selected"]) == 0 {
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,
SelectedDevices: selectedDevicesFromIDs(devices, selectedIDs),
SelectedQuery: selectedQueryString(selectedIDs),
SelectedDevicesURL: selectedURL("/devices", selectedIDs),
BatchConfigURL: selectedURL("/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/plan", []byte(raw))
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("POST /v1/config/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/apply", []byte(raw))
data := u.loadConfigUIData(dev)
data.Message = fmt.Sprintf("POST /v1/config/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")
_, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
task, err := u.tasks.CreateTask("face_gallery_reload", []string{id}, nil)
if err != nil {
http.Redirect(w, r, "/devices/"+url.PathEscape(id)+"?error="+url.QueryEscape(err.Error()), http.StatusFound)
return
}
http.Redirect(w, r, "/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
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)
// Register person and photo in DB so the face gallery list reflects it.
if strings.TrimSpace(u.dbPath) != "" {
repo := storage.NewFaceGalleryRepo(u.dbPath)
pid, _ := repo.FindOrCreatePerson(name)
_ = repo.AddPhoto(pid, name+"/"+hdr.Filename)
}
http.Redirect(w, r, "/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, "/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound)
return
}
// Parse build report for quality diagnostics.
// Output sections: per person used samples, failure reasons, persons skipped.
persons := "0"
photosOK := "0"
photosFailed := "0"
failureReasons := map[string]string{}
perPerson := map[string]string{}
currentSection := ""
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "enrolled_persons:"):
persons = strings.TrimSpace(strings.TrimPrefix(line, "enrolled_persons:"))
case strings.HasPrefix(line, "ok_images:"):
photosOK = strings.TrimSpace(strings.TrimPrefix(line, "ok_images:"))
case strings.HasPrefix(line, "failed_images:"):
photosFailed = strings.TrimSpace(strings.TrimPrefix(line, "failed_images:"))
case strings.HasPrefix(line, "-- per person"):
currentSection = "per_person"
case strings.HasPrefix(line, "-- failure reasons"):
currentSection = "failures"
case strings.HasPrefix(line, "-- persons skipped"):
currentSection = "skipped"
case strings.HasPrefix(line, "-- db selfcheck"):
currentSection = ""
default:
if currentSection == "per_person" && strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
perPerson[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
} else if currentSection == "failures" && strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
failureReasons[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
}
}
// Register the built face_gallery.db as a standard resource so the
// Resource sync system can detect and distribute it to edge devices.
if u.resourcesRepo != nil {
resourceSvc := service.NewResourceManagementService(u.resourcesRepo)
if err := resourceSvc.RegisterResource(builder.OutputDB, "face_gallery", "face_gallery"); err != nil {
log.Printf("register face_gallery resource: %v", err)
}
}
// Build result message with quality diagnostics.
reasonCN := map[string]string{
"no_face": "无人脸", "small_face": "人脸太小", "align_fail": "对齐失败",
"infer_fail": "识别失败", "read_fail": "读取失败", "det_fail": "检测失败",
}
var reasonParts []string
for k, v := range failureReasons {
label := k
if cn, ok := reasonCN[k]; ok {
label = cn
}
reasonParts = append(reasonParts, fmt.Sprintf("%s×%s", label, v))
}
msg := fmt.Sprintf("生成成功,共 %s 人,%s 张通过", persons, photosOK)
if photosFailed != "0" && photosFailed != "" {
msg += fmt.Sprintf(",%s 张失败", photosFailed)
if len(reasonParts) > 0 {
msg += fmt.Sprintf("(%s)", strings.Join(reasonParts, "、"))
}
}
// Write per-person quality stats for the face gallery page to display.
if len(perPerson) > 0 {
metaPath := filepath.Join("resources", "standard_resources", "face_gallery", "build_meta.json")
if metaJSON, err := json.Marshal(perPerson); err == nil {
os.WriteFile(metaPath, metaJSON, 0o644)
}
}
http.Redirect(w, r, "/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")
// Load build quality stats if available.
metaPath := filepath.Join("resources", "standard_resources", "face_gallery", "build_meta.json")
if metaBytes, err := os.ReadFile(metaPath); err == nil {
var meta map[string]string
if json.Unmarshal(metaBytes, &meta) == nil {
data.FaceGalleryQuality = meta
}
}
if strings.TrimSpace(u.dbPath) != "" {
if repo := storage.NewFaceGalleryRepo(u.dbPath); repo != nil {
if persons, err := repo.ListPersons(); err == nil {
data.FaceGalleryPersons = persons
}
}
}
// Load face_gallery resource status board for device sync section
u.ensureDevicesLoaded()
data.Devices = u.registry.GetDevices()
if u.resourcesRepo != nil {
allResources, _ := u.resourcesRepo.List()
faceResources := make([]storage.StandardResourceRecord, 0)
for _, r := range allResources {
if r.ResourceType == "face_gallery" {
faceResources = append(faceResources, r)
}
}
if len(faceResources) > 0 {
data.StandardResources = faceResources
board := service.FetchAndBuildResourceBoard(u.agent, data.Devices, faceResources)
data.ResourceStatusBoard = &board
}
}
u.render(w, r, "face_gallery", data)
}
func (u *UI) actionFaceGallerySync(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 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{"resource_name": "face_gallery"}
task, err := u.tasks.CreateTask("resource_sync_all", deviceIDs, payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
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, "/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("/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, "*")
// URL-decode to normalize case-insensitive percent-encoding
if decoded, err := url.PathUnescape(path); err == nil {
path = decoded
}
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, "/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, "/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, "/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
}