package web
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"io"
"mime"
"mime/multipart"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/service"
"3588AdminBackend/internal/storage"
"github.com/go-chi/chi/v5"
)
type UI struct {
discovery *service.DiscoveryService
registry *service.RegistryService
agent *service.AgentClient
tasks *service.TaskService
templates *service.TemplateService
preview *service.ConfigPreviewService
stateRepo *storage.DeviceConfigStateRepo
auditRepo *storage.AuditLogsRepo
dbPath string
resourcesRepo *storage.ResourcesRepo
alarmCollector *service.AlarmCollector
autoConfig *service.AutoConfigService
tpl *template.Template
}
const (
deviceAssignmentPreviewDevicePrefix = "demo-edge-"
deviceAssignmentPreviewDeviceCount = 8
)
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
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
DeviceMetrics []DeviceMetric
ConsoleDevices []ConsoleDeviceData
ConsoleVideoSources []service.ConfigVideoSourceAsset
ConsoleAllVideoSources []service.ConfigVideoSourceAsset
ConsoleUnassignedUnits []service.RecognitionUnitAsset
FaceGalleryPersons []storage.PersonRecord
Templates []service.Template
Template *service.Template
AssetTab string
AssetTemplates []service.ConfigTemplateAsset
AssetTemplate *service.ConfigTemplateAsset
AssetTemplateEditing bool
AssetProfiles []service.ConfigProfileAsset
AssetProfile *service.ConfigProfileAsset
AssetProfileEditor *service.ConfigProfileEditor
AssetProfileEditing bool
AssetProfileFormAction string
ActiveInstanceIndex int
RecognitionUnits []service.RecognitionUnitAsset
RecognitionUnit *service.RecognitionUnitAsset
RecognitionUnitEditing bool
DeviceAssignments []service.DeviceAssignmentAsset
DeviceAssignment *service.DeviceAssignmentAsset
DeviceAssignmentEditing bool
DeviceAssignmentBoard *service.DeviceAssignmentBoard
DeviceAssignmentBoardJSON template.JS
MaxUnitsPerDevice int
AssetTemplateMap map[string]service.ConfigTemplateAsset
AssetVideoSources []service.ConfigVideoSourceAsset
AssetVideoSource *service.ConfigVideoSourceAsset
AssetVideoSourceEditing bool
AssetIntegrations []service.ConfigIntegrationServiceAsset
AssetIntegration *service.ConfigIntegrationServiceAsset
AssetIntegrationEditing bool
AssetOverlays []service.ConfigOverlayAsset
AssetOverlay *service.ConfigOverlayAsset
AssetOverlayEditing bool
AssetInstanceCount int
SelectedDeviceIDs []string
SelectedDevices []*models.Device
SelectedQuery string
SelectedDevicesURL string
BatchConfigURL string
ReloadSummary string
RollbackSummary string
TemplateDraftName string
TemplateDraftDescription string
TemplateCloneSource string
TemplateCloneName string
TemplateCloneDesc string
TemplateCreateMode string
OverlayDraftJSON string
IntegrationConfigDraft string
AuditEntries []storage.AuditLogRecord
PersistedConfig *storage.DeviceConfigStateRecord
DBPath string
RawJSON string
RawText string
SchemaJSON string
StateJSON string
FaceGalleryJSON string
TaskID string
DeviceIDs string
RunningTaskCount int
FailedTaskCount int
SuccessTaskCount int
}
type DeviceOverviewRow struct {
Device *models.Device
ConfigStatus *ConfigStatusView
ConfigStatusErr string
}
type TaskDeviceRow struct {
Device *models.Device
Status models.TaskStatus
Progress float64
Error string
}
type ConfigStatusView struct {
OK bool `json:"ok"`
ConfigPath string `json:"config_path"`
Exists bool `json:"exists"`
Sha256 string `json:"sha256"`
Size int64 `json:"size"`
Metadata ConfigStatusMetadata `json:"metadata"`
Candidate *ConfigStatusLastGoodFile `json:"candidate"`
MediaServer ConfigStatusMediaServer `json:"media_server"`
PreviousConfig *ConfigStatusLastGoodFile `json:"previous_config"`
PreviousConfigPath string `json:"previous_config_path"`
}
type ConfigStatusMetadata struct {
ConfigID string `json:"config_id"`
ConfigVersion string `json:"config_version"`
BusinessName string `json:"business_name"`
Template string `json:"template"`
Profile string `json:"profile"`
Overlays []string `json:"overlays"`
RenderedAt string `json:"rendered_at"`
RenderedBy string `json:"rendered_by"`
InstanceName string `json:"instance_name"`
InstanceDisplayName string `json:"instance_display_name"`
}
type ConfigStatusMediaServer struct {
Running bool `json:"running"`
PID int `json:"pid"`
Version string `json:"version"`
}
type ConfigStatusLastGoodFile struct {
Path string `json:"path"`
Exists bool `json:"exists"`
Sha256 string `json:"sha256"`
Metadata ConfigStatusMetadata `json:"metadata"`
}
func NewUI(discovery *service.DiscoveryService, registry *service.RegistryService, agent *service.AgentClient, tasks *service.TaskService, templates *service.TemplateService, preview ...*service.ConfigPreviewService) (*UI, error) {
tpl, err := template.New("layout").Funcs(template.FuncMap{
"json": func(v any) string {
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
},
"rawHTML": func(v string) template.HTML {
return template.HTML(v)
},
"hasString": func(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
},
"shortHash": func(v string) string {
v = strings.TrimSpace(v)
if len(v) > 8 {
return v[:8]
}
return v
},
"modelTypeLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "face_detection":
return "人脸检测"
case "face_recognition":
return "人脸识别"
case "object_detection":
return "通用检测"
case "ppe_detection":
return "PPE检测"
case "shoe_detection":
return "工鞋检测"
case "other":
return "其他"
default:
return "-"
}
},
"resourceTypeLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "face_gallery":
return "人脸库"
case "dataset":
return "数据集"
case "calibration":
return "标定文件"
default:
return v
}
},
"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 "资源同步"
default:
return "其他任务"
}
},
"taskActionLabel": func(v any) string {
switch fmt.Sprint(v) {
case "config_apply":
return "下发设备分配"
case "model_sync_one":
return "更新单个模型"
case "model_sync_all":
return "更新全部模型"
case "resource_sync_one":
return "同步单个资源"
case "resource_sync_all":
return "同步全部资源"
case "reload":
return "重载识别服务"
case "rollback":
return "回滚识别配置"
case "media_start":
return "启动视频分析服务"
case "media_restart":
return "重启视频分析服务"
case "media_stop":
return "停止视频分析服务"
default:
return fmt.Sprint(v)
}
},
"taskGroupClass": func(v any) string {
switch fmt.Sprint(v) {
case "config_apply":
return "pill run"
case "model_sync_one", "model_sync_all":
return "pill warn"
case "resource_sync_one", "resource_sync_all":
return "pill warn"
case "media_start", "media_restart", "media_stop":
return "pill ok"
case "reload", "rollback":
return "pill warn"
default:
return "pill"
}
},
"taskStatusLabel": func(v any) string {
switch fmt.Sprint(v) {
case "success":
return "成功"
case "failed":
return "失败"
case "running":
return "执行中"
default:
return "待执行"
}
},
"taskStatusClass": func(v any) string {
switch fmt.Sprint(v) {
case "success":
return "pill ok"
case "failed":
return "pill bad"
case "running":
return "pill run"
default:
return "pill"
}
},
"auditField": func(details string, key string) string {
var m map[string]any
if err := json.Unmarshal([]byte(details), &m); err != nil {
return ""
}
if v, ok := m[key].(string); ok {
return strings.TrimSpace(v)
}
return ""
},
"auditActionLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "config_apply":
return "下发设备分配"
case "reload":
return "重载运行配置"
case "rollback":
return "回滚运行配置"
case "media_start":
return "启动服务"
case "media_restart":
return "重启服务"
case "media_stop":
return "停止服务"
default:
return strings.TrimSpace(v)
}
},
"auditStatusLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "success":
return "成功"
case "failed":
return "失败"
case "running":
return "执行中"
case "pending":
return "待执行"
default:
return strings.TrimSpace(v)
}
},
"ago": func(ms int64) string {
if ms <= 0 {
return "-"
}
d := time.Since(time.UnixMilli(ms))
if d < 0 {
d = 0
}
s := int64(d.Seconds())
switch {
case s < 60:
return fmt.Sprintf("%d秒前", s)
case s < 3600:
return fmt.Sprintf("%d分钟前", s/60)
case s < 86400:
return fmt.Sprintf("%d小时前", s/3600)
default:
return fmt.Sprintf("%d天前", s/86400)
}
},
"icon": func(name string) template.HTML {
return template.HTML(tablerIconSVG(name))
},
"slotTypeLabel": func(v string) string {
switch strings.TrimSpace(v) {
case "video_source":
return "视频源"
case "object_storage":
return "对象存储"
case "alarm_service":
return "告警服务"
case "stream_publish":
return "视频输出"
default:
return strings.TrimSpace(v)
}
},
"inputBindingRef": func(bindings map[string]service.InputBindingEditor, slot string) string {
if len(bindings) == 0 {
return ""
}
return strings.TrimSpace(bindings[slot].VideoSourceRef)
},
"serviceBindingRef": func(bindings map[string]service.ServiceBindingEditor, slot string) string {
if len(bindings) == 0 {
return ""
}
return strings.TrimSpace(bindings[slot].ServiceRef)
},
"outputBindingValue": func(bindings map[string]service.OutputBindingEditor, slot string, field string) string {
if len(bindings) == 0 {
return ""
}
item, ok := bindings[slot]
if !ok {
return ""
}
switch strings.TrimSpace(field) {
case "publish_hls_path":
return strings.TrimSpace(item.PublishHLSPath)
case "publish_rtsp_port":
return strings.TrimSpace(item.PublishRTSPPort)
case "publish_rtsp_path":
return strings.TrimSpace(item.PublishRTSPPath)
case "channel_no":
return strings.TrimSpace(item.ChannelNo)
default:
return ""
}
},
"sub": func(a, b int) int {
return a - b
},
"loop": func(count int) []int {
var r []int
for i := 0; i < count; i++ {
r = append(r, i)
}
return r
},
"mul": func(a, b float64) float64 {
return a * b
},
"div": func(a, b float64) float64 {
if b == 0 {
return 0
}
return a / b
},
"shortID": func(v string) string {
if len(v) > 8 {
return v[:8]
}
return v
},
"add": func(a, b int) int {
return a + b
},
}).ParseFS(uiFS, "ui/templates/*.html")
if err != nil {
return nil, err
}
previewSvc := service.NewConfigPreviewService(nil)
if len(preview) > 0 && preview[0] != nil {
previewSvc = preview[0]
}
return &UI{
discovery: discovery,
registry: registry,
agent: agent,
tasks: tasks,
templates: templates,
preview: previewSvc,
tpl: tpl,
}, nil
}
func (u *UI) SetStateRepo(repo *storage.DeviceConfigStateRepo) {
if u == nil {
return
}
u.stateRepo = repo
}
func (u *UI) SetAuditRepo(repo *storage.AuditLogsRepo) {
if u == nil {
return
}
u.auditRepo = repo
}
func (u *UI) SetDBPath(path string) {
if u == nil {
return
}
u.dbPath = strings.TrimSpace(path)
}
func (u *UI) SetResourcesRepo(repo *storage.ResourcesRepo) {
if u == nil {
return
}
u.resourcesRepo = repo
}
func (u *UI) SetAlarmCollector(ac *service.AlarmCollector) {
if u == nil {
return
}
u.alarmCollector = ac
}
func (u *UI) SetAutoConfig(ac *service.AutoConfigService) {
if u == nil {
return
}
u.autoConfig = ac
}
func tablerIconSVG(name string) string {
icons := map[string]string{
"devices": ``,
"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) Routes() (chi.Router, error) {
r := chi.NewRouter()
assets, err := fs.Sub(uiFS, "ui/assets")
if err != nil {
return nil, err
}
assetHandler := http.StripPrefix("/assets/", http.FileServer(http.FS(assets)))
r.Handle("/assets/*", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
p := req.URL.Path
w.Header().Set("Cache-Control", "no-store, max-age=0")
w.Header().Set("Pragma", "no-cache")
switch {
case strings.HasSuffix(p, ".css"):
w.Header().Set("Content-Type", "text/css; charset=utf-8")
case strings.HasSuffix(p, ".js"):
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
}
assetHandler.ServeHTTP(w, req)
}))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/dashboard", http.StatusFound)
})
r.Get("/dashboard", u.pageDashboard)
r.Get("/console", u.pageConsole)
r.Post("/console", u.actionConsoleSave)
r.Get("/devices", u.pageDevices)
r.Get("/devices/{id}/control", u.pageDeviceControl)
r.Get("/plans", u.redirectPlansToSceneTemplates)
r.Get("/plans/{name}", u.redirectPlanToSceneTemplate)
r.Get("/scene-templates", u.pagePlans)
r.Get("/scene-templates/{name}", u.pagePlan)
r.Post("/scene-templates/create", u.actionPlanCreate)
r.Post("/scene-templates/{name}", u.actionPlanSave)
r.Post("/scene-templates/{name}/delete", u.actionPlanDelete)
r.Get("/scene-templates/{name}/export", u.pagePlanExport)
r.Get("/recognition-units", u.pageRecognitionUnits)
r.Post("/recognition-units", u.actionRecognitionUnitSave)
r.Post("/recognition-units/delete", u.actionRecognitionUnitDelete)
r.Get("/device-assignments", u.pageDeviceAssignments)
r.Post("/device-assignments", u.actionDeviceAssignmentSave)
r.Post("/device-assignments/{id}/delete", u.actionDeviceAssignmentDelete)
r.Get("/assets", u.pageAssets)
r.Get("/assets/video-sources", u.pageAssetVideoSources)
r.Post("/assets/video-sources", u.actionAssetVideoSourceSave)
r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete)
r.Get("/assets/templates", u.pageAssetTemplates)
r.Post("/assets/templates/create", u.actionAssetTemplateCreate)
r.Post("/assets/templates/{name}/clone", u.actionAssetTemplateClone)
r.Get("/assets/templates/{name}", u.pageAssetTemplate)
r.Post("/assets/templates/{name}/rename", u.actionAssetTemplateRename)
r.Post("/assets/templates/{name}/delete", u.actionAssetTemplateDelete)
r.Get("/assets/templates/{name}/graph", u.pageAssetTemplateGraph)
r.Post("/assets/templates/{name}/graph", u.actionAssetTemplateGraphSave)
r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport)
r.Get("/assets/profiles", u.redirectAssetProfilesToPlans)
r.Get("/assets/profiles/{name}", u.redirectAssetProfileToPlan)
r.Post("/assets/profiles/{name}", u.actionPlanSave)
r.Get("/assets/profiles/{name}/export", u.pagePlanExport)
r.Get("/assets/integrations", u.pageAssetIntegrations)
r.Post("/assets/integrations", u.actionAssetIntegrationSave)
r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete)
r.Get("/assets/overlays", u.pageAssetOverlays)
r.Post("/assets/overlays", u.actionAssetOverlaySave)
r.Post("/assets/overlays/{name}/clone", u.actionAssetOverlayClone)
r.Post("/assets/overlays/{name}/delete", u.actionAssetOverlayDelete)
r.Get("/assets/overlays/{name}", u.pageAssetOverlay)
r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport)
r.Get("/audit", u.pageAudit)
r.Get("/system", u.pageSystem)
r.Get("/system/db-backup", u.pageSystemDBBackup)
r.Get("/resources", u.pageResources)
r.Post("/system/db-restore", u.actionSystemDBRestore)
r.Get("/api/graph-node-types", u.apiGraphNodeTypes)
r.Get("/device-config", u.pageDeviceConfig)
r.Get("/device-config/{id}", u.pageDeviceConfigDetail)
r.Get("/devices-add", u.pageDeviceAdd)
r.Post("/devices-add", u.actionDeviceAdd)
r.Post("/devices/batch-action", u.actionDevicesBatchAction)
r.Get("/devices/batch-config", u.pageDeviceBatchConfig)
r.Post("/devices/batch-config", u.actionDeviceBatchConfig)
r.Post("/discovery/search", u.actionDiscoverySearch)
r.Get("/devices/{id}", u.pageDevice)
r.Post("/devices/{id}/alias", u.actionDeviceAliasSave)
r.Post("/devices/{id}/action", u.actionDeviceAction)
r.Get("/devices/{id}/logs", u.pageDeviceLogs)
r.Get("/devices/{id}/graphs", u.pageDeviceGraphs)
r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply)
r.Post("/devices/{id}/plan-apply", u.actionDevicePlanApply)
r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI)
r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly)
r.Get("/devices/{id}/config-preview", u.pageDeviceConfigPreview)
r.Post("/devices/{id}/config-preview", u.actionDeviceConfigPreview)
r.Post("/devices/{id}/config-candidate", u.actionDeviceConfigCandidate)
r.Post("/devices/{id}/config-candidate/apply", u.actionDeviceConfigCandidateApply)
r.Post("/devices/{id}/config-ui/plan", u.actionDeviceConfigUIPlan)
r.Post("/devices/{id}/config-ui/apply", u.actionDeviceConfigUIApply)
r.Post("/devices/{id}/face-gallery/upload", u.actionDeviceFaceGalleryUpload)
r.Post("/devices/{id}/face-gallery/reload", u.actionDeviceFaceGalleryReload)
r.Post("/devices/{id}/models/upload", u.actionDeviceModelUpload)
r.Post("/devices/{id}/media-server/configs/upload", u.actionDeviceMediaServerConfigUpload)
r.Post("/devices/{id}/media-server/configs/upload-batch", u.actionDeviceMediaServerConfigUploadBatch)
r.Get("/tasks", u.pageTasks)
r.Post("/tasks", u.actionCreateTask)
r.Get("/tasks/{id}", u.pageTask)
r.Get("/templates", u.pageTemplates)
r.Get("/templates/{name}", u.pageTemplate)
r.Get("/models", u.pageModels)
r.Post("/models/sync", u.actionModelSync)
r.Post("/resources/sync", u.actionResourceSync)
r.Get("/diagnostics", u.pageDiagnostics)
r.Get("/alarms", u.pageAlarms)
r.Get("/face-gallery", u.pageFaceGallery)
r.Get("/face-photo/*", u.serveFacePhoto)
r.Post("/face-gallery/import", u.actionFaceGalleryImport)
r.Post("/face-gallery/build", u.actionFaceGalleryBuild)
r.Post("/face-gallery/add", u.actionFaceGalleryAdd)
r.Post("/face-gallery/delete", u.actionFaceGalleryDelete)
r.Post("/face-gallery/rename", u.actionFaceGalleryRename)
r.Post("/face-gallery/add-photo", u.actionFaceGalleryAddPhoto)
r.Post("/face-gallery/delete-photo", u.actionFaceGalleryDeletePhoto)
r.Get("/monitor", u.pageMonitor)
r.Get("/hls/*", u.proxyHLS)
r.Get("/api/monitor/channels", u.apiMonitorChannels)
r.Get("/api/device-metrics", u.apiDeviceMetrics)
r.Get("/recognition", u.pageRecognition)
r.Get("/logs", u.pageLogs)
r.Get("/api", u.pageAPIConsole)
return r, nil
}
func (u *UI) render(w http.ResponseWriter, r *http.Request, content string, data PageData) {
data.Version = version
data.Year = time.Now().Year()
var buf bytes.Buffer
if err := u.tpl.ExecuteTemplate(&buf, content, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.ContentHTML = template.HTML(buf.String())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, max-age=0")
w.Header().Set("Pragma", "no-cache")
if err := u.tpl.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (u *UI) findDevice(id string) (*models.Device, bool) {
u.ensureDevicesLoaded()
devices := u.registry.GetDevices()
for _, d := range devices {
if d.DeviceID == id {
return d, true
}
}
if u.discovery != nil {
_, _ = u.discovery.SearchDefault()
devices = u.registry.GetDevices()
for _, d := range devices {
if d.DeviceID == id {
return d, true
}
}
}
return nil, false
}
func (u *UI) ensureDevicesLoaded() {
if u.registry == nil || u.discovery == nil {
return
}
devices := u.registry.GetDevices()
if len(devices) > 0 {
// Check if any device is online; if not, try discovery to refresh
hasOnline := false
for _, d := range devices {
if d.Online {
hasOnline = true
break
}
}
if hasOnline {
return
}
}
_, _ = u.discovery.SearchDefault()
if len(u.registry.GetDevices()) == 0 {
_, _ = u.discovery.SearchDefault()
}
}
func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) {
data := u.deviceOverviewPageData(r, nil, "")
if u.tasks != nil {
for _, task := range u.tasks.ListTasks() {
switch task.Status {
case models.TaskRunning:
data.RunningTaskCount++
case models.TaskFailed:
data.FailedTaskCount++
case models.TaskSuccess:
data.SuccessTaskCount++
}
}
}
data.Title = "总览"
if u.alarmCollector != nil {
data.AlarmRecords = u.alarmCollector.GetRecent(5)
all := u.alarmCollector.GetRecent(9999)
today := time.Now().Format("2006-01-02")
for _, a := range all {
if strings.HasPrefix(a.Timestamp, today) {
data.TodayAlarmCount++
}
}
}
data.Tasks = nil
if u.tasks != nil {
data.Tasks = u.tasks.ListTasks()
}
data.AttentionDevices = nil
// Load device metrics
if u.agent != nil {
for _, dev := range data.Devices {
if dev == nil || !dev.Online { continue }
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil)
if err != nil || code != 200 { continue }
var m struct {
CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"`
Memory struct{ TotalKB uint64 `json:"total_kb"`; AvailableKB uint64 `json:"available_kb"` } `json:"memory"`
NPU struct {
UsagePct float64 `json:"usage_pct"`
Cores map[string]float64 `json:"cores"`
} `json:"npu"`
Disk struct{ UsedPct float64 `json:"used_pct"` } `json:"disk"`
Temperature struct{ Celsius float64 `json:"celsius"` } `json:"temperature"`
}
json.Unmarshal(body, &m)
var mem float64
if m.Memory.TotalKB > 0 { mem = float64(m.Memory.TotalKB-m.Memory.AvailableKB) / float64(m.Memory.TotalKB) * 100 }
npuVal := m.NPU.UsagePct
if len(m.NPU.Cores) > 0 {
for _, v := range m.NPU.Cores {
if v > npuVal {
npuVal = v
}
}
}
data.DeviceMetrics = append(data.DeviceMetrics, DeviceMetric{
Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: mem, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius,
})
}
}
for _, dev := range data.Devices {
if dev != nil && !dev.Online {
data.AttentionDevices = append(data.AttentionDevices, dev)
}
}
u.render(w, r, "dashboard", data)
}
func (u *UI) pageConsole(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
data := PageData{Title: "管控台"}
data.Message = strings.TrimSpace(r.URL.Query().Get("msg"))
devices := u.registry.GetDevices()
data.Devices = devices
// Load video sources
var allSources []service.ConfigVideoSourceAsset
if u.preview != nil {
if items, err := u.preview.ListVideoSources(); err == nil {
allSources = items
}
}
// Load recognition units and device assignments
var units []service.RecognitionUnitAsset
var assignments []service.DeviceAssignmentAsset
if u.preview != nil {
if items, err := u.preview.ListRecognitionUnits(); err == nil {
units = items
}
if items, err := u.preview.ListDeviceAssignments(); err == nil {
assignments = items
}
}
// Build device->unit mapping
assignedUnits := map[string][]service.RecognitionUnitAsset{}
for _, a := range assignments {
for _, ref := range a.RecognitionUnits {
for _, u := range units {
if u.Ref == ref {
assignedUnits[a.DeviceID] = append(assignedUnits[a.DeviceID], u)
break
}
}
}
}
// Track used video sources
usedSources := map[string]bool{}
for _, unitList := range assignedUnits {
for _, u := range unitList {
if u.VideoSourceRef != "" {
usedSources[u.VideoSourceRef] = true
}
}
}
// Build source name lookup
sourceNames := map[string]string{}
for _, s := range allSources {
sourceNames[s.Name] = s.Name
}
// Query each device's capabilities from agent to filter available features.
deviceAvailableFeatures := map[string][]ConsoleFeature{}
if u.agent != nil {
for _, dev := range devices {
if dev == nil || !dev.Online || dev.DeviceID == "" {
continue
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/capabilities", nil)
if err != nil || code != 200 {
continue
}
var capsResp struct {
Capabilities []struct {
Key string `json:"key"`
Label string `json:"label"`
Available bool `json:"available"`
Description string `json:"description"`
} `json:"capabilities"`
}
if json.Unmarshal(body, &capsResp) != nil {
continue
}
features := make([]ConsoleFeature, 0, len(capsResp.Capabilities))
for _, c := range capsResp.Capabilities {
features = append(features, ConsoleFeature{
Key: c.Key, Label: c.Label, Description: c.Description,
Available: c.Available, Enabled: false,
})
}
deviceAvailableFeatures[dev.DeviceID] = features
}
}
// Load persisted device features as the source of truth.
// This represents what capabilities the device is configured for,
// not just what the last deployment used.
persistedFeatures := map[string]map[string]bool{}
if u.preview != nil {
for _, dev := range devices {
if dev == nil || dev.DeviceID == "" {
continue
}
if feats, err := u.preview.GetDeviceFeatures(dev.DeviceID); err == nil && len(feats) > 0 {
m := map[string]bool{}
for _, f := range feats {
m[f] = true
}
persistedFeatures[dev.DeviceID] = m
}
}
}
// Load device metrics (NPU, etc)
metrics := map[string]DeviceMetric{}
if u.agent != nil {
for _, dev := range devices {
if dev == nil || !dev.Online {
continue
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/metrics", nil)
if err != nil || code != 200 {
continue
}
var m struct {
CPU struct{ UsagePct float64 `json:"usage_pct"` } `json:"cpu"`
Memory struct {
TotalKB float64 `json:"total_kb"`
AvailableKB float64 `json:"available_kb"`
} `json:"memory"`
NPU struct {
UsagePct float64 `json:"usage_pct"`
Cores map[string]float64 `json:"cores"`
} `json:"npu"`
Disk struct{ UsedPct float64 `json:"used_pct"` } `json:"disk"`
Temperature struct{ Celsius float64 `json:"celsius"` } `json:"temperature"`
}
json.Unmarshal(body, &m)
npuVal := m.NPU.UsagePct
// Pick max core if per-core data available
if len(m.NPU.Cores) > 0 {
for _, v := range m.NPU.Cores {
if v > npuVal {
npuVal = v
}
}
}
memPct := 0.0
if m.Memory.TotalKB > 0 {
memPct = (m.Memory.TotalKB - m.Memory.AvailableKB) / m.Memory.TotalKB * 100
}
metrics[dev.DeviceID] = DeviceMetric{
Name: dev.DisplayName(), CPU: m.CPU.UsagePct, Mem: memPct, NPU: npuVal, Disk: m.Disk.UsedPct, Temperature: m.Temperature.Celsius,
}
}
}
// Build console device data
var consoleDevices []ConsoleDeviceData
for _, dev := range devices {
if dev == nil {
continue
}
maxUnits := 4
// Gather channels (one per assigned recognition unit)
var channels []ConsoleChannel
var cameras []string
for _, u := range assignedUnits[dev.DeviceID] {
// HLS channel name matches recognition unit Name (used in publish path)
chName := u.Name
displayName := u.Name
if u.DisplayName != "" {
displayName = u.DisplayName
}
srcName := u.VideoSourceRef
if srcName == "" {
srcName = displayName
}
channels = append(channels, ConsoleChannel{Name: chName, Display: displayName, SourceName: srcName})
if u.VideoSourceRef != "" {
cameras = append(cameras, u.VideoSourceRef)
}
}
// Determine which features are enabled from persisted device features.
activeKeys := persistedFeatures[dev.DeviceID]
if activeKeys == nil {
activeKeys = map[string]bool{}
}
// Features from device capabilities (from agent /v1/capabilities).
// Overlay persisted DB state for checkmarks and conflict detection.
available := deviceAvailableFeatures[dev.DeviceID]
var features []ConsoleFeature
if len(available) > 0 {
for _, f := range available {
f.Enabled = activeKeys[f.Key]
// Conflict: user enabled a feature that the device no longer supports.
if f.Enabled && !f.Available {
f.Conflict = true
}
features = append(features, f)
}
// Show persisted features that the agent didn't report (e.g. new features
// added to registry but old agent doesn't know them).
seen := map[string]bool{}
for _, f := range features {
seen[f.Key] = true
}
for key := range activeKeys {
if !seen[key] {
features = append(features, ConsoleFeature{
Key: key, Label: key, Enabled: true, Available: false, Conflict: true,
})
}
}
} else {
// Old agent: show persisted keys.
for key := range activeKeys {
features = append(features, ConsoleFeature{Key: key, Label: key, Enabled: true, Available: true})
}
}
npu, diskUsage, temp, cpuUsage, memUsage := 0.0, 0.0, 0.0, 0.0, 0.0
if m, ok := metrics[dev.DeviceID]; ok {
cpuUsage = m.CPU
memUsage = m.Mem
npu = m.NPU
diskUsage = m.Disk
temp = m.Temperature
}
consoleDevices = append(consoleDevices, ConsoleDeviceData{
Device: dev,
CPUUsage: cpuUsage,
MemUsage: memUsage,
NPUUsage: npu,
DiskUsage: diskUsage,
Temperature: temp,
MaxUnits: maxUnits,
Channels: channels,
Features: features,
Cameras: cameras,
})
}
// Unassigned video sources
var unassigned []service.ConfigVideoSourceAsset
for _, s := range allSources {
if !usedSources[s.Name] {
unassigned = append(unassigned, s)
}
}
data.ConsoleDevices = consoleDevices
data.ConsoleVideoSources = unassigned
data.ConsoleAllVideoSources = allSources
u.render(w, r, "console", data)
}
func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if u.autoConfig == nil || u.preview == nil {
http.Redirect(w, r, "/ui/console?error="+url.QueryEscape("自动配置服务未初始化"), http.StatusFound)
return
}
u.ensureDevicesLoaded()
devices := u.registry.GetDevices()
// Step 1: collect form features per device.
formFeatures := map[string][]string{}
for _, dev := range devices {
if dev == nil || dev.DeviceID == "" {
continue
}
features := cleanFormList(r.Form["device_"+dev.DeviceID+"_feature"])
if len(features) > 0 {
formFeatures[dev.DeviceID] = features
}
}
// Step 2: build requests using form features (user intent) and
// existing recognition unit video sources.
var requests []service.AutoConfigRequest
for _, dev := range devices {
if dev == nil || dev.DeviceID == "" {
continue
}
features := formFeatures[dev.DeviceID]
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, "/ui/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, "/ui/console?msg="+url.QueryEscape("正在下发配置...")+"&task="+taskIDs[0], http.StatusFound)
return
}
if len(taskIDs) > 1 {
http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape(fmt.Sprintf("正在为 %d 台设备下发配置...", len(taskIDs))), http.StatusFound)
return
}
if len(errs) > 0 {
http.Redirect(w, r, "/ui/console?error="+url.QueryEscape(strings.Join(errs, "; ")), http.StatusFound)
return
}
http.Redirect(w, r, "/ui/console?msg="+url.QueryEscape("配置已保存"), http.StatusFound)
}
func (u *UI) pageDevices(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, ""))
}
func (u *UI) pageDeviceAdd(w http.ResponseWriter, r *http.Request) {
u.render(w, r, "device_add", PageData{Title: "新增设备"})
}
func (u *UI) pageDeviceConfig(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
u.render(w, r, "device_config", PageData{
Title: "设备配置入口",
Devices: u.registry.GetDevices(),
})
}
func (u *UI) pageDeviceConfigDetail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound)
}
func (u *UI) actionDeviceAdd(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
deviceID := strings.TrimSpace(r.FormValue("device_id"))
deviceName := strings.TrimSpace(r.FormValue("device_name"))
ip := strings.TrimSpace(r.FormValue("ip"))
agentPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("agent_port")))
mediaPort, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("media_port")))
if deviceID == "" || ip == "" {
u.render(w, r, "device_add", PageData{Title: "新增设备", Error: "节点标识和 IP 不能为空"})
return
}
if agentPort == 0 {
agentPort = 9100
}
if mediaPort == 0 {
mediaPort = 9000
}
dev := &models.Device{
DeviceID: deviceID,
DeviceName: deviceName,
IP: ip,
AgentPort: agentPort,
MediaPort: mediaPort,
Online: true,
LastSeenMs: time.Now().UnixMilli(),
}
u.registry.UpdateDevice(dev)
http.Redirect(w, r, "/ui/devices", http.StatusFound)
}
func (u *UI) actionDiscoverySearch(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
timeoutMs, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("timeout_ms")))
if timeoutMs <= 0 {
timeoutMs = 1200
}
found, err := u.discovery.Search(timeoutMs)
devices := u.registry.GetDevices()
online := 0
for _, d := range devices {
if d.Online {
online++
}
}
data := PageData{Title: "设备", Devices: devices, Found: found, FoundCount: len(found), DeviceCount: len(devices), OnlineCount: online, OfflineCount: len(devices) - online}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "devices", data)
}
func (u *UI) actionDevicesBatchAction(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
action := strings.TrimSpace(r.FormValue("action"))
deviceIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
if len(deviceIDs) == 0 {
u.render(w, r, "devices", u.deviceOverviewPageData(r, nil, "请先选择设备"))
return
}
typeStr := ""
switch action {
case "media_start", "media_restart", "media_stop", "reload", "rollback":
typeStr = action
default:
u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, "不支持的操作: "+action))
return
}
if u.tasks == nil {
http.Error(w, "task service not initialized", http.StatusInternalServerError)
return
}
var payload any
if typeStr == "media_start" || typeStr == "media_restart" {
cfgName := strings.TrimSpace(r.FormValue("config"))
if cfgName != "" {
payload = map[string]any{"config": cfgName}
}
}
task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
if err != nil {
u.render(w, r, "devices", u.deviceOverviewPageData(r, deviceIDs, err.Error()))
return
}
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
}
func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
data := u.deviceBatchConfigPageData(r, selectedIDsFromQuery(r.URL.Query()["selected"]))
u.render(w, r, "device_batch_config", data)
}
func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
data := u.deviceBatchConfigPageData(r, selectedIDs)
if len(selectedIDs) == 0 {
data.Error = "请先选择需要下发分配的设备"
u.render(w, r, "device_batch_config", data)
return
}
if u.tasks == nil {
data.Error = "task service not initialized"
u.render(w, r, "device_batch_config", data)
return
}
configs := make(map[string]any, len(selectedIDs))
for _, deviceID := range selectedIDs {
preview, err := u.preview.RenderDeviceAssignment(deviceID)
if err != nil {
data.Error = err.Error()
u.render(w, r, "device_batch_config", data)
return
}
if data.ConfigPreview == nil {
data.ConfigPreview = preview
}
var configDoc any
if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil {
data.Error = "生成配置 JSON 无效: " + err.Error()
u.render(w, r, "device_batch_config", data)
return
}
configs[deviceID] = configDoc
}
task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"configs": configs})
if err != nil {
data.Error = err.Error()
u.render(w, r, "device_batch_config", data)
return
}
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
}
func (u *UI) pageDevice(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
u.render(w, r, "device", u.deviceDetailPageData(dev))
}
func (u *UI) deviceDetailPageData(dev *models.Device) PageData {
data := u.deviceControlPageData(dev)
data.Title = "设备详情"
if data.ConfigStatus == nil && u.stateRepo != nil && dev != nil {
if state, err := u.stateRepo.Get(dev.DeviceID); err == nil && state != nil {
data.PersistedConfig = state
}
}
if u.preview != nil {
if profiles, err := u.preview.ListProfileAssets(); err == nil {
data.AssetProfiles = profiles
selectedProfile := ""
if data.ConfigStatus != nil && strings.TrimSpace(data.ConfigStatus.Metadata.Profile) != "" {
selectedProfile = strings.TrimSpace(data.ConfigStatus.Metadata.Profile)
} else if data.PersistedConfig != nil && strings.TrimSpace(data.PersistedConfig.ProfileName) != "" {
selectedProfile = strings.TrimSpace(data.PersistedConfig.ProfileName)
}
if selectedProfile == "" && len(profiles) > 0 {
selectedProfile = profiles[0].Name
}
data.SelectedProfile = selectedProfile
for i := range profiles {
if strings.TrimSpace(profiles[i].Name) == selectedProfile {
data.AssetProfile = &profiles[i]
data.SelectedTemplate = profileAssetTemplate(&profiles[i])
break
}
}
if data.AssetProfile == nil && len(profiles) > 0 {
data.AssetProfile = &profiles[0]
data.SelectedProfile = profiles[0].Name
data.SelectedTemplate = profileAssetTemplate(&profiles[0])
}
} else if data.Error == "" {
data.Error = err.Error()
}
if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil {
data.DeviceAssignment = assignment
data.SelectedAssignmentDevice = assignment.DeviceID
data.SelectedProfile = assignment.ProfileName
}
}
return data
}
func (u *UI) pageDeviceControl(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
http.Redirect(w, r, "/ui/devices/"+url.PathEscape(id)+"#device-config", http.StatusFound)
}
func (u *UI) actionDeviceAction(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
action := strings.TrimSpace(r.FormValue("action"))
method := "POST"
path := ""
switch action {
case "reload":
path = "/v1/media-server/reload"
case "rollback":
path = "/v1/media-server/rollback"
case "media_start":
path = "/v1/media-server/start"
case "media_restart":
path = "/v1/media-server/restart"
case "media_stop":
path = "/v1/media-server/stop"
case "media_status":
method = "GET"
path = "/v1/media-server/status"
case "info":
method = "GET"
path = "/v1/info"
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
body, code, err := u.agent.Do(method, dev.IP, dev.AgentPort, path, nil)
msg := fmt.Sprintf("%s %s -> %d", method, path, code)
returnTo := strings.TrimSpace(r.FormValue("return_to"))
if returnTo == "control" || returnTo == "config" {
data := u.deviceDetailPageData(dev)
data.Message = msg
data.RawText = string(body)
data.ResultTitle = "执行结果摘要"
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "device", data)
return
}
data := PageData{Title: "设备详情", Device: dev, Message: msg, RawText: string(body)}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "device", data)
}
func (u *UI) pageDeviceLogs(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
limit := strings.TrimSpace(r.URL.Query().Get("limit"))
path := "/v1/logs/recent"
if limit != "" {
path += "?limit=" + urlQueryEscape(limit)
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, path, nil)
data := PageData{Title: "诊断日志", Device: dev, Message: fmt.Sprintf("GET %s -> %d", path, code), RawText: string(body)}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "device_logs", data)
}
func (u *UI) pageDeviceGraphs(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/graphs", nil)
data := PageData{Title: "运行指标", Device: dev, Message: fmt.Sprintf("GET /v1/graphs -> %d", code), RawText: string(body)}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "device_graphs", data)
}
func (u *UI) actionDeviceConfigApply(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
raw := strings.TrimSpace(r.FormValue("json"))
if raw == "" {
raw = `{"config":{}}`
}
body, code, err := u.agent.Do("PUT", dev.IP, dev.AgentPort, "/v1/config", []byte(raw))
data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT /v1/config -> %d", code), RawText: string(body), RawJSON: raw}
if err != nil {
data.Error = err.Error()
}
u.render(w, r, "device", data)
}
func (u *UI) actionDeviceAliasSave(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
_ = r.ParseForm()
alias := strings.TrimSpace(r.FormValue("device_alias"))
data := u.deviceDetailPageData(dev)
if err := u.registry.SetDeviceAlias(id, alias); err != nil {
data.Error = err.Error()
u.render(w, r, "device", data)
return
}
dev.DeviceAlias = alias
data.Device = dev
data.Message = "设备别名已保存"
u.render(w, r, "device", data)
}
func (u *UI) actionDeviceModelUpload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
if err := r.ParseMultipartForm(100 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
if name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
path := fmt.Sprintf("/v1/models/%s", name)
resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/octet-stream", hdr.Size)
out := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)}
if derr != nil {
out.Error = derr.Error()
}
u.render(w, r, "device", out)
}
func (u *UI) actionDeviceMediaServerConfigUpload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
if err := r.ParseMultipartForm(50 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name, err := normalizeConfigName(r.FormValue("name"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
path := "/v1/media-server/configs/" + url.PathEscape(name)
resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size)
data := PageData{Title: "设备详情", Device: dev, Message: fmt.Sprintf("PUT %s -> %d", path, code), RawText: string(resp)}
if derr != nil {
data.Error = derr.Error()
}
u.render(w, r, "device", data)
}
func (u *UI) actionDeviceMediaServerConfigUploadBatch(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
dev, ok := u.findDevice(id)
if !ok {
http.NotFound(w, r)
return
}
if err := r.ParseMultipartForm(200 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if r.MultipartForm == nil || len(r.MultipartForm.File) == 0 {
http.Error(w, "files is required", http.StatusBadRequest)
return
}
files := r.MultipartForm.File["files"]
if len(files) == 0 {
http.Error(w, "files is required", http.StatusBadRequest)
return
}
var sb strings.Builder
errCount := 0
for _, hdr := range files {
name, nerr := normalizeConfigName(filepath.Base(hdr.Filename))
if nerr != nil {
errCount++
sb.WriteString(fmt.Sprintf("%s -> invalid name: %v\n", hdr.Filename, nerr))
continue
}
file, err := hdr.Open()
if err != nil {
errCount++
sb.WriteString(fmt.Sprintf("%s -> open failed: %v\n", name, err))
continue
}
path := "/v1/media-server/configs/" + url.PathEscape(name)
resp, code, derr := u.agent.DoStream("PUT", dev.IP, dev.AgentPort, path, file, "application/json", hdr.Size)
_ = file.Close()
if derr != nil {
errCount++
sb.WriteString(fmt.Sprintf("%s -> %d error: %v\n", name, code, derr))
continue
}
if len(resp) > 0 {
sb.WriteString(fmt.Sprintf("%s -> %d %s\n", name, code, strings.TrimSpace(string(resp))))
} else {
sb.WriteString(fmt.Sprintf("%s -> %d\n", name, code))
}
}
data := PageData{Title: "设备详情", Device: dev, Message: "批量上传完成", RawText: sb.String()}
if errCount > 0 {
data.Error = fmt.Sprintf("部分失败: %d/%d", errCount, len(files))
}
u.render(w, r, "device", data)
}
func (u *UI) pageTasks(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
devices := u.registry.GetDevices()
selectedIDs := filterSelectedDeviceIDs(devices, selectedIDsFromQuery(r.URL.Query()["selected"]))
data := PageData{
Title: "任务中心",
Tasks: u.tasks.ListTasks(),
Devices: devices,
SelectedDeviceIDs: selectedIDs,
SelectedDevices: selectedDevicesFromIDs(devices, selectedIDs),
DeviceIDs: strings.Join(selectedIDs, ","),
}
u.render(w, r, "tasks", data)
}
func (u *UI) taskPageData(task *models.Task) PageData {
data := PageData{Title: "任务中心", Task: task}
if task == nil {
return data
}
devices := make(map[string]*models.Device)
if u.registry != nil {
for _, dev := range u.registry.GetDevices() {
if dev == nil {
continue
}
devices[dev.DeviceID] = dev
}
}
rows := make([]TaskDeviceRow, 0, len(task.DeviceIDs))
for _, did := range task.DeviceIDs {
row := TaskDeviceRow{}
if dev := devices[did]; dev != nil {
row.Device = dev
} else {
row.Device = &models.Device{DeviceID: did}
}
if ds := task.Devices[did]; ds != nil {
row.Status = ds.Status
row.Progress = ds.Progress
row.Error = ds.Error
}
rows = append(rows, row)
}
data.TaskDeviceRows = rows
return data
}
func (u *UI) actionCreateTask(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
typeStr := strings.TrimSpace(r.FormValue("type"))
if typeStr == "" {
typeStr = "config_apply"
}
ids := strings.TrimSpace(r.FormValue("device_ids"))
var deviceIDs []string
if ids != "" {
for _, p := range strings.Split(ids, ",") {
p = strings.TrimSpace(p)
if p != "" {
deviceIDs = append(deviceIDs, p)
}
}
} else {
deviceIDs = filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"])
ids = strings.Join(deviceIDs, ",")
}
raw := strings.TrimSpace(r.FormValue("payload_json"))
if raw == "" {
raw = `{"config":{}}`
}
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: "高级参数 JSON 无效: " + err.Error(), RawJSON: raw, DeviceIDs: ids})
return
}
task, err := u.tasks.CreateTask(typeStr, deviceIDs, payload)
if err != nil {
u.render(w, r, "tasks", PageData{Title: "任务中心", Tasks: u.tasks.ListTasks(), Devices: u.registry.GetDevices(), Error: err.Error(), RawJSON: raw, DeviceIDs: ids})
return
}
http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound)
}
func (u *UI) pageTask(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
items := u.tasks.ListTasks()
var task *models.Task
for i := range items {
if items[i].ID == id {
t := items[i]
task = &t
break
}
}
if task == nil {
http.NotFound(w, r)
return
}
data := u.taskPageData(task)
data.TaskID = id
u.render(w, r, "task", data)
}
func (u *UI) pageTemplates(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/assets/templates", http.StatusFound)
}
func (u *UI) pageTemplate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound)
}
func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
data := PageData{Title: "模型管理", Devices: u.registry.GetDevices()}
for _, dev := range data.Devices {
if dev == nil {
continue
}
if dev.Online {
data.OnlineCount++
}
}
board := service.ModelStatusBoard{}
if strings.TrimSpace(u.dbPath) != "" {
if store, err := storage.OpenSQLite(u.dbPath); err == nil {
modelsRepo := storage.NewModelsRepo(store.DB())
if items, err := modelsRepo.List(); err == nil {
data.StandardModels = items
installed := map[string][]service.InstalledModelStatus{}
for _, device := range data.Devices {
if device == nil || !device.Online {
continue
}
items, err := service.FetchInstalledModelStatuses(u.agent, device)
if err == nil {
installed[device.DeviceID] = items
}
}
board = service.BuildModelStatusBoard(items, data.Devices, installed)
}
_ = store.Close()
}
}
data.ModelStatusBoard = &board
u.render(w, r, "models", data)
}
func (u *UI) actionModelSync(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
action := strings.TrimSpace(r.FormValue("action"))
if action == "" {
action = "model_sync_all"
}
if action != "model_sync_one" && action != "model_sync_all" {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
deviceIDs := make([]string, 0)
for _, id := range r.Form["device_id"] {
id = strings.TrimSpace(id)
if id != "" {
deviceIDs = append(deviceIDs, id)
}
}
if len(deviceIDs) == 0 {
http.Error(w, "missing device_id", http.StatusBadRequest)
return
}
payload := map[string]any{}
if modelName := strings.TrimSpace(r.FormValue("model_name")); modelName != "" {
payload["model_name"] = modelName
}
task, err := u.tasks.CreateTask(action, deviceIDs, payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}