Compare commits
218 Commits
p0-dashboa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 24386d9cf0 | |||
| e4608f48a0 | |||
| b256ecc5d2 | |||
| 5433065925 | |||
| d1f85ee794 | |||
| 1ae06ec1fb | |||
| 43cb4b8411 | |||
| 6e32d15c86 | |||
| 3084ddc524 | |||
| 6229a340a8 | |||
| 1de96c4c02 | |||
| 723239812b | |||
| 45470d0908 | |||
| 8d8daf1b7f | |||
| b2cb0b9a67 | |||
| 0895d19fe0 | |||
| 76f4b5af9a | |||
| aa5b6bb940 | |||
| 523e4ba1fe | |||
| 941f0678a1 | |||
| e68a1ab8ba | |||
| a98c8fa77b | |||
| 6abfa7024b | |||
| 6f8d4e8c93 | |||
| 7b8cf92b87 | |||
| 7c1fea3620 | |||
| dd83080ae2 | |||
| bf5f0bbefe | |||
| e8a8432c48 | |||
| ac305f3106 | |||
| 425bf2ca92 | |||
| a1ed9e2457 | |||
| 6f5aaab21f | |||
| d6af1ebc88 | |||
| 931faf999e | |||
| e7d7d9a23b | |||
| 66547ddd16 | |||
| 597baa1191 | |||
| 2c3978e745 | |||
| 40f0696a69 | |||
| 9c88c899b3 | |||
| acf599df6d | |||
| 4968d6203a | |||
| a801340d14 | |||
| 40e8b35e6b | |||
| 158c13642e | |||
| 257f3ac034 | |||
| aa20bdaeb1 | |||
| 47655b75d2 | |||
| b369cfd81a | |||
| 8f7467bb63 | |||
| cf687a7b5d | |||
| 976fdd0e12 | |||
| 0910b1c881 | |||
| ab125a1489 | |||
| b20827ee07 | |||
| 25d860ca33 | |||
| 332e36bd8f | |||
| 210465cc71 | |||
| 873cc3e1a4 | |||
| 04908155a4 | |||
| 2cc5ac43a9 | |||
| b0895d8fce | |||
| c817b42ff7 | |||
| a4e0d1316f | |||
| 30a93d1c84 | |||
| 50c74e5339 | |||
| 3144e4a0dd | |||
| 10f2a48508 | |||
| d5cf7bf4f9 | |||
| b4ef331be8 | |||
| 64f2e88c26 | |||
| 55f235b23b | |||
| ca7be71f90 | |||
| d166214659 | |||
| 04617a382c | |||
| 3c78cb193a | |||
| 79270d2121 | |||
| 7678006d4d | |||
| 77c5eb4740 | |||
| 1790d2ec14 | |||
| 90bc2e3a32 | |||
| 9a5313416a | |||
| 2beac76088 | |||
| eb2252f055 | |||
| e55b1e8389 | |||
| 0fbb2e85dc | |||
| 1c772fa28e | |||
| 1096218c1f | |||
| 77f17791ed | |||
| 52760cfa99 | |||
| cb827bb57c | |||
| 109269f2e0 | |||
| 7120805c0c | |||
| 99cd956e4c | |||
| a21a0f8649 | |||
| 3ae6f71cf9 | |||
| 3eb966a560 | |||
| d59e028353 | |||
| 337c136829 | |||
| 1938a26f5a | |||
| 820f72cdb3 | |||
| f92491ebf1 | |||
| 9e6842015a | |||
| 8a59f66572 | |||
| c6591d1eae | |||
| 20261dad96 | |||
| 6034af086c | |||
| 64febdc0cb | |||
| 6ff7acf283 | |||
| 6fcabebfe8 | |||
| c6c8978ea3 | |||
| dd40d3d93e | |||
| 27ce2dd4d8 | |||
| 1430d33e5f | |||
| 36b5b903e1 | |||
| bde5161b54 | |||
| b51ee0f6a1 | |||
| a10f5dd71d | |||
| 998c8081ff | |||
| 16f9ef9bef | |||
| 5313bb5a89 | |||
| b1dc893d8e | |||
| 9b0e1eaa3d | |||
| 17a0c35a1d | |||
| 2e5220d826 | |||
| 198494cd78 | |||
| 31c1a09b86 | |||
| 5c53b07301 | |||
| c52fc4c819 | |||
| 139d668b77 | |||
| c2ba769be4 | |||
| 7f3dd6a2ef | |||
| 1a0efea189 | |||
| abebcd3cfa | |||
| 772af08a1f | |||
| 48a769309b | |||
| fa5ef7a88a | |||
| 5b4afa25e8 | |||
| bf0f838fbc | |||
| b2b037bb5b | |||
| b08bf2725f | |||
| 30f3ae66a4 | |||
| 76d72c7fe5 | |||
| c8c04dbc68 | |||
| d9f3c6ca58 | |||
| 2ba159c71c | |||
| 3c9f4ec98b | |||
| f8f69dec74 | |||
| e30e7bf882 | |||
| b73ccdefed | |||
| e411385718 | |||
| 24b9400767 | |||
| 97af8904ef | |||
| 3d109b4b11 | |||
| c440186ce3 | |||
| a110436e2d | |||
| 848580f3ee | |||
| 56dcee8741 | |||
| 7a8e30daba | |||
| 5c7fe47a36 | |||
| e284048435 | |||
| a0f22230cb | |||
| c57ca0b5a0 | |||
| fe7ff6719d | |||
| b3e816e028 | |||
| 4443440f0f | |||
| 29db6be66d | |||
| 0a9a1bd005 | |||
| ab32118f75 | |||
| b7757c7387 | |||
| 32fcd43801 | |||
| 248f62ca5d | |||
| a1143562c4 | |||
| 4ad664ecc5 | |||
| 0207593975 | |||
| 890755251d | |||
| 220a66b1c7 | |||
| e1e5f783c0 | |||
| c09a999710 | |||
| d09031157d | |||
| 51079466a3 | |||
| 33fadd17bd | |||
| af7447ccad | |||
| f5d7da6fd2 | |||
| 103f9296b0 | |||
| 2b11844062 | |||
| a0c6df78b6 | |||
| 08ec88c45e | |||
| 2369a1e3dc | |||
| 96e689fec0 | |||
| 9094f1f0fc | |||
| ca5b687ee1 | |||
| 232526de06 | |||
| 3adc1e1a43 | |||
| 139baeef5f | |||
| a58c99a2f3 | |||
| a594e05692 | |||
| c71922a05d | |||
| 53577b0ea6 | |||
| b2fbae9e4b | |||
| bc5bd7d3fc | |||
| 3d7313585c | |||
| 13d22ce848 | |||
| bf0fed23f9 | |||
| b50ccda82d | |||
| 3f3a2a0987 | |||
| 59a18f3e62 | |||
| 3f6f3ddbb5 | |||
| c14173acd2 | |||
| 2b989aa368 | |||
| 27584f931a | |||
| 770e50ab7b | |||
| aa76607092 | |||
| ccde3aeea6 | |||
| 44ad821213 | |||
| d422ffd48e | |||
| 46f264d4d4 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -5,6 +5,8 @@ bin/
|
||||
dist/
|
||||
tmp/
|
||||
*.exe
|
||||
*.exe~
|
||||
*~
|
||||
*.test
|
||||
*.out
|
||||
|
||||
@ -39,3 +41,6 @@ nul
|
||||
dataset/
|
||||
__pycache__/
|
||||
managerd.db
|
||||
assets-import.tar.gz
|
||||
deps/
|
||||
safesightd-linux-arm64
|
||||
|
||||
@ -4,6 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Build, Test & Restart Commands
|
||||
|
||||
### 本地 Windows 编译
|
||||
- Build: `go build -o managerd ./cmd/managerd` 或 `pwsh -File scripts/managerd.ps1 build`
|
||||
- Run: `./managerd` (uses `managerd.json` config) or `./managerd <config-path>`
|
||||
- 重启服务(编译+启停+健康检查一站式):`pwsh -File scripts/managerd.ps1 restart`
|
||||
@ -13,6 +14,25 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Test single test: `go test ./internal/storage/... -run TestAssetsRepo_SaveTemplate`
|
||||
- Verbose test: `go test ./internal/web/... -v -run TestUI_ActionDevicesBatchAction`
|
||||
|
||||
### 81 (ARM64) 编译部署(主要开发流程)
|
||||
|
||||
Windows 上改代码 push → 81 上 pull 编译部署:
|
||||
```bash
|
||||
# 管理端
|
||||
cd ~/apps/safesight-control
|
||||
git pull
|
||||
GOPROXY=https://goproxy.cn,direct bash scripts/build.sh
|
||||
sudo systemctl restart safesightd
|
||||
|
||||
# Edge agent
|
||||
cd ~/apps/safesight-edge/agent
|
||||
git pull
|
||||
GOPROXY=https://goproxy.cn,direct go build -ldflags="-s -w" -o safesight-edge-agent ./cmd/safesight-agent/
|
||||
sudo systemctl restart safesight-edge-agent
|
||||
```
|
||||
|
||||
81 已安装 Go 1.23.4,设置 goproxy.cn 镜像代理。
|
||||
|
||||
## Codebase Architecture
|
||||
|
||||
### Layered Structure (no DI framework — manual wiring in main.go)
|
||||
@ -97,9 +117,13 @@ The config preview rendering pipeline: merge template + profile + overlays → r
|
||||
- **重启服务**:统一用 `pwsh -File scripts/managerd.ps1 restart`,它会先编译、停旧进程、再启动新进程并做健康检查。不要手动 kill + build + start。
|
||||
- **如无必要,不使用数据库事务**:SQLite 只允许一个写者,事务会阻塞所有其他写操作。单个 `Exec` 已是原子操作,绝大多数场景不需要包裹事务。仅模板重命名等必须原子完成的多步操作才用事务。
|
||||
- **调试时只加日志,不改代码**:遇到 bug 先用 `fmt.Printf` 全量打印关键路径的状态和数据流,定位根因后再动手修。不要猜测、不要加 workaround(重试、超时、锁之类)。
|
||||
- **UDP 仅用于设备发现**:`dev.Online` 只用于 UI 指示灯,不做业务操作的准入判断。所有 agent HTTP 调用直接发出,由 HTTP 结果判断设备是否可达。
|
||||
- **运行时配置持久化到文件**:系统配置(告警保留天数、公司名称等)通过 UI 修改后写入 `safesightd.json`,配置模板 `safesightd.json.template` 为只读参考。
|
||||
|
||||
## 测试设备
|
||||
|
||||
- RK3588: `orangepi@10.0.0.81` / password: `orangepi` / key: `~/.ssh/id_ed25519_rk3588`
|
||||
- Agent:9100 / Media:9000 / Token:`4fe2d69fda23d0d5d04a1486d4920e68`
|
||||
- 详细命令参考 [docs/test-devices.md](docs/test-devices.md)
|
||||
|
||||
- **Git push 失败重试**:HTTP 凭证偶尔过期,push 失败后再执行一次即可(第二次会弹出 Windows 凭据框)。
|
||||
38
Readme.md
38
Readme.md
@ -1,6 +1,6 @@
|
||||
# managerd
|
||||
# safesightd
|
||||
|
||||
`managerd` 是 RK3588 管理端后端服务,负责设备发现、设备注册表维护、代理访问设备端 `rk3588-agent`,并提供内嵌 Web UI、OpenAPI 页面和 HTTP API。
|
||||
`safesightd` 是 SafeSight Control 管理端后端服务,负责设备发现、设备注册表维护、代理访问设备端 `rk3588-agent`,并提供内嵌 Web UI、OpenAPI 页面和 HTTP API。
|
||||
|
||||
## 当前状态
|
||||
|
||||
@ -16,37 +16,37 @@
|
||||
默认通过单个可执行文件运行:
|
||||
|
||||
```bash
|
||||
managerd
|
||||
safesightd
|
||||
```
|
||||
|
||||
也可以指定配置文件路径:
|
||||
|
||||
```bash
|
||||
managerd path/to/managerd.json
|
||||
safesightd path/to/safesightd.json
|
||||
```
|
||||
|
||||
在当前仓库的 Windows 开发环境中,推荐使用统一脚本入口:
|
||||
|
||||
```bat
|
||||
scripts\managerd.bat build
|
||||
scripts\managerd.bat start
|
||||
scripts\managerd.bat stop
|
||||
scripts\managerd.bat restart
|
||||
scripts\managerd.bat status
|
||||
scripts\safesightd.bat build
|
||||
scripts\safesightd.bat start
|
||||
scripts\safesightd.bat stop
|
||||
scripts\safesightd.bat restart
|
||||
scripts\safesightd.bat status
|
||||
```
|
||||
|
||||
各动作说明:
|
||||
|
||||
- `build`: 编译 `.\cmd\managerd`,生成根目录下的 `managerd.exe`
|
||||
- `start`: 启动当前仓库中的 `managerd.exe`,并检查 `/health`
|
||||
- `stop`: 停止当前仓库对应的 `managerd.exe`
|
||||
- `build`: 编译 `.\cmd\safesightd`,生成根目录下的 `safesightd.exe`
|
||||
- `start`: 启动当前仓库中的 `safesightd.exe`,并检查 `/health`
|
||||
- `stop`: 停止当前仓库对应的 `safesightd.exe`
|
||||
- `restart`: 先停止再启动
|
||||
- `status`: 查看进程状态与健康检查结果
|
||||
|
||||
脚本实际入口文件:
|
||||
|
||||
- `scripts/managerd.bat`
|
||||
- `scripts/managerd.ps1`
|
||||
- `scripts/safesightd.bat`
|
||||
- `scripts/safesightd.ps1`
|
||||
|
||||
程序启动后:
|
||||
|
||||
@ -192,7 +192,7 @@ Response:
|
||||
|
||||
### 设备代理接口
|
||||
|
||||
以下接口由 managerd 转发到设备端 agent:
|
||||
以下接口由 safesightd 转发到设备端 agent:
|
||||
|
||||
- `GET /api/devices/{id}/info` -> `GET /v1/info`
|
||||
- `POST /api/devices/{id}/reload` -> `POST /v1/media-server/reload`
|
||||
@ -233,7 +233,7 @@ Response:
|
||||
- `name`: 模型名
|
||||
- `file`: 二进制文件
|
||||
|
||||
行为:managerd 读取上传文件,并转发为设备端 agent 的 `PUT /v1/models/{name}`。
|
||||
行为:safesightd 读取上传文件,并转发为设备端 agent 的 `PUT /v1/models/{name}`。
|
||||
|
||||
### Templates
|
||||
|
||||
@ -288,7 +288,7 @@ SSE 事件名为 `device_update`,数据格式:
|
||||
|
||||
## 配置文件
|
||||
|
||||
`managerd.json` 示例:
|
||||
`safesightd.json` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -303,7 +303,7 @@ SSE 事件名为 `device_update`,数据格式:
|
||||
|
||||
字段说明:
|
||||
|
||||
- `listen`: managerd 监听地址
|
||||
- `listen`: safesightd 监听地址
|
||||
- `discovery_port`: 设备发现广播端口
|
||||
- `discovery_timeout_ms`: 搜索超时
|
||||
- `offline_after_ms`: 多久未更新则视为离线
|
||||
@ -312,7 +312,7 @@ SSE 事件名为 `device_update`,数据格式:
|
||||
|
||||
## 目录说明
|
||||
|
||||
- `cmd/managerd`: 程序入口
|
||||
- `cmd/safesightd`: 程序入口
|
||||
- `internal/api`: API 路由与处理器
|
||||
- `internal/service`: discovery、registry、task、template、agent client
|
||||
- `internal/web`: 内嵌 Web UI
|
||||
|
||||
@ -7,11 +7,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"3588AdminBackend/internal/api"
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/service"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"3588AdminBackend/internal/web"
|
||||
"safesight-control/internal/api"
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/service"
|
||||
"safesight-control/internal/storage"
|
||||
"safesight-control/internal/web"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@ -19,10 +19,10 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := "managerd.json"
|
||||
if len(os.Args) > 1 {
|
||||
cfgPath = os.Args[1]
|
||||
}
|
||||
cfgPath := "safesightd.json"
|
||||
if len(os.Args) > 1 {
|
||||
cfgPath = os.Args[1]
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
@ -42,15 +42,16 @@ func main() {
|
||||
taskRepo := storage.NewTasksRepo(store.DB())
|
||||
assetsRepo := storage.NewAssetsRepo(store.DB())
|
||||
modelsRepo := storage.NewModelsRepo(store.DB())
|
||||
if imported, err := service.ImportStandardTemplatesFromDir(assetsRepo, filepath.Join("templates", "standard_templates")); err != nil {
|
||||
log.Fatalf("import standard templates: %v", err)
|
||||
} else if imported > 0 {
|
||||
log.Printf("imported %d standard templates", imported)
|
||||
// Seed config assets on first run (when DB templates table is empty).
|
||||
// Reads from configs/ directory bundled in the deployment package.
|
||||
seedCount, seedErr := service.ImportSeedAssets(assetsRepo, "configs")
|
||||
if seedErr != nil {
|
||||
log.Printf("import seed assets: %v", seedErr)
|
||||
} else if seedCount > 0 {
|
||||
log.Printf("imported %d seed config assets", seedCount)
|
||||
}
|
||||
if imported, err := service.ImportStandardOverlaysFromDir(assetsRepo, filepath.Join("templates", "standard_overlays")); err != nil {
|
||||
log.Fatalf("import standard overlays: %v", err)
|
||||
} else if imported > 0 {
|
||||
log.Printf("imported %d standard overlays", imported)
|
||||
if err := service.EnsureDefaultIntegrations(assetsRepo); err != nil {
|
||||
log.Printf("ensure default integrations: %v", err)
|
||||
}
|
||||
standardModelsDir := filepath.Join("models", "standard_models")
|
||||
modelSvc := service.NewModelManagementService(modelsRepo)
|
||||
@ -74,9 +75,7 @@ func main() {
|
||||
log.Printf("load persisted tasks: %v", err)
|
||||
}
|
||||
alarmCollector := service.NewAlarmCollector(store.DB(), agentClient, regSvc)
|
||||
if cfg.AlarmRetentionDays > 0 {
|
||||
alarmCollector.SetRetention(cfg.AlarmRetentionDays)
|
||||
}
|
||||
alarmCollector.SetRetention(cfg.AlarmRetentionDays)
|
||||
alarmCollector.Start()
|
||||
tplSvc := service.NewTemplateService(cfg)
|
||||
h := api.NewHandler(discoSvc, regSvc, agentClient, taskSvc, tplSvc)
|
||||
@ -85,7 +84,7 @@ func main() {
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"},
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-RK-Token", "X-Model-Sha256"},
|
||||
MaxAge: 300,
|
||||
@ -96,7 +95,7 @@ func main() {
|
||||
})
|
||||
r.Get("/openapi.json", api.OpenAPI)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui", http.StatusFound)
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
})
|
||||
|
||||
previewSvc := service.NewConfigPreviewService(cfg, assetsRepo)
|
||||
@ -107,15 +106,20 @@ func main() {
|
||||
ui.SetStateRepo(stateRepo)
|
||||
ui.SetAuditRepo(auditRepo)
|
||||
ui.SetDBPath(cfg.DBPathOrDefault())
|
||||
ui.SetConfig(cfg, cfgPath)
|
||||
ui.SetResourcesRepo(resourcesRepo)
|
||||
ui.SetAlarmCollector(alarmCollector)
|
||||
autoCfgSvc := service.NewAutoConfigService(previewSvc, taskSvc, service.NewFeatureRegistry())
|
||||
configVersionsRepo := storage.NewConfigVersionsRepo(store.DB())
|
||||
autoCfgSvc.SetConfigVersionsRepo(configVersionsRepo)
|
||||
ui.SetAutoConfig(autoCfgSvc)
|
||||
ui.SetConfigVersionsRepo(configVersionsRepo)
|
||||
uiRouter, err := ui.Routes()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to init ui routes: %v", err)
|
||||
}
|
||||
r.Mount("/ui", http.StripPrefix("/ui", uiRouter))
|
||||
r.Mount("/", uiRouter)
|
||||
ui.RegisterAlarmRoutes(r)
|
||||
|
||||
// API Routes
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
@ -136,10 +140,10 @@ func main() {
|
||||
r.Get("/devices/{id}/logs", h.ProxyAgent)
|
||||
r.Post("/devices/{id}/config/apply", h.ProxyAgent)
|
||||
r.Get("/devices/{id}/models", h.ProxyAgent)
|
||||
r.Post("/devices/{id}/media-server/start", h.ProxyAgent)
|
||||
r.Post("/devices/{id}/media-server/restart", h.ProxyAgent)
|
||||
r.Post("/devices/{id}/media-server/stop", h.ProxyAgent)
|
||||
r.Get("/devices/{id}/media-server/status", h.ProxyAgent)
|
||||
r.Post("/devices/{id}/edge-server/start", h.ProxyAgent)
|
||||
r.Post("/devices/{id}/edge-server/restart", h.ProxyAgent)
|
||||
r.Post("/devices/{id}/edge-server/stop", h.ProxyAgent)
|
||||
r.Get("/devices/{id}/edge-server/status", h.ProxyAgent)
|
||||
|
||||
// Generic passthrough for device agent /v1/* (covers config/ui/*, face-gallery, etc.)
|
||||
r.Handle("/devices/{id}/v1/*", http.HandlerFunc(h.ProxyAgentV1))
|
||||
@ -157,8 +161,8 @@ func main() {
|
||||
r.Post("/devices/{id}/models/upload", h.UploadModel)
|
||||
})
|
||||
|
||||
fmt.Printf("Starting managerd on %s\n", cfg.Listen)
|
||||
if err := http.ListenAndServe(cfg.Listen, r); err != nil {
|
||||
log.Fatalf("failed to start server: %v", err)
|
||||
}
|
||||
fmt.Printf("Starting safesightd on %s\n", cfg.Listen)
|
||||
if err := http.ListenAndServe(cfg.Listen, r); err != nil {
|
||||
log.Fatalf("failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
27924
deps/get-pip.py
vendored
Normal file
27924
deps/get-pip.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
deps/pip-26.1.2-py3-none-any.whl
vendored
Normal file
BIN
deps/pip-26.1.2-py3-none-any.whl
vendored
Normal file
Binary file not shown.
@ -286,4 +286,4 @@ func (u *UI) pageDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
| 后端 | `internal/web/ui/templates/monitor.html` | 新建:视频监控页面 |
|
||||
| 后端 | `internal/web/ui/templates/dashboard.html` | 修改:实时化改造 |
|
||||
| 后端 | `internal/web/ui.go` | 修改:新页面 handler + 路由 |
|
||||
| 后端 | `cmd/managerd/main.go` | 修改:注入新服务 |
|
||||
| 后端 | `cmd/safesightd/main.go` | 修改:注入新服务 |
|
||||
|
||||
27
docs/bugfix-record.md
Normal file
27
docs/bugfix-record.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Bug 修复记录
|
||||
|
||||
## 2026-07-26:视频源中文名删除失败
|
||||
|
||||
**现象**:视频源用中文名时,删除提示"视频源已删除"但刷新后仍在列表。
|
||||
|
||||
**根因**:`chi.URLParam("name")` 对中文路径返回 URL 编码值(如 `%e6%9d%8e%e5%9b%9b`),而非解码后的文本(`李四`)。SQL `DELETE WHERE name = '%e6%9d%8e%e5%9b%9b'` 匹配不到实际行,静默失败。
|
||||
|
||||
**修复**:删除改用整数 ID(`/assets/video-sources/{id}/delete`),彻底避免 URL 编码问题。同时为服务和存储层添加 `DeleteVideoSourceByID(id)` 方法。
|
||||
|
||||
**影响范围**:视频源页面新建、编辑、改名、删除均使用 ID 做主键,不再依赖 name 作为标识符。
|
||||
|
||||
**教训**:URL 路径中的中文参数应避免使用,优先用整数 ID 或 query string(chi 会正确解码 query string)。如需修改其他资产页面的删除逻辑,同样使用 ID。
|
||||
|
||||
---
|
||||
|
||||
## 待改进:告警置信度语义不一致
|
||||
|
||||
**现象**:告警中心的"置信度"字段:
|
||||
- 陌生人脸告警:显示 0.08(相似度越低越严重)
|
||||
- 劳保鞋告警:显示 0.55(置信度越高越严重)
|
||||
|
||||
两者方向相反,用户无法通过数值判断紧急程度。
|
||||
|
||||
**根因**:陌生人脸告警上报的是识别相似度(vs 已知人员的距离),而非"陌生度"。正确做法应报 `1 - similarity`,使方向统一为"越高越严重"。
|
||||
|
||||
**改进方向**:edge-server 侧修改陌生人脸告警的上报逻辑,管理端无需改动。
|
||||
86
docs/deployment.md
Normal file
86
docs/deployment.md
Normal file
@ -0,0 +1,86 @@
|
||||
# SafeSight Control 部署指南
|
||||
|
||||
## 打包
|
||||
|
||||
在开发机上(需 Git Bash 或 Linux/WSL):
|
||||
|
||||
```bash
|
||||
cd safesight-control
|
||||
bash scripts/package.sh
|
||||
```
|
||||
|
||||
生成 `safesight-control-*.tar.gz`,包含:
|
||||
- ARM64 + AMD64 二进制
|
||||
- service 文件、配置示例
|
||||
- **种子配置数据**(configs/:templates、profiles、overlays)
|
||||
- **人脸库构建工具**(tools/:Python 脚本 + ONNX 模型)
|
||||
- **离线 Python 依赖**(deps/:onnxruntime wheel)
|
||||
- **辅助脚本**(scripts/:import-assets、register-face-gallery)
|
||||
|
||||
---
|
||||
|
||||
## 部署(ARM64)
|
||||
|
||||
```bash
|
||||
# 传到设备
|
||||
scp safesight-control-*.tar.gz user@server:/tmp/
|
||||
|
||||
# 解压并一键安装
|
||||
cd /tmp && tar -xzf safesight-control-*.tar.gz
|
||||
cd safesight-control-*
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
AMD64 部署同上,`install.sh` 自动识别架构。
|
||||
|
||||
首次启动自动导入种子数据,无需手动操作。
|
||||
|
||||
---
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `/opt/safesightd/config/safesightd.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"listen": "0.0.0.0:18080",
|
||||
"discovery_port": 35688,
|
||||
"discovery_timeout_ms": 1200,
|
||||
"offline_after_ms": 10000,
|
||||
"agent_token": "4fe2d69fda23d0d5d04a1486d4920e68",
|
||||
"concurrency": 5,
|
||||
"data_dir": "/opt/safesightd/data",
|
||||
"db_path": "/opt/safesightd/data/app.db",
|
||||
"log_dir": "/opt/safesightd/data/logs",
|
||||
"alarm_retention_days": 30
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 人脸库构建(离线环境)
|
||||
|
||||
```bash
|
||||
# 1. 离线安装 Python 依赖(仅首次)
|
||||
sudo pip3 install /opt/safesightd/deps/*.whl
|
||||
|
||||
# 2. 通过 Web UI 导入照片后,触发构建
|
||||
curl -X POST http://localhost:18080/face-gallery/build
|
||||
|
||||
# 3. 如从外部传入已构建的 face_gallery.db:
|
||||
sudo mkdir -p /opt/safesightd/resources/standard_resources/face_gallery
|
||||
sudo cp face_gallery.db /opt/safesightd/resources/standard_resources/face_gallery/
|
||||
sudo python3 /opt/safesightd/scripts/register-face-gallery.py
|
||||
sudo systemctl restart safesightd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
systemctl status safesightd
|
||||
curl http://localhost:18080/api/devices
|
||||
```
|
||||
|
||||
浏览器打开 `http://<IP>:18080`
|
||||
25
docs/device-online-design.md
Normal file
25
docs/device-online-design.md
Normal file
@ -0,0 +1,25 @@
|
||||
# 设备发现与在线状态设计原则
|
||||
|
||||
## 核心划分
|
||||
|
||||
| 机制 | 职责 | 使用场景 |
|
||||
|------|------|---------|
|
||||
| **UDP 广播 (35688)** | **仅设备发现** | 扫描局域网、发现新设备、刷新设备列表 |
|
||||
| **HTTP API (端口 9100)** | **判断设备是否可达** | 所有业务操作前确认设备在线 |
|
||||
| **registry.Online 字段** | **仅用于 UI 展示** | 设备列表的在线/离线指示灯 |
|
||||
|
||||
## 禁止行为
|
||||
|
||||
- ❌ 业务操作依赖 `dev.Online` 做准入判断
|
||||
- ❌ 在 HTTP 调用链中引用 `dev.Online` 跳过请求
|
||||
- ❌ 用 UDP 发现超时判断设备是否在线
|
||||
|
||||
## 正确做法
|
||||
|
||||
- ✅ `dev.Online` 只在 UI 层展示在线/离线状态
|
||||
- ✅ 业务操作直接发起 HTTP 请求,由 HTTP 成功/失败判断可达性
|
||||
- ✅ UDP 搜索只在页面加载、手动刷新、设备列表时触发
|
||||
|
||||
## 设计理由
|
||||
|
||||
UDP 包可能在交换机上被丢弃,设备可能已通过 DHCP 换 IP,广播可能被防火墙拦截。`Online` 字段只能作为近似的 UI 提示,不能作为业务逻辑依据。HTTP 请求的真实结果才是唯一可靠的在线判断。
|
||||
64
docs/unit-test-plan.md
Normal file
64
docs/unit-test-plan.md
Normal file
@ -0,0 +1,64 @@
|
||||
# 单元测试开发计划
|
||||
|
||||
## 进度总览
|
||||
|
||||
| 日期 | 内容 | 测试数 |
|
||||
|------|------|--------|
|
||||
| 2026-07-27 | P0 全部 + P1 #4 | 新增 8 个 |
|
||||
| 2026-07-27 | 清理 9 个静态内容测试 | 删除 9 个 |
|
||||
| 2026-07-27 | 修复预存失败 | 修复 7 个 |
|
||||
| 2026-07-27 | P1 #5 #6 + P2 #7 #8 + 新功能 | 新增 18 个 |
|
||||
| **当前** | 服务层 88 全过,Web 层 107 全过 | **195 测试,0 失败** |
|
||||
|
||||
## 现有测试评估
|
||||
|
||||
现有测试全部集中在 `FeatureRegistry.ResolveTemplate`(模板选择算法),其他核心函数覆盖率 **0%**。测试模式存在以下问题:
|
||||
|
||||
1. **依赖实现细节**:测试检查具体的模板名(如 `std_face_recognition_stream`),改配置就失败
|
||||
2. **弱断言**:部分测试只检查错误,不验证输出正确性
|
||||
3. **缺乏端到端**:没有测试从输入到输出的完整业务流程
|
||||
4. **无数据隔离**:`BuildPipeline` 依赖 SQLite、Agent、Registry,难以单独测试
|
||||
|
||||
## 测试原则
|
||||
|
||||
- 面向功能行为,不面向实现细节
|
||||
- 验证给定输入产生预期输出
|
||||
- 测试边界条件(空输入、重复、中文)
|
||||
- 测试相互独立,可并行运行
|
||||
|
||||
## 计划
|
||||
|
||||
### P0 — 直接造成过生产问题的函数 ✅
|
||||
|
||||
| # | 函数 | 测试点 | 状态 |
|
||||
|---|------|--------|------|
|
||||
| 1 | `deriveSafeInstanceName` | ASCII名原样返回、中文名生成 hash 前缀 | ✅ |
|
||||
| 2 | `cleanSourceNames` | 去重、去空格、空元素过滤、排序 | ✅ |
|
||||
| 3 | `BuildPipeline` | 创建识别单元、去重、空源保护 | ✅ |
|
||||
|
||||
### P1 — 核心业务流程
|
||||
|
||||
| # | 函数 | 测试点 | 状态 |
|
||||
|---|------|--------|------|
|
||||
| 4 | `actionConsoleSave` | 功能选择+source合并→批量下发 | ✅ |
|
||||
| 5 | `actionWizardApply` + `apiWizardDeviceInfo` | 设备选择、通道加载 | ✅ |
|
||||
| 6 | `injectIntegrationsIntoConfig` | alarm 注入、storage 注入 | ✅ |
|
||||
|
||||
### P2 — 辅助函数
|
||||
|
||||
| # | 函数 | 测试点 | 状态 |
|
||||
|---|------|--------|------|
|
||||
| 7 | `ResolveChannelToSource` | 通道名→视频源名、_alarm_后缀剥离 | ✅ |
|
||||
| 8 | `SaveVideoSourceAsset` 新签名 | 新建、改名、冲突检测 | ✅ |
|
||||
|
||||
### 新增 — 2026-07-27 新功能
|
||||
|
||||
| # | 函数 | 测试点 | 状态 |
|
||||
|---|------|--------|------|
|
||||
| 9 | `BuildPipeline` 模板克隆 | 标准模板首次使用自动克隆、复用已有克隆 | ✅ |
|
||||
| 10 | `GetDevicesUsingTemplate` | 匹配设备、无匹配、跨模板隔离 | ✅ |
|
||||
| 11 | `GetTuningItems` / `SaveTuningItems` | 硬编码定义完整性、路径解析、值读写 | ✅ |
|
||||
| 12 | `mergeRuntimeInstancePatch` | nodes 包装到 override 下、params 保留 | ✅ |
|
||||
| 13 | `SaveDeviceOverlays` / `GetDeviceOverlays` | 保存、覆盖、清空、未知设备 | ✅ |
|
||||
| 14 | `actionDeviceOverlaysSave` | 保存 overlay 勾选、清空 | ✅ |
|
||||
| 15 | 调参路径工具函数 | splitTuningPath、resolveTuningPath、setTuningPath | ✅ |
|
||||
228
docs/产品化改进计划.md
Normal file
228
docs/产品化改进计划.md
Normal file
@ -0,0 +1,228 @@
|
||||
# SafeSight Control 产品化改进计划
|
||||
|
||||
## 目标
|
||||
|
||||
将后台管理从"工程师界面"改造为"安全管理员界面",让非技术用户能够独立完成:
|
||||
1. 添加摄像头并部署检测场景
|
||||
2. 日常查看安全违规情况
|
||||
3. 处理和跟踪告警事件
|
||||
|
||||
## 优先级说明
|
||||
|
||||
- **P0** — 不做就不可交付给普通用户
|
||||
- **P1** — 严重影响日常体验
|
||||
- **P2** — 锦上添花,提升满意度
|
||||
|
||||
---
|
||||
|
||||
## P0:必须立即完成(交付底线)
|
||||
|
||||
### 1. 告警中心增强
|
||||
|
||||
**现状**:告警已持久化到 SQLite、有日期筛选分页、仪表盘有今日统计。缺的是工作流和等级。
|
||||
|
||||
**改什么**:
|
||||
- DB 迁移:`alarm_records` 表增加 `status`, `severity`, `acknowledged_at`, `acknowledged_by`, `resolved_at`, `resolved_by` 列
|
||||
- 告警状态流转:未处理 → 已确认 → 已关闭,记录操作人和时间
|
||||
- 严重等级:`info / warning / critical`,默认值 `warning`
|
||||
- 筛选增强:加设备下拉筛选、规则类型下拉筛选(现有日期筛选保留)
|
||||
- SSE 实时推送:新告警到达时推送浏览器
|
||||
|
||||
**实现要点**:
|
||||
- migration 用 `hasColumn` 检查列存在,`ALTER TABLE ADD COLUMN` 增量添加
|
||||
- `AlarmRecord` 结构体扩展字段,`saveAlarm` 设置默认值
|
||||
- API:`POST /ui/alarms/{id}/acknowledge`、`/resolve`、`/severity`
|
||||
- 模板:加严重等级列(蓝/橙/红 pill)、状态列、操作按钮列
|
||||
- SSE:`GET /api/alarms/stream`,浏览器 EventSource 连接,为 P0-2 仪表盘打基础
|
||||
|
||||
### 2. 仪表盘重做
|
||||
|
||||
**现状问题**:4 个数字 + 离线列表,看不到任何安全违规信息
|
||||
|
||||
**改什么**:
|
||||
- 顶部统计卡片改为:今日告警总数、未处理告警数、在线设备数、运行中检测路数
|
||||
- 中部新增"最近告警"实时流(SSE 推送),卡片式展示:抓拍图 + 规则名 + 设备名 + 时间
|
||||
- 底部新增"设备状态概览":在线/离线/异常卡片网格
|
||||
- 点击任意告警卡片跳转到告警中心对应详情
|
||||
|
||||
**实现要点**:
|
||||
- 新增 SSE 端点 `/api/alarms/stream`,`AlarmCollector` 收到告警时广播
|
||||
- 仪表盘页面 JS 连接 SSE,追加告警卡片到 DOM
|
||||
- 告警卡片带抓拍缩略图(/ui/hls 代理或 snapshot 直连)
|
||||
|
||||
### 3. 一键配置向导(场景化部署)
|
||||
|
||||
**现状问题**:配置一台摄像头需要跨 5 个页面 9 个步骤
|
||||
|
||||
**改什么**:
|
||||
- 新增页面 `/ui/wizard`,标准模式下直接可见
|
||||
- **_步骤1/3_:选择设备** — 列出在线设备,每个设备显示当前已加载的摄像头
|
||||
- **_步骤2/3_:配置检测** — 显示该设备上所有视频源,每个视频源可以:
|
||||
- 勾选要启用的检测项(安全帽、工鞋、人脸等)
|
||||
- 设置告警灵敏度(高/中/低)
|
||||
- 预览视频画面
|
||||
- **_步骤3/3_:确认并下发** — 展示配置摘要,确认后自动生成模板+Profile+识别单元→下发到设备
|
||||
|
||||
**实现要点**:
|
||||
- 后端新增 `/api/wizard/preview` 和 `/api/wizard/apply` 端点
|
||||
- 内部调用现有的 `AutoConfigService` 和任务系统
|
||||
- 前端用步骤条(stepper)展示进度,每步一个卡片
|
||||
- "检测项"从设备 `/v1/capabilities` 获取,用直观的图标+描述展示
|
||||
|
||||
### 4. 基础登录鉴权
|
||||
|
||||
**现状问题**:任何人都能访问 18080 端口做任何操作
|
||||
|
||||
**改什么**:
|
||||
- 内置单管理员账号,密码在 `safesightd.json` 配置
|
||||
- 登录页 `/ui/login`,成功后设 session cookie
|
||||
- 所有 `/api/` 和 `/ui/` 路由(除 login 和静态资源)需要鉴权
|
||||
- 半小时无操作自动退出
|
||||
|
||||
**实现要点**:
|
||||
- 新建 `internal/auth/` 包,提供 `Login(password) bool` + `SessionMiddleware`
|
||||
- 用 `crypto/bcrypt` 存密码哈希(配置中存 `password_hash`)
|
||||
- 用随机 token + 内存 map 做 session(不需要 Redis)
|
||||
|
||||
### 5. 监控页 HLS.js 本地化
|
||||
|
||||
**现状问题**:monitor.html 从 CDN 加载 hls.js,断网部署下无法使用
|
||||
|
||||
**改什么**:
|
||||
- 将 hls.js 引用改为本地 `/ui/assets/vendor/hls.min.js`
|
||||
- 与管控台页面保持一致
|
||||
|
||||
**实现要点**:
|
||||
- 1 行 templat 修改
|
||||
|
||||
---
|
||||
|
||||
## P1:短期必须完成(严重影响体验)
|
||||
|
||||
### 6. 告警浏览器通知
|
||||
|
||||
**现状问题**:告警要手动刷新页面才能看到
|
||||
|
||||
**改什么**:
|
||||
- 告警产生时,浏览器标签页标题闪烁 `[N] SafeSight`(N 为新告警数)
|
||||
- 有未处理告警时,顶栏通知铃图标显示红点+数量
|
||||
- 可选:支持桌面通知(`Notification API`),用户授权后弹窗
|
||||
|
||||
**实现要点**:
|
||||
- 复用 P0 的 SSE 端点 `/api/alarms/stream`
|
||||
- JS 监听 SSE 事件,更新顶栏通知铃和页面标题
|
||||
|
||||
### 7. 设备列表服务健康指示
|
||||
|
||||
**现状问题**:设备列表只显示在线/离线,看不到内部服务状态
|
||||
|
||||
**改什么**:
|
||||
- 设备列表每行增加媒体服务状态指示灯(运行中/已停止/异常)
|
||||
- 在线但媒体服务异常的设备,行背景变淡橙色警告
|
||||
- 悬停显示详细状态:CPU 使用率、内存、最后心跳时间
|
||||
|
||||
**实现要点**:
|
||||
- 后端在设备列表查询时附带最近一次 metrics 摘要
|
||||
- CSS 新增 `.device-row.warn` 样式
|
||||
|
||||
### 8. 操作结果提示优化
|
||||
|
||||
**现状问题**:成功/失败用 URL 参数传递消息,粗暴且易丢失
|
||||
|
||||
**改什么**:
|
||||
- 改用 session flash message:操作完成后在 HTTP header 设 cookie,下次页面渲染时展示
|
||||
- 页面顶部显示 toast 式消息条,3 秒自动消失,绿色成功/红色失败
|
||||
- 替代现有的 URL query 参数消息机制
|
||||
|
||||
**实现要点**:
|
||||
- middleware 层面处理 flash cookie
|
||||
- 模板 layout.html 顶部增加 toast 组件
|
||||
|
||||
### 9. 术语统一
|
||||
|
||||
**现状问题**:同一概念在多个页面叫法不同
|
||||
|
||||
**改什么**:
|
||||
- 全局统一:
|
||||
- "视频通道" → "检测通道"(与视频源区分)
|
||||
- "场景模板" → "检测模板"(与场景管理区分)
|
||||
- "识别单元" → "检测通道"(与设备赋值对齐)
|
||||
- 菜单项和页面标题同步修改
|
||||
|
||||
**实现要点**:
|
||||
- 批量替换模板中的中文标签
|
||||
- 路由不变,只改显示文本
|
||||
|
||||
---
|
||||
|
||||
## P2:长期优化(提升满意度)
|
||||
|
||||
### 10. 时序图表
|
||||
|
||||
- 告警趋势图(按天/周/月,按规则类型分组)
|
||||
- 设备资源使用趋势图(CPU/内存/NPU)
|
||||
- 检测统计图(今日/本周识别次数、各设备负载)
|
||||
|
||||
**实现**:使用轻量 Canvas 图表库(如 Chart.js 或 uPlot),数据从聚合查询获取
|
||||
|
||||
### 11. 设备分组与标签
|
||||
|
||||
- 支持按车间/区域分组设备
|
||||
- 设备列表增加分组筛选
|
||||
- 批量操作支持按组选择
|
||||
|
||||
### 12. 配置版本对比
|
||||
|
||||
- 查看历史配置版本列表
|
||||
- 选择两个版本做 diff 对比(JSON diff)
|
||||
- 回滚前预览变更内容
|
||||
|
||||
### 13. 报告导出
|
||||
|
||||
- 告警报告导出为 PDF/CSV
|
||||
- 设备状态报告
|
||||
- 支持选择时间范围和过滤条件
|
||||
|
||||
### 14. 模型管理简化
|
||||
|
||||
**现状**:模型通过目录扫描同步到 DB,Task 系统支持 `model_sync_all`/`model_sync_one` 批量分发,`ModelStatusBoard` 展示设备上的模型 SHA256 对比矩阵。缺的是上传入口和兼容性判断。
|
||||
|
||||
**架构**:管理端 `models/standard_models/` → 启动时同步到 `standard_models` 表 → UI 对比矩阵 → Task 推送到 Edge 的 `/v1/models/{name}`。架构正确,只需补全缺口。
|
||||
|
||||
**改什么**:
|
||||
- Web 页面上传新模型:文件上传 → 存盘 → 入库 → 可选自动分发
|
||||
- 版本管理:版本号从固定 "auto" 改为基于时间戳自动生成
|
||||
- 兼容性矩阵:设备不支持某类检测能力时(如无人脸识别),对应模型显示 "N/A" 而非 "缺失"
|
||||
- "更新全部"只推送给需要更新的设备(缺失或不一致),避免无效传输
|
||||
|
||||
### 15. 人脸库优化
|
||||
|
||||
**现状**:人员 CRUD(`face_persons`/`face_photos` 表)正常,Python Build 脚本产出 `face_gallery.db` 到 `resources/standard_resources/face_gallery/`。但分发用的是设备详情页的逐台手动上传,没有利用已有的 Resource Task 分发系统。
|
||||
|
||||
**架构**:已有 Resource 分发基础设施(`resource_sync_all`/`resource_sync_one` Task、`ResourceStatusBoard` 对比矩阵、`/v1/resources/status` 端点),人脸库应作为 `resource_type=face_gallery` 的资源接入这套体系,而非走独立的设备上传通道。
|
||||
|
||||
**改什么**:
|
||||
- Build 产出自动注册:Build 完成后将 `face_gallery.db` 的 hash/size 自动写入 `standard_resources` 表,让 Resource 分发系统感知
|
||||
- 同步到设备:人脸库页面增加设备同步状态区域(复用 `ResourceStatusBoard` 筛选 `face_gallery`),加"同步到全部设备"按钮
|
||||
- 搜索过滤:人员列表增加关键词搜索
|
||||
- 人脸质量评分反馈:在人员详情中展示每张照片的质量等级(依赖 build_gallery.py 输出)
|
||||
|
||||
---
|
||||
|
||||
## 实施时序
|
||||
|
||||
```
|
||||
已完成:P0-5(HLS 修复)
|
||||
↓
|
||||
进行中:P0-1(告警增强) → P0-2(仪表盘重做)
|
||||
↓
|
||||
待开始:P0-3(配置向导) → P1(体验打磨) → P0-4(登录) → P2(长期优化)
|
||||
```
|
||||
|
||||
## 技术债务同步处理
|
||||
|
||||
在实现上述功能时,顺手清理:
|
||||
- 提取模板函数到独立文件(当前约 300 行挤在一个函数里)
|
||||
- 统一设备健康检查的 timeout 策略
|
||||
- 为所有新增 API 建立测试用例
|
||||
- `.gitignore` 覆盖所有构建产物
|
||||
@ -1,7 +1,7 @@
|
||||
# 产品化评估与后续规划
|
||||
|
||||
> 评估日期:2026-05-06
|
||||
> 范围:视觉识别服务(OrangePi3588Media)+ 管理后台(3588AdminBackend)
|
||||
> 范围:视觉识别服务(OrangePi3588Media)+ 管理后台(SafeSight Control)
|
||||
|
||||
---
|
||||
|
||||
@ -75,7 +75,7 @@
|
||||
两个项目的架构基础扎实:
|
||||
|
||||
- **media-server**:插件化 DAG 流水线,JSON 配置驱动,热更新/回滚,硬件解耦抽象清晰
|
||||
- **managerd**:分层架构(API→Service→Storage),RESTful,HTML/template 服务端渲染,无框架依赖
|
||||
- **safesightd**:分层架构(API→Service→Storage),RESTful,HTML/template 服务端渲染,无框架依赖
|
||||
|
||||
从产品化角度看,两边都有显著缺口,下面按优先级列出。
|
||||
|
||||
@ -107,7 +107,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 三、管理后台(managerd)
|
||||
## 三、管理后台(safesightd)
|
||||
|
||||
### ✅ 优势
|
||||
|
||||
@ -249,7 +249,7 @@
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| `cmd/managerd` 目录名歧义 | 既是源码目录又被 gitignore 匹配为二进制 |
|
||||
| `cmd/safesightd` 目录名歧义 | 既是源码目录又被 gitignore 匹配为二进制 |
|
||||
| 模板函数分散 | `modelTypeLabel`、`resourceTypeLabel` 等散落在 NewUI 中,应集中管理 |
|
||||
| agent 配置示例过时 | `agent.config.example.json` 路径与生产环境不一致 |
|
||||
| deploy.sh 交互选择 | 应支持完全无交互部署(CI/CD 友好) |
|
||||
|
||||
165
docs/实施跟踪.md
Normal file
165
docs/实施跟踪.md
Normal file
@ -0,0 +1,165 @@
|
||||
# 产品化改进实施跟踪
|
||||
|
||||
## P0 — 交付底线
|
||||
|
||||
| # | 事项 | 状态 | 开始 | 完成 |
|
||||
|---|------|------|------|------|
|
||||
| 1 | 告警中心增强 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||
| 2 | 仪表盘重做(实时告警流+统计卡片) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||
| 3 | 一键配置向导(场景化部署 3 步流程) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||
| 4 | 基础登录鉴权 | ⬜ 待开始 | - | - |
|
||||
| 5 | 监控页 HLS.js 本地化 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||
|
||||
## P1 — 体验改善
|
||||
|
||||
| # | 事项 | 状态 | 开始 | 完成 |
|
||||
|---|------|------|------|------|
|
||||
| 6 | 告警浏览器通知(SSE / 标题闪烁) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||
| 7 | 设备列表服务健康指示 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||
| 8 | 操作结果提示优化(toast 替代 URL 消息) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||
| 9 | 术语统一 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||
|
||||
| 16 | 通道部署页配置同步状态指示 | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
|
||||
|
||||
## P2 — 长期优化
|
||||
|
||||
| # | 事项 | 状态 | 开始 | 完成 |
|
||||
|---|------|------|------|------|
|
||||
| 10 | 时序图表(告警趋势) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||
| 11 | 设备分组与标签 | ⏸️ 推迟 | - | - |
|
||||
| 12 | 配置版本对比 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||
| 13 | 报告导出(CSV) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
|
||||
| 14 | 模型管理简化 | 🔄 进行中 | 2026-07-20 | - |
|
||||
| 14.1 | └ 上传新模型(文件上传→存盘→入库) | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
|
||||
| 14.2 | └ 设备兼容性矩阵(N/A vs 缺失) | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
|
||||
| 14.3 | └ 版本管理(时间戳替代 auto) | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
|
||||
| 14.4 | └ "更新全部"智能过滤(只推送给需要更新的设备) | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
|
||||
| 15 | 人脸库优化(搜索+同步+质量评分) | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
|
||||
| 15.1 | └ Build 产出自动注册为标准资源 | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
|
||||
| 15.2 | └ 人脸库页面接入 Resource 同步系统 | ⬜ 待开始 | - | - |
|
||||
| 15.3 | └ 人员搜索与过滤 | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
|
||||
| 15.4 | └ 人脸质量评分展示 | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
|
||||
|
||||
## 进行中
|
||||
|
||||
- **P2-14 模型管理简化** — 详见 [模型与人脸库改进方案](./模型与人脸库改进方案.md)
|
||||
- **P2-15 人脸库优化** — 详见 [模型与人脸库改进方案](./模型与人脸库改进方案.md)
|
||||
|
||||
## 已完成事项
|
||||
|
||||
### 2026-07-20
|
||||
|
||||
- **新增:通道部署页配置同步状态指示**
|
||||
- `DeviceAssignmentBoardCard` 新增 `ConfigSynced` 字段
|
||||
- `pageDeviceAssignments`:查询 Edge `/v1/config/status` 的 sha256,与 `config_versions` 中最新的 config_id 对比
|
||||
- 每个设备卡片显示:✅ 配置已同步 或 ⚠ 配置待更新
|
||||
- 改动文件:`config_assets.go` (+1)、`ui.go` (+22)、`device_assignments.html` (+3)、`style.css` (+3)
|
||||
|
||||
- **P2-15.4 人脸质量评分展示**
|
||||
- `actionFaceGalleryBuild` 增强输出解析:提取 per_person_used、failure_reasons、failed_images 等诊断信息
|
||||
- Build 完成后写入 `build_meta.json`,供页面读取每人的通过数
|
||||
- 结果消息增强:显示 "生成成功,共 3 人,12 张通过,2 张失败(无人脸×2)"
|
||||
- 人脸库卡片:每人照片数旁显示绿色"N通过" pill(如 "3通过")
|
||||
- 改动文件:`ui.go` (+48)、`face_gallery.html` (+1)
|
||||
|
||||
- **P2-15 人脸库优化 全部完成** ✅
|
||||
|
||||
- **P2-14.4 "更新全部"智能过滤**
|
||||
- `ModelStatusRow` 新增 `NeedsSync` 字段,`BuildModelStatusBoard` 在设备有缺失/不一致模型时设为 true
|
||||
- 模板"更新全部模型"表单改为只提交 `NeedsSync=true` 的设备,避免对已同步设备做无效推送
|
||||
- 改动文件:`model_management.go` (+2)、`models.html` (+1)
|
||||
|
||||
- **P2-14.3 版本管理**
|
||||
- 新增 `fileVersion` 辅助函数:基于文件修改时间生成 `YYYYMMDD-HHMMSS` 版本号
|
||||
- `SyncStandardModelsFromDirectory`、`SyncStandardResourcesFromDirectory`、`RegisterResource` 的 `Version` 字段从固定 `"auto"` 改为调用 `fileVersion`
|
||||
- `actionModelUpload` 的默认版本也改为统一格式
|
||||
- 改动文件:`model_management.go` (+11)、`resource_management.go` (+3)、`ui.go` (+1)
|
||||
|
||||
- **P2-14.2 设备兼容性矩阵**
|
||||
- `BuildModelStatusBoard` 新增 `deviceCapabilities` 参数,根据设备 `/v1/capabilities` 判断
|
||||
- `ModelTypeCapability` 函数映射模型类型→能力Key(`face_detection`→`face_det`、`face_recognition`→`face_recog`,其余不限制)
|
||||
- 设备不支持某类模型时,状态显示灰色「不适用」pill 而非红色「缺失」
|
||||
- `pageModels` handler 查询所有在线设备的能力信息,传给 `BuildModelStatusBoard`
|
||||
- Summary 计算:只有 ok/na 的设备算 Complete,合理
|
||||
- 改动文件:`model_management.go` (+22)、`ui.go` (+32)、`models.html` (+2)、`model_management_test.go` (+2)、`style.css` (+1)
|
||||
|
||||
- **P2-14.1 上传新模型**
|
||||
- 新增 `actionModelUpload` handler:multipart 接收 → 存盘 `models/standard_models/` → SHA256 hash → 写入 `standard_models` 表
|
||||
- 表单字段:名称、类型(下拉)、版本(可选,留空自动生成日期版号)、描述、文件
|
||||
- "自动分发"复选框:勾选后立即创建 `model_sync_all` 任务推送到全部在线设备
|
||||
- 路由:`POST /ui/models/upload`
|
||||
- 模板:死按钮改为弹出模态框,仿 face_gallery 风格
|
||||
- 改动文件:`internal/web/ui.go` (+82)、`models.html` (+38)
|
||||
|
||||
- **P2-15.3 人员搜索与过滤**
|
||||
- 人员列表上方新增搜索框,输入关键词实时过滤(前端 JS,无需后端)
|
||||
- 卡片增加 `person-card` class + `data-name` 属性用于匹配
|
||||
- 改动文件:`face_gallery.html` (+15)
|
||||
|
||||
- **P2-15.2 人脸库页面接入 Resource 同步系统**
|
||||
- `pageFaceGallery`:加载 `resource_type=face_gallery` 的标准资源,构建 `ResourceStatusBoard` 传给模板
|
||||
- 新增 `actionFaceGallerySync` handler:接收 `device_id[]`,创建 `resource_sync_all` 任务,跳转任务详情
|
||||
- 模板新增"设备同步状态"卡片:SHA256 对比矩阵 + "同步到全部设备"按钮 + 单设备"同步"按钮
|
||||
- 路由:`POST /ui/face-gallery/sync`
|
||||
- 改动文件:`internal/web/ui.go` (+41)、`face_gallery.html` (+49)
|
||||
|
||||
- **P2-15.1 Build 产出自动注册为标准资源**
|
||||
- `ResourceManagementService` 新增 `RegisterResource` 方法:对单个文件 hash + 写入 `standard_resources` 表
|
||||
- `actionFaceGalleryBuild`:Build 成功后自动调用注册,Resource 分发系统即刻感知新版本
|
||||
- 改动文件:`internal/service/resource_management.go` (+20)、`internal/web/ui.go` (+8)
|
||||
|
||||
### 2026-07-17
|
||||
|
||||
- **P2-12 配置版本对比**
|
||||
- DB 新表 `config_versions`,部署时自动保存配置 JSON 快照
|
||||
- 设备详情页"配置历史"按钮 → 版本列表 → 选两版并排对比
|
||||
- 差异行红/绿高亮
|
||||
|
||||
- **P0-3 一键配置向导**
|
||||
- 3 步引导:选设备 → 配检测(从设备读取运行通道,下拉选已有视频源或手动添加) → 一键下发
|
||||
- 标准模式可见,复用的 AutoConfigService 链路
|
||||
|
||||
- **P2-13 报告导出**
|
||||
- API:`/api/alarms/export?from=&to=&device=&rule_type=` 导出 CSV
|
||||
- Excel 兼容(BOM + UTF-8),支持筛选条件继承,告警页面一键下载
|
||||
|
||||
- **P2-10 告警趋势图**
|
||||
- 仪表盘新增 Canvas 柱状图:近 7 日告警数量
|
||||
- API:`/api/alarms/daily-count?days=7` 返回每日统计
|
||||
- 纯 Canvas 绘制,无外部图表库依赖
|
||||
|
||||
- **P1-9 术语统一**
|
||||
- "视频通道" → "检测通道"(5 个模板 + 菜单 + Handler 标题)
|
||||
- "场景" → "场景管理"(Handler 标题与菜单对齐)
|
||||
- "人脸库管理" → "人脸库"(与菜单对齐)
|
||||
|
||||
- **P1-8 操作结果提示优化**
|
||||
- JS 读取 URL 中 `?msg=` / `?error=` 参数,显示为右上角浮动 toast
|
||||
- 绿色成功 / 红色失败,4 秒自动滑出消失
|
||||
- 展示同时清理 URL,刷新不重复显示
|
||||
|
||||
- **P1-7 设备服务健康指示**
|
||||
- 模板本已有媒体服务状态(运行中/未运行/待确认)
|
||||
- 新增:在线但媒体服务异常的行背景变淡橙色警告
|
||||
|
||||
- **P1-6 告警浏览器通知**
|
||||
- 顶栏铃铛图标显示未处理告警数(红点+数字徽章)
|
||||
- 页面标题闪烁 `(N) SafeSight`
|
||||
- 每 15 秒轮询 `/api/alarms/unacknowledged-count`
|
||||
- `render` 统一注入计数,所有页面生效
|
||||
|
||||
- **P0-2 仪表盘重做**
|
||||
- 统计卡片:今日告警/未处理告警/在线设备/检测路数
|
||||
- SSE 实时告警流:EventSource 连接 /api/alarms/stream,新告警实时推入
|
||||
|
||||
- **P0-1 告警中心增强**
|
||||
- DB 迁移:`alarm_records` 增加 `status`, `severity`, `acknowledged_at/by`, `resolved_at/by` 列
|
||||
- 服务层:`AlarmRecord` 扩展 + SSE 广播 + 确认/关闭/改等级 API
|
||||
- 页面:严重等级列 + 状态列 + 操作按钮 + 设备/规则类型下拉筛选
|
||||
|
||||
- **P0-5 监控页 HLS.js 本地化**
|
||||
- 改动:`monitor.html:18` — CDN 引用改为本地 `/ui/assets/vendor/hls.min.js`
|
||||
|
||||
---
|
||||
|
||||
**已完成:14/15(93.3%)** | **剩余:P0-4(登录)** | **推迟:P2-11(分组)**
|
||||
174
docs/模型与人脸库改进方案.md
Normal file
174
docs/模型与人脸库改进方案.md
Normal file
@ -0,0 +1,174 @@
|
||||
# 模型管理 & 人脸库管理 改进实施方案
|
||||
|
||||
> 关联文档:[产品化改进计划](./产品化改进计划.md) → P2-14 / P2-15
|
||||
> 跟踪状态:[实施跟踪](./实施跟踪.md)
|
||||
|
||||
## 改造原则
|
||||
|
||||
最小侵入,最大复用。人脸库的核心问题是"基建已就绪但没接上",不重写已有逻辑,而是**连通断点**。
|
||||
|
||||
---
|
||||
|
||||
## 一、人脸库管理(P2-15)
|
||||
|
||||
### 问题回顾
|
||||
|
||||
| 现有能力 | 问题 |
|
||||
|----------|------|
|
||||
| 人员 CRUD、照片管理 | ✅ 正常 |
|
||||
| Python Build 脚本生成 face_gallery.db | ✅ 正常 |
|
||||
| 产出落到 `resources/standard_resources/face_gallery/` | ✅ 已就位 |
|
||||
| Resource Task 系统(`resource_sync_all`/`resource_sync_one`) | ✅ 已就位 |
|
||||
| ResourceStatusBoard 对比矩阵 | ✅ 已就位 |
|
||||
| 从人脸库页面触发批量同步 | ❌ 缺失 |
|
||||
| 查看设备上人脸库版本/状态 | ❌ 缺失 |
|
||||
| 搜索过滤人员 | ❌ 缺失 |
|
||||
|
||||
### 改动清单
|
||||
|
||||
#### 1.1 人脸库页面增加"同步到设备"区域
|
||||
|
||||
**文件**: `internal/web/ui/templates/face_gallery.html`
|
||||
|
||||
在人脸库页面底部新增一个卡片区域:
|
||||
- 显示当前 `face_gallery.db` 构建状态(最后构建时间、人数、照片数)
|
||||
- 显示设备同步状态矩阵(复用 `ResourceStatusBoard`,筛选 `resource_type=face_gallery`)
|
||||
- "同步到全部设备"按钮 → 创建 `resource_sync_all` 任务
|
||||
- 单设备"同步"按钮 → 创建 `resource_sync_one` 任务
|
||||
|
||||
**后端改动**:
|
||||
- `pageFaceGallery` handler 增加:查询 `standard_resources` 中 `resource_type=face_gallery` 的记录、构建 `ResourceStatusBoard`
|
||||
- 新增 `actionFaceGallerySync` handler:接收 `device_id[]`,调用 `taskSvc.CreateTask("resource_sync_all", deviceIDs, payload)`
|
||||
|
||||
#### 1.2 Build 后自动注册为标准资源
|
||||
|
||||
**文件**: `internal/web/ui.go` - `actionFaceGalleryBuild`
|
||||
|
||||
现有 Build 后只返回消息,需要增加:
|
||||
1. Build 成功后扫描 `resources/standard_resources/face_gallery/` 目录
|
||||
2. 调用 `resourceSvc.SyncStandardResourcesFromDirectory()` 或其局部逻辑
|
||||
3. 将 `face_gallery.db` 的 SHA256/hash/size 写入 `standard_resources` 表
|
||||
|
||||
这样 Build 完后,Resource 分发系统就能自动感知到新版本。
|
||||
|
||||
**实现要点**:
|
||||
```go
|
||||
// actionFaceGalleryBuild 中,build 成功后:
|
||||
hash, size := hashFile(outputDB)
|
||||
resourcesRepo.Save(StandardResourceRecord{
|
||||
Name: "face_gallery", ResourceType: "face_gallery",
|
||||
SHA256: hash, SizeBytes: size,
|
||||
FilePath: outputDB,
|
||||
})
|
||||
```
|
||||
|
||||
#### 1.3 人员搜索与过滤
|
||||
|
||||
**文件**: `internal/storage/face_gallery_repo.go` + `face_gallery.html`
|
||||
|
||||
- `FaceGalleryRepo` 新增 `SearchPersons(keyword string) ([]PersonRecord, error)`
|
||||
- 模板增加搜索框,输入关键词实时过滤人员列表(前端 JS 过滤即可,数据量小)
|
||||
|
||||
#### 1.4 人脸质量评分展示
|
||||
|
||||
**文件**: `internal/service/face_gallery_builder.go` + `face_gallery.html`
|
||||
|
||||
- Build 脚本的输出中解析每张照片的 quality score(如果 build_gallery.py 有输出的话)
|
||||
- 在人员详情编辑弹窗中,每张照片旁显示质量标签(高/中/低)
|
||||
|
||||
如果 `build_gallery.py` 当前不输出质量分,则作为可选项推迟。
|
||||
|
||||
---
|
||||
|
||||
## 二、模型管理(P2-14)
|
||||
|
||||
### 问题回顾
|
||||
|
||||
| 现有能力 | 问题 |
|
||||
|----------|------|
|
||||
| 目录扫描同步到 DB | ✅ 正常 |
|
||||
| ModelStatusBoard 对比矩阵 | ✅ 正常 |
|
||||
| Task 系统批量同步 | ✅ 正常 |
|
||||
| "更新全部模型"按钮 | ✅ 已就位 |
|
||||
| **上传新模型** UI | ❌ 按钮存在但无功能 |
|
||||
| 模型版本管理 | ❌ 版本字段固定为 "auto" |
|
||||
| 设备兼容性检查 | ❌ 不做区分,所有模型对所有设备 |
|
||||
|
||||
### 改动清单
|
||||
|
||||
#### 2.1 "上传新模型"功能实现
|
||||
|
||||
**文件**: `internal/web/ui/templates/models.html` + `internal/web/ui.go`
|
||||
|
||||
- 点击"上传新模型"弹出模态框
|
||||
- 表单:模型名、模型类型(下拉选择)、描述、文件选择(.rknn)
|
||||
- 上传后:
|
||||
1. 保存文件到 `models/standard_models/{filename}`
|
||||
2. 计算 SHA256
|
||||
3. 写入 `standard_models` 表
|
||||
4. 可选:立即分发到所有在线设备(checkbox)
|
||||
|
||||
**新增 handler**: `POST /ui/models/upload`
|
||||
```go
|
||||
func (u *UI) actionModelUpload(w http.ResponseWriter, r *http.Request) {
|
||||
// multipart form: name, model_type, description, file
|
||||
// 1. Save file to models/standard_models/
|
||||
// 2. Hash + save to standard_models table
|
||||
// 3. Optional: create model_sync_all task
|
||||
}
|
||||
```
|
||||
|
||||
**路由注册**: 在 `Routes()` 中添加 `r.Post("/models/upload", u.actionModelUpload)`
|
||||
|
||||
#### 2.2 模型版本管理
|
||||
|
||||
**文件**: `internal/storage/models_repo.go` + `internal/service/model_management.go` + `models.html`
|
||||
|
||||
- `StandardModelRecord.Version` 字段从固定 "auto" 改为基于文件修改时间的语义化版本(如 `2026-07-17-v1`)
|
||||
- 上传时允许用户指定版本号(可留空自动生成)
|
||||
- 模板中已有版本列,展示即可
|
||||
|
||||
#### 2.3 设备兼容性矩阵
|
||||
|
||||
**文件**: `internal/service/model_management.go` + `models.html`
|
||||
|
||||
当前 `ModelStatusBoard` 对所有设备检查所有模型。改进:
|
||||
- `BuildModelStatusBoard` 接收可选的设备 capability 信息
|
||||
- 如果设备不支持人脸识别(没有 face_recognition capability),则 `face_recognition` 类型的模型标记为 "N/A" 而非 "缺失"
|
||||
- 矩阵中增加灰色 "N/A" pill,区别于红色的 "缺失"
|
||||
|
||||
**实现要点**:
|
||||
- 在 `pageModels` 中查询设备 `/v1/capabilities`,缓存到 map
|
||||
- 传给 `BuildModelStatusBoard` 一个新参数 `deviceCapabilities map[string][]string`
|
||||
- Cell 增加第三种状态 `"na"` → 灰色 pill "不适用"
|
||||
|
||||
#### 2.4 "更新全部模型"增强
|
||||
|
||||
当前已有此功能,只做小优化:
|
||||
- 改为只更新需要更新的设备(缺失或不一致的),而不是全量推送
|
||||
- 这在前端层面做过滤:只提交 `status != "ok"` 的设备 ID
|
||||
|
||||
---
|
||||
|
||||
## 三、实施顺序
|
||||
|
||||
```
|
||||
第1步:人脸库 - Build后自动注册为标准资源 (1.2) ← ✅ 已完成
|
||||
第2步:人脸库 - 同步到设备区域 (1.1) ← 页面UI接入Task系统
|
||||
第3步:人脸库 - 人员搜索过滤 (1.3) ← 交互优化
|
||||
第4步:模型管理 - 上传新模型功能 (2.1) ← 补全核心功能
|
||||
第5步:模型管理 - 设备兼容性矩阵 (2.3) ← 展示优化
|
||||
第6步:模型管理 - 版本管理 (2.2) ← 锦上添花
|
||||
第7步:人脸库 - 质量评分展示 (1.4) ← 可选
|
||||
```
|
||||
|
||||
## 四、涉及文件一览
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|----------|
|
||||
| `internal/web/ui.go` | 新增/修改 handlers,增加 face gallery sync + model upload |
|
||||
| `internal/web/ui/templates/face_gallery.html` | 增加同步区域、搜索框、质量展示 |
|
||||
| `internal/web/ui/templates/models.html` | 实现上传模态框、兼容性显示 |
|
||||
| `internal/storage/face_gallery_repo.go` | 新增 `SearchPersons` |
|
||||
| `internal/service/model_management.go` | `BuildModelStatusBoard` 支持 capability 过滤 |
|
||||
| `internal/service/face_gallery_builder.go` | 可选:解析质量分 |
|
||||
@ -1,4 +1,4 @@
|
||||
# 3588AdminBackend 离线部署文档
|
||||
# SafeSight Control 离线部署文档
|
||||
|
||||
> **目标环境**: Ubuntu 22.04.5 LTS (离线环境,无法连接互联网)
|
||||
> **部署方式**: 在 Windows 上交叉编译,上传安装包到 Ubuntu 进行离线部署
|
||||
@ -21,7 +21,7 @@
|
||||
|
||||
### 1.1 项目简介
|
||||
|
||||
3588AdminBackend 是 Orange Pi 3588 媒体服务器的后台管理端,提供以下功能:
|
||||
SafeSight Control 是 Orange Pi 3588 媒体服务器的后台管理端,提供以下功能:
|
||||
|
||||
- 设备发现与注册管理
|
||||
- 设备配置下发与批量任务管理
|
||||
@ -47,7 +47,7 @@
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. 安装 Go 1.23+ │ │
|
||||
│ │ 2. 运行 build-windows.ps1 │ │
|
||||
│ │ 3. 生成 3588AdminBackend-离线部署包-YYYYMMDD.tar.gz │ │
|
||||
│ │ 3. 生成 SafeSight Control-离线部署包-YYYYMMDD.tar.gz │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
@ -56,7 +56,7 @@
|
||||
│ Ubuntu(生产环境) │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 无需 Go 运行时!直接运行编译好的静态二进制文件 │ │
|
||||
│ │ - tar -xzf 3588AdminBackend-离线部署包-*.tar.gz │ │
|
||||
│ │ - tar -xzf SafeSight Control-离线部署包-*.tar.gz │ │
|
||||
│ │ - sudo ./scripts/3588admin install │ │
|
||||
│ │ - sudo ./scripts/3588admin start │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
@ -96,14 +96,14 @@
|
||||
|
||||
将项目源码复制到 Windows 机器上,例如:
|
||||
```
|
||||
C:\Users\YourName\3588AdminBackend\
|
||||
C:\Users\YourName\SafeSight Control\
|
||||
```
|
||||
|
||||
目录结构应包含:
|
||||
```
|
||||
3588AdminBackend/
|
||||
SafeSight Control/
|
||||
├── cmd/
|
||||
│ └── managerd/
|
||||
│ └── safesightd/
|
||||
├── internal/
|
||||
├── scripts/
|
||||
│ └── deploy/
|
||||
@ -125,7 +125,7 @@ C:\Users\YourName\3588AdminBackend\
|
||||
|
||||
```powershell
|
||||
# 进入项目目录
|
||||
cd C:\Users\YourName\3588AdminBackend\scripts\deploy
|
||||
cd C:\Users\YourName\SafeSight Control\scripts\deploy
|
||||
|
||||
# 执行构建脚本
|
||||
.\build-windows.ps1
|
||||
@ -133,21 +133,21 @@ cd C:\Users\YourName\3588AdminBackend\scripts\deploy
|
||||
|
||||
或一行命令:
|
||||
```powershell
|
||||
& "C:<Users>YourName<3588AdminBackend<scripts>deploy<build-windows.ps1"
|
||||
& "C:<Users>YourName<SafeSight Control<scripts>deploy<build-windows.ps1"
|
||||
```
|
||||
|
||||
### 3.2 构建输出
|
||||
|
||||
构建完成后,会在 `build\` 目录下生成:
|
||||
```
|
||||
3588AdminBackend-离线部署包-YYYYMMDD.tar.gz
|
||||
SafeSight Control-离线部署包-YYYYMMDD.tar.gz
|
||||
```
|
||||
|
||||
构建输出示例:
|
||||
```
|
||||
========== 3588AdminBackend Windows 构建 ==========
|
||||
项目目录: C:<Users>YourName<3588AdminBackend
|
||||
构建目录: C:<Users>YourName<3588AdminBackend<build
|
||||
========== SafeSight Control Windows 构建 ==========
|
||||
项目目录: C:<Users>YourName<SafeSight Control
|
||||
构建目录: C:<Users>YourName<SafeSight Control<build
|
||||
Go 版本: go version go1.23.3 windows/amd64
|
||||
|
||||
[1/6] 清理旧构建...
|
||||
@ -163,12 +163,12 @@ Go 版本: go version go1.23.3 windows/amd64
|
||||
========== 构建完成 ==========
|
||||
|
||||
输出文件:
|
||||
3588AdminBackend-离线部署包-20260225.tar.gz (12.34 MB)
|
||||
SafeSight Control-离线部署包-20260225.tar.gz (12.34 MB)
|
||||
|
||||
部署包位置: C:<Users>YourName<3588AdminBackend<build<3588AdminBackend-离线部署包-20260225.tar.gz
|
||||
部署包位置: C:<Users>YourName<SafeSight Control<build<SafeSight Control-离线部署包-20260225.tar.gz
|
||||
|
||||
使用说明:
|
||||
1. 将 3588AdminBackend-离线部署包-20260225.tar.gz 上传到 Ubuntu 服务器
|
||||
1. 将 SafeSight Control-离线部署包-20260225.tar.gz 上传到 Ubuntu 服务器
|
||||
2. 解压并运行: sudo ./scripts/3588admin install
|
||||
```
|
||||
|
||||
@ -178,12 +178,12 @@ Go 版本: go version go1.23.3 windows/amd64
|
||||
|
||||
```powershell
|
||||
# 1. 进入项目目录
|
||||
cd C:<Users>YourName<3588AdminBackend
|
||||
cd C:<Users>YourName<SafeSight Control
|
||||
|
||||
# 2. 交叉编译 Linux 版本
|
||||
$env:GOOS = "linux"
|
||||
$env:GOARCH = "amd64"
|
||||
go build -ldflags="-s -w" -o managerd-linux-amd64 .\cmd\managerd
|
||||
go build -ldflags="-s -w" -o safesightd-linux-amd64 .\cmd\safesightd
|
||||
|
||||
# 3. 创建部署目录
|
||||
mkdir build<3588admin-deploy<bin
|
||||
@ -191,14 +191,14 @@ mkdir build<3588admin-deploy<config
|
||||
mkdir build<3588admin-deploy<scripts
|
||||
|
||||
# 4. 复制文件
|
||||
copy managerd-linux-amd64 build<3588admin-deploy<bin<managerd
|
||||
copy safesightd-linux-amd64 build<3588admin-deploy<bin<safesightd
|
||||
copy scripts>deploy<3588admin build<3588admin-deploy<scripts<
|
||||
copy scripts>deploy<3588admin.service build<3588admin-deploy<scripts<
|
||||
copy managerd.json build<3588admin-deploy<config<managerd.json.example
|
||||
copy safesightd.json build<3588admin-deploy<config<safesightd.json.example
|
||||
|
||||
# 5. 打包
|
||||
cd build
|
||||
tar -czf 3588AdminBackend-离线部署包-$(Get-Date -Format 'yyyyMMdd').tar.gz 3588admin-deploy<
|
||||
tar -czf SafeSight Control-离线部署包-$(Get-Date -Format 'yyyyMMdd').tar.gz 3588admin-deploy<
|
||||
```
|
||||
|
||||
### 3.4 上传到 Ubuntu 服务器
|
||||
@ -207,7 +207,7 @@ tar -czf 3588AdminBackend-离线部署包-$(Get-Date -Format 'yyyyMMdd').tar.gz
|
||||
|
||||
```powershell
|
||||
# 使用 scp(如果 Windows 有 OpenSSH)
|
||||
scp .\build<3588AdminBackend-离线部署包-*.tar.gz user@ubuntu-server:/tmp/
|
||||
scp .\build<SafeSight Control-离线部署包-*.tar.gz user@ubuntu-server:/tmp/
|
||||
|
||||
# 或使用 WinSCP、FileZilla 等图形化工具
|
||||
```
|
||||
@ -225,10 +225,10 @@ scp .\build<3588AdminBackend-离线部署包-*.tar.gz user@ubuntu-server:/tmp/
|
||||
cd /tmp
|
||||
|
||||
# 解压
|
||||
tar -xzf 3588AdminBackend-离线部署包-*.tar.gz
|
||||
tar -xzf SafeSight Control-离线部署包-*.tar.gz
|
||||
|
||||
# 进入部署目录
|
||||
cd 3588AdminBackend-离线部署包-*/
|
||||
cd SafeSight Control-离线部署包-*/
|
||||
|
||||
# 查看内容
|
||||
ls -la
|
||||
@ -283,7 +283,7 @@ sudo systemctl status 3588admin
|
||||
|
||||
```bash
|
||||
# 编辑配置文件
|
||||
sudo nano /opt/3588admin/config/managerd.json
|
||||
sudo nano /opt/3588admin/config/safesightd.json
|
||||
```
|
||||
|
||||
**关键配置项说明**:
|
||||
@ -346,7 +346,7 @@ sudo systemctl status 3588admin
|
||||
|
||||
```bash
|
||||
# 检查进程
|
||||
ps aux | grep managerd
|
||||
ps aux | grep safesightd
|
||||
|
||||
# 检查端口监听
|
||||
sudo netstat -tlnp | grep 18080
|
||||
@ -357,7 +357,7 @@ sudo ss -tlnp | grep 18080
|
||||
curl http://127.0.0.1:18080/api/devices
|
||||
|
||||
# 查看日志
|
||||
tail -f /opt/3588admin/logs/managerd.log
|
||||
tail -f /opt/3588admin/logs/safesightd.log
|
||||
```
|
||||
|
||||
### 5.4 防火墙配置
|
||||
@ -403,14 +403,14 @@ Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process
|
||||
|
||||
```bash
|
||||
# 1. 检查二进制文件架构是否匹配
|
||||
file /opt/3588admin/bin/managerd
|
||||
file /opt/3588admin/bin/safesightd
|
||||
# 应输出: ELF 64-bit LSB executable, x86-64
|
||||
|
||||
# 2. 直接运行查看错误信息
|
||||
/opt/3588admin/bin/managerd -config /opt/3588admin/config/managerd.json
|
||||
/opt/3588admin/bin/safesightd -config /opt/3588admin/config/safesightd.json
|
||||
|
||||
# 3. 检查配置文件格式
|
||||
python3 -m json.tool /opt/3588admin/config/managerd.json
|
||||
python3 -m json.tool /opt/3588admin/config/safesightd.json
|
||||
```
|
||||
|
||||
### 6.3 端口被占用
|
||||
@ -426,7 +426,7 @@ sudo lsof -i :18080
|
||||
sudo netstat -tlnp | grep 18080
|
||||
|
||||
# 修改配置文件使用其他端口
|
||||
sudo nano /opt/3588admin/config/managerd.json
|
||||
sudo nano /opt/3588admin/config/safesightd.json
|
||||
# 修改 "listen" 为 "127.0.0.1:18081" 或其他端口
|
||||
```
|
||||
|
||||
@ -457,7 +457,7 @@ sudo ufw status | grep 35688
|
||||
|
||||
```bash
|
||||
# 修复权限
|
||||
sudo chmod +x /opt/3588admin/bin/managerd
|
||||
sudo chmod +x /opt/3588admin/bin/safesightd
|
||||
sudo chmod +x /opt/3588admin/scripts/3588admin
|
||||
|
||||
# 确保以 root 运行
|
||||
@ -468,13 +468,13 @@ sudo 3588admin start
|
||||
|
||||
```bash
|
||||
# 实时查看日志
|
||||
tail -f /opt/3588admin/logs/managerd.log
|
||||
tail -f /opt/3588admin/logs/safesightd.log
|
||||
|
||||
# 查看最近 100 行
|
||||
tail -n 100 /opt/3588admin/logs/managerd.log
|
||||
tail -n 100 /opt/3588admin/logs/safesightd.log
|
||||
|
||||
# 搜索错误信息
|
||||
grep -i error /opt/3588admin/logs/managerd.log
|
||||
grep -i error /opt/3588admin/logs/safesightd.log
|
||||
```
|
||||
|
||||
---
|
||||
@ -485,11 +485,11 @@ grep -i error /opt/3588admin/logs/managerd.log
|
||||
|
||||
**Windows 构建机**:
|
||||
```
|
||||
C:<Users>YourName<3588AdminBackend<
|
||||
C:<Users>YourName<SafeSight Control<
|
||||
├── build< # 构建输出
|
||||
│ └── 3588AdminBackend-离线部署包-20260225<
|
||||
│ └── SafeSight Control-离线部署包-20260225<
|
||||
│ ├── bin<
|
||||
│ │ └── managerd # Linux 静态二进制文件
|
||||
│ │ └── safesightd # Linux 静态二进制文件
|
||||
│ ├── config<
|
||||
│ ├── scripts<
|
||||
│ │ └── 3588admin # 管理脚本
|
||||
@ -508,11 +508,11 @@ C:<Users>YourName<3588AdminBackend<
|
||||
```
|
||||
/opt/3588admin/
|
||||
├── bin/
|
||||
│ └── managerd # 主程序(Windows 交叉编译生成)
|
||||
│ └── safesightd # 主程序(Windows 交叉编译生成)
|
||||
├── config/
|
||||
│ └── managerd.json # 配置文件
|
||||
│ └── safesightd.json # 配置文件
|
||||
├── logs/
|
||||
│ └── managerd.log # 运行日志
|
||||
│ └── safesightd.log # 运行日志
|
||||
├── scripts/
|
||||
│ ├── 3588admin # 统一管理脚本
|
||||
│ └── 3588admin.service # 服务文件(复制用)
|
||||
@ -555,7 +555,7 @@ sudo 3588admin stop
|
||||
sudo systemctl stop 3588admin
|
||||
|
||||
# 2. 备份配置
|
||||
cp /opt/3588admin/config/managerd.json ~/managerd.json.bak
|
||||
cp /opt/3588admin/config/safesightd.json ~/safesightd.json.bak
|
||||
|
||||
# 3. 在 Windows 上重新构建新版本
|
||||
# 运行 build-windows.ps1
|
||||
@ -589,4 +589,4 @@ echo "卸载完成"
|
||||
|
||||
**文档版本**: 2.1 (Windows 构建版)
|
||||
**最后更新**: 2026-02-25
|
||||
**适用版本**: 3588AdminBackend V1.0+
|
||||
**适用版本**: SafeSight Control V1.0+
|
||||
|
||||
5
go.mod
5
go.mod
@ -1,6 +1,6 @@
|
||||
module 3588AdminBackend
|
||||
module safesight-control
|
||||
|
||||
go 1.23.3
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
@ -14,6 +14,7 @@ require (
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
golang.org/x/text v0.17.0 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@ -15,6 +15,10 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
|
||||
@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"safesight-control/internal/models"
|
||||
"safesight-control/internal/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@ -170,22 +170,22 @@ func (h *Handler) ProxyAgent(w http.ResponseWriter, r *http.Request) {
|
||||
agentPath = "/v1/config/candidate/apply"
|
||||
method = "POST"
|
||||
case r.URL.Path == fmt.Sprintf("/api/devices/%s/reload", id):
|
||||
agentPath = "/v1/media-server/reload"
|
||||
agentPath = "/v1/edge-server/reload"
|
||||
method = "POST"
|
||||
case r.URL.Path == fmt.Sprintf("/api/devices/%s/rollback", id):
|
||||
agentPath = "/v1/media-server/rollback"
|
||||
agentPath = "/v1/edge-server/rollback"
|
||||
method = "POST"
|
||||
case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/start", id):
|
||||
agentPath = "/v1/media-server/start"
|
||||
case r.URL.Path == fmt.Sprintf("/api/devices/%s/edge-server/start", id):
|
||||
agentPath = "/v1/edge-server/start"
|
||||
method = "POST"
|
||||
case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/restart", id):
|
||||
agentPath = "/v1/media-server/restart"
|
||||
case r.URL.Path == fmt.Sprintf("/api/devices/%s/edge-server/restart", id):
|
||||
agentPath = "/v1/edge-server/restart"
|
||||
method = "POST"
|
||||
case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/stop", id):
|
||||
agentPath = "/v1/media-server/stop"
|
||||
case r.URL.Path == fmt.Sprintf("/api/devices/%s/edge-server/stop", id):
|
||||
agentPath = "/v1/edge-server/stop"
|
||||
method = "POST"
|
||||
case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/status", id):
|
||||
agentPath = "/v1/media-server/status"
|
||||
case r.URL.Path == fmt.Sprintf("/api/devices/%s/edge-server/status", id):
|
||||
agentPath = "/v1/edge-server/status"
|
||||
case r.URL.Path == fmt.Sprintf("/api/devices/%s/graphs", id):
|
||||
agentPath = "/v1/graphs"
|
||||
case name != "" && r.URL.Path == fmt.Sprintf("/api/devices/%s/graphs/%s", id, name):
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/service"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/models"
|
||||
"safesight-control/internal/service"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@ -274,8 +274,8 @@ func TestHandler_ProxyAgentMapsLongRunningRollback(t *testing.T) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/media-server/rollback" {
|
||||
t.Fatalf("expected /v1/media-server/rollback, got %s", r.URL.Path)
|
||||
if r.URL.Path != "/v1/edge-server/rollback" {
|
||||
t.Fatalf("expected /v1/edge-server/rollback, got %s", r.URL.Path)
|
||||
}
|
||||
time.Sleep(3500 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@ -6,281 +6,281 @@ import (
|
||||
)
|
||||
|
||||
func OpenAPI(w http.ResponseWriter, r *http.Request) {
|
||||
spec := map[string]any{
|
||||
"openapi": "3.0.3",
|
||||
"info": map[string]any{
|
||||
"title": "managerd API",
|
||||
"version": "v1",
|
||||
},
|
||||
"paths": map[string]any{
|
||||
"/health": map[string]any{
|
||||
"get": map[string]any{
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "ok",
|
||||
"content": map[string]any{"text/plain": map[string]any{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/discovery/search": map[string]any{
|
||||
"post": map[string]any{
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"timeout_ms": map[string]any{"type": "integer"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "devices",
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/devices": map[string]any{
|
||||
"get": map[string]any{
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "devices",
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"post": map[string]any{
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"device_id": map[string]any{"type": "string"},
|
||||
"device_name": map[string]any{"type": "string"},
|
||||
"ip": map[string]any{"type": "string"},
|
||||
"agent_port": map[string]any{"type": "integer"},
|
||||
"media_port": map[string]any{"type": "integer"},
|
||||
},
|
||||
"required": []any{"device_id", "ip"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "device", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/info": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "agent info", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/reload": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/rollback": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/media-server/start": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/media-server/restart": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/media-server/stop": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/media-server/status": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "status", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/graphs": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "graphs", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/graphs/{name}": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{
|
||||
map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
|
||||
map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "graph", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/logs": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{
|
||||
map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
|
||||
map[string]any{"name": "limit", "in": "query", "required": false, "schema": map[string]any{"type": "integer"}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "logs", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/config/apply": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/models": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "models", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/models/upload": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"multipart/form-data": map[string]any{
|
||||
"schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string"},
|
||||
"file": map[string]any{"type": "string", "format": "binary"},
|
||||
},
|
||||
"required": []any{"name", "file"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/config": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "config", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
"put": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/config/ui/schema": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "schema", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/config/ui/state": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "state", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/config/ui/plan": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "plan", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/config/ui/apply": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/face-gallery": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "face gallery info", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
"put": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/octet-stream": map[string]any{"schema": map[string]any{"type": "string", "format": "binary"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/face-gallery/reload": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/templates": map[string]any{
|
||||
"get": map[string]any{
|
||||
"responses": map[string]any{"200": map[string]any{"description": "templates", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "array"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/templates/{name}": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "template", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/tasks": map[string]any{
|
||||
"get": map[string]any{
|
||||
"responses": map[string]any{"200": map[string]any{"description": "tasks", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
"post": map[string]any{
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "task_id", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/tasks/{id}/events": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "SSE", "content": map[string]any{"text/event-stream": map[string]any{}}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
spec := map[string]any{
|
||||
"openapi": "3.0.3",
|
||||
"info": map[string]any{
|
||||
"title": "SafeSight Control API",
|
||||
"version": "v1",
|
||||
},
|
||||
"paths": map[string]any{
|
||||
"/health": map[string]any{
|
||||
"get": map[string]any{
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "ok",
|
||||
"content": map[string]any{"text/plain": map[string]any{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/discovery/search": map[string]any{
|
||||
"post": map[string]any{
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"timeout_ms": map[string]any{"type": "integer"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "devices",
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/devices": map[string]any{
|
||||
"get": map[string]any{
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "devices",
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"post": map[string]any{
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"device_id": map[string]any{"type": "string"},
|
||||
"device_name": map[string]any{"type": "string"},
|
||||
"ip": map[string]any{"type": "string"},
|
||||
"agent_port": map[string]any{"type": "integer"},
|
||||
"media_port": map[string]any{"type": "integer"},
|
||||
},
|
||||
"required": []any{"device_id", "ip"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "device", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/info": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "agent info", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/reload": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/rollback": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/edge-server/start": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/edge-server/restart": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/edge-server/stop": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/edge-server/status": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "status", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/graphs": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "graphs", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/graphs/{name}": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{
|
||||
map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
|
||||
map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "graph", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/logs": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{
|
||||
map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
|
||||
map[string]any{"name": "limit", "in": "query", "required": false, "schema": map[string]any{"type": "integer"}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "logs", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/config/apply": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/models": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "models", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/models/upload": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"multipart/form-data": map[string]any{
|
||||
"schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string"},
|
||||
"file": map[string]any{"type": "string", "format": "binary"},
|
||||
},
|
||||
"required": []any{"name", "file"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/config": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "config", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
"put": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/config/ui/schema": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "schema", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/config/ui/state": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "state", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/config/ui/plan": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "plan", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/config/ui/apply": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/face-gallery": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "face gallery info", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
"put": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/octet-stream": map[string]any{"schema": map[string]any{"type": "string", "format": "binary"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/devices/{id}/v1/face-gallery/reload": map[string]any{
|
||||
"post": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "ok"}},
|
||||
},
|
||||
},
|
||||
"/api/templates": map[string]any{
|
||||
"get": map[string]any{
|
||||
"responses": map[string]any{"200": map[string]any{"description": "templates", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "array"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/templates/{name}": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "template", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/tasks": map[string]any{
|
||||
"get": map[string]any{
|
||||
"responses": map[string]any{"200": map[string]any{"description": "tasks", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
"post": map[string]any{
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}},
|
||||
},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "task_id", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object"}}}}},
|
||||
},
|
||||
},
|
||||
"/api/tasks/{id}/events": map[string]any{
|
||||
"get": map[string]any{
|
||||
"parameters": []any{map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}},
|
||||
"responses": map[string]any{"200": map[string]any{"description": "SSE", "content": map[string]any{"text/event-stream": map[string]any{}}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(spec)
|
||||
|
||||
@ -19,6 +19,8 @@ type Config struct {
|
||||
LogDir string `json:"log_dir,omitempty"`
|
||||
MediaRepoPath string `json:"media_repo_path,omitempty"` // explicit import-only source; not used for runtime rendering
|
||||
AlarmRetentionDays int `json:"alarm_retention_days"` // auto-delete alarms older than N days, 0=keep forever
|
||||
CompanyName string `json:"company_name,omitempty"`
|
||||
TimeSyncThresholdSec int `json:"time_sync_threshold_sec"`
|
||||
DeviceAliases map[string]string `json:"device_aliases,omitempty"`
|
||||
path string
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/config"
|
||||
"safesight-control/internal/config"
|
||||
)
|
||||
|
||||
type AgentClient struct {
|
||||
@ -106,7 +106,7 @@ func isLongOp(method, path string) bool {
|
||||
if method == http.MethodPost && path == "/v1/config/candidate/apply" {
|
||||
return true
|
||||
}
|
||||
if method == http.MethodPost && path == "/v1/media-server/rollback" {
|
||||
if method == http.MethodPost && path == "/v1/edge-server/rollback" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/v1/config/ui/") {
|
||||
|
||||
@ -8,22 +8,28 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"safesight-control/internal/models"
|
||||
)
|
||||
|
||||
type AlarmRecord struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Channel string `json:"channel"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
RuleName string `json:"rule_name"`
|
||||
RuleType string `json:"rule_type"`
|
||||
ObjectLabel string `json:"object_label"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
SnapshotURL string `json:"snapshot_url"`
|
||||
ClipURL string `json:"clip_url"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
CollectedAt string `json:"collected_at"`
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Channel string `json:"channel"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
RuleName string `json:"rule_name"`
|
||||
RuleType string `json:"rule_type"`
|
||||
ObjectLabel string `json:"object_label"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
SnapshotURL string `json:"snapshot_url"`
|
||||
ClipURL string `json:"clip_url"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
CollectedAt string `json:"collected_at"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
AcknowledgedAt string `json:"acknowledged_at,omitempty"`
|
||||
AcknowledgedBy string `json:"acknowledged_by,omitempty"`
|
||||
ResolvedAt string `json:"resolved_at,omitempty"`
|
||||
ResolvedBy string `json:"resolved_by,omitempty"`
|
||||
}
|
||||
|
||||
type AlarmCollector struct {
|
||||
@ -31,12 +37,13 @@ type AlarmCollector struct {
|
||||
agent *AgentClient
|
||||
registry *RegistryService
|
||||
mu sync.Mutex
|
||||
lastID string // last alarm ID seen, to avoid duplicates
|
||||
retentionDays int // auto-delete alarms older than N days, 0=keep
|
||||
lastID string
|
||||
retentionDays int
|
||||
subscribers map[chan AlarmRecord]struct{}
|
||||
}
|
||||
|
||||
func NewAlarmCollector(db *sql.DB, agent *AgentClient, registry *RegistryService) *AlarmCollector {
|
||||
return &AlarmCollector{db: db, agent: agent, registry: registry}
|
||||
return &AlarmCollector{db: db, agent: agent, registry: registry, subscribers: make(map[chan AlarmRecord]struct{})}
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) Start() {
|
||||
@ -49,6 +56,13 @@ func (c *AlarmCollector) SetRetention(days int) {
|
||||
c.retentionDays = days
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) GetRetention() int {
|
||||
if c == nil {
|
||||
return 30
|
||||
}
|
||||
return c.retentionDays
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) cleanupLoop() {
|
||||
c.cleanupOldAlarms()
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
@ -58,6 +72,11 @@ func (c *AlarmCollector) cleanupLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// RunCleanup triggers immediate alarm cleanup.
|
||||
func (c *AlarmCollector) RunCleanup() {
|
||||
c.cleanupOldAlarms()
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) cleanupOldAlarms() {
|
||||
if c.db == nil || c.retentionDays <= 0 {
|
||||
return
|
||||
@ -201,39 +220,46 @@ func (c *AlarmCollector) saveAlarm(alarm AlarmRecord) {
|
||||
return
|
||||
}
|
||||
_, err := c.db.Exec(`
|
||||
INSERT INTO alarm_records(id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO alarm_records(id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at, status, severity)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
`, alarm.ID, alarm.DeviceID, alarm.Channel, alarm.Timestamp, alarm.RuleName, alarm.RuleType, alarm.ObjectLabel, alarm.Confidence, alarm.SnapshotURL, alarm.ClipURL, alarm.DurationMs, alarm.CollectedAt)
|
||||
`, alarm.ID, alarm.DeviceID, alarm.Channel, alarm.Timestamp, alarm.RuleName, alarm.RuleType, alarm.ObjectLabel, alarm.Confidence, alarm.SnapshotURL, alarm.ClipURL, alarm.DurationMs, alarm.CollectedAt, "unacknowledged", "warning")
|
||||
if err != nil {
|
||||
log.Printf("alarm collector: save error: %v", err)
|
||||
}
|
||||
c.broadcast(alarm)
|
||||
}
|
||||
|
||||
// GetFiltered returns alarm records within a date range, with pagination.
|
||||
// Returns records and total count (ignoring limit/offset for count).
|
||||
func (c *AlarmCollector) GetFiltered(from, to string, limit, offset int) ([]AlarmRecord, int) {
|
||||
// GetFiltered returns alarm records within a date range, with pagination and optional filters.
|
||||
// deviceID and ruleType filter values, empty string means no filter.
|
||||
func (c *AlarmCollector) GetFiltered(from, to string, limit, offset int, deviceID, ruleType string) ([]AlarmRecord, int) {
|
||||
if c.db == nil {
|
||||
return nil, 0
|
||||
}
|
||||
fromTS := from + "T00:00:00"
|
||||
toTS := to + "T23:59:59"
|
||||
|
||||
// Count total
|
||||
var total int
|
||||
c.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM alarm_records
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
`, fromTS, toTS).Scan(&total)
|
||||
cond := "WHERE timestamp >= ? AND timestamp <= ?"
|
||||
args := []any{fromTS, toTS}
|
||||
if deviceID != "" {
|
||||
cond += " AND device_id = ?"
|
||||
args = append(args, deviceID)
|
||||
}
|
||||
if ruleType != "" {
|
||||
cond += " AND rule_type = ?"
|
||||
args = append(args, ruleType)
|
||||
}
|
||||
|
||||
// Fetch page
|
||||
rows, err := c.db.Query(`
|
||||
SELECT id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at
|
||||
FROM alarm_records
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
var total int
|
||||
c.db.QueryRow(`SELECT COUNT(*) FROM alarm_records `+cond, args...).Scan(&total)
|
||||
|
||||
fullQuery := `SELECT id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at, status, severity, acknowledged_at, acknowledged_by, resolved_at, resolved_by
|
||||
FROM alarm_records ` + cond + `
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, fromTS, toTS, limit, offset)
|
||||
LIMIT ? OFFSET ?`
|
||||
fullArgs := append(args, limit, offset)
|
||||
|
||||
rows, err := c.db.Query(fullQuery, fullArgs...)
|
||||
if err != nil {
|
||||
return nil, total
|
||||
}
|
||||
@ -242,7 +268,7 @@ LIMIT ? OFFSET ?
|
||||
var alarms []AlarmRecord
|
||||
for rows.Next() {
|
||||
var a AlarmRecord
|
||||
if err := rows.Scan(&a.ID, &a.DeviceID, &a.Channel, &a.Timestamp, &a.RuleName, &a.RuleType, &a.ObjectLabel, &a.Confidence, &a.SnapshotURL, &a.ClipURL, &a.DurationMs, &a.CollectedAt); err != nil {
|
||||
if err := rows.Scan(&a.ID, &a.DeviceID, &a.Channel, &a.Timestamp, &a.RuleName, &a.RuleType, &a.ObjectLabel, &a.Confidence, &a.SnapshotURL, &a.ClipURL, &a.DurationMs, &a.CollectedAt, &a.Status, &a.Severity, &a.AcknowledgedAt, &a.AcknowledgedBy, &a.ResolvedAt, &a.ResolvedBy); err != nil {
|
||||
continue
|
||||
}
|
||||
alarms = append(alarms, a)
|
||||
@ -256,7 +282,7 @@ func (c *AlarmCollector) GetRecent(limit int) []AlarmRecord {
|
||||
return nil
|
||||
}
|
||||
rows, err := c.db.Query(`
|
||||
SELECT id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at
|
||||
SELECT id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at, status, severity, acknowledged_at, acknowledged_by, resolved_at, resolved_by
|
||||
FROM alarm_records
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
@ -269,7 +295,178 @@ LIMIT ?
|
||||
var alarms []AlarmRecord
|
||||
for rows.Next() {
|
||||
var a AlarmRecord
|
||||
if err := rows.Scan(&a.ID, &a.DeviceID, &a.Channel, &a.Timestamp, &a.RuleName, &a.RuleType, &a.ObjectLabel, &a.Confidence, &a.SnapshotURL, &a.ClipURL, &a.DurationMs, &a.CollectedAt); err != nil {
|
||||
if err := rows.Scan(&a.ID, &a.DeviceID, &a.Channel, &a.Timestamp, &a.RuleName, &a.RuleType, &a.ObjectLabel, &a.Confidence, &a.SnapshotURL, &a.ClipURL, &a.DurationMs, &a.CollectedAt, &a.Status, &a.Severity, &a.AcknowledgedAt, &a.AcknowledgedBy, &a.ResolvedAt, &a.ResolvedBy); err != nil {
|
||||
continue
|
||||
}
|
||||
alarms = append(alarms, a)
|
||||
}
|
||||
return alarms
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) Subscribe() chan AlarmRecord {
|
||||
ch := make(chan AlarmRecord, 32)
|
||||
c.mu.Lock()
|
||||
c.subscribers[ch] = struct{}{}
|
||||
c.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) Unsubscribe(ch chan AlarmRecord) {
|
||||
c.mu.Lock()
|
||||
delete(c.subscribers, ch)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) broadcast(alarm AlarmRecord) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for ch := range c.subscribers {
|
||||
select {
|
||||
case ch <- alarm:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) AcknowledgeAlarm(id, actor string) error {
|
||||
if c.db == nil {
|
||||
return fmt.Errorf("db not available")
|
||||
}
|
||||
_, err := c.db.Exec(`UPDATE alarm_records SET status='acknowledged', acknowledged_at=?, acknowledged_by=? WHERE id=?`,
|
||||
time.Now().Format(time.RFC3339), actor, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) ResolveAlarm(id, actor string) error {
|
||||
if c.db == nil {
|
||||
return fmt.Errorf("db not available")
|
||||
}
|
||||
_, err := c.db.Exec(`UPDATE alarm_records SET status='resolved', resolved_at=?, resolved_by=? WHERE id=?`,
|
||||
time.Now().Format(time.RFC3339), actor, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) UpdateSeverity(id, severity string) error {
|
||||
if c.db == nil {
|
||||
return fmt.Errorf("db not available")
|
||||
}
|
||||
_, err := c.db.Exec(`UPDATE alarm_records SET severity=? WHERE id=?`, severity, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) GetDistinctRuleTypes() []string {
|
||||
if c.db == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := c.db.Query(`SELECT DISTINCT rule_type FROM alarm_records WHERE rule_type != '' ORDER BY rule_type`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var types []string
|
||||
for rows.Next() {
|
||||
var t string
|
||||
if err := rows.Scan(&t); err == nil {
|
||||
types = append(types, t)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) GetDistinctDeviceIDs() []string {
|
||||
if c.db == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := c.db.Query(`SELECT DISTINCT device_id FROM alarm_records ORDER BY device_id`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) GetUnacknowledgedCount() int {
|
||||
if c.db == nil {
|
||||
return 0
|
||||
}
|
||||
var count int
|
||||
c.db.QueryRow(`SELECT COUNT(*) FROM alarm_records WHERE status='unacknowledged'`).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
type DailyAlarmCount struct {
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) GetDailyCounts(days int) []DailyAlarmCount {
|
||||
if c.db == nil {
|
||||
return nil
|
||||
}
|
||||
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
rows, err := c.db.Query(`
|
||||
SELECT substr(timestamp,1,10) as day, COUNT(*) as cnt
|
||||
FROM alarm_records
|
||||
WHERE timestamp >= ?
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
`, since)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var day string
|
||||
var cnt int
|
||||
if rows.Scan(&day, &cnt) == nil {
|
||||
result[day] = cnt
|
||||
}
|
||||
}
|
||||
var list []DailyAlarmCount
|
||||
for i := days; i >= 0; i-- {
|
||||
d := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
|
||||
list = append(list, DailyAlarmCount{Date: d, Count: result[d]})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) ExportFiltered(from, to, deviceID, ruleType string) []AlarmRecord {
|
||||
if c.db == nil {
|
||||
return nil
|
||||
}
|
||||
fromTS := from + "T00:00:00"
|
||||
toTS := to + "T23:59:59"
|
||||
cond := "WHERE timestamp >= ? AND timestamp <= ?"
|
||||
args := []any{fromTS, toTS}
|
||||
if deviceID != "" {
|
||||
cond += " AND device_id = ?"
|
||||
args = append(args, deviceID)
|
||||
}
|
||||
if ruleType != "" {
|
||||
cond += " AND rule_type = ?"
|
||||
args = append(args, ruleType)
|
||||
}
|
||||
rows, err := c.db.Query(`
|
||||
SELECT id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at, status, severity, acknowledged_at, acknowledged_by, resolved_at, resolved_by
|
||||
FROM alarm_records `+cond+`
|
||||
ORDER BY timestamp DESC
|
||||
`, args...)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var alarms []AlarmRecord
|
||||
for rows.Next() {
|
||||
var a AlarmRecord
|
||||
if err := rows.Scan(&a.ID, &a.DeviceID, &a.Channel, &a.Timestamp, &a.RuleName, &a.RuleType, &a.ObjectLabel, &a.Confidence, &a.SnapshotURL, &a.ClipURL, &a.DurationMs, &a.CollectedAt, &a.Status, &a.Severity, &a.AcknowledgedAt, &a.AcknowledgedBy, &a.ResolvedAt, &a.ResolvedBy); err != nil {
|
||||
continue
|
||||
}
|
||||
alarms = append(alarms, a)
|
||||
|
||||
@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
// FeatureDef describes a detection feature that users can toggle on/off.
|
||||
@ -304,8 +306,9 @@ func (r *FeatureRegistry) ValidateFeatureKeys(keys []string) error {
|
||||
// AutoConfigRequest is a single device's configuration intent from the console.
|
||||
type AutoConfigRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Features []string `json:"features"` // feature keys: "face", "shoe", ...
|
||||
SourceNames []string `json:"source_names"` // assigned video source names
|
||||
Features []string `json:"features"`
|
||||
SourceNames []string `json:"source_names"`
|
||||
Overlays []string `json:"overlays,omitempty"`
|
||||
}
|
||||
|
||||
// AutoConfigResult reports what happened for a single device.
|
||||
@ -321,9 +324,10 @@ type AutoConfigResult struct {
|
||||
|
||||
// AutoConfigService orchestrates the full pipeline from user intent to deployed config.
|
||||
type AutoConfigService struct {
|
||||
preview *ConfigPreviewService
|
||||
tasks *TaskService
|
||||
registry *FeatureRegistry
|
||||
preview *ConfigPreviewService
|
||||
tasks *TaskService
|
||||
registry *FeatureRegistry
|
||||
configVersions *storage.ConfigVersionsRepo
|
||||
}
|
||||
|
||||
// NewAutoConfigService creates a new AutoConfigService.
|
||||
@ -335,6 +339,10 @@ func NewAutoConfigService(preview *ConfigPreviewService, tasks *TaskService, reg
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AutoConfigService) SetConfigVersionsRepo(repo *storage.ConfigVersionsRepo) {
|
||||
s.configVersions = repo
|
||||
}
|
||||
|
||||
// BuildPipeline executes the full auto-config pipeline for a single device:
|
||||
// 1. Resolve template from feature selection
|
||||
// 2. If composed, generate the composed template
|
||||
@ -364,6 +372,31 @@ func (s *AutoConfigService) BuildPipeline(req AutoConfigRequest, deploy bool) (*
|
||||
templateName := resolveResult.TemplateName
|
||||
result.Composed = resolveResult.Composed
|
||||
|
||||
// ---- 1.5. Clone standard template to user-editable copy ----
|
||||
if strings.HasPrefix(templateName, "std_") {
|
||||
userName := strings.TrimPrefix(templateName, "std_")
|
||||
if _, _, err := s.preview.readAssetJSON("templates", userName); err != nil {
|
||||
raw, _, err := s.preview.readAssetJSON("templates", templateName)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("读取标准模板失败: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
raw["name"] = userName
|
||||
raw["description"] = "从 " + templateName + " 复制"
|
||||
delete(raw, "source")
|
||||
body, err := marshalConfigJSON(raw)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("序列化模板失败: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
if err := s.preview.SaveTemplateAsset(userName, stringValue(raw["description"]), string(body)); err != nil {
|
||||
result.Error = fmt.Sprintf("保存用户模板失败: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
}
|
||||
templateName = userName
|
||||
}
|
||||
|
||||
// ---- 2. Compose template if needed ----
|
||||
if resolveResult.Composed {
|
||||
composed, err := s.composeFromFeatureTemplates(req.Features, resolveResult.composePrimary, resolveResult.composeFeatures)
|
||||
@ -393,8 +426,16 @@ func (s *AutoConfigService) BuildPipeline(req AutoConfigRequest, deploy bool) (*
|
||||
}
|
||||
|
||||
// ---- 4. Create recognition units for each video source ----
|
||||
editor.Instances = editor.Instances[:0] // clear previous instances on re-deploy
|
||||
usedNames := map[string]bool{}
|
||||
for _, srcName := range cleanSources {
|
||||
unitName := deriveSafeInstanceName(srcName)
|
||||
// Ensure unique instance name in case different source names derive to the same unitName
|
||||
baseName := unitName
|
||||
for i := 1; usedNames[unitName]; i++ {
|
||||
unitName = fmt.Sprintf("%s-%d", baseName, i)
|
||||
}
|
||||
usedNames[unitName] = true
|
||||
inst := ConfigProfileInstanceEditor{
|
||||
Name: unitName,
|
||||
Template: templateName,
|
||||
@ -455,7 +496,7 @@ func (s *AutoConfigService) BuildPipeline(req AutoConfigRequest, deploy bool) (*
|
||||
}
|
||||
|
||||
if !deploy {
|
||||
preview, err := s.preview.RenderDeviceAssignment(req.DeviceID)
|
||||
preview, err := s.preview.RenderDeviceAssignment(req.DeviceID, req.Overlays)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("配置渲染失败: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
@ -465,18 +506,25 @@ func (s *AutoConfigService) BuildPipeline(req AutoConfigRequest, deploy bool) (*
|
||||
}
|
||||
|
||||
// ---- 6 & 7. Render and deploy ----
|
||||
preview, err := s.preview.RenderDeviceAssignment(req.DeviceID)
|
||||
preview, err := s.preview.RenderDeviceAssignment(req.DeviceID, req.Overlays)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("配置渲染失败: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
result.ConfigSHA256 = preview.Sha256
|
||||
|
||||
if s.configVersions != nil {
|
||||
_ = s.configVersions.Save(req.DeviceID, preview.Sha256, preview.JSON)
|
||||
}
|
||||
|
||||
var configDoc any
|
||||
if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil {
|
||||
result.Error = fmt.Sprintf("配置 JSON 无效: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
if configMap, ok := configDoc.(map[string]any); ok {
|
||||
injectIntegrationsIntoConfig(configMap, s.preview)
|
||||
}
|
||||
|
||||
task, err := s.tasks.CreateTask("config_apply", []string{req.DeviceID}, map[string]any{"config": configDoc})
|
||||
if err != nil {
|
||||
@ -564,6 +612,37 @@ func (s *ConfigPreviewService) SaveDeviceFeatures(deviceID string, features []st
|
||||
return s.assets.SaveDeviceFeatures(deviceID, string(body))
|
||||
}
|
||||
|
||||
// SaveDeviceOverlays persists the overlay names for a device.
|
||||
func (s *ConfigPreviewService) SaveDeviceOverlays(deviceID string, overlays []string) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil
|
||||
}
|
||||
if len(overlays) == 0 {
|
||||
overlays = []string{}
|
||||
}
|
||||
body, err := json.Marshal(overlays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.assets.SaveDeviceOverlays(deviceID, string(body))
|
||||
}
|
||||
|
||||
// GetDeviceOverlays returns the persisted overlay names for a device.
|
||||
func (s *ConfigPreviewService) GetDeviceOverlays(deviceID string) ([]string, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rec, err := s.assets.GetDeviceOverlays(deviceID)
|
||||
if err != nil || rec == "" {
|
||||
return nil, err
|
||||
}
|
||||
var overlays []string
|
||||
if err := json.Unmarshal([]byte(rec), &overlays); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return overlays, nil
|
||||
}
|
||||
|
||||
func cleanSourceNames(names []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(names))
|
||||
@ -584,24 +663,127 @@ func cleanSourceNames(names []string) []string {
|
||||
|
||||
// deriveSafeInstanceName converts a user-facing video source name into a valid
|
||||
// recognition unit instance name ([A-Za-z0-9_.-]+). If the source name is already
|
||||
// valid, it's used as-is. Otherwise, a sanitised version is generated.
|
||||
// valid, it's used as-is. Otherwise, a unique hash-based name is generated.
|
||||
func deriveSafeInstanceName(srcName string) string {
|
||||
// If already valid per validateConfigName rules, use as-is.
|
||||
if safeConfigName.MatchString(srcName) {
|
||||
return srcName
|
||||
}
|
||||
// Build a safe name from the source: keep only ASCII letters, digits, underscores.
|
||||
var b strings.Builder
|
||||
for _, r := range srcName {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
derived := strings.Trim(b.String(), "-_.")
|
||||
if derived != "" && safeConfigName.MatchString(derived) {
|
||||
return derived
|
||||
}
|
||||
// Fallback: use a short hash suffix to guarantee uniqueness.
|
||||
// For non-ASCII names, use a hash prefix to guarantee uniqueness
|
||||
// (extracting digits/ASCII from Chinese names causes collisions e.g. "5跨主通道" and "5跨焙烧区" both → "5").
|
||||
sum := sha256.Sum256([]byte(srcName))
|
||||
return "src_" + strings.ToLower(hex.EncodeToString(sum[:4]))
|
||||
}
|
||||
|
||||
func injectIntegrationsIntoConfig(config map[string]any, preview *ConfigPreviewService) {
|
||||
if preview == nil {
|
||||
return
|
||||
}
|
||||
integrations, err := preview.ListIntegrationServices()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var alarmCfg *AlarmServiceConfig
|
||||
var storageCfg *ObjectStorageConfig
|
||||
for _, item := range integrations {
|
||||
if !item.Enabled {
|
||||
continue
|
||||
}
|
||||
switch item.Type {
|
||||
case "alarm_service":
|
||||
if item.AlarmService != nil {
|
||||
alarmCfg = item.AlarmService
|
||||
}
|
||||
case "object_storage":
|
||||
if item.ObjectStorage != nil {
|
||||
storageCfg = item.ObjectStorage
|
||||
}
|
||||
}
|
||||
}
|
||||
if alarmCfg == nil && storageCfg == nil {
|
||||
return
|
||||
}
|
||||
templates, _ := config["templates"].(map[string]any)
|
||||
if templates == nil {
|
||||
return
|
||||
}
|
||||
for _, tpl := range templates {
|
||||
tplMap, _ := tpl.(map[string]any)
|
||||
if tplMap == nil {
|
||||
continue
|
||||
}
|
||||
nodes, _ := tplMap["nodes"].([]any)
|
||||
for _, n := range nodes {
|
||||
node, _ := n.(map[string]any)
|
||||
if node == nil || node["type"] != "alarm" {
|
||||
continue
|
||||
}
|
||||
actions, _ := node["actions"].(map[string]any)
|
||||
if actions == nil {
|
||||
continue
|
||||
}
|
||||
if storageCfg != nil {
|
||||
for _, key := range []string{"snapshot", "clip"} {
|
||||
action, _ := actions[key].(map[string]any)
|
||||
if action == nil {
|
||||
action = map[string]any{}
|
||||
actions[key] = action
|
||||
}
|
||||
upload, _ := action["upload"].(map[string]any)
|
||||
if upload == nil {
|
||||
upload = map[string]any{"type": "minio"}
|
||||
action["upload"] = upload
|
||||
}
|
||||
if storageCfg.Endpoint != "" {
|
||||
upload["endpoint"] = storageCfg.Endpoint
|
||||
}
|
||||
if storageCfg.Bucket != "" {
|
||||
upload["bucket"] = storageCfg.Bucket
|
||||
}
|
||||
if storageCfg.AccessKey != "" {
|
||||
upload["access_key"] = storageCfg.AccessKey
|
||||
}
|
||||
if storageCfg.SecretKey != "" {
|
||||
upload["secret_key"] = storageCfg.SecretKey
|
||||
}
|
||||
}
|
||||
}
|
||||
if alarmCfg != nil {
|
||||
extAPI, _ := actions["external_api"].(map[string]any)
|
||||
if extAPI == nil {
|
||||
extAPI = map[string]any{"enable": true}
|
||||
actions["external_api"] = extAPI
|
||||
}
|
||||
if alarmCfg.GetTokenURL != "" {
|
||||
extAPI["getTokenUrl"] = alarmCfg.GetTokenURL
|
||||
}
|
||||
if alarmCfg.PutMessageURL != "" {
|
||||
extAPI["putMessageUrl"] = alarmCfg.PutMessageURL
|
||||
}
|
||||
if alarmCfg.TenantCode != "" {
|
||||
extAPI["tenantCode"] = alarmCfg.TenantCode
|
||||
}
|
||||
setDefault(extAPI, "timeout_ms", float64(3000))
|
||||
setDefault(extAPI, "include_media_url", true)
|
||||
setDefault(extAPI, "token_header", "X-Access-Token")
|
||||
setDefault(extAPI, "token_json_path", "responseBody.token")
|
||||
setDefault(extAPI, "token_cache_sec", float64(1200))
|
||||
}
|
||||
// Always add agent alarm reporting (http action to local agent)
|
||||
if _, ok := actions["http"]; !ok {
|
||||
actions["http"] = map[string]any{
|
||||
"enable": true,
|
||||
"url": "http://127.0.0.1:9100/v1/alarms/report",
|
||||
"method": "POST",
|
||||
"timeout_ms": 3000,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setDefault(m map[string]any, key string, val any) {
|
||||
if _, ok := m[key]; !ok {
|
||||
m[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
func TestFeatureRegistry_ResolveTemplate_SingleFeature(t *testing.T) {
|
||||
@ -176,3 +180,223 @@ func TestFeatureRegistry_AllFeatures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveSafeInstanceName_ASCII(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, expect string
|
||||
}{
|
||||
{"cam1", "cam1"},
|
||||
{"CAM_1", "CAM_1"},
|
||||
{"a-b.c_d", "a-b.c_d"},
|
||||
{"123", "123"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := deriveSafeInstanceName(tt.input)
|
||||
if got != tt.expect {
|
||||
t.Errorf("deriveSafeInstanceName(%q) = %q, want %q", tt.input, got, tt.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveSafeInstanceName_Chinese(t *testing.T) {
|
||||
// Chinese names must produce unique hash-based names, not collide on extracted digits
|
||||
a := deriveSafeInstanceName("5跨主通道")
|
||||
b := deriveSafeInstanceName("5跨焙烧区")
|
||||
if a == b {
|
||||
t.Errorf("Chinese names '5跨主通道' and '5跨焙烧区' derived to same name %q", a)
|
||||
}
|
||||
// Must start with src_ prefix for hash-based names
|
||||
if a[:4] != "src_" || b[:4] != "src_" {
|
||||
t.Errorf("Chinese names should derive to src_ prefix, got %q, %q", a, b)
|
||||
}
|
||||
// Must be consistent
|
||||
if a != deriveSafeInstanceName("5跨主通道") {
|
||||
t.Errorf("deriveSafeInstanceName not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanSourceNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, expect []string
|
||||
}{
|
||||
{[]string{"a", "b", "c"}, []string{"a", "b", "c"}},
|
||||
{[]string{"a", "a", "b"}, []string{"a", "b"}},
|
||||
{[]string{" a ", "a", "b"}, []string{"a", "b"}},
|
||||
{[]string{"", "a", ""}, []string{"a"}},
|
||||
{[]string{"c", "a", "b"}, []string{"a", "b", "c"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := cleanSourceNames(tt.input)
|
||||
if len(got) != len(tt.expect) {
|
||||
t.Errorf("cleanSourceNames(%v) = %v (len=%d), want %v (len=%d)", tt.input, got, len(got), tt.expect, len(tt.expect))
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.expect[i] {
|
||||
t.Errorf("cleanSourceNames(%v)[%d] = %q, want %q", tt.input, i, got[i], tt.expect[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanSourceNames_DuplicatesWithSpaces(t *testing.T) {
|
||||
// Real-world scenario: wizard JS sends ["1", "1"] or ["1 ", "1"]
|
||||
got := cleanSourceNames([]string{"1", "1"})
|
||||
if len(got) != 1 || got[0] != "1" {
|
||||
t.Errorf("duplicate '1' not deduped: %v", got)
|
||||
}
|
||||
got = cleanSourceNames([]string{"1 ", "1"})
|
||||
if len(got) != 1 || got[0] != "1" {
|
||||
t.Errorf("space-padded duplicate not deduped: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPipeline_CreatesRecognitionUnits(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "pipeline.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
// Seed a template with minimal valid format
|
||||
tplBody := `{"name":"std_face_recognition_stream","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`
|
||||
if err := repo.SaveTemplate("std_face_recognition_stream", "test", tplBody); err != nil {
|
||||
t.Fatalf("SaveTemplate: %v", err)
|
||||
}
|
||||
// Seed video sources
|
||||
for _, name := range []string{"cam1", "cam2"} {
|
||||
svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{
|
||||
Name: name, SourceType: "rtsp",
|
||||
Config: VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"},
|
||||
}, "")
|
||||
}
|
||||
|
||||
reg := NewFeatureRegistry()
|
||||
auto := NewAutoConfigService(svc, &TaskService{}, reg)
|
||||
|
||||
result, err := auto.BuildPipeline(AutoConfigRequest{
|
||||
DeviceID: "test-device-001",
|
||||
Features: []string{"face"},
|
||||
SourceNames: []string{"cam1", "cam2"},
|
||||
}, false)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPipeline: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result, got nil")
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Fatalf("BuildPipeline error: %s", result.Error)
|
||||
}
|
||||
units, _ := svc.ListRecognitionUnits()
|
||||
if len(units) != 2 {
|
||||
t.Errorf("expected 2 recognition units, got %d", len(units))
|
||||
}
|
||||
assignment, err := svc.GetDeviceAssignment("test-device-001")
|
||||
if err != nil || assignment == nil {
|
||||
t.Errorf("expected device assignment, got err=%v", err)
|
||||
} else if len(assignment.RecognitionUnits) != 2 {
|
||||
t.Errorf("expected 2 units in assignment, got %d", len(assignment.RecognitionUnits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPipeline_DeduplicatesSources(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "pipeline2.db"))
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
repo.SaveTemplate("std_face_recognition_stream", "test", `{"name":"std_face_recognition_stream","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`)
|
||||
svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{
|
||||
Name: "cam1", SourceType: "rtsp",
|
||||
Config: VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"},
|
||||
}, "")
|
||||
auto := NewAutoConfigService(svc, &TaskService{}, NewFeatureRegistry())
|
||||
result, err := auto.BuildPipeline(AutoConfigRequest{
|
||||
DeviceID: "test-device-002", Features: []string{"face"},
|
||||
SourceNames: []string{"cam1", "cam1"},
|
||||
}, false)
|
||||
if err != nil || result == nil || result.Error != "" {
|
||||
t.Fatalf("BuildPipeline: err=%v result=%v", err, result)
|
||||
}
|
||||
units, _ := svc.ListRecognitionUnits()
|
||||
if len(units) != 1 {
|
||||
t.Errorf("expected 1 unit after dedup, got %d", len(units))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPipeline_EmptySources(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "pipeline3.db"))
|
||||
defer store.Close()
|
||||
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
|
||||
auto := NewAutoConfigService(svc, &TaskService{}, NewFeatureRegistry())
|
||||
_, err := auto.BuildPipeline(AutoConfigRequest{
|
||||
DeviceID: "test-device-003", Features: []string{"face"},
|
||||
SourceNames: []string{},
|
||||
}, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty sources")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPipeline_ClonesStandardTemplate(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "clone.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
// Seed standard template
|
||||
tplBody := `{"name":"std_face_recognition_stream","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`
|
||||
if err := repo.SaveTemplate("std_face_recognition_stream", "standard", tplBody); err != nil {
|
||||
t.Fatalf("SaveTemplate: %v", err)
|
||||
}
|
||||
svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{
|
||||
Name: "cam1", SourceType: "rtsp",
|
||||
Config: VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"},
|
||||
}, "")
|
||||
|
||||
reg := NewFeatureRegistry()
|
||||
auto := NewAutoConfigService(svc, &TaskService{}, reg)
|
||||
|
||||
result, err := auto.BuildPipeline(AutoConfigRequest{
|
||||
DeviceID: "test-clone-001",
|
||||
Features: []string{"face"},
|
||||
SourceNames: []string{"cam1"},
|
||||
}, false)
|
||||
|
||||
if err != nil || result == nil || result.Error != "" {
|
||||
t.Fatalf("BuildPipeline: err=%v result=%v", err, result)
|
||||
}
|
||||
|
||||
// Verify user template was cloned
|
||||
userTpl, err := repo.GetTemplate("face_recognition_stream")
|
||||
if err != nil || userTpl == nil {
|
||||
t.Error("expected user template face_recognition_stream to be cloned from std_face_recognition_stream")
|
||||
}
|
||||
|
||||
// Verify profile uses cloned template, not standard
|
||||
profile, err := repo.GetProfile("auto_test-clone-001")
|
||||
if err != nil || profile == nil {
|
||||
t.Fatalf("GetProfile: err=%v", err)
|
||||
}
|
||||
if profile.TemplateName != "face_recognition_stream" {
|
||||
t.Errorf("expected profile to use face_recognition_stream, got %s", profile.TemplateName)
|
||||
}
|
||||
|
||||
// Second deploy should reuse existing clone (no error)
|
||||
result2, err := auto.BuildPipeline(AutoConfigRequest{
|
||||
DeviceID: "test-clone-001",
|
||||
Features: []string{"face"},
|
||||
SourceNames: []string{"cam1"},
|
||||
}, false)
|
||||
if err != nil || result2 == nil || result2.Error != "" {
|
||||
t.Fatalf("second BuildPipeline: err=%v result=%v", err, result2)
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,8 +8,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"safesight-control/internal/models"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
var legacyBuiltinTemplateAliases = map[string]string{
|
||||
@ -80,6 +80,7 @@ type ConfigIntegrationServiceAsset struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AddressSummary string `json:"address_summary"`
|
||||
RefCount int `json:"ref_count"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ObjectStorage *ObjectStorageConfig `json:"object_storage,omitempty"`
|
||||
TokenService *TokenServiceConfig `json:"token_service,omitempty"`
|
||||
AlarmService *AlarmServiceConfig `json:"alarm_service,omitempty"`
|
||||
@ -107,6 +108,7 @@ type AlarmServiceConfig struct {
|
||||
}
|
||||
|
||||
type ConfigVideoSourceAsset struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
SourceType string `json:"source_type"`
|
||||
@ -165,6 +167,7 @@ type DeviceAssignmentBoardCard struct {
|
||||
AssignedCount int `json:"assigned_count"`
|
||||
MaxUnits int `json:"max_units"`
|
||||
Status string `json:"status"`
|
||||
ConfigSynced bool `json:"config_synced"`
|
||||
Units []DeviceAssignmentBoardUnit `json:"units"`
|
||||
}
|
||||
|
||||
@ -822,7 +825,7 @@ func (s *ConfigPreviewService) GetVideoSource(name string) (*ConfigVideoSourceAs
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) SaveVideoSourceAsset(asset ConfigVideoSourceAsset) error {
|
||||
func (s *ConfigPreviewService) SaveVideoSourceAsset(asset ConfigVideoSourceAsset, originalName string) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("基础配置仓库未初始化")
|
||||
}
|
||||
@ -834,8 +837,7 @@ func (s *ConfigPreviewService) SaveVideoSourceAsset(asset ConfigVideoSourceAsset
|
||||
if sourceType == "" {
|
||||
return fmt.Errorf("视频源类型不能为空")
|
||||
}
|
||||
urlValue := strings.TrimSpace(asset.Config.URL)
|
||||
if urlValue == "" {
|
||||
if strings.TrimSpace(asset.Config.URL) == "" {
|
||||
return fmt.Errorf("视频源地址不能为空")
|
||||
}
|
||||
raw := map[string]any{
|
||||
@ -858,7 +860,29 @@ func (s *ConfigPreviewService) SaveVideoSourceAsset(asset ConfigVideoSourceAsset
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.assets.SaveVideoSource(name, sourceType, strings.TrimSpace(asset.Area), strings.TrimSpace(asset.Description), string(body))
|
||||
if err := s.assets.SaveVideoSource(asset.ID, name, sourceType, strings.TrimSpace(asset.Area), strings.TrimSpace(asset.Description), string(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
// If renaming, update recognition units
|
||||
originalName = strings.TrimSpace(originalName)
|
||||
if originalName != "" && originalName != name {
|
||||
units, _ := s.ListRecognitionUnits()
|
||||
for _, u := range units {
|
||||
if u.VideoSourceRef == originalName {
|
||||
u.VideoSourceRef = name
|
||||
ref := recognitionUnitRef(u.SceneTemplateName, u.Name)
|
||||
s.SaveRecognitionUnit(u, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) DeleteVideoSourceByID(id int) error {
|
||||
if s == nil || s.assets == nil {
|
||||
return fmt.Errorf("基础配置仓库未初始化")
|
||||
}
|
||||
return s.assets.DeleteVideoSourceByID(id)
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) DeleteVideoSource(name string) error {
|
||||
@ -1225,6 +1249,7 @@ func (s *ConfigPreviewService) SaveDeviceAssignment(asset DeviceAssignmentAsset)
|
||||
return fmt.Errorf("scene template is required")
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
deduped := make([]string, 0, len(asset.RecognitionUnits))
|
||||
for _, ref := range asset.RecognitionUnits {
|
||||
profileName, _, err := parseRecognitionUnitRef(ref)
|
||||
if err != nil {
|
||||
@ -1237,7 +1262,9 @@ func (s *ConfigPreviewService) SaveDeviceAssignment(asset DeviceAssignmentAsset)
|
||||
return fmt.Errorf("duplicate recognition unit: %s", ref)
|
||||
}
|
||||
seen[ref] = struct{}{}
|
||||
deduped = append(deduped, ref)
|
||||
}
|
||||
asset.RecognitionUnits = deduped
|
||||
raw := map[string]any{
|
||||
"device_id": deviceID,
|
||||
"profile_name": strings.TrimSpace(asset.ProfileName),
|
||||
@ -1638,6 +1665,7 @@ func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord)
|
||||
Type: firstString(record.ServiceType, stringValue(raw["type"])),
|
||||
Description: firstString(raw["description"], record.Description),
|
||||
Enabled: boolValue(raw["enabled"], record.Enabled),
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
Raw: raw,
|
||||
}
|
||||
item.TypeLabel = integrationTypeLabel(item.Type)
|
||||
@ -1681,6 +1709,7 @@ func videoSourceAssetFromRecord(record storage.VideoSourceRecord) (*ConfigVideoS
|
||||
sourceMap = configMap
|
||||
}
|
||||
item := &ConfigVideoSourceAsset{
|
||||
ID: record.ID,
|
||||
Name: firstString(raw["name"], record.Name),
|
||||
Path: repoAssetPath("video_sources", record.Name),
|
||||
SourceType: firstString(record.SourceType, stringValue(raw["source_type"])),
|
||||
@ -1824,3 +1853,22 @@ func intValue(v any) int {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveChannelToSource maps an alarm channel (e.g. "src_52ef4499_alarm_violation")
|
||||
// to its video source name (e.g. "5跨主通道").
|
||||
func (s *ConfigPreviewService) ResolveChannelToSource(channel string) string {
|
||||
if s == nil {
|
||||
return channel
|
||||
}
|
||||
unitName := channel
|
||||
if idx := strings.LastIndex(channel, "_alarm_"); idx > 0 {
|
||||
unitName = channel[:idx]
|
||||
}
|
||||
units, _ := s.ListRecognitionUnits()
|
||||
for _, u := range units {
|
||||
if u.Name == unitName && u.VideoSourceRef != "" {
|
||||
return u.VideoSourceRef
|
||||
}
|
||||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
@ -7,9 +7,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/models"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
func mustSaveTemplateRecord(t *testing.T, repo *storage.AssetsRepo, name string, description string, body string) {
|
||||
@ -405,7 +405,7 @@ func TestListVideoSources(t *testing.T) {
|
||||
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
0, "gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
@ -439,7 +439,7 @@ func TestDeleteVideoSourceBlocksWhenReferenced(t *testing.T) {
|
||||
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
0, "gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
@ -474,7 +474,7 @@ func TestSaveVideoSourceAssetAllowsChineseName(t *testing.T) {
|
||||
Config: VideoSourceConfig{
|
||||
URL: "rtsp://10.0.0.1/live",
|
||||
},
|
||||
})
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected chinese video source name to be accepted, got %v", err)
|
||||
}
|
||||
|
||||
@ -12,8 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
var safeConfigName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
||||
@ -212,7 +212,7 @@ func (s *ConfigPreviewService) BuildProfileEditorForDeviceAssignment(deviceID st
|
||||
return editor, assignment, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) RenderDeviceAssignment(deviceID string) (*ConfigPreviewResult, error) {
|
||||
func (s *ConfigPreviewService) RenderDeviceAssignment(deviceID string, overlays ...[]string) (*ConfigPreviewResult, error) {
|
||||
editor, assignment, err := s.BuildProfileEditorForDeviceAssignment(deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -224,12 +224,17 @@ func (s *ConfigPreviewService) RenderDeviceAssignment(deviceID string) (*ConfigP
|
||||
if strings.TrimSpace(templateName) == "" {
|
||||
templateName = editor.RawTemplateName()
|
||||
}
|
||||
var ol []string
|
||||
if len(overlays) > 0 {
|
||||
ol = overlays[0]
|
||||
}
|
||||
return s.RenderProfileEditor(*editor, ConfigPreviewRequest{
|
||||
Template: templateName,
|
||||
Profile: assignment.ProfileName,
|
||||
ConfigID: "assignment_" + deviceID,
|
||||
ConfigVersion: time.Now().Format("20060102.150405"),
|
||||
DeviceID: deviceID,
|
||||
Overlays: ol,
|
||||
})
|
||||
}
|
||||
|
||||
@ -251,56 +256,56 @@ func profileOverlayNames(raw map[string]any) []string {
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) renderFromAssets(req ConfigPreviewRequest, templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput) (*ConfigPreviewResult, error) {
|
||||
if err := validateConfigName(req.Template); err != nil {
|
||||
return nil, fmt.Errorf("invalid template: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(req.Profile) != "" {
|
||||
if err := validateConfigName(req.Profile); err != nil {
|
||||
return nil, fmt.Errorf("invalid profile: %w", err)
|
||||
}
|
||||
}
|
||||
for _, overlay := range req.Overlays {
|
||||
if err := validateConfigName(overlay); err != nil {
|
||||
return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
|
||||
}
|
||||
}
|
||||
if req.ConfigID == "" {
|
||||
req.ConfigID = "preview_" + time.Now().Format("20060102.150405")
|
||||
}
|
||||
if req.ConfigVersion == "" {
|
||||
req.ConfigVersion = time.Now().Format("20060102.150405")
|
||||
}
|
||||
resolvedProfileRaw, err := s.resolveSceneBindings(profileRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"config_id": req.ConfigID,
|
||||
"config_version": req.ConfigVersion,
|
||||
"rendered_at": time.Now().Format(time.RFC3339),
|
||||
"rendered_by": "managerd",
|
||||
}
|
||||
if strings.TrimSpace(req.DeviceID) != "" {
|
||||
metadata["device_id"] = strings.TrimSpace(req.DeviceID)
|
||||
}
|
||||
doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := marshalConfigJSON(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
renderedMetadata, _ := doc["metadata"].(map[string]any)
|
||||
sum := sha256.Sum256(body)
|
||||
return &ConfigPreviewResult{
|
||||
Request: req,
|
||||
Root: previewRenderRoot(templatePath, profilePath),
|
||||
Sha256: hex.EncodeToString(sum[:]),
|
||||
Size: len(body),
|
||||
Metadata: renderedMetadata,
|
||||
JSON: string(body),
|
||||
}, nil
|
||||
if err := validateConfigName(req.Template); err != nil {
|
||||
return nil, fmt.Errorf("invalid template: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(req.Profile) != "" {
|
||||
if err := validateConfigName(req.Profile); err != nil {
|
||||
return nil, fmt.Errorf("invalid profile: %w", err)
|
||||
}
|
||||
}
|
||||
for _, overlay := range req.Overlays {
|
||||
if err := validateConfigName(overlay); err != nil {
|
||||
return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
|
||||
}
|
||||
}
|
||||
if req.ConfigID == "" {
|
||||
req.ConfigID = "preview_" + time.Now().Format("20060102.150405")
|
||||
}
|
||||
if req.ConfigVersion == "" {
|
||||
req.ConfigVersion = time.Now().Format("20060102.150405")
|
||||
}
|
||||
resolvedProfileRaw, err := s.resolveSceneBindings(profileRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"config_id": req.ConfigID,
|
||||
"config_version": req.ConfigVersion,
|
||||
"rendered_at": time.Now().Format(time.RFC3339),
|
||||
"rendered_by": "safesightd",
|
||||
}
|
||||
if strings.TrimSpace(req.DeviceID) != "" {
|
||||
metadata["device_id"] = strings.TrimSpace(req.DeviceID)
|
||||
}
|
||||
doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := marshalConfigJSON(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
renderedMetadata, _ := doc["metadata"].(map[string]any)
|
||||
sum := sha256.Sum256(body)
|
||||
return &ConfigPreviewResult{
|
||||
Request: req,
|
||||
Root: previewRenderRoot(templatePath, profilePath),
|
||||
Sha256: hex.EncodeToString(sum[:]),
|
||||
Size: len(body),
|
||||
Metadata: renderedMetadata,
|
||||
JSON: string(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPreviewService) resolveSceneBindings(raw map[string]any) (map[string]any, error) {
|
||||
@ -427,13 +432,13 @@ func resolvedServiceBinding(asset *ConfigIntegrationServiceAsset) map[string]any
|
||||
}
|
||||
|
||||
func previewRenderRoot(templatePath string, profilePath string) string {
|
||||
if strings.HasPrefix(templatePath, "sqlite:") || strings.HasPrefix(profilePath, "sqlite:") {
|
||||
return "SQLite"
|
||||
}
|
||||
if dir := filepath.Dir(templatePath); strings.TrimSpace(dir) != "" && dir != "." {
|
||||
return dir
|
||||
}
|
||||
return "managerd"
|
||||
if strings.HasPrefix(templatePath, "sqlite:") || strings.HasPrefix(profilePath, "sqlite:") {
|
||||
return "SQLite"
|
||||
}
|
||||
if dir := filepath.Dir(templatePath); strings.TrimSpace(dir) != "" && dir != "." {
|
||||
return dir
|
||||
}
|
||||
return "safesightd"
|
||||
}
|
||||
|
||||
func writeResolvedConfigFile(pattern string, raw map[string]any) (string, error) {
|
||||
|
||||
@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
func mustImportAssetsFromMediaRepo(t *testing.T, svc *ConfigPreviewService) {
|
||||
@ -177,7 +177,7 @@ func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testin
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
0, "gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
@ -218,27 +218,27 @@ func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testin
|
||||
t.Fatalf("RenderProfileEditor: %v", err)
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
|
||||
t.Fatalf("unmarshal render result: %v", err)
|
||||
}
|
||||
templates, _ := doc["templates"].(map[string]any)
|
||||
renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any)
|
||||
nodes, _ := renderedTemplate["nodes"].([]any)
|
||||
inputNode, _ := nodes[0].(map[string]any)
|
||||
if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" {
|
||||
t.Fatalf("expected expanded input url, got %#v", inputNode)
|
||||
}
|
||||
publishNode, _ := nodes[1].(map[string]any)
|
||||
outputs, _ := publishNode["outputs"].([]any)
|
||||
output, _ := outputs[0].(map[string]any)
|
||||
if got := stringValue(output["path"]); got != "./web/hls/cam1/index.m3u8" {
|
||||
t.Fatalf("expected expanded output path, got %#v", output)
|
||||
}
|
||||
metadata, _ := doc["metadata"].(map[string]any)
|
||||
if got := stringValue(metadata["rendered_by"]); got != "managerd" {
|
||||
t.Fatalf("expected managerd renderer metadata, got %#v", metadata)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
|
||||
t.Fatalf("unmarshal render result: %v", err)
|
||||
}
|
||||
templates, _ := doc["templates"].(map[string]any)
|
||||
renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any)
|
||||
nodes, _ := renderedTemplate["nodes"].([]any)
|
||||
inputNode, _ := nodes[0].(map[string]any)
|
||||
if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" {
|
||||
t.Fatalf("expected expanded input url, got %#v", inputNode)
|
||||
}
|
||||
publishNode, _ := nodes[1].(map[string]any)
|
||||
outputs, _ := publishNode["outputs"].([]any)
|
||||
output, _ := outputs[0].(map[string]any)
|
||||
if got := stringValue(output["path"]); got != "./web/hls/cam1/index.m3u8" {
|
||||
t.Fatalf("expected expanded output path, got %#v", output)
|
||||
}
|
||||
metadata, _ := doc["metadata"].(map[string]any)
|
||||
if got := stringValue(metadata["rendered_by"]); got != "safesightd" {
|
||||
t.Fatalf("expected safesightd renderer metadata, got %#v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreviewServiceRenderProfileEditorAllowsUnboundOptionalServiceSlot(t *testing.T) {
|
||||
@ -267,7 +267,7 @@ func TestConfigPreviewServiceRenderProfileEditorAllowsUnboundOptionalServiceSlot
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
0, "gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
@ -351,7 +351,7 @@ func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) {
|
||||
t.Fatalf("SaveOverlay: %v", err)
|
||||
}
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
0, "gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
@ -435,7 +435,7 @@ func TestConfigPreviewServiceRenderUsesProfileOverlayWhenRequestOmitsIt(t *testi
|
||||
t.Fatalf("SaveOverlay: %v", err)
|
||||
}
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
0, "gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
@ -497,7 +497,7 @@ func TestConfigPreviewServiceResolveSceneBindings(t *testing.T) {
|
||||
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
"gate_cam_01",
|
||||
0, "gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
"东门主入口摄像头",
|
||||
@ -570,3 +570,257 @@ func mustWrite(t *testing.T, path string, body string) {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── P1 #6: injectIntegrationsIntoConfig ──
|
||||
|
||||
func TestInjectIntegrationsIntoConfig_InjectsAlarmAndStorage(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
// Seed integration services
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "alarm", "alarm_service",
|
||||
`{"get_token_url":"http://10.0.0.1/api/token","put_message_url":"http://10.0.0.1/api/msg","tenant_code":"01"}`)
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "storage", "object_storage",
|
||||
`{"endpoint":"http://10.0.0.1:9000","bucket":"alarms","access_key":"ak","secret_key":"sk"}`)
|
||||
|
||||
cfg := map[string]any{
|
||||
"templates": map[string]any{
|
||||
"tpl__inst": map[string]any{
|
||||
"nodes": []any{
|
||||
map[string]any{"id": "alarm_violation", "type": "alarm", "actions": map[string]any{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
injectIntegrationsIntoConfig(cfg, svc)
|
||||
|
||||
tpl := cfg["templates"].(map[string]any)["tpl__inst"].(map[string]any)
|
||||
nodes := tpl["nodes"].([]any)
|
||||
node := nodes[0].(map[string]any)
|
||||
actions := node["actions"].(map[string]any)
|
||||
|
||||
// Verify HTTP action was injected
|
||||
if http, ok := actions["http"].(map[string]any); !ok {
|
||||
t.Error("expected http action to be injected")
|
||||
} else {
|
||||
if http["url"] != "http://127.0.0.1:9100/v1/alarms/report" {
|
||||
t.Errorf("http url = %v", http["url"])
|
||||
}
|
||||
if http["enable"] != true {
|
||||
t.Error("http action should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify snapshot upload was injected
|
||||
if snap, ok := actions["snapshot"].(map[string]any); !ok {
|
||||
t.Error("expected snapshot action")
|
||||
} else {
|
||||
if upload, ok := snap["upload"].(map[string]any); !ok {
|
||||
t.Error("expected snapshot.upload")
|
||||
} else {
|
||||
if upload["bucket"] != "alarms" {
|
||||
t.Errorf("snapshot bucket = %v", upload["bucket"])
|
||||
}
|
||||
if upload["access_key"] != "ak" {
|
||||
t.Errorf("snapshot access_key = %v", upload["access_key"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify clip upload was injected
|
||||
if clip, ok := actions["clip"].(map[string]any); !ok {
|
||||
t.Error("expected clip action")
|
||||
} else {
|
||||
if upload, ok := clip["upload"].(map[string]any); !ok {
|
||||
t.Error("expected clip.upload")
|
||||
} else {
|
||||
if upload["endpoint"] != "http://10.0.0.1:9000" {
|
||||
t.Errorf("clip endpoint = %v", upload["endpoint"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify external_api was injected
|
||||
if ext, ok := actions["external_api"].(map[string]any); !ok {
|
||||
t.Error("expected external_api action")
|
||||
} else {
|
||||
if ext["getTokenUrl"] != "http://10.0.0.1/api/token" {
|
||||
t.Errorf("external_api getTokenUrl = %v", ext["getTokenUrl"])
|
||||
}
|
||||
if ext["putMessageUrl"] != "http://10.0.0.1/api/msg" {
|
||||
t.Errorf("external_api putMessageUrl = %v", ext["putMessageUrl"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectIntegrationsIntoConfig_SkipsDisabledServices(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
// Only alarm service, no storage
|
||||
saveIntegrationServiceForPreviewTest(t, repo, "alarm", "alarm_service",
|
||||
`{"get_token_url":"http://10.0.0.1/api/token","put_message_url":"http://10.0.0.1/api/msg","tenant_code":"01"}`)
|
||||
|
||||
cfg := map[string]any{
|
||||
"templates": map[string]any{
|
||||
"tpl__inst": map[string]any{
|
||||
"nodes": []any{
|
||||
map[string]any{"id": "alarm_violation", "type": "alarm", "actions": map[string]any{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
injectIntegrationsIntoConfig(cfg, svc)
|
||||
|
||||
tpl := cfg["templates"].(map[string]any)["tpl__inst"].(map[string]any)
|
||||
node := tpl["nodes"].([]any)[0].(map[string]any)
|
||||
actions := node["actions"].(map[string]any)
|
||||
|
||||
// HTTP and external_api should still be injected
|
||||
if _, ok := actions["http"]; !ok {
|
||||
t.Error("expected http action")
|
||||
}
|
||||
if _, ok := actions["external_api"]; !ok {
|
||||
t.Error("expected external_api action")
|
||||
}
|
||||
// But snapshot/clip should NOT have upload since no storage service
|
||||
if snap, ok := actions["snapshot"].(map[string]any); ok {
|
||||
if _, ok := snap["upload"]; ok {
|
||||
t.Log("snapshot.upload may exist with defaults (ok)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── P2 #7: ResolveChannelToSource ──
|
||||
|
||||
func TestResolveChannelToSource_UnknownReturnsInput(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
defer store.Close()
|
||||
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
|
||||
|
||||
// Unknown channel returns the channel name itself (fallback)
|
||||
if got := svc.ResolveChannelToSource("nonexistent"); got != "nonexistent" {
|
||||
t.Errorf("expected 'nonexistent', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── P2 #8: SaveVideoSourceAsset ──
|
||||
|
||||
func TestSaveVideoSourceAsset_Create(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
asset := ConfigVideoSourceAsset{
|
||||
Name: "cam1", SourceType: "rtsp",
|
||||
Config: VideoSourceConfig{URL: "rtsp://10.0.0.1/live", Resolution: "1920x1080", FPS: "30"},
|
||||
}
|
||||
if err := svc.SaveVideoSourceAsset(asset, ""); err != nil {
|
||||
t.Fatalf("SaveVideoSourceAsset: %v", err)
|
||||
}
|
||||
|
||||
sources, err := svc.ListVideoSources()
|
||||
if err != nil {
|
||||
t.Fatalf("ListVideoSources: %v", err)
|
||||
}
|
||||
if len(sources) != 1 {
|
||||
t.Errorf("expected 1 source, got %d", len(sources))
|
||||
} else if sources[0].Name != "cam1" {
|
||||
t.Errorf("expected cam1, got %s", sources[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveVideoSourceAsset_EmptyName(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
|
||||
defer store.Close()
|
||||
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
|
||||
|
||||
err := svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{Name: "", SourceType: "rtsp"}, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
// ── New: mergeRuntimeInstancePatch wraps nodes under override ──
|
||||
|
||||
func TestMergeRuntimeInstancePatch_WrapsNodesUnderOverride(t *testing.T) {
|
||||
overlay := map[string]any{
|
||||
"nodes": map[string]any{
|
||||
"alarm_violation": map[string]any{
|
||||
"rules": []any{
|
||||
map[string]any{"name": "test_rule", "cooldown_ms": 30000},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
instance := map[string]any{
|
||||
"name": "inst1",
|
||||
"template": "tpl__inst1",
|
||||
}
|
||||
|
||||
result := mergeRuntimeInstancePatch(instance, overlay)
|
||||
|
||||
// Edge expects instance.override.nodes, NOT instance.nodes
|
||||
if _, ok := result["nodes"]; ok {
|
||||
t.Error("nodes should NOT be at instance root level")
|
||||
}
|
||||
ov, ok := result["override"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected instance.override to be present")
|
||||
}
|
||||
nodes, ok := ov["nodes"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected instance.override.nodes to be present")
|
||||
}
|
||||
if node, ok := nodes["alarm_violation"].(map[string]any); !ok {
|
||||
t.Error("expected alarm_violation node override")
|
||||
} else {
|
||||
rules := node["rules"].([]any)
|
||||
if len(rules) != 1 {
|
||||
t.Errorf("expected 1 rule, got %d", len(rules))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeRuntimeInstancePatch_PreservesParams(t *testing.T) {
|
||||
patch := map[string]any{
|
||||
"params": map[string]any{"key": "value"},
|
||||
"nodes": map[string]any{"detect_face": map[string]any{"conf_thresh": 0.8}},
|
||||
}
|
||||
|
||||
instance := map[string]any{
|
||||
"name": "inst1",
|
||||
"template": "tpl__inst1",
|
||||
}
|
||||
|
||||
result := mergeRuntimeInstancePatch(instance, patch)
|
||||
|
||||
// params at root level
|
||||
if params, ok := result["params"].(map[string]any); !ok {
|
||||
t.Error("expected params at instance root")
|
||||
} else {
|
||||
if params["key"] != "value" {
|
||||
t.Errorf("params key = %v", params["key"])
|
||||
}
|
||||
}
|
||||
|
||||
// nodes under override
|
||||
ov, ok := result["override"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected instance.override")
|
||||
}
|
||||
if _, ok := ov["nodes"]; !ok {
|
||||
t.Error("expected instance.override.nodes")
|
||||
}
|
||||
}
|
||||
|
||||
@ -365,11 +365,22 @@ func mergeRuntimeInstancePatch(instance map[string]any, patch map[string]any) ma
|
||||
existing, _ := merged["override"].(map[string]any)
|
||||
merged["override"] = deepMergeMap(existing, override)
|
||||
}
|
||||
// Edge expects node overrides at instance.override.nodes, not instance.nodes.
|
||||
// Overlay payload keys other than params/override (e.g. nodes) go under override.
|
||||
hasNodeOverrides := false
|
||||
instOverride := map[string]any{}
|
||||
if existing, ok := merged["override"].(map[string]any); ok {
|
||||
instOverride = existing
|
||||
}
|
||||
for key, value := range patch {
|
||||
if key == "name" || key == "template" || key == "params" || key == "override" {
|
||||
continue
|
||||
}
|
||||
merged[key] = deepMergeAny(merged[key], value)
|
||||
instOverride[key] = deepMergeAny(instOverride[key], value)
|
||||
hasNodeOverrides = true
|
||||
}
|
||||
if hasNodeOverrides {
|
||||
merged["override"] = instOverride
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@ -6,9 +6,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const discoveryMagicV1 = "RK3588SYS_DISCOVERY_V1"
|
||||
|
||||
260
internal/service/integration_test.go
Normal file
260
internal/service/integration_test.go
Normal file
@ -0,0 +1,260 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Integration tests against a running safesightd instance.
|
||||
// Set SAFESIGHT_URL env (default http://10.0.0.81:18080).
|
||||
// Run with: go test -tags=integration -v -count=1 ./internal/service/
|
||||
|
||||
func safeSightURL() string {
|
||||
if u := os.Getenv("SAFESIGHT_URL"); u != "" {
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
return "http://10.0.0.81:18080"
|
||||
}
|
||||
|
||||
func apiGet(t *testing.T, path string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(safeSightURL() + path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return resp, body
|
||||
}
|
||||
|
||||
func apiPost(t *testing.T, path string, payload any) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
var bodyReader io.Reader
|
||||
if payload != nil {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
resp, err := http.Post(safeSightURL()+path, "application/json", bodyReader)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return resp, respBody
|
||||
}
|
||||
|
||||
func apiPostForm(t *testing.T, path string, form map[string]string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
data := make([]string, 0, len(form))
|
||||
for k, v := range form {
|
||||
data = append(data, k+"="+v)
|
||||
}
|
||||
body := strings.NewReader(strings.Join(data, "&"))
|
||||
resp, err := http.Post(safeSightURL()+path, "application/x-www-form-urlencoded", body)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return resp, respBody
|
||||
}
|
||||
|
||||
func getDeviceID(t *testing.T) string {
|
||||
t.Helper()
|
||||
resp, body := apiGet(t, "/devices")
|
||||
if resp.StatusCode != 200 {
|
||||
return ""
|
||||
}
|
||||
page := string(body)
|
||||
skip := map[string]bool{"batch-config": true, "cleanup": true, "export-channels": true, "add": true, "config": true}
|
||||
for _, line := range strings.Split(page, "\n") {
|
||||
if idx := strings.Index(line, "href=\"/devices/"); idx >= 0 {
|
||||
rest := line[idx+len("href=\"/devices/"):]
|
||||
if end := strings.Index(rest, "\""); end > 10 {
|
||||
id := rest[:end]
|
||||
if !skip[id] {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Health check ──
|
||||
|
||||
func TestIntegration_HealthCheck(t *testing.T) {
|
||||
resp, body := apiGet(t, "/dashboard")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("health check failed: %d — %s", resp.StatusCode, string(body))
|
||||
}
|
||||
t.Logf("safesightd running, page size=%d bytes", len(body))
|
||||
}
|
||||
|
||||
// ── Device listing ──
|
||||
|
||||
func TestIntegration_ListDevices(t *testing.T) {
|
||||
resp, _ := apiGet(t, "/devices")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /devices: %d", resp.StatusCode)
|
||||
}
|
||||
t.Logf("devices page OK")
|
||||
}
|
||||
|
||||
// ── Template listing ──
|
||||
|
||||
func TestIntegration_ListTemplates(t *testing.T) {
|
||||
resp, body := apiGet(t, "/assets/templates")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /assets/templates: %d", resp.StatusCode)
|
||||
}
|
||||
page := string(body)
|
||||
for _, name := range []string{"std_workshop_face_recognition_shoe_alarm"} {
|
||||
if !strings.Contains(page, name) {
|
||||
t.Errorf("template %q not found", name)
|
||||
}
|
||||
}
|
||||
t.Logf("templates page OK")
|
||||
}
|
||||
|
||||
// ── Overlay asset listing ──
|
||||
|
||||
func TestIntegration_ListOverlays(t *testing.T) {
|
||||
resp, _ := apiGet(t, "/assets/overlays")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /assets/overlays: %d", resp.StatusCode)
|
||||
}
|
||||
t.Logf("overlays page OK")
|
||||
}
|
||||
|
||||
// ── Console page ──
|
||||
|
||||
func TestIntegration_ConsolePage(t *testing.T) {
|
||||
resp, _ := apiGet(t, "/console")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /console: %d", resp.StatusCode)
|
||||
}
|
||||
t.Logf("console page OK")
|
||||
}
|
||||
|
||||
// ── Alarm center ──
|
||||
|
||||
func TestIntegration_AlarmsPage(t *testing.T) {
|
||||
resp, _ := apiGet(t, "/alarms")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /alarms: %d", resp.StatusCode)
|
||||
}
|
||||
t.Logf("alarms page OK")
|
||||
}
|
||||
|
||||
// ── Device detail page with overlay section ──
|
||||
|
||||
func TestIntegration_DeviceDetailWithOverlays(t *testing.T) {
|
||||
deviceID := getDeviceID(t)
|
||||
if deviceID == "" {
|
||||
t.Skip("no device found")
|
||||
}
|
||||
|
||||
resp, body := apiGet(t, "/devices/"+deviceID)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET device detail: %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(body), "调试参数覆盖") {
|
||||
t.Error("missing '调试参数覆盖' section")
|
||||
}
|
||||
t.Logf("device %s detail page OK", deviceID)
|
||||
}
|
||||
|
||||
// ── Template settings page ──
|
||||
|
||||
func TestIntegration_TemplateSettingsPage(t *testing.T) {
|
||||
resp, body := apiGet(t, "/assets/templates")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET templates: %d", resp.StatusCode)
|
||||
}
|
||||
page := string(body)
|
||||
|
||||
// Find user template (cloned from standard, no std_ prefix but with "workshop" or "face" pattern)
|
||||
userTpl := ""
|
||||
for _, candidate := range []string{"workshop_face_recognition_shoe_alarm", "face_recognition_stream"} {
|
||||
if strings.Contains(page, `">`+candidate+`<`) {
|
||||
userTpl = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if userTpl == "" {
|
||||
t.Skip("no user template found — deploy via wizard first")
|
||||
}
|
||||
|
||||
resp, body = apiGet(t, "/assets/templates/" + userTpl + "/settings")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET settings: %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(body), "参数调整") {
|
||||
t.Error("missing '参数调整' on settings page")
|
||||
}
|
||||
t.Logf("template %s settings page OK", userTpl)
|
||||
}
|
||||
|
||||
// ── Wizard deploy roundtrip ──
|
||||
|
||||
func TestIntegration_WizardDeployRoundtrip(t *testing.T) {
|
||||
deviceID := getDeviceID(t)
|
||||
if deviceID == "" {
|
||||
t.Skip("no device found")
|
||||
}
|
||||
t.Logf("device: %s", deviceID)
|
||||
|
||||
// Get video sources
|
||||
resp, body := apiGet(t, "/assets/video-sources")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET video-sources: %d", resp.StatusCode)
|
||||
}
|
||||
page := string(body)
|
||||
|
||||
// Find a video source name from table cell
|
||||
sourceName := ""
|
||||
for _, line := range strings.Split(page, "\n") {
|
||||
if idx := strings.Index(line, ">车间"); idx >= 0 {
|
||||
rest := line[idx+1:]
|
||||
if end := strings.Index(rest, "<"); end > 1 {
|
||||
sourceName = rest[:end]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if sourceName == "" {
|
||||
t.Skip("no video source found")
|
||||
}
|
||||
t.Logf("video source: %s", sourceName)
|
||||
|
||||
// Deploy via wizard
|
||||
req := map[string]any{
|
||||
"device_id": deviceID,
|
||||
"features": []string{"face"},
|
||||
"source_names": []string{sourceName},
|
||||
}
|
||||
resp, body = apiPost(t, "/wizard/apply", req)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("wizard apply: %d — %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var result AutoConfigResult
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
t.Fatalf("parse result: %v", err)
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Fatalf("wizard error: %s", result.Error)
|
||||
}
|
||||
t.Logf("wizard OK: template=%s task=%s", result.TemplateName, result.TaskID)
|
||||
}
|
||||
@ -10,9 +10,10 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"safesight-control/internal/models"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
type ModelManagementService struct {
|
||||
@ -37,7 +38,9 @@ type ModelStatusCell struct {
|
||||
type ModelStatusRow struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceIP string `json:"device_ip"`
|
||||
Online bool `json:"online"`
|
||||
NeedsSync bool `json:"needs_sync"`
|
||||
Cells []ModelStatusCell `json:"cells"`
|
||||
ExtraModelCount int `json:"extra_model_count"`
|
||||
ExtraModels []InstalledModelStatus `json:"extra_models"`
|
||||
@ -87,7 +90,7 @@ func (s *ModelManagementService) SyncStandardModelsFromDirectory(dir string) err
|
||||
record := storage.StandardModelRecord{
|
||||
Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())),
|
||||
FileName: entry.Name(),
|
||||
Version: "auto",
|
||||
Version: fileVersion(fullPath),
|
||||
SHA256: sum,
|
||||
SizeBytes: size,
|
||||
ModelType: inferModelType(entry.Name()),
|
||||
@ -132,7 +135,25 @@ func inferModelType(fileName string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices []*models.Device, installed map[string][]InstalledModelStatus) ModelStatusBoard {
|
||||
// ModelTypeCapability maps a model type to the device capability key it requires.
|
||||
// Returns empty string if the model type has no specific capability requirement.
|
||||
// Keys must match what safesight-edge agent reports via GET /v1/capabilities.
|
||||
func ModelTypeCapability(modelType string) string {
|
||||
switch modelType {
|
||||
case "face_detection":
|
||||
return "face"
|
||||
case "face_recognition":
|
||||
return "face"
|
||||
case "shoe_detection":
|
||||
return "shoe"
|
||||
case "ppe_detection":
|
||||
return "helmet"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices []*models.Device, installed map[string][]InstalledModelStatus, deviceCapabilities map[string]map[string]bool) ModelStatusBoard {
|
||||
board := ModelStatusBoard{
|
||||
Summary: ModelStatusSummary{
|
||||
StandardModels: len(standardModels),
|
||||
@ -155,6 +176,7 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices
|
||||
row := ModelStatusRow{
|
||||
DeviceID: device.DeviceID,
|
||||
DeviceName: device.DisplayName(),
|
||||
DeviceIP: device.IP,
|
||||
Online: device.Online,
|
||||
Cells: make([]ModelStatusCell, 0, len(standardModels)),
|
||||
ExtraModels: make([]InstalledModelStatus, 0),
|
||||
@ -177,7 +199,19 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices
|
||||
hasMismatch = true
|
||||
}
|
||||
} else {
|
||||
hasMissing = true
|
||||
// Check if device is incompatible with this model type
|
||||
if deviceCapabilities != nil {
|
||||
caps, hasCaps := deviceCapabilities[device.DeviceID]
|
||||
if hasCaps {
|
||||
reqCap := ModelTypeCapability(model.ModelType)
|
||||
if reqCap != "" && !caps[reqCap] {
|
||||
cell.Status = "na"
|
||||
}
|
||||
}
|
||||
}
|
||||
if cell.Status == "missing" {
|
||||
hasMissing = true
|
||||
}
|
||||
}
|
||||
if cell.Status == "missing" {
|
||||
hasMissing = true
|
||||
@ -194,6 +228,7 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices
|
||||
return row.ExtraModels[i].Name < row.ExtraModels[j].Name
|
||||
})
|
||||
row.ExtraModelCount = len(row.ExtraModels)
|
||||
row.NeedsSync = hasMissing || hasMismatch
|
||||
switch {
|
||||
case hasMismatch:
|
||||
board.Summary.MismatchDevices++
|
||||
@ -229,3 +264,13 @@ func FetchInstalledModelStatuses(agent *AgentClient, device *models.Device) ([]I
|
||||
}
|
||||
return resp.Models, nil
|
||||
}
|
||||
|
||||
// fileVersion generates a version string from the file's modification time.
|
||||
// Format: YYYYMMDD-HHMMSS, e.g. "20260720-143052".
|
||||
func fileVersion(path string) string {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return time.Now().Format("20060102") + "-v1"
|
||||
}
|
||||
return info.ModTime().Format("20060102-150405")
|
||||
}
|
||||
|
||||
@ -5,8 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"safesight-control/internal/models"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
func TestSyncStandardModelsFromDirectory(t *testing.T) {
|
||||
@ -58,7 +58,7 @@ func TestBuildModelStatusBoardMarksMissingAndMismatch(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
board := BuildModelStatusBoard(modelsList, devices, installed)
|
||||
board := BuildModelStatusBoard(modelsList, devices, installed, nil)
|
||||
|
||||
if board.Summary.MissingDevices != 1 {
|
||||
t.Fatalf("expected one device with missing models, got %#v", board.Summary)
|
||||
@ -86,7 +86,7 @@ func TestBuildModelStatusBoardSeparatesExtraModels(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
board := BuildModelStatusBoard(modelsList, devices, installed)
|
||||
board := BuildModelStatusBoard(modelsList, devices, installed, nil)
|
||||
|
||||
if len(board.Rows) != 1 {
|
||||
t.Fatalf("unexpected board rows: %#v", board.Rows)
|
||||
|
||||
@ -2,12 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/models"
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/models"
|
||||
)
|
||||
|
||||
type RegistryService struct {
|
||||
@ -144,6 +145,12 @@ func (s *RegistryService) SetDeviceAlias(deviceID string, alias string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RegistryService) RemoveDevice(deviceID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.devices, deviceID)
|
||||
}
|
||||
|
||||
func (s *RegistryService) GetDevices() []*models.Device {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@ -152,6 +159,12 @@ func (s *RegistryService) GetDevices() []*models.Device {
|
||||
for _, dev := range s.devices {
|
||||
list = append(list, dev)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].IP != list[j].IP {
|
||||
return list[i].IP < list[j].IP
|
||||
}
|
||||
return list[i].DisplayName() < list[j].DisplayName()
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/models"
|
||||
"safesight-control/internal/storage"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRegistryService_UpdateAndGet(t *testing.T) {
|
||||
|
||||
@ -10,8 +10,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"safesight-control/internal/models"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
type ResourceManagementService struct {
|
||||
@ -36,6 +36,7 @@ type ResourceStatusCell struct {
|
||||
type ResourceStatusRow struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceIP string `json:"device_ip"`
|
||||
Online bool `json:"online"`
|
||||
Cells []ResourceStatusCell `json:"cells"`
|
||||
ExtraCount int `json:"extra_resource_count"`
|
||||
@ -59,6 +60,27 @@ func NewResourceManagementService(repo *storage.ResourcesRepo) *ResourceManageme
|
||||
return &ResourceManagementService{repo: repo}
|
||||
}
|
||||
|
||||
// RegisterResource hashes a single file and saves it to the standard_resources table.
|
||||
// This is used when a resource is built/updated at runtime (e.g. face gallery build).
|
||||
func (s *ResourceManagementService) RegisterResource(filePath, resourceType, name string) error {
|
||||
if s == nil || s.repo == nil {
|
||||
return fmt.Errorf("resources repo is not configured")
|
||||
}
|
||||
sum, size, err := hashFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record := storage.StandardResourceRecord{
|
||||
Name: name,
|
||||
ResourceType: resourceType,
|
||||
Version: fileVersion(filePath),
|
||||
SHA256: sum,
|
||||
SizeBytes: size,
|
||||
FilePath: filePath,
|
||||
}
|
||||
return s.repo.Save(record)
|
||||
}
|
||||
|
||||
func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir string) error {
|
||||
if s == nil || s.repo == nil {
|
||||
return fmt.Errorf("resources repo is not configured")
|
||||
@ -84,7 +106,7 @@ func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir strin
|
||||
continue
|
||||
}
|
||||
for _, sub := range subEntries {
|
||||
if sub.IsDir() {
|
||||
if sub.IsDir() || sub.Name() == "build_meta.json" {
|
||||
continue
|
||||
}
|
||||
fullPath := filepath.Join(subDir, sub.Name())
|
||||
@ -95,7 +117,7 @@ func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir strin
|
||||
record := storage.StandardResourceRecord{
|
||||
Name: strings.TrimSuffix(sub.Name(), filepath.Ext(sub.Name())),
|
||||
ResourceType: resourceType,
|
||||
Version: "auto",
|
||||
Version: fileVersion(fullPath),
|
||||
SHA256: sum,
|
||||
SizeBytes: size,
|
||||
FilePath: fullPath,
|
||||
@ -114,7 +136,7 @@ func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir strin
|
||||
}
|
||||
record := storage.StandardResourceRecord{
|
||||
Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())),
|
||||
Version: "auto",
|
||||
Version: fileVersion(fullPath),
|
||||
SHA256: sum,
|
||||
SizeBytes: size,
|
||||
FilePath: fullPath,
|
||||
@ -169,6 +191,7 @@ func BuildResourceStatusBoard(standardResources []storage.StandardResourceRecord
|
||||
row := ResourceStatusRow{
|
||||
DeviceID: device.DeviceID,
|
||||
DeviceName: device.DisplayName(),
|
||||
DeviceIP: device.IP,
|
||||
Online: device.Online,
|
||||
Cells: make([]ResourceStatusCell, 0, len(standardResources)),
|
||||
ExtraResources: make([]InstalledResourceStatus, 0),
|
||||
@ -248,7 +271,7 @@ func FetchAndBuildResourceBoard(agent *AgentClient, devices []*models.Device, st
|
||||
}()
|
||||
|
||||
for _, device := range devices {
|
||||
if device == nil || !device.Online {
|
||||
if device == nil {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
|
||||
@ -5,8 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"safesight-control/internal/models"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
func TestSyncStandardResourcesFromDirectory(t *testing.T) {
|
||||
|
||||
@ -3,13 +3,99 @@ package service
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"3588AdminBackend/internal/storage"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
// ImportSeedAssets imports all config assets (templates, profiles, overlays) from a
|
||||
// seed configs directory on first run. Returns total import count.
|
||||
// The seed directory is expected to have subdirectories: templates/, profiles/, overlays/.
|
||||
func ImportSeedAssets(repo *storage.AssetsRepo, seedDir string) (int, error) {
|
||||
if repo == nil {
|
||||
return 0, nil
|
||||
}
|
||||
seedDir = filepath.Clean(strings.TrimSpace(seedDir))
|
||||
if seedDir == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if _, err := os.Stat(seedDir); os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Check if DB already has assets (not first run).
|
||||
existing, err := repo.ListTemplates()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, kind := range []string{"templates", "profiles", "overlays"} {
|
||||
dir := filepath.Join(seedDir, kind)
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return total, err
|
||||
}
|
||||
for _, f := range files {
|
||||
if f.IsDir() || !strings.HasSuffix(strings.ToLower(f.Name()), ".json") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, f.Name())
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return total, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if raw == nil {
|
||||
raw = map[string]any{}
|
||||
}
|
||||
name := strings.TrimSpace(firstString(raw["name"], strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))))
|
||||
if name == "" {
|
||||
log.Printf("skip %s: empty name", path)
|
||||
continue
|
||||
}
|
||||
desc := stringValue(raw["description"])
|
||||
|
||||
switch kind {
|
||||
case "templates":
|
||||
if strings.TrimSpace(stringValue(raw["source"])) == "" {
|
||||
raw["source"] = "standard"
|
||||
}
|
||||
b, _ := marshalConfigJSON(raw)
|
||||
if err := repo.SaveTemplate(name, desc, string(b)); err != nil {
|
||||
return total, fmt.Errorf("template %s: %w", name, err)
|
||||
}
|
||||
case "profiles":
|
||||
tmplName := stringValue(raw["primary_template_name"])
|
||||
bizName := stringValue(raw["business_name"])
|
||||
b, _ := marshalConfigJSON(raw)
|
||||
if err := repo.SaveProfile(name, tmplName, bizName, desc, string(b)); err != nil {
|
||||
return total, fmt.Errorf("profile %s: %w", name, err)
|
||||
}
|
||||
case "overlays":
|
||||
b, _ := marshalConfigJSON(raw)
|
||||
if err := repo.SaveOverlay(name, desc, string(b)); err != nil {
|
||||
return total, fmt.Errorf("overlay %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
total++
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func ImportStandardTemplatesFromDir(repo *storage.AssetsRepo, dir string) (int, error) {
|
||||
if repo == nil {
|
||||
return 0, fmt.Errorf("asset repository is not configured")
|
||||
@ -141,3 +227,40 @@ func ImportStandardOverlaysFromDir(repo *storage.AssetsRepo, dir string) (int, e
|
||||
}
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
// EnsureDefaultIntegrations creates the default alarm and storage integration
|
||||
// services if they don't already exist. Call during startup.
|
||||
func EnsureDefaultIntegrations(repo *storage.AssetsRepo) error {
|
||||
defaults := []struct {
|
||||
Name string
|
||||
ServiceType string
|
||||
Description string
|
||||
Enabled bool
|
||||
BodyJSON string
|
||||
}{
|
||||
{
|
||||
Name: "alarm",
|
||||
ServiceType: "alarm_service",
|
||||
Description: "告警推送 HTTP API",
|
||||
Enabled: true,
|
||||
BodyJSON: `{"get_token_url":"http://10.0.0.49:8080/api/getToken","put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}`,
|
||||
},
|
||||
{
|
||||
Name: "storage",
|
||||
ServiceType: "object_storage",
|
||||
Description: "告警截图和视频存储",
|
||||
Enabled: true,
|
||||
BodyJSON: `{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}`,
|
||||
},
|
||||
}
|
||||
for _, item := range defaults {
|
||||
existing, _ := repo.GetIntegrationService(item.Name)
|
||||
if existing != nil {
|
||||
continue
|
||||
}
|
||||
if err := repo.SaveIntegrationService(item.Name, item.ServiceType, item.Description, item.Enabled, item.BodyJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -7,13 +7,15 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"github.com/google/uuid"
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/models"
|
||||
"safesight-control/internal/storage"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TaskRepository interface {
|
||||
@ -120,6 +122,10 @@ func (s *TaskService) ListTasks() []models.Task {
|
||||
items = append(items, snap)
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].CompletedAt > items[j].CompletedAt
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
@ -209,6 +215,7 @@ func (s *TaskService) runTask(task *models.Task) {
|
||||
} else {
|
||||
task.Status = models.TaskFailed
|
||||
}
|
||||
task.CompletedAt = time.Now().Format(time.RFC3339)
|
||||
task.Mu.Unlock()
|
||||
s.persistTask(task)
|
||||
}
|
||||
@ -290,11 +297,6 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
|
||||
return
|
||||
}
|
||||
|
||||
if !dev.Online {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "device offline")
|
||||
return
|
||||
}
|
||||
|
||||
switch task.Type {
|
||||
case "config_apply":
|
||||
cfgPayload, err := extractConfigPayloadForDevice(task.Payload, did)
|
||||
@ -321,7 +323,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
|
||||
s.appendAuditLog(task, did, models.TaskSuccess, "")
|
||||
|
||||
case "reload":
|
||||
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/reload", nil, "", 0)
|
||||
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/edge-server/reload", nil, "", 0)
|
||||
if err != nil {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
|
||||
return
|
||||
@ -334,7 +336,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
|
||||
s.appendAuditLog(task, did, models.TaskSuccess, "")
|
||||
|
||||
case "rollback":
|
||||
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/rollback", nil, "", 0)
|
||||
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/edge-server/rollback", nil, "", 0)
|
||||
if err != nil {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
|
||||
return
|
||||
@ -352,7 +354,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
|
||||
return
|
||||
}
|
||||
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/start", bodyR, "", bodyLen)
|
||||
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/edge-server/start", bodyR, "", bodyLen)
|
||||
if err != nil {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
|
||||
return
|
||||
@ -370,7 +372,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
|
||||
return
|
||||
}
|
||||
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/restart", bodyR, "", bodyLen)
|
||||
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/edge-server/restart", bodyR, "", bodyLen)
|
||||
if err != nil {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
|
||||
return
|
||||
@ -383,7 +385,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
|
||||
s.appendAuditLog(task, did, models.TaskSuccess, "")
|
||||
|
||||
case "media_stop":
|
||||
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/stop", nil, "", 0)
|
||||
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/edge-server/stop", nil, "", 0)
|
||||
if err != nil {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
|
||||
return
|
||||
@ -427,6 +429,56 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
|
||||
s.appendAuditLog(task, did, models.TaskSuccess, "")
|
||||
|
||||
case "time_sync":
|
||||
nowMS := time.Now().UnixMilli()
|
||||
// Check current drift first — skip if already in sync
|
||||
devBody, devCode, devErr := s.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/time", nil)
|
||||
if devErr == nil && devCode == 200 {
|
||||
var tresp struct {
|
||||
UnixMS int64 `json:"unix_ms"`
|
||||
}
|
||||
if json.Unmarshal(devBody, &tresp) == nil {
|
||||
diff := (nowMS - tresp.UnixMS) / 1000
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
threshold := int64(s.cfg.TimeSyncThresholdSec)
|
||||
if threshold <= 0 {
|
||||
threshold = 5
|
||||
}
|
||||
if diff <= threshold {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "时间已同步")
|
||||
s.appendAuditLog(task, did, models.TaskSuccess, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(map[string]int64{"unix_ms": nowMS})
|
||||
_, code, err := s.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/time/set", body)
|
||||
if err != nil {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
|
||||
return
|
||||
}
|
||||
if code >= 400 {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code))
|
||||
return
|
||||
}
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
|
||||
s.appendAuditLog(task, did, models.TaskSuccess, "")
|
||||
|
||||
case "face_gallery_reload":
|
||||
_, code, err := s.agent.Do("POST", dev.IP, dev.AgentPort, "/v1/face-gallery/reload", nil)
|
||||
if err != nil {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
|
||||
return
|
||||
}
|
||||
if code >= 400 {
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, fmt.Sprintf("agent error: %d", code))
|
||||
return
|
||||
}
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskSuccess, 1.0, "")
|
||||
s.appendAuditLog(task, did, models.TaskSuccess, "")
|
||||
|
||||
default:
|
||||
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, "unsupported task type")
|
||||
}
|
||||
|
||||
@ -1,23 +1,23 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"3588AdminBackend/internal/config"
|
||||
"3588AdminBackend/internal/models"
|
||||
"3588AdminBackend/internal/storage"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/models"
|
||||
"safesight-control/internal/storage"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func waitForTaskDone(t *testing.T, task *models.Task, timeout time.Duration) models.TaskStatus {
|
||||
@ -147,8 +147,8 @@ func TestTaskService_MediaStart_IgnoresInvalidConfigShape(t *testing.T) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/media-server/start" {
|
||||
t.Fatalf("expected path /v1/media-server/start, got %s", r.URL.Path)
|
||||
if r.URL.Path != "/v1/edge-server/start" {
|
||||
t.Fatalf("expected path /v1/edge-server/start, got %s", r.URL.Path)
|
||||
}
|
||||
bodyBytes, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"3588AdminBackend/internal/config"
|
||||
"safesight-control/internal/config"
|
||||
)
|
||||
|
||||
type Template struct {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"3588AdminBackend/internal/config"
|
||||
"os"
|
||||
"testing"
|
||||
"safesight-control/internal/config"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTemplateService(t *testing.T) {
|
||||
|
||||
345
internal/service/tuning.go
Normal file
345
internal/service/tuning.go
Normal file
@ -0,0 +1,345 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TuningItem defines one adjustable parameter in a template.
|
||||
type TuningItem struct {
|
||||
Path string `json:"path"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
Step float64 `json:"step"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// GetTuningItems looks up hardcoded tuning definitions for the template and
|
||||
// resolves current values from the template's actual node parameters.
|
||||
func (s *ConfigPreviewService) GetTuningItems(templateName string) ([]TuningItem, error) {
|
||||
stdName := "std_" + templateName
|
||||
defs := tuningDefinitions[stdName]
|
||||
if len(defs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
raw, _, err := s.readAssetJSON("templates", templateName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Deep copy defs and resolve current values from template.
|
||||
items := make([]TuningItem, len(defs))
|
||||
copy(items, defs)
|
||||
|
||||
templateBody, _ := raw["template"].(map[string]any)
|
||||
nodes, _ := templateBody["nodes"].([]any)
|
||||
nodeMap := make(map[string]map[string]any, len(nodes))
|
||||
for _, n := range nodes {
|
||||
nm, _ := n.(map[string]any)
|
||||
if nm != nil {
|
||||
nodeMap[stringValue(nm["id"])] = nm
|
||||
}
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
nodeID, path := splitTuningPath(items[i].Path)
|
||||
if node, ok := nodeMap[nodeID]; ok {
|
||||
if v, ok := resolveTuningPath(node, path); ok {
|
||||
items[i].Value = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// SaveTuningItems writes tuning values directly into the template's node
|
||||
// parameters and saves the template. Returns affected device IDs.
|
||||
func (s *ConfigPreviewService) SaveTuningItems(templateName string, values map[string]float64) ([]string, error) {
|
||||
stdName := "std_" + templateName
|
||||
defs := tuningDefinitions[stdName]
|
||||
if len(defs) == 0 {
|
||||
return nil, fmt.Errorf("该模板没有可调参数")
|
||||
}
|
||||
|
||||
raw, _, err := s.readAssetJSON("templates", templateName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve current values from defs, overriding with form values.
|
||||
items := make([]TuningItem, len(defs))
|
||||
copy(items, defs)
|
||||
for i := range items {
|
||||
if v, ok := values[items[i].Path]; ok {
|
||||
items[i].Value = v
|
||||
}
|
||||
}
|
||||
|
||||
// Write values into template's node parameters.
|
||||
templateBody, _ := raw["template"].(map[string]any)
|
||||
if templateBody == nil {
|
||||
return nil, fmt.Errorf("template body not found")
|
||||
}
|
||||
nodes, _ := templateBody["nodes"].([]any)
|
||||
if nodes == nil {
|
||||
return nil, fmt.Errorf("template nodes not found")
|
||||
}
|
||||
nodeMap := make(map[string]map[string]any, len(nodes))
|
||||
for _, n := range nodes {
|
||||
nm, _ := n.(map[string]any)
|
||||
if nm != nil {
|
||||
nodeMap[stringValue(nm["id"])] = nm
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
nodeID, path := splitTuningPath(item.Path)
|
||||
node, ok := nodeMap[nodeID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("node %q not found for %s", nodeID, item.Path)
|
||||
}
|
||||
if err := setTuningPath(node, path, item.Value); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", item.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the modified template.
|
||||
desc := stringValue(raw["description"])
|
||||
body, err := marshalConfigJSON(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = body
|
||||
if err := s.SaveTemplateAsset(templateName, desc, string(body)); err != nil {
|
||||
return nil, fmt.Errorf("保存模板失败: %w", err)
|
||||
}
|
||||
|
||||
return s.GetDevicesUsingTemplate(templateName)
|
||||
}
|
||||
|
||||
// GetDevicesUsingTemplate returns device IDs that use the given template.
|
||||
func (s *ConfigPreviewService) GetDevicesUsingTemplate(templateName string) ([]string, error) {
|
||||
if s == nil || s.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
profileNames, err := s.profileNamesReferencingTemplate(templateName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assignments, err := s.ListDeviceAssignments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deviceSet := map[string]bool{}
|
||||
for _, a := range assignments {
|
||||
for _, pn := range profileNames {
|
||||
if a.ProfileName == pn {
|
||||
deviceSet[a.DeviceID] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
devices := make([]string, 0, len(deviceSet))
|
||||
for id := range deviceSet {
|
||||
devices = append(devices, id)
|
||||
}
|
||||
sort.Strings(devices)
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// RedeployDevices re-renders config for each device (using existing assignments)
|
||||
// and creates deploy tasks. Returns task IDs.
|
||||
func (s *ConfigPreviewService) RedeployDevices(deviceIDs []string) ([]string, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// The actual deployment is done by the caller (AutoConfigService or UI handler)
|
||||
// because it needs access to AutoConfigService.BuildPipeline.
|
||||
// For now, just validate the device IDs exist.
|
||||
return deviceIDs, nil
|
||||
}
|
||||
|
||||
// --- path parsing helpers ---
|
||||
|
||||
// tuningDefinitions maps standard template names to their adjustable parameters.
|
||||
// To expose a new parameter: add a TuningItem entry below.
|
||||
var tuningDefinitions = map[string][]TuningItem{
|
||||
"std_workshop_face_recognition_shoe_alarm": {
|
||||
// ── 劳保鞋违规告警 ──
|
||||
{Path: "alarm_violation.rules[0].cooldown_ms", Label: "告警冷却", Type: "slider", Min: 5000, Max: 300000, Step: 1000, Unit: "ms", Group: "劳保鞋违规", Hint: "测试 15s,生产 60s", Desc: "同一违规两次告警的最短间隔,值越大告警越少"},
|
||||
{Path: "alarm_violation.rules[0].min_duration_ms", Label: "最小持续时间", Type: "slider", Min: 200, Max: 10000, Step: 100, Unit: "ms", Group: "劳保鞋违规", Hint: "测试 800ms,生产 1500ms", Desc: "违规需持续多久才触发告警,值越大越不敏感"},
|
||||
{Path: "alarm_violation.rules[0].min_hits", Label: "最小命中帧数", Type: "slider", Min: 1, Max: 10, Step: 1, Unit: "帧", Group: "劳保鞋违规", Hint: "测试 2,生产 3", Desc: "连续检测到多少次才确认违规,值越大误报越少"},
|
||||
{Path: "alarm_violation.rules[0].min_score", Label: "最低置信度", Type: "slider", Min: 0.1, Max: 0.9, Step: 0.05, Group: "劳保鞋违规", Hint: "测试 0.3,生产 0.5", Desc: "检测结果的可信度门槛,值越高告警越准确但可能漏报"},
|
||||
// ── 陌生人脸告警 ──
|
||||
{Path: "alarm_violation.face_rules[0].cooldown_ms", Label: "告警冷却", Type: "slider", Min: 2000, Max: 120000, Step: 1000, Unit: "ms", Group: "陌生人脸", Hint: "测试 7s,生产 30s", Desc: "同一陌生人重复告警的最短间隔,值越大告警越少"},
|
||||
{Path: "alarm_violation.face_rules[0].min_hits", Label: "命中帧数", Type: "slider", Min: 1, Max: 10, Step: 1, Unit: "帧", Group: "陌生人脸", Hint: "测试 2,生产 4", Desc: "连续识别为陌生人的帧数,值越大误报越少但响应变慢"},
|
||||
{Path: "alarm_violation.face_rules[0].max_known_sim", Label: "相似度上限", Type: "slider", Min: 0.1, Max: 0.8, Step: 0.05, Group: "陌生人脸", Hint: "测试 0.35,生产 0.2", Desc: "相似度低于此值才判定为陌生人,值越低陌生人越少"},
|
||||
{Path: "alarm_violation.face_rules[0].hit_window_ms", Label: "命中窗口", Type: "slider", Min: 500, Max: 10000, Step: 500, Unit: "ms", Group: "陌生人脸", Hint: "测试 1500ms,生产 3000ms", Desc: "累计命中帧数的时间窗口,窗口越大越容易触发告警"},
|
||||
// ── 已知人员告警 ──
|
||||
{Path: "alarm_violation.face_rules[1].cooldown_ms", Label: "告警冷却", Type: "slider", Min: 2000, Max: 120000, Step: 1000, Unit: "ms", Group: "已知人员", Hint: "测试 7s,生产 30s", Desc: "同一已知人员重复告警的最短间隔,值越大告警越少"},
|
||||
{Path: "alarm_violation.face_rules[1].min_sim", Label: "相似度阈值", Type: "slider", Min: 0.3, Max: 0.95, Step: 0.05, Group: "已知人员", Hint: "测试 0.6,生产 0.75", Desc: "相似度超过此值才认作已知人员,值越高匹配越严格"},
|
||||
{Path: "alarm_violation.face_rules[1].min_hits", Label: "命中帧数", Type: "slider", Min: 1, Max: 10, Step: 1, Unit: "帧", Group: "已知人员", Hint: "测试 2,生产 3", Desc: "连续识别为同一人的帧数,值越大越可靠但识别变慢"},
|
||||
{Path: "alarm_violation.face_rules[1].hit_window_ms", Label: "命中窗口", Type: "slider", Min: 500, Max: 10000, Step: 500, Unit: "ms", Group: "已知人员", Hint: "测试 1500ms,生产 2000ms", Desc: "累计命中帧数的时间窗口,窗口越大越容易触发告警"},
|
||||
// ── 人脸识别 ──
|
||||
{Path: "recognize_face.threshold.accept", Label: "接受阈值", Type: "slider", Min: 0.3, Max: 0.9, Step: 0.05, Group: "人脸识别", Hint: "默认 0.45", Desc: "人脸特征匹配的全局门槛,值越高误识别越少但漏识别增多"},
|
||||
{Path: "detect_face.conf_thresh", Label: "检测置信度", Type: "slider", Min: 0.2, Max: 0.9, Step: 0.05, Group: "人脸识别", Hint: "默认 0.5", Desc: "检测到人脸的可信度门槛,值越高误检越少"},
|
||||
// ── 工鞋检测 ──
|
||||
{Path: "detect_shoe.conf", Label: "检测置信度", Type: "slider", Min: 0.1, Max: 0.8, Step: 0.05, Group: "工鞋检测", Hint: "默认 0.22", Desc: "工鞋检测模型的可信度门槛,值越高检测越严格"},
|
||||
{Path: "rule_shoe_association.person_shoe_check.min_shoe_score", Label: "工鞋最低分数", Type: "slider", Min: 0.1, Max: 0.6, Step: 0.02, Group: "工鞋检测", Hint: "默认 0.22", Desc: "工鞋检测结果的最低可信度,值越高误检越少"},
|
||||
{Path: "rule_shoe_association.person_shoe_check.min_person_score", Label: "人员最低分数", Type: "slider", Min: 0.1, Max: 0.8, Step: 0.05, Group: "工鞋检测", Hint: "默认 0.3", Desc: "人员检测结果的最低可信度,值越高只对清晰人体做鞋检测"},
|
||||
// ── 人体检测 ──
|
||||
{Path: "detect_person.conf", Label: "检测置信度", Type: "slider", Min: 0.1, Max: 0.8, Step: 0.05, Group: "人体检测", Hint: "默认 0.35", Desc: "检测到人体的可信度门槛,值越高误检越少但可能漏人"},
|
||||
},
|
||||
}
|
||||
|
||||
func splitTuningPath(fullPath string) (nodeID string, rest string) {
|
||||
dot := strings.IndexByte(fullPath, '.')
|
||||
if dot < 0 {
|
||||
return fullPath, ""
|
||||
}
|
||||
return fullPath[:dot], fullPath[dot+1:]
|
||||
}
|
||||
|
||||
func resolveTuningPath(root map[string]any, path string) (float64, bool) {
|
||||
if path == "" {
|
||||
return 0, false
|
||||
}
|
||||
seg := path
|
||||
rest := ""
|
||||
if dot := strings.IndexByte(path, '.'); dot >= 0 {
|
||||
seg = path[:dot]
|
||||
rest = path[dot+1:]
|
||||
}
|
||||
|
||||
bracket := strings.IndexByte(seg, '[')
|
||||
if bracket >= 0 {
|
||||
name := seg[:bracket]
|
||||
idxStr := seg[bracket+1:]
|
||||
if len(idxStr) > 0 && idxStr[len(idxStr)-1] == ']' {
|
||||
idxStr = idxStr[:len(idxStr)-1]
|
||||
}
|
||||
idx, err := strconv.Atoi(idxStr)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
arr, _ := root[name].([]any)
|
||||
if idx < 0 || idx >= len(arr) {
|
||||
return 0, false
|
||||
}
|
||||
if rest == "" {
|
||||
v, _ := toFloat(arr[idx])
|
||||
return v, true
|
||||
}
|
||||
cm, _ := arr[idx].(map[string]any)
|
||||
if cm == nil {
|
||||
return 0, false
|
||||
}
|
||||
return resolveTuningPath(cm, rest)
|
||||
}
|
||||
|
||||
val, ok := root[seg]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if rest == "" {
|
||||
v, ok := toFloat(val)
|
||||
return v, ok
|
||||
}
|
||||
cm, _ := val.(map[string]any)
|
||||
if cm == nil {
|
||||
return 0, false
|
||||
}
|
||||
return resolveTuningPath(cm, rest)
|
||||
}
|
||||
|
||||
func setTuningPath(root map[string]any, path string, value float64) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("empty path")
|
||||
}
|
||||
seg := path
|
||||
rest := ""
|
||||
if dot := strings.IndexByte(path, '.'); dot >= 0 {
|
||||
seg = path[:dot]
|
||||
rest = path[dot+1:]
|
||||
}
|
||||
|
||||
bracket := strings.IndexByte(seg, '[')
|
||||
if bracket >= 0 {
|
||||
name := seg[:bracket]
|
||||
idxStr := seg[bracket+1:]
|
||||
if len(idxStr) > 0 && idxStr[len(idxStr)-1] == ']' {
|
||||
idxStr = idxStr[:len(idxStr)-1]
|
||||
}
|
||||
idx, err := strconv.Atoi(idxStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid array index: %s", idxStr)
|
||||
}
|
||||
arr, _ := root[name].([]any)
|
||||
if idx < 0 || idx >= len(arr) {
|
||||
return fmt.Errorf("index %d out of range", idx)
|
||||
}
|
||||
if rest == "" {
|
||||
arr[idx] = value
|
||||
return nil
|
||||
}
|
||||
cm, _ := arr[idx].(map[string]any)
|
||||
if cm == nil {
|
||||
cm = map[string]any{}
|
||||
arr[idx] = cm
|
||||
}
|
||||
return setTuningPath(cm, rest, value)
|
||||
}
|
||||
|
||||
if rest == "" {
|
||||
root[seg] = value
|
||||
return nil
|
||||
}
|
||||
cm, _ := root[seg].(map[string]any)
|
||||
if cm == nil {
|
||||
cm = map[string]any{}
|
||||
root[seg] = cm
|
||||
}
|
||||
return setTuningPath(cm, rest, value)
|
||||
}
|
||||
|
||||
func floatValue(m map[string]any, key string, defaultVal float64) float64 {
|
||||
if v, ok := m[key]; ok {
|
||||
if f, ok := toFloat(v); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func toFloat(v any) (float64, bool) {
|
||||
switch vv := v.(type) {
|
||||
case float64:
|
||||
return vv, true
|
||||
case float32:
|
||||
return float64(vv), true
|
||||
case int:
|
||||
return float64(vv), true
|
||||
case int64:
|
||||
return float64(vv), true
|
||||
case json.Number:
|
||||
f, err := vv.Float64()
|
||||
return f, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
245
internal/service/tuning_test.go
Normal file
245
internal/service/tuning_test.go
Normal file
@ -0,0 +1,245 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"safesight-control/internal/config"
|
||||
"safesight-control/internal/storage"
|
||||
)
|
||||
|
||||
// ── GetDevicesUsingTemplate ──
|
||||
|
||||
func TestGetDevicesUsingTemplate(t *testing.T) {
|
||||
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "tuning.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
// Seed template
|
||||
repo.SaveTemplate("workshop_face_recognition_shoe_alarm", "user template",
|
||||
`{"name":"workshop_face_recognition_shoe_alarm","template":{"nodes":[{"id":"in","type":"input_rtsp"}],"edges":[]}}`)
|
||||
|
||||
// Seed profile referencing the template
|
||||
repo.SaveProfile("auto_dev1", "workshop_face_recognition_shoe_alarm", "", "",
|
||||
`{"name":"auto_dev1","primary_template_name":"workshop_face_recognition_shoe_alarm"}`)
|
||||
repo.SaveProfile("auto_dev2", "workshop_face_recognition_shoe_alarm", "", "",
|
||||
`{"name":"auto_dev2","primary_template_name":"workshop_face_recognition_shoe_alarm"}`)
|
||||
repo.SaveProfile("auto_other", "other_template", "", "",
|
||||
`{"name":"auto_other","primary_template_name":"other_template"}`)
|
||||
|
||||
// Seed device assignments (bodyJSON must be a JSON object, not array)
|
||||
repo.SaveDeviceAssignment("dev1", "auto_dev1", "", `{"recognition_units":[]}`)
|
||||
repo.SaveDeviceAssignment("dev2", "auto_dev2", "", `{"recognition_units":[]}`)
|
||||
repo.SaveDeviceAssignment("dev3", "auto_other", "", `{"recognition_units":[]}`)
|
||||
|
||||
// Verify setup: list profiles and assignments
|
||||
profiles, _ := repo.ListProfiles()
|
||||
for _, p := range profiles {
|
||||
t.Logf(" profile: name=%s template=%s", p.Name, p.TemplateName)
|
||||
}
|
||||
assignments, _ := repo.ListDeviceAssignments()
|
||||
for _, a := range assignments {
|
||||
t.Logf(" assignment: device=%s profile=%s", a.DeviceID, a.ProfileName)
|
||||
}
|
||||
|
||||
devices, err := svc.GetDevicesUsingTemplate("workshop_face_recognition_shoe_alarm")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDevicesUsingTemplate: %v", err)
|
||||
}
|
||||
if len(devices) != 2 {
|
||||
t.Errorf("expected 2 devices, got %d: %v", len(devices), devices)
|
||||
}
|
||||
found := map[string]bool{}
|
||||
for _, d := range devices {
|
||||
found[d] = true
|
||||
}
|
||||
if !found["dev1"] || !found["dev2"] {
|
||||
t.Errorf("expected dev1 and dev2, got %v", devices)
|
||||
}
|
||||
if found["dev3"] {
|
||||
t.Error("dev3 should not be affected (uses other_template)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDevicesUsingTemplate_NoMatches(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "tuning2.db"))
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
devices, err := svc.GetDevicesUsingTemplate("nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDevicesUsingTemplate: %v", err)
|
||||
}
|
||||
if len(devices) != 0 {
|
||||
t.Errorf("expected 0 devices, got %d", len(devices))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tuning Definitions ──
|
||||
|
||||
func TestTuningDefinitions_HaveConsistentPaths(t *testing.T) {
|
||||
defs := tuningDefinitions["std_workshop_face_recognition_shoe_alarm"]
|
||||
if len(defs) == 0 {
|
||||
t.Fatal("expected tuning definitions for std_workshop_face_recognition_shoe_alarm")
|
||||
}
|
||||
|
||||
for _, item := range defs {
|
||||
if item.Path == "" {
|
||||
t.Error("tuning item has empty path")
|
||||
}
|
||||
if item.Label == "" {
|
||||
t.Errorf("tuning item %q has empty label", item.Path)
|
||||
}
|
||||
if item.Group == "" {
|
||||
t.Errorf("tuning item %q has empty group", item.Path)
|
||||
}
|
||||
if item.Min >= item.Max {
|
||||
t.Errorf("tuning item %q: min(%v) >= max(%v)", item.Path, item.Min, item.Max)
|
||||
}
|
||||
if item.Step <= 0 {
|
||||
t.Errorf("tuning item %q: step must be > 0", item.Path)
|
||||
}
|
||||
// Verify path format: node_id.field...
|
||||
nodeID, path := splitTuningPath(item.Path)
|
||||
if nodeID == "" || path == "" {
|
||||
t.Errorf("tuning item %q: invalid path format, expected node_id.field...", item.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuningDefinitions_GroupsNotEmpty(t *testing.T) {
|
||||
defs := tuningDefinitions["std_workshop_face_recognition_shoe_alarm"]
|
||||
groups := map[string]int{}
|
||||
for _, item := range defs {
|
||||
groups[item.Group]++
|
||||
}
|
||||
|
||||
// Each group should have at least 1 parameter
|
||||
for group, count := range groups {
|
||||
if count == 0 {
|
||||
t.Errorf("group %q is empty", group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tuning Path Resolution ──
|
||||
|
||||
func TestSplitTuningPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
full, node, rest string
|
||||
}{
|
||||
{"alarm_violation.rules[0].cooldown_ms", "alarm_violation", "rules[0].cooldown_ms"},
|
||||
{"detect_face.conf_thresh", "detect_face", "conf_thresh"},
|
||||
{"node_id", "node_id", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
node, rest := splitTuningPath(tt.full)
|
||||
if node != tt.node || rest != tt.rest {
|
||||
t.Errorf("splitTuningPath(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.full, node, rest, tt.node, tt.rest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTuningPath(t *testing.T) {
|
||||
node := map[string]any{
|
||||
"threshold": map[string]any{"accept": 0.45},
|
||||
"rules": []any{
|
||||
map[string]any{"cooldown_ms": float64(60000), "min_score": 0.5},
|
||||
},
|
||||
}
|
||||
|
||||
if v, ok := resolveTuningPath(node, "threshold.accept"); !ok || v != 0.45 {
|
||||
t.Errorf("threshold.accept: got %v, want 0.45", v)
|
||||
}
|
||||
if v, ok := resolveTuningPath(node, "rules[0].cooldown_ms"); !ok || v != 60000 {
|
||||
t.Errorf("rules[0].cooldown_ms: got %v, want 60000", v)
|
||||
}
|
||||
if v, ok := resolveTuningPath(node, "rules[0].min_score"); !ok || v != 0.5 {
|
||||
t.Errorf("rules[0].min_score: got %v, want 0.5", v)
|
||||
}
|
||||
if _, ok := resolveTuningPath(node, "nonexistent.field"); ok {
|
||||
t.Error("nonexistent.field should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTuningPath(t *testing.T) {
|
||||
node := map[string]any{
|
||||
"threshold": map[string]any{"accept": 0.45},
|
||||
"rules": []any{
|
||||
map[string]any{"cooldown_ms": float64(60000)},
|
||||
},
|
||||
}
|
||||
|
||||
if err := setTuningPath(node, "threshold.accept", 0.6); err != nil {
|
||||
t.Fatalf("setTuningPath: %v", err)
|
||||
}
|
||||
if v, _ := resolveTuningPath(node, "threshold.accept"); v != 0.6 {
|
||||
t.Errorf("after set: got %v, want 0.6", v)
|
||||
}
|
||||
|
||||
if err := setTuningPath(node, "rules[0].cooldown_ms", 30000); err != nil {
|
||||
t.Fatalf("setTuningPath: %v", err)
|
||||
}
|
||||
if v, _ := resolveTuningPath(node, "rules[0].cooldown_ms"); v != 30000 {
|
||||
t.Errorf("after set: got %v, want 30000", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTuningPath_InvalidIndex(t *testing.T) {
|
||||
node := map[string]any{
|
||||
"rules": []any{map[string]any{"x": float64(1)}},
|
||||
}
|
||||
if err := setTuningPath(node, "rules[5].x", 42); err == nil {
|
||||
t.Error("expected error for out-of-range index")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Device Overlay Persistence ──
|
||||
|
||||
func TestSaveAndGetDeviceOverlays(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "overlay.db"))
|
||||
defer store.Close()
|
||||
repo := storage.NewAssetsRepo(store.DB())
|
||||
svc := NewConfigPreviewService(&config.Config{}, repo)
|
||||
|
||||
if err := svc.SaveDeviceOverlays("dev1", []string{"face_debug", "production_quiet"}); err != nil {
|
||||
t.Fatalf("SaveDeviceOverlays: %v", err)
|
||||
}
|
||||
|
||||
overlays, err := svc.GetDeviceOverlays("dev1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceOverlays: %v", err)
|
||||
}
|
||||
if len(overlays) != 2 {
|
||||
t.Errorf("expected 2 overlays, got %d: %v", len(overlays), overlays)
|
||||
}
|
||||
|
||||
// Overwrite
|
||||
if err := svc.SaveDeviceOverlays("dev1", []string{}); err != nil {
|
||||
t.Fatalf("SaveDeviceOverlays (empty): %v", err)
|
||||
}
|
||||
overlays, _ = svc.GetDeviceOverlays("dev1")
|
||||
if len(overlays) != 0 {
|
||||
t.Errorf("expected 0 overlays after clear, got %d", len(overlays))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDeviceOverlays_UnknownDevice(t *testing.T) {
|
||||
store, _ := storage.OpenSQLite(filepath.Join(t.TempDir(), "overlay2.db"))
|
||||
defer store.Close()
|
||||
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
|
||||
|
||||
overlays, err := svc.GetDeviceOverlays("nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceOverlays: %v", err)
|
||||
}
|
||||
if len(overlays) != 0 {
|
||||
t.Errorf("expected empty for unknown device, got %v", overlays)
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,7 @@ type IntegrationServiceRecord struct {
|
||||
}
|
||||
|
||||
type VideoSourceRecord struct {
|
||||
ID int
|
||||
Name string
|
||||
SourceType string
|
||||
Area string
|
||||
@ -113,21 +114,28 @@ ON CONFLICT(name) DO UPDATE SET
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) SaveVideoSource(name string, sourceType string, area string, description string, bodyJSON string) error {
|
||||
func (r *AssetsRepo) SaveVideoSource(id int, name string, sourceType string, area string, description string, bodyJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
if id > 0 {
|
||||
// Check name uniqueness before update
|
||||
var existingID int
|
||||
err := r.db.QueryRow(`SELECT id FROM video_sources WHERE name = ? AND id != ?`, name, id).Scan(&existingID)
|
||||
if err == nil {
|
||||
return fmt.Errorf("视频源名称 %q 已存在", name)
|
||||
}
|
||||
_, err = r.db.Exec(`
|
||||
UPDATE video_sources SET name=?, source_type=?, area=?, description=?, body_json=?, updated_at=?
|
||||
WHERE id=?`, name, sourceType, area, description, bodyJSON, now, id)
|
||||
return err
|
||||
}
|
||||
// Insert new
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO video_sources(name, source_type, area, description, body_json, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM video_sources WHERE name = ?), ?), ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
source_type=excluded.source_type,
|
||||
area=excluded.area,
|
||||
description=excluded.description,
|
||||
body_json=excluded.body_json,
|
||||
updated_at=excluded.updated_at
|
||||
`, name, sourceType, area, description, bodyJSON, name, now, now)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, sourceType, area, description, bodyJSON, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -211,7 +219,7 @@ func (r *AssetsRepo) ListVideoSources() ([]VideoSourceRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := r.db.Query(`
|
||||
SELECT name, source_type, area, description, body_json, created_at, updated_at
|
||||
SELECT id, name, source_type, area, description, body_json, created_at, updated_at
|
||||
FROM video_sources
|
||||
ORDER BY updated_at DESC, name ASC
|
||||
`)
|
||||
@ -223,7 +231,7 @@ ORDER BY updated_at DESC, name ASC
|
||||
var out []VideoSourceRecord
|
||||
for rows.Next() {
|
||||
var item VideoSourceRecord
|
||||
if err := rows.Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
@ -317,10 +325,10 @@ func (r *AssetsRepo) GetVideoSource(name string) (*VideoSourceRecord, error) {
|
||||
}
|
||||
var item VideoSourceRecord
|
||||
err := r.db.QueryRow(`
|
||||
SELECT name, source_type, area, description, body_json, created_at, updated_at
|
||||
SELECT id, name, source_type, area, description, body_json, created_at, updated_at
|
||||
FROM video_sources
|
||||
WHERE name = ?
|
||||
`, name).Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
|
||||
`, name).Scan(&item.ID, &item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@ -384,6 +392,14 @@ func (r *AssetsRepo) DeleteIntegrationService(name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) DeleteVideoSourceByID(id int) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.Exec(`DELETE FROM video_sources WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *AssetsRepo) DeleteVideoSource(name string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
@ -801,3 +817,35 @@ func (r *AssetsRepo) GetDeviceFeatures(deviceID string) (*DeviceFeatureRecord, e
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// SaveDeviceOverlays upserts the overlay selection for a device.
|
||||
func (r *AssetsRepo) SaveDeviceOverlays(deviceID string, overlaysJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO device_features(device_id, overlays_json, updated_at)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
overlays_json=excluded.overlays_json,
|
||||
updated_at=excluded.updated_at
|
||||
`, deviceID, overlaysJSON, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDeviceOverlays returns the persisted overlays JSON for a device.
|
||||
func (r *AssetsRepo) GetDeviceOverlays(deviceID string) (string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return "", nil
|
||||
}
|
||||
var overlaysJSON string
|
||||
err := r.db.QueryRow(
|
||||
"SELECT overlays_json FROM device_features WHERE device_id = ?",
|
||||
deviceID,
|
||||
).Scan(&overlaysJSON)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
return overlaysJSON, nil
|
||||
}
|
||||
|
||||
@ -150,6 +150,7 @@ func TestAssetsRepoStoresVideoSource(t *testing.T) {
|
||||
|
||||
repo := NewAssetsRepo(store.DB())
|
||||
if err := repo.SaveVideoSource(
|
||||
0,
|
||||
"gate_cam_01",
|
||||
"rtsp",
|
||||
"东门入口",
|
||||
|
||||
63
internal/storage/config_versions_repo.go
Normal file
63
internal/storage/config_versions_repo.go
Normal file
@ -0,0 +1,63 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ConfigVersionRecord struct {
|
||||
ID int `json:"id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ConfigID string `json:"config_id"`
|
||||
ConfigJSON string `json:"config_json"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type ConfigVersionsRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewConfigVersionsRepo(db *sql.DB) *ConfigVersionsRepo {
|
||||
return &ConfigVersionsRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *ConfigVersionsRepo) Save(deviceID, configID, configJSON string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.Exec(`INSERT INTO config_versions(device_id, config_id, config_json, created_at) VALUES(?,?,?,?)`,
|
||||
deviceID, configID, configJSON, time.Now().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ConfigVersionsRepo) List(deviceID string) ([]ConfigVersionRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := r.db.Query(`SELECT id, device_id, config_id, config_json, created_at FROM config_versions WHERE device_id=? ORDER BY id DESC LIMIT 20`, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var results []ConfigVersionRecord
|
||||
for rows.Next() {
|
||||
var rec ConfigVersionRecord
|
||||
if err := rows.Scan(&rec.ID, &rec.DeviceID, &rec.ConfigID, &rec.ConfigJSON, &rec.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, rec)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func (r *ConfigVersionsRepo) GetByID(id int) (*ConfigVersionRecord, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var rec ConfigVersionRecord
|
||||
err := r.db.QueryRow(`SELECT id, device_id, config_id, config_json, created_at FROM config_versions WHERE id=?`, id).Scan(&rec.ID, &rec.DeviceID, &rec.ConfigID, &rec.ConfigJSON, &rec.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"safesight-control/internal/models"
|
||||
)
|
||||
|
||||
type DevicesRepo struct {
|
||||
|
||||
@ -3,7 +3,7 @@ package storage
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"safesight-control/internal/models"
|
||||
)
|
||||
|
||||
func TestDevicesRepoUpsertsRuntimeSnapshot(t *testing.T) {
|
||||
|
||||
@ -58,7 +58,7 @@ func (r *FaceGalleryRepo) ListPersons() ([]PersonRecord, error) {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(`SELECT id, name, created_at FROM face_persons ORDER BY name`)
|
||||
rows, err := db.Query(`SELECT id, name, created_at FROM face_persons ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -180,3 +180,4 @@ func (r *FaceGalleryRepo) DeletePhoto(photoID int) error {
|
||||
if path != "" { os.Remove(filepath.Join("dataset", path)) }
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -153,6 +153,7 @@ CREATE TABLE IF NOT EXISTS device_config_state (
|
||||
CREATE TABLE IF NOT EXISTS device_features (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
features_json TEXT NOT NULL DEFAULT '[]',
|
||||
overlays_json TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
@ -189,7 +190,16 @@ func migrate(db *sql.DB) error {
|
||||
if err := migrateProfilePrimaryTemplateName(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateProfilesToSceneTemplates(db)
|
||||
if err := migrateProfilesToSceneTemplates(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateAlarmWorkflow(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateDeviceFeaturesOverlays(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateConfigVersions(db)
|
||||
}
|
||||
|
||||
func migrateProfilePrimaryTemplateName(db *sql.DB) error {
|
||||
@ -431,6 +441,69 @@ func splitLegacyProfileDocument(item struct {
|
||||
return raw, units, nil
|
||||
}
|
||||
|
||||
func migrateAlarmWorkflow(db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
hasStatus, err := hasColumn(db, "alarm_records", "status")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasStatus {
|
||||
return nil
|
||||
}
|
||||
stmts := []string{
|
||||
`ALTER TABLE alarm_records ADD COLUMN status TEXT NOT NULL DEFAULT 'unacknowledged'`,
|
||||
`ALTER TABLE alarm_records ADD COLUMN severity TEXT NOT NULL DEFAULT 'warning'`,
|
||||
`ALTER TABLE alarm_records ADD COLUMN acknowledged_at TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE alarm_records ADD COLUMN acknowledged_by TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE alarm_records ADD COLUMN resolved_at TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE alarm_records ADD COLUMN resolved_by TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("migrate alarm_workflow: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateDeviceFeaturesOverlays(db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
has, err := hasColumn(db, "device_features", "overlays_json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return nil
|
||||
}
|
||||
_, err = db.Exec(`ALTER TABLE device_features ADD COLUMN overlays_json TEXT NOT NULL DEFAULT '[]'`)
|
||||
return err
|
||||
}
|
||||
|
||||
func migrateConfigVersions(db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var count int
|
||||
db.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name='config_versions'`).Scan(&count)
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS config_versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
config_id TEXT NOT NULL DEFAULT '',
|
||||
config_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func bindingStringFromAny(bindings map[string]any, slot string, field string) string {
|
||||
entry, _ := bindings[slot].(map[string]any)
|
||||
return strings.TrimSpace(stringAny(entry[field]))
|
||||
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"safesight-control/internal/models"
|
||||
)
|
||||
|
||||
type TasksRepo struct {
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
"safesight-control/internal/models"
|
||||
)
|
||||
|
||||
func openTestStore(t *testing.T) *Store {
|
||||
|
||||
1943
internal/web/ui.go
1943
internal/web/ui.go
File diff suppressed because it is too large
Load Diff
@ -24,9 +24,9 @@
|
||||
--button-soft-text:#f4f4f4;
|
||||
--input-bg:#101010;
|
||||
--selected-row:#303030;
|
||||
--danger-soft:#242424;
|
||||
--danger-soft-hover:#303030;
|
||||
--danger-soft-text:#dddddd;
|
||||
--danger-soft:#2d1b1b;
|
||||
--danger-soft-hover:#3d2525;
|
||||
--danger-soft-text:#e46f72;
|
||||
--assignment-low-bg:#24190d;
|
||||
--assignment-busy-bg:#3a2812;
|
||||
--assignment-full-bg:#4b3110;
|
||||
@ -129,12 +129,21 @@ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
.side-nav .nav-group summary{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500;list-style:none;cursor:pointer;user-select:none}
|
||||
.side-nav .nav-group summary:hover{background:var(--sidebar-hover)}
|
||||
.side-nav .nav-group summary::-webkit-details-marker{display:none}
|
||||
.side-nav .nav-group summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--sidebar-muted);border-bottom:1.5px solid var(--sidebar-muted);transform:rotate(45deg);transition:transform .16s ease,border-color .16s ease}
|
||||
.side-nav .nav-group[open] summary::after{transform:rotate(225deg);border-color:var(--sidebar-text)}
|
||||
.side-nav .nav-group > summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--sidebar-muted);border-bottom:1.5px solid var(--sidebar-muted);transform:rotate(45deg);transition:transform .16s ease,border-color .16s ease}
|
||||
.side-nav .nav-group[open] > summary::after{transform:rotate(225deg);border-color:var(--sidebar-text)}
|
||||
.side-nav .nav-group[open] summary{background:var(--sidebar-hover)}
|
||||
.side-nav .nav-group-items{display:flex;flex-direction:column;gap:4px;margin-top:4px}
|
||||
.side-nav .nav-subitem{padding:8px 10px 8px 34px;font-size:12px;font-weight:500;color:var(--sidebar-muted)}
|
||||
.side-nav .nav-subitem:hover{background:var(--sidebar-hover);color:var(--sidebar-text)}
|
||||
.side-nav .nav-subgroup{margin:0}
|
||||
.side-nav .nav-subgroup summary{display:flex;align-items:center;gap:10px;padding:8px 10px 8px 34px;color:var(--sidebar-muted);font-size:12px;font-weight:500;list-style:none;cursor:pointer;user-select:none}
|
||||
.side-nav .nav-subgroup summary:hover{background:var(--sidebar-hover);color:var(--sidebar-text)}
|
||||
.side-nav .nav-subgroup summary::-webkit-details-marker{display:none}
|
||||
.side-nav .nav-subgroup summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--sidebar-muted);border-bottom:1.5px solid var(--sidebar-muted);transform:rotate(45deg);transition:transform .16s ease}
|
||||
.side-nav .nav-subgroup[open] summary::after{transform:rotate(225deg)}
|
||||
.side-nav .nav-subgroup[open] summary{color:var(--sidebar-text)}
|
||||
.side-nav .nav-subgroup-items{display:flex;flex-direction:column;gap:2px;margin-top:2px}
|
||||
.side-nav .nav-subgroup .nav-subitem{padding-left:48px}
|
||||
.side-nav .nav-subicon{width:22px;height:20px;font-size:9px}
|
||||
.side-nav .nav-subicon .ui-icon{width:12px;height:12px}
|
||||
.nav-icon{width:28px;height:24px;border-radius:3px;border:1px solid rgba(255,255,255,.12);display:grid;place-items:center;font-size:10px;color:var(--primary)}
|
||||
@ -149,6 +158,14 @@ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
.topbar-icon-btn:hover{background:var(--button-soft-hover);border-color:var(--primary);color:var(--text)}
|
||||
.topbar-icon-btn .ui-icon{width:16px;height:16px}
|
||||
.topbar-dot{position:absolute;right:6px;top:6px;width:6px;height:6px;border-radius:999px;background:var(--red)}
|
||||
.topbar-badge{position:absolute;right:-4px;top:-4px;min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:var(--red);color:#fff;font-size:10px;font-weight:600;line-height:16px;text-align:center}
|
||||
.toast-container{position:fixed;top:68px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none}
|
||||
.toast{display:flex;align-items:center;gap:8px;padding:10px 14px;border-radius:var(--radius);font-size:13px;font-weight:500;box-shadow:var(--shadow);pointer-events:auto;animation:toastIn .25s ease}
|
||||
.toast.success{background:var(--surface-strong);border:1px solid var(--green);color:var(--green)}
|
||||
.toast.error{background:var(--danger-soft);border:1px solid var(--danger-soft-hover);color:var(--danger-soft-text)}
|
||||
.toast.out{animation:toastOut .25s ease forwards}
|
||||
@keyframes toastIn{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:translateX(0)}}
|
||||
@keyframes toastOut{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(40px)}}
|
||||
.theme-menu{position:relative}
|
||||
.theme-menu-panel{position:fixed;right:28px;top:54px;min-width:132px;padding:6px;border:1px solid var(--border-strong);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow);z-index:9999}
|
||||
.theme-menu-panel button{display:flex;width:100%;justify-content:flex-start;min-height:28px;border-color:transparent;background:transparent;color:var(--text)}
|
||||
@ -187,6 +204,8 @@ textarea{min-height:140px;line-height:1.55}
|
||||
.msg{background:var(--surface-soft);border:1px solid var(--border-strong);color:var(--green)}
|
||||
.error{background:var(--danger-soft);border:1px solid var(--danger-soft-hover);color:var(--danger-soft-text)}
|
||||
|
||||
.inline-form{display:inline-flex;margin:0;padding:0}
|
||||
|
||||
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
|
||||
.stat{padding:14px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow)}
|
||||
.stat .k{font-size:12px;color:var(--muted)}
|
||||
@ -209,6 +228,7 @@ th{background:var(--surface-soft);font-size:12px;font-weight:600;color:var(--mut
|
||||
.table-wrap td .table-key{color:var(--table-link)}
|
||||
tbody tr:hover{background:var(--surface-soft)}
|
||||
tbody tr.selected{background:var(--selected-row)}
|
||||
tbody tr.device-row-warn{background:rgba(255,180,100,.06)}
|
||||
tbody tr.selected td{color:var(--text)}
|
||||
tbody tr.selected .mono,
|
||||
tbody tr.selected .table-key,
|
||||
@ -241,6 +261,7 @@ tbody tr[data-nav-row]{cursor:pointer}
|
||||
.pill.bad{background:var(--danger-soft);border-color:var(--danger-soft-hover);color:var(--danger-soft-text)}
|
||||
.pill.run{background:var(--surface-soft);border-color:var(--border-strong);color:var(--primary)}
|
||||
.pill.warn{background:var(--surface-soft);border-color:var(--border-strong);color:var(--amber)}
|
||||
.pill.muted-pill{background:var(--surface-soft);border-color:var(--border);color:var(--muted)}
|
||||
|
||||
.actions{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.actions.compact{gap:6px}
|
||||
@ -390,6 +411,15 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va
|
||||
.assignment-slider span{font-size:12px;color:var(--muted)}
|
||||
.assignment-slider strong{font-size:12px;font-weight:500;color:var(--text)}
|
||||
.assignment-slider input[type=range]{width:180px}
|
||||
.tuning-item{display:flex;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--border)}
|
||||
.tuning-item:last-child{border-bottom:none}
|
||||
.tuning-label-wrap{min-width:160px}.tuning-label{display:block;font-size:13px;font-weight:500;color:var(--text)}.tuning-desc{display:block;font-size:11px;color:var(--muted);margin-top:1px;font-style:italic}
|
||||
.tuning-control{display:flex;align-items:center;gap:8px;flex:1}
|
||||
.tuning-control input[type=range]{flex:1;max-width:300px}
|
||||
.tuning-control output{min-width:70px;font-size:12px;color:var(--muted);text-align:right}
|
||||
.device-item{margin:2px}
|
||||
.overlay-check-row{display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;font-size:13px}
|
||||
.overlay-check-row input[type=checkbox]{flex-shrink:0;width:16px;height:16px}
|
||||
.assignment-board-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:12px}
|
||||
.assignment-device-card{padding:10px 11px;border:1px solid var(--border);border-radius:var(--radius);background:var(--assignment-low-bg)}
|
||||
.assignment-device-card.state-idle{background:var(--surface-soft)}
|
||||
@ -401,6 +431,9 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va
|
||||
.assignment-device-head .mono{display:block;margin-top:4px;font-size:11px;color:var(--muted)}
|
||||
.assignment-device-metrics{display:flex;align-items:center;gap:8px}
|
||||
.assignment-device-metrics strong{font-size:12px;font-weight:500;color:var(--text)}
|
||||
.config-sync-indicator{font-size:11px;padding:2px 0 4px}
|
||||
.config-sync-indicator.synced{color:var(--green)}
|
||||
.config-sync-indicator.pending{color:var(--amber)}
|
||||
.assignment-chip-list{display:flex;flex-wrap:wrap;gap:6px;min-height:24px}
|
||||
.assignment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
|
||||
.assignment-chip-unassigned{background:var(--surface-strong)}
|
||||
@ -432,4 +465,16 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va
|
||||
.assignment-board-grid{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
.sidebar-footer{margin-top:auto;padding:16px 8px 0;border-top:1px solid rgba(255,255,255,.08);font-size:11px;color:var(--sidebar-muted);line-height:1.6;text-align:center}
|
||||
.sidebar-footer{margin-top:auto;padding:16px 8px 0;font-size:11px;color:var(--sidebar-muted);line-height:1.6;text-align:center}
|
||||
.sidebar-footer .mode-toggle{display:flex;align-items:center;justify-content:center;gap:10px;padding:8px 10px;margin-bottom:12px;border-bottom:1px solid rgba(255,255,255,.08);border-radius:0;cursor:pointer;user-select:none}
|
||||
.sidebar-footer .mode-toggle:hover{background:var(--sidebar-hover)}
|
||||
.mode-label{font-size:11px;font-weight:500;transition:color .16s ease}
|
||||
.mode-label-left{color:var(--sidebar-text)}
|
||||
.mode-label-right{color:var(--sidebar-muted)}
|
||||
body[data-mode=expert] .mode-label-left{color:var(--sidebar-muted)}
|
||||
body[data-mode=expert] .mode-label-right{color:var(--sidebar-text)}
|
||||
.mode-track{position:relative;width:36px;height:20px;border-radius:10px;background:var(--surface-strong);border:1px solid var(--border);transition:background .16s ease,border-color .16s ease;flex-shrink:0}
|
||||
body[data-mode=expert] .mode-track{background:var(--primary-strong);border-color:var(--primary)}
|
||||
.mode-thumb{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--sidebar-text);transition:transform .16s ease}
|
||||
body[data-mode=expert] .mode-thumb{transform:translateX(16px)}
|
||||
body:not([data-mode=expert]) .expert-only{display:none}
|
||||
|
||||
@ -1,15 +1,34 @@
|
||||
{{define "alarms"}}
|
||||
<div class="card">
|
||||
<form method="get" action="/ui/alarms" style="display:flex;gap:8px;align-items:center;margin-bottom:12px;justify-content:space-between">
|
||||
<form method="get" action="/alarms" style="display:flex;gap:8px;align-items:center;margin-bottom:12px;justify-content:space-between;flex-wrap:wrap">
|
||||
<span style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input type="date" name="from" value="{{.SelectedFrom}}" style="width:140px;font-size:12px">
|
||||
<input type="date" name="from" value="{{.SelectedFrom}}" style="width:130px;font-size:12px">
|
||||
<span class="muted small">至</span>
|
||||
<input type="date" name="to" value="{{.SelectedTo}}" style="width:140px;font-size:12px">
|
||||
<input type="date" name="to" value="{{.SelectedTo}}" style="width:130px;font-size:12px">
|
||||
{{if .AlarmFilterDevices}}
|
||||
<select name="device" onchange="this.form.submit()" style="font-size:11px;padding:2px 4px;width:130px;flex-shrink:0">
|
||||
<option value="">全部设备</option>
|
||||
{{range .AlarmFilterDevices}}
|
||||
<option value="{{.}}" {{if eq . $.SelectedAlarmDevice}}selected{{end}}>{{index $.AlarmDeviceNames .}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{end}}
|
||||
{{if .AlarmFilterRuleTypes}}
|
||||
<select name="rule_type" onchange="this.form.submit()" style="font-size:11px;padding:2px 4px;width:120px;flex-shrink:0">
|
||||
<option value="">全部类型</option>
|
||||
{{range .AlarmFilterRuleTypes}}
|
||||
<option value="{{.}}" {{if eq . $.SelectedAlarmRuleType}}selected{{end}}>{{ruleLabel .}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{end}}
|
||||
<button class="btn" style="font-size:12px;padding:4px 12px">筛选</button>
|
||||
<a href="/ui/alarms" class="btn ghost" style="font-size:11px">今天</a>
|
||||
<a href="/ui/alarms?from={{.WeekStart}}&to={{.WeekEnd}}" class="btn ghost" style="font-size:11px">本周</a>
|
||||
<a href="/ui/alarms?from={{.MonthStart}}&to={{.MonthEnd}}" class="btn ghost" style="font-size:11px">本月</a>
|
||||
<a href="/ui/alarms?from=2020-01-01&to={{.SelectedTo}}" class="btn ghost" style="font-size:11px">全部</a>
|
||||
<a href="/alarms" class="btn ghost" style="font-size:11px">今天</a>
|
||||
<a href="/alarms?from={{.WeekStart}}&to={{.WeekEnd}}" class="btn ghost" style="font-size:11px">本周</a>
|
||||
<a href="/alarms?from={{.MonthStart}}&to={{.MonthEnd}}" class="btn ghost" style="font-size:11px">本月</a>
|
||||
<a href="/alarms?from=2020-01-01&to={{.SelectedTo}}" class="btn ghost" style="font-size:11px">全部</a>
|
||||
{{if .AlarmRecords}}
|
||||
<a class="btn ghost js-export-csv" style="font-size:11px;margin-left:8px">导出 CSV</a>
|
||||
{{end}}
|
||||
</span>
|
||||
<select name="per_page" onchange="this.form.submit()" style="font-size:11px;padding:2px 4px;width:90px;flex-shrink:0">
|
||||
<option value="10" {{if eq .PerPage 10}}selected{{end}}>10条/页</option>
|
||||
@ -26,33 +45,51 @@
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>设备</th>
|
||||
<th>通道</th>
|
||||
<th>视频源</th>
|
||||
<th>规则</th>
|
||||
<th>目标</th>
|
||||
<th>置信度</th>
|
||||
<th>截图</th>
|
||||
<th>等级</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AlarmRecords}}
|
||||
<tr>
|
||||
<td class="mono small">{{.Timestamp}}</td>
|
||||
<tr id="alarm-{{.ID}}">
|
||||
<td class="mono small">{{formatTime .Timestamp}}</td>
|
||||
<td>
|
||||
<a href="/ui/devices/{{.DeviceID}}">{{shortID .DeviceID}}</a>
|
||||
<a href="/devices/{{.DeviceID}}">{{index $.AlarmDeviceNames .DeviceID}}</a>
|
||||
<div class="muted small mono">{{index $.AlarmDeviceIPs .DeviceID}}</div>
|
||||
</td>
|
||||
<td>{{.Channel}}</td>
|
||||
<td>{{alarmChannelSource .Channel}}</td>
|
||||
<td>
|
||||
<span class="pill warn">{{ruleLabel .RuleName}}</span>
|
||||
</td>
|
||||
<td>{{if .ObjectLabel}}{{.ObjectLabel}}{{else}}-{{end}}</td>
|
||||
<td>{{if .Confidence}}{{printf "%.2f" .Confidence}}{{else}}-{{end}}</td>
|
||||
<td>
|
||||
<span class="pill {{severityClass .Severity}}">{{severityLabel .Severity}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="pill {{statusClass .Status}}">{{statusLabel .Status}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style="display:flex;gap:4px;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn ghost" style="font-size:10px;padding:2px 6px;min-height:22px" data-alarm-action="acknowledge" data-alarm-id="{{.ID}}">确认</button>
|
||||
<button class="btn ghost" style="font-size:10px;padding:2px 6px;min-height:22px" data-alarm-action="resolve" data-alarm-id="{{.ID}}">关闭</button>
|
||||
<select style="font-size:10px;padding:1px 2px;width:70px;min-height:22px" data-alarm-action="severity" data-alarm-id="{{.ID}}" data-current="{{.Severity}}">
|
||||
<option value="info" {{if eq .Severity "info"}}selected{{end}}>信息</option>
|
||||
<option value="warning" {{if eq .Severity "warning"}}selected{{end}}>警告</option>
|
||||
<option value="critical" {{if eq .Severity "critical"}}selected{{end}}>严重</option>
|
||||
</select>
|
||||
{{if .SnapshotURL}}
|
||||
<a href="{{.SnapshotURL}}" target="_blank" class="btn ghost">查看截图</a>
|
||||
{{else}}-{{end}}
|
||||
{{if .ClipURL}}
|
||||
<a href="{{.ClipURL}}" target="_blank" class="btn ghost">视频片段</a>
|
||||
<a href="{{.SnapshotURL}}" target="_blank" class="btn ghost" style="font-size:10px;padding:2px 6px;min-height:22px">截图</a>
|
||||
{{end}}
|
||||
{{if .ClipURL}}
|
||||
<a href="{{.ClipURL}}" target="_blank" class="btn ghost" style="font-size:10px;padding:2px 6px;min-height:22px">视频</a>
|
||||
{{end}}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@ -61,21 +98,21 @@
|
||||
{{if gt .TotalAlarmPages 1}}
|
||||
<div style="margin-top:12px;display:flex;gap:4px;align-items:center">
|
||||
<span class="muted small" style="margin-right:8px">{{.TotalAlarmCount}} 条,{{.TotalAlarmPages}} 页</span>
|
||||
{{$from := $.SelectedFrom}}{{$to := $.SelectedTo}}{{$pp := $.PerPage}}
|
||||
{{$from := $.SelectedFrom}}{{$to := $.SelectedTo}}{{$pp := $.PerPage}}{{$dev := $.SelectedAlarmDevice}}{{$rt := $.SelectedAlarmRuleType}}
|
||||
{{if gt .CurrentPage 1}}
|
||||
<a href="/ui/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}" class="btn ghost" style="font-size:11px;padding:2px 6px">«</a>
|
||||
<a href="/ui/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{sub .CurrentPage 1}}" class="btn ghost" style="font-size:11px;padding:2px 6px">‹</a>
|
||||
<a href="/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px">«</a>
|
||||
<a href="/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{sub .CurrentPage 1}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px">‹</a>
|
||||
{{end}}
|
||||
{{range .PageNumbers}}
|
||||
{{if eq . -1}}
|
||||
<span class="muted small" style="padding:2px 4px">…</span>
|
||||
{{else}}
|
||||
<a href="/ui/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{.}}" class="btn {{if eq . $.CurrentPage}}primary{{else}}ghost{{end}}" style="font-size:11px;padding:2px 8px">{{.}}</a>
|
||||
<a href="/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{.}}&device={{$dev}}&rule_type={{$rt}}" class="btn {{if eq . $.CurrentPage}}primary{{else}}ghost{{end}}" style="font-size:11px;padding:2px 8px">{{.}}</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if lt .CurrentPage .TotalAlarmPages}}
|
||||
<a href="/ui/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{add .CurrentPage 1}}" class="btn ghost" style="font-size:11px;padding:2px 6px">›</a>
|
||||
<a href="/ui/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{.TotalAlarmPages}}" class="btn ghost" style="font-size:11px;padding:2px 6px">»</a>
|
||||
<a href="/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{add .CurrentPage 1}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px">›</a>
|
||||
<a href="/alarms?from={{$from}}&to={{$to}}&per_page={{$pp}}&page={{.TotalAlarmPages}}&device={{$dev}}&rule_type={{$rt}}" class="btn ghost" style="font-size:11px;padding:2px 6px">»</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@ -87,4 +124,50 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
function postAlarmAction(id, action, body) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/alarms/" + id + "/" + action);
|
||||
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 204) {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
xhr.send(body || "");
|
||||
}
|
||||
document.querySelectorAll("[data-alarm-action]").forEach(function(el) {
|
||||
var action = el.getAttribute("data-alarm-action");
|
||||
var id = el.getAttribute("data-alarm-id");
|
||||
if (action === "acknowledge" || action === "resolve") {
|
||||
el.addEventListener("click", function() {
|
||||
postAlarmAction(id, action);
|
||||
});
|
||||
} else if (action === "severity") {
|
||||
el.addEventListener("change", function() {
|
||||
postAlarmAction(id, "severity", "severity=" + encodeURIComponent(el.value));
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var exportBtn = document.querySelector(".js-export-csv");
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener("click", function(){
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var exportParams = new URLSearchParams();
|
||||
if (params.get("from")) exportParams.set("from", params.get("from"));
|
||||
if (params.get("to")) exportParams.set("to", params.get("to"));
|
||||
if (params.get("device")) exportParams.set("device", params.get("device"));
|
||||
if (params.get("rule_type")) exportParams.set("rule_type", params.get("rule_type"));
|
||||
window.location.href = "/api/alarms/export?" + exportParams.toString();
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "overlay"}}<span>{{.AssetOverlay.Name}}</span></h2>
|
||||
</div>
|
||||
<a class="btn secondary" href="/ui/assets/overlays">返回调试参数列表</a>
|
||||
<a class="btn secondary" href="/assets/overlays">返回调试参数列表</a>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>调试参数</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
|
||||
|
||||
@ -7,14 +7,15 @@
|
||||
<div class="form-hint">标准调试参数应作为基线,复制后再做定制化修改。</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/assets/overlays?new=1">{{icon "apply"}}<span>新增调试参数</span></a>
|
||||
{{if .AssetOverlay}}
|
||||
{{if .AssetOverlay.ReadOnly}}
|
||||
<form method="post" action="/ui/assets/overlays/{{.AssetOverlay.Name}}/clone">
|
||||
<form method="post" action="/assets/overlays/{{.AssetOverlay.Name}}/clone">
|
||||
<button type="submit" class="btn secondary">{{icon "add"}}<span>复制标准调试参数</span></button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a class="btn secondary" href="/ui/assets/overlays?name={{.AssetOverlay.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/ui/assets/overlays/{{.AssetOverlay.Name}}/delete" onsubmit="return confirm('确认删除这个调试参数吗?');">
|
||||
<a class="btn secondary" href="/assets/overlays?name={{.AssetOverlay.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/assets/overlays/{{.AssetOverlay.Name}}/delete" onsubmit="return confirm('确认删除这个调试参数吗?');">
|
||||
<button type="submit" class="btn secondary">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@ -32,8 +33,8 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetOverlays}}
|
||||
<tr data-nav-row data-nav-href="/ui/assets/overlays?name={{.Name}}"{{if and $.AssetOverlay (eq $.AssetOverlay.Name .Name)}} class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/assets/overlays?name={{.Name}}">{{.Name}}</a></td>
|
||||
<tr data-nav-row data-nav-href="/assets/overlays?name={{.Name}}"{{if and $.AssetOverlay (eq $.AssetOverlay.Name .Name)}} class="selected"{{end}}>
|
||||
<td><a class="mono" href="/assets/overlays?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td>{{if .ReadOnly}}标准参数{{else}}用户参数{{end}}</td>
|
||||
</tr>
|
||||
@ -64,13 +65,13 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn secondary js-export-json"
|
||||
data-export-url="/ui/assets/overlays/{{.AssetOverlay.Name}}/export"
|
||||
data-export-url="/assets/overlays/{{.AssetOverlay.Name}}/export"
|
||||
data-default-filename="{{.AssetOverlay.Name}}.json"
|
||||
>{{icon "apply"}}<span>导出为 JSON</span></button>
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetOverlayEditing}}
|
||||
<form method="post" action="/ui/assets/overlays">
|
||||
<form method="post" action="/assets/overlays">
|
||||
<div class="field-grid">
|
||||
<label><span>调试参数名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetOverlay.Name}}" autofocus /></label>
|
||||
<label class="full"><span>描述</span><input name="description" value="{{.AssetOverlay.Description}}" /></label>
|
||||
@ -78,7 +79,7 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
|
||||
<a class="btn secondary" href="/ui/assets/overlays">{{icon "close"}}<span>取消</span></a>
|
||||
<a class="btn secondary" href="/assets/overlays">{{icon "close"}}<span>取消</span></a>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/plans?new=1">{{icon "apply"}}<span>新建场景</span></a>
|
||||
<a class="btn secondary" href="/plans?new=1">{{icon "apply"}}<span>新建场景</span></a>
|
||||
{{if .SelectedProfile}}
|
||||
<a class="btn secondary" href="/ui/plans?name={{.SelectedProfile}}&edit=1">编辑</a>
|
||||
<form method="post" action="/ui/plans/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景配置吗?');">
|
||||
<a class="btn secondary" href="/plans?name={{.SelectedProfile}}&edit=1">编辑</a>
|
||||
<form method="post" action="/plans/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景配置吗?');">
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@ -20,13 +20,13 @@
|
||||
<tr>
|
||||
<th>场景配置</th>
|
||||
<th>描述</th>
|
||||
<th>视频通道</th>
|
||||
<th>检测通道</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetProfiles}}
|
||||
<tr data-profile-row data-profile-href="/ui/plans?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/plans?name={{.Name}}">{{.Name}}</a></td>
|
||||
<tr data-profile-row data-profile-href="/plans?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
|
||||
<td><a class="mono" href="/plans?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td>{{len .Instances}}</td>
|
||||
</tr>
|
||||
@ -60,12 +60,12 @@
|
||||
<div class="actions compact">
|
||||
{{if .AssetProfileEditing}}
|
||||
<button type="submit">{{icon "apply"}}<span>保存场景配置</span></button>
|
||||
<a class="btn secondary" href="/ui/plans{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
<a class="btn secondary" href="/plans{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
{{end}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn secondary js-export-json"
|
||||
data-export-url="/ui/plans/{{.AssetProfileEditor.Name}}/export"
|
||||
data-export-url="/plans/{{.AssetProfileEditor.Name}}/export"
|
||||
data-default-filename="{{.AssetProfileEditor.Name}}.json"
|
||||
>{{icon "apply"}}<span>导出为 JSON</span></button>
|
||||
</div>
|
||||
@ -100,7 +100,7 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频通道</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>检测通道</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<span class="pill">{{len .AssetProfileEditor.Instances}} 路</span>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "template"}}<span>{{.AssetTemplate.Name}}</span></h2>
|
||||
</div>
|
||||
<a class="btn secondary" href="/ui/assets/templates">返回模板列表</a>
|
||||
<a class="btn secondary" href="/assets/templates">返回模板列表</a>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>模板名</span><strong class="mono">{{.AssetTemplate.Name}}</strong></div>
|
||||
|
||||
@ -8,16 +8,16 @@
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
{{if not .AssetTemplate.ReadOnly}}
|
||||
<form class="graph-save-form" method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/graph">
|
||||
<form class="graph-save-form" method="post" action="/assets/templates/{{.AssetTemplate.Name}}/graph">
|
||||
<input type="hidden" name="json" />
|
||||
{{if .TemplateCloneSource}}<input type="hidden" name="clone_source" value="{{.TemplateCloneSource}}" />{{end}}
|
||||
<button type="submit" class="primary">{{icon "apply"}}<span>保存模板</span></button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .AssetTemplate.ReadOnly}}
|
||||
<a class="btn secondary" href="/ui/assets/templates?clone={{.AssetTemplate.Name}}">{{icon "add"}}<span>复制后编辑</span></a>
|
||||
<a class="btn secondary" href="/assets/templates?clone={{.AssetTemplate.Name}}">{{icon "add"}}<span>复制后编辑</span></a>
|
||||
{{end}}
|
||||
<a class="btn ghost" href="/ui/assets/templates{{if .TemplateCloneSource}}?clone_source={{.TemplateCloneSource}}&clone_name={{.AssetTemplate.Name}}{{else}}?name={{.AssetTemplate.Name}}{{end}}">返回模板</a>
|
||||
<a class="btn ghost" href="/assets/templates{{if .TemplateCloneSource}}?clone_source={{.TemplateCloneSource}}&clone_name={{.AssetTemplate.Name}}{{else}}?name={{.AssetTemplate.Name}}{{end}}">返回模板</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
<div class="graph-editor" data-template-name="{{.AssetTemplate.Name}}">
|
||||
<aside class="graph-sidebar">
|
||||
<h3>节点库</h3>
|
||||
<div class="graph-node-palette-list" data-catalog-url="/ui/api/graph-node-types"></div>
|
||||
<div class="graph-node-palette-list" data-catalog-url="/api/graph-node-types"></div>
|
||||
</aside>
|
||||
|
||||
<section class="graph-canvas-wrap">
|
||||
@ -71,7 +71,7 @@
|
||||
</div>
|
||||
|
||||
<template id="graph-template-json">{{rawHTML .RawJSON}}</template>
|
||||
<script src="/ui/assets/graph_editor.js"></script>
|
||||
<script src="/assets/graph_editor.js"></script>
|
||||
</div>
|
||||
{{template "asset_tabs_end" .}}
|
||||
{{end}}
|
||||
|
||||
@ -10,12 +10,12 @@
|
||||
{{if .AssetTemplate}}
|
||||
{{if .TemplateCloneSource}}
|
||||
{{else if .AssetTemplate.ReadOnly}}
|
||||
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/clone">
|
||||
<form method="post" action="/assets/templates/{{.AssetTemplate.Name}}/clone">
|
||||
<button type="submit" class="btn secondary">{{icon "add"}}<span>复制标准模板</span></button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a class="btn secondary" href="/ui/assets/templates?name={{.AssetTemplate.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/delete" onsubmit="return confirm('确认删除这个用户模板吗?');">
|
||||
<a class="btn secondary" href="/assets/templates?name={{.AssetTemplate.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/assets/templates/{{.AssetTemplate.Name}}/delete" onsubmit="return confirm('确认删除这个用户模板吗?');">
|
||||
<button type="submit" class="btn secondary">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@ -34,8 +34,8 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetTemplates}}
|
||||
<tr data-nav-row data-nav-href="/ui/assets/templates?name={{.Name}}"{{if and $.AssetTemplate (eq $.AssetTemplate.Name .Name)}} class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/assets/templates?name={{.Name}}">{{.Name}}</a></td>
|
||||
<tr data-nav-row data-nav-href="/assets/templates?name={{.Name}}"{{if and $.AssetTemplate (eq $.AssetTemplate.Name .Name)}} class="selected"{{end}}>
|
||||
<td><a class="mono" href="/assets/templates?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td>{{.NodeCount}}节点 / {{.EdgeCount}}连线</td>
|
||||
<td>{{if .ReadOnly}}标准模板{{else}}用户模板{{end}}</td>
|
||||
@ -64,19 +64,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/assets/templates/{{.AssetTemplate.Name}}/graph{{if .TemplateCloneSource}}?clone_source={{.TemplateCloneSource}}{{end}}">{{icon "assets"}}<span>{{if .AssetTemplate.ReadOnly}}可视化预览{{else if .TemplateCloneSource}}进入可视化编辑{{else}}可视化编辑{{end}}</span></a>
|
||||
<a class="btn secondary" href="/assets/templates/{{.AssetTemplate.Name}}/graph{{if .TemplateCloneSource}}?clone_source={{.TemplateCloneSource}}{{end}}">{{icon "assets"}}<span>{{if .AssetTemplate.ReadOnly}}可视化预览{{else if .TemplateCloneSource}}进入可视化编辑{{else}}可视化编辑{{end}}</span></a>
|
||||
{{if not .AssetTemplate.ReadOnly}}
|
||||
<a class="btn secondary" href="/assets/templates/{{.AssetTemplate.Name}}/settings">{{icon "edit"}}<span>参数调整</span></a>
|
||||
{{end}}
|
||||
{{if not .TemplateCloneSource}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn secondary js-export-json"
|
||||
data-export-url="/ui/assets/templates/{{.AssetTemplate.Name}}/export"
|
||||
data-export-url="/assets/templates/{{.AssetTemplate.Name}}/export"
|
||||
data-default-filename="{{.AssetTemplate.Name}}.json"
|
||||
>{{icon "apply"}}<span>导出为 JSON</span></button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetTemplateEditing}}
|
||||
<form method="post" action="{{if .TemplateCloneSource}}/ui/assets/templates/create{{else}}/ui/assets/templates/{{.AssetTemplate.Name}}/rename{{end}}">
|
||||
<form method="post" action="{{if .TemplateCloneSource}}/assets/templates/create{{else}}/assets/templates/{{.AssetTemplate.Name}}/rename{{end}}">
|
||||
<div class="field-grid">
|
||||
<label><span>模板名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetTemplate.Name}}" class="mono" autofocus /></label>
|
||||
<label><span>模板类型</span><input value="{{if .AssetTemplate.ReadOnly}}标准模板(只读){{else}}用户模板{{end}}" readonly /></label>
|
||||
@ -98,7 +101,7 @@
|
||||
{{end}}
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn secondary">{{icon "apply"}}<span>{{if .TemplateCloneSource}}创建模板{{else}}保存{{end}}</span></button>
|
||||
<a class="btn secondary" href="/ui/assets/templates">{{icon "close"}}<span>取消</span></a>
|
||||
<a class="btn secondary" href="/assets/templates">{{icon "close"}}<span>取消</span></a>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
|
||||
@ -2,19 +2,19 @@
|
||||
<div class="card-tabs asset-tab-wrap">
|
||||
<ul class="nav nav-tabs asset-tabs" role="tablist" aria-label="配置管理页面">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="/ui/assets" class="nav-link{{if eq .AssetTab "overview"}} active{{end}}" role="tab" {{if eq .AssetTab "overview"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>总览</a>
|
||||
<a href="/assets" class="nav-link{{if eq .AssetTab "overview"}} active{{end}}" role="tab" {{if eq .AssetTab "overview"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>总览</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="/ui/assets/video-sources" class="nav-link{{if eq .AssetTab "video-sources"}} active{{end}}" role="tab" {{if eq .AssetTab "video-sources"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>视频源</a>
|
||||
<a href="/assets/video-sources" class="nav-link{{if eq .AssetTab "video-sources"}} active{{end}}" role="tab" {{if eq .AssetTab "video-sources"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>视频源</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="/ui/assets/templates" class="nav-link{{if eq .AssetTab "templates"}} active{{end}}" role="tab" {{if eq .AssetTab "templates"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>识别模板</a>
|
||||
<a href="/assets/templates" class="nav-link{{if eq .AssetTab "templates"}} active{{end}}" role="tab" {{if eq .AssetTab "templates"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>识别模板</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="/ui/assets/integrations" class="nav-link{{if eq .AssetTab "integrations"}} active{{end}}" role="tab" {{if eq .AssetTab "integrations"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>第三方服务</a>
|
||||
<a href="/assets/integrations" class="nav-link{{if eq .AssetTab "integrations"}} active{{end}}" role="tab" {{if eq .AssetTab "integrations"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>第三方服务</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="/ui/assets/overlays" class="nav-link{{if eq .AssetTab "overlays"}} active{{end}}" role="tab" {{if eq .AssetTab "overlays"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>调试参数</a>
|
||||
<a href="/assets/overlays" class="nav-link{{if eq .AssetTab "overlays"}} active{{end}}" role="tab" {{if eq .AssetTab "overlays"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>调试参数</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
@ -38,13 +38,13 @@
|
||||
<h2 class="title-with-icon">{{icon "service"}}<span>第三方服务列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/assets/integrations?new=1">{{icon "apply"}}<span>新增服务</span></a>
|
||||
{{if .AssetIntegration.Name}}
|
||||
<a class="btn secondary" href="/ui/assets/integrations?name={{.AssetIntegration.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/ui/assets/integrations/{{.AssetIntegration.Name}}/delete">
|
||||
<a class="btn secondary" href="/assets/integrations?new=1">{{icon "apply"}}<span>新增服务</span></a>
|
||||
{{if .AssetIntegration}}{{if .AssetIntegration.Name}}
|
||||
<a class="btn secondary" href="/assets/integrations?name={{.AssetIntegration.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/assets/integrations/{{.AssetIntegration.Name}}/delete" onsubmit="return confirm('确认删除服务 {{.AssetIntegration.Name}}?')">
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@ -61,8 +61,8 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetIntegrations}}
|
||||
<tr data-nav-row data-nav-href="/ui/assets/integrations?name={{.Name}}"{{if and $.AssetIntegration (eq $.AssetIntegration.Name .Name)}} class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/assets/integrations?name={{.Name}}">{{.Name}}</a></td>
|
||||
<tr data-nav-row data-nav-href="/assets/integrations?name={{.Name}}"{{if and $.AssetIntegration (eq $.AssetIntegration.Name .Name)}} class="selected"{{end}}>
|
||||
<td><a class="mono" href="/assets/integrations?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{.TypeLabel}}</td>
|
||||
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .AddressSummary}}{{.AddressSummary}}{{else}}-{{end}}</td>
|
||||
@ -77,7 +77,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetIntegration}}
|
||||
<form method="post" action="/ui/assets/integrations">
|
||||
<form method="post" action="/assets/integrations">
|
||||
<div class="card editor-state {{if .AssetIntegrationEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
@ -95,7 +95,7 @@
|
||||
</div>
|
||||
{{if .AssetIntegrationEditing}}
|
||||
<div class="field-grid">
|
||||
<label><span>服务名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetIntegration.Name}}" autofocus /></label>
|
||||
<label><span>服务名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetIntegration.Name}}" pattern="[A-Za-z0-9_.-]+" title="只允许英文、数字和 ._- " required autofocus /></label>
|
||||
<label>
|
||||
<span>服务类型<span class="required-mark">*</span></span>
|
||||
<select name="type" onchange="toggleIntegrationFields(this.value)">
|
||||
@ -136,7 +136,7 @@
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">{{icon "apply"}}<span>保存</span></button>
|
||||
<a class="btn secondary" href="/ui/assets/integrations?name={{.AssetIntegration.Name}}">{{icon "close"}}<span>取消</span></a>
|
||||
<a class="btn secondary" href="/assets/integrations?name={{.AssetIntegration.Name}}">{{icon "close"}}<span>取消</span></a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-sheet">
|
||||
@ -177,20 +177,27 @@ function toggleIntegrationFields(type) {
|
||||
toggleIntegrationFields('{{.AssetIntegration.Type}}');
|
||||
</script>
|
||||
{{else if eq .AssetTab "video-sources"}}
|
||||
{{if .Message}}<div class="msg">{{.Message}}</div>{{end}}
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频源列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/assets/video-sources?new=1">{{icon "apply"}}<span>新增视频源</span></a>
|
||||
{{if .AssetVideoSource.Name}}
|
||||
<a class="btn secondary" href="/ui/assets/video-sources?name={{.AssetVideoSource.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/ui/assets/video-sources/{{.AssetVideoSource.Name}}/delete">
|
||||
<a class="btn secondary" href="/assets/video-sources/template" title="下载 CSV 模板">{{icon "apply"}}<span>模板下载</span></a>
|
||||
<button class="btn secondary" type="button" onclick="document.getElementById('csv-import-input').click()">{{icon "apply"}}<span>CSV 导入</span></button>
|
||||
<a class="btn secondary" href="/assets/video-sources?new=1">{{icon "apply"}}<span>新增视频源</span></a>
|
||||
{{if .AssetVideoSource}}{{if .AssetVideoSource.Name}}
|
||||
<a class="btn secondary" href="/assets/video-sources?name={{.AssetVideoSource.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
|
||||
<form method="post" action="/assets/video-sources/{{.AssetVideoSource.ID}}/delete" onsubmit="return confirm('确认删除视频源 {{.AssetVideoSource.Name}}?')">
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}{{end}}
|
||||
</div>
|
||||
<form id="csv-import-form" method="post" action="/assets/video-sources/import" enctype="multipart/form-data" style="display:none">
|
||||
<input type="file" id="csv-import-input" name="file" accept=".csv" onchange="this.form.submit()">
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
@ -207,8 +214,8 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetVideoSources}}
|
||||
<tr data-nav-row data-nav-href="/ui/assets/video-sources?name={{.Name}}"{{if and $.AssetVideoSource (eq $.AssetVideoSource.Name .Name)}} class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/assets/video-sources?name={{.Name}}">{{.Name}}</a></td>
|
||||
<tr data-nav-row data-nav-href="/assets/video-sources?name={{.Name}}"{{if $.AssetVideoSource}}{{if eq $.AssetVideoSource.Name .Name}} class="selected"{{end}}{{end}}>
|
||||
<td><a class="mono" href="/assets/video-sources?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{.SourceTypeLabel}}</td>
|
||||
<td>{{if .Area}}{{.Area}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .Config.URL}}{{.Config.URL}}{{else}}-{{end}}</td>
|
||||
@ -224,7 +231,9 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetVideoSource}}
|
||||
<form method="post" action="/ui/assets/video-sources">
|
||||
<form method="post" action="/assets/video-sources">
|
||||
{{if .AssetVideoSource.ID}}<input type="hidden" name="id" value="{{.AssetVideoSource.ID}}">{{end}}
|
||||
{{if .AssetVideoSource.Name}}<input type="hidden" name="original_name" value="{{.AssetVideoSource.Name}}">{{end}}
|
||||
<div class="card editor-state {{if .AssetVideoSourceEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
@ -267,7 +276,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit">{{icon "apply"}}<span>保存</span></button>
|
||||
<a class="btn secondary" href="/ui/assets/video-sources?name={{.AssetVideoSource.Name}}">{{icon "close"}}<span>取消</span></a>
|
||||
<a class="btn secondary" href="/assets/video-sources?name={{.AssetVideoSource.Name}}">{{icon "close"}}<span>取消</span></a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-sheet">
|
||||
@ -321,7 +330,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
|
||||
</div>
|
||||
<div class="asset-list">
|
||||
{{range .AssetTemplates}}
|
||||
<a class="asset-row asset-link" href="/ui/assets/templates/{{.Name}}">
|
||||
<a class="asset-row asset-link" href="/assets/templates/{{.Name}}">
|
||||
<span class="mono">{{.Name}}</span>
|
||||
<span class="muted small">{{.NodeCount}}节点 / {{.EdgeCount}}连线</span>
|
||||
</a>
|
||||
@ -341,7 +350,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
|
||||
</div>
|
||||
<div class="asset-list">
|
||||
{{range .AssetVideoSources}}
|
||||
<a class="asset-row asset-link" href="/ui/assets/video-sources?name={{.Name}}">
|
||||
<a class="asset-row asset-link" href="/assets/video-sources?name={{.Name}}">
|
||||
<span class="mono">{{.Name}}</span>
|
||||
<span class="muted small">{{if .Area}}{{.Area}} / {{end}}{{if .Config.Resolution}}{{.Config.Resolution}}{{else}}未设置分辨率{{end}}</span>
|
||||
</a>
|
||||
@ -361,7 +370,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
|
||||
</div>
|
||||
<div class="asset-list">
|
||||
{{range .AssetIntegrations}}
|
||||
<a class="asset-row asset-link" href="/ui/assets/integrations">
|
||||
<a class="asset-row asset-link" href="/assets/integrations">
|
||||
<span>{{.Name}}</span>
|
||||
<span class="muted small">{{if .AddressSummary}}{{.AddressSummary}}{{else}}{{.TypeLabel}}{{end}}</span>
|
||||
</a>
|
||||
@ -381,7 +390,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
|
||||
</div>
|
||||
<div class="asset-list">
|
||||
{{range .AssetOverlays}}
|
||||
<a class="asset-row asset-link" href="/ui/assets/overlays/{{.Name}}">
|
||||
<a class="asset-row asset-link" href="/assets/overlays/{{.Name}}">
|
||||
<span>{{.Name}}</span>
|
||||
<span class="muted small">{{.OverrideTargetNum}} 个目标</span>
|
||||
</a>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
{{template "device_nav" .}}
|
||||
<div class="card">
|
||||
<h2>识别方案配置</h2>
|
||||
<div class="muted small">节点:<a class="mono" href="/ui/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>({{.Device.DeviceName}})。</div>
|
||||
<div class="muted small">节点:<a class="mono" href="/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>({{.Device.DeviceName}})。</div>
|
||||
</div>
|
||||
|
||||
<div class="card config-wizard">
|
||||
@ -40,8 +40,8 @@
|
||||
<h2 id="cfg-status-title">加载中</h2>
|
||||
<div class="muted small" id="cfg-status-msg">正在读取配置结构和当前状态...</div>
|
||||
<div class="actions" id="cfg-help" style="display:none;margin-top:10px">
|
||||
<a href="/ui/devices">去设备列表重新扫描</a>
|
||||
<a href="/ui/devices/{{.Device.DeviceID}}/config-ui">打开高级 JSON</a>
|
||||
<a href="/devices">去设备列表重新扫描</a>
|
||||
<a href="/devices/{{.Device.DeviceID}}/config-ui">打开高级 JSON</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -135,7 +135,7 @@
|
||||
const idFromPath = (() => {
|
||||
try{
|
||||
const parts = String(location.pathname || '').split('/');
|
||||
// /ui/devices/{id}/config-friendly
|
||||
// /devices/{id}/config-friendly
|
||||
if(parts.length > 3 && parts[1] === 'ui' && parts[2] === 'devices') return decodeURIComponent(parts[3] || '');
|
||||
}catch(e){}
|
||||
return '';
|
||||
@ -412,8 +412,8 @@
|
||||
setStatus('加载中', '正在读取配置结构和当前状态...');
|
||||
let schema, state;
|
||||
try{
|
||||
const schemaUrl = `${apiBase}/config/ui/schema`;
|
||||
const stateUrl = `${apiBase}/config/ui/state`;
|
||||
const schemaUrl = `${apiBase}/config/schema`;
|
||||
const stateUrl = `${apiBase}/config/state`;
|
||||
const [s1, s2] = await Promise.all([fetch(schemaUrl), fetch(stateUrl)]);
|
||||
const t1 = await s1.text();
|
||||
const t2 = await s2.text();
|
||||
@ -514,7 +514,7 @@
|
||||
try{
|
||||
const payload = buildPayload();
|
||||
renderPayload();
|
||||
const out = await postJson(`${apiBase}/config/ui/plan`, payload);
|
||||
const out = await postJson(`${apiBase}/config/plan`, payload);
|
||||
resultEl.style.display = '';
|
||||
resultPre.textContent = pretty(out);
|
||||
}catch(e){
|
||||
@ -530,7 +530,7 @@
|
||||
try{
|
||||
const payload = buildPayload();
|
||||
renderPayload();
|
||||
const out = await postJson(`${apiBase}/config/ui/apply`, payload);
|
||||
const out = await postJson(`${apiBase}/config/apply`, payload);
|
||||
resultEl.style.display = '';
|
||||
resultPre.textContent = pretty(out);
|
||||
}catch(e){
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-preview">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/config-preview">
|
||||
<div class="info-list">
|
||||
<div><span>设备</span><strong class="mono">{{.Device.DeviceID}}</strong></div>
|
||||
<div><span>场景模板</span><strong class="mono">{{if .DeviceAssignment}}{{.DeviceAssignment.ProfileName}}{{else}}-{{end}}</strong></div>
|
||||
@ -17,7 +17,7 @@
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">生成预览</button>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}#device-config">返回设备详情</a>
|
||||
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}#device-config">返回设备详情</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
{{template "device_nav" .}}
|
||||
<div class="card">
|
||||
<h2>高级识别配置</h2>
|
||||
<div class="muted small">节点:<a class="mono" href="/ui/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>({{.Device.DeviceName}})。</div>
|
||||
<div class="muted small">节点:<a class="mono" href="/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>({{.Device.DeviceName}})。</div>
|
||||
</div>
|
||||
|
||||
<div class="card advanced">
|
||||
@ -21,11 +21,11 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>编辑目标识别配置</h2>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-ui/plan">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/config-ui/plan">
|
||||
<textarea name="json" spellcheck="false">{{if .RawJSON}}{{.RawJSON}}{{else}}{"instances":[]}{{end}}</textarea>
|
||||
<div class="actions" style="margin-top:10px">
|
||||
<button type="submit">预览变更</button>
|
||||
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-ui/apply">部署到设备</button>
|
||||
<button type="submit" formaction="/devices/{{.Device.DeviceID}}/config-ui/apply">部署到设备</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -34,14 +34,14 @@
|
||||
<h2>人脸库</h2>
|
||||
<pre>{{.FaceGalleryJSON}}</pre>
|
||||
<div class="row" style="margin-top:10px; gap:16px">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/upload" enctype="multipart/form-data" class="row">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/face-gallery/upload" enctype="multipart/form-data" class="row">
|
||||
<div>
|
||||
<div class="muted small">人脸库文件</div>
|
||||
<input type="file" name="file" />
|
||||
</div>
|
||||
<div style="align-self:end"><button type="submit">上传人脸库</button></div>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/reload" style="align-self:end">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/face-gallery/reload" style="align-self:end">
|
||||
<button type="submit">重新加载</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
128
internal/web/ui/templates/config_versions.html
Normal file
128
internal/web/ui/templates/config_versions.html
Normal file
@ -0,0 +1,128 @@
|
||||
{{define "config_versions"}}
|
||||
{{template "device_header" .}}
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>配置历史</h2>
|
||||
<div class="muted small">每次下发配置时自动保存的快照,可对比任意两个版本。</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}">← 返回设备</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ConfigVersions}}
|
||||
<div style="margin-bottom:16px">
|
||||
<span style="font-size:12px;color:var(--muted);margin-right:8px">对比:</span>
|
||||
<select id="version-a" style="width:200px;font-size:12px">
|
||||
<option value="">选择版本 A</option>
|
||||
{{range .ConfigVersions}}
|
||||
<option value="{{.ID}}">{{formatTime .CreatedAt}} · {{shortID .ConfigID}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<span style="margin:0 8px;color:var(--muted)">vs</span>
|
||||
<select id="version-b" style="width:200px;font-size:12px">
|
||||
<option value="">选择版本 B</option>
|
||||
{{range .ConfigVersions}}
|
||||
<option value="{{.ID}}">{{formatTime .CreatedAt}} · {{shortID .ConfigID}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="diff-result" style="display:none">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px" id="label-a"></div>
|
||||
<pre id="diff-a" style="height:400px;overflow:auto;font-size:11px;line-height:1.5;margin:0"></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px" id="label-b"></div>
|
||||
<pre id="diff-b" style="height:400px;overflow:auto;font-size:11px;line-height:1.5;margin:0"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" style="margin-top:16px">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>配置 ID</th><th>大小</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .ConfigVersions}}
|
||||
<tr>
|
||||
<td class="mono small">{{formatTime .CreatedAt}}</td>
|
||||
<td class="mono small">{{shortID .ConfigID}}</td>
|
||||
<td class="small">{{len .ConfigJSON}} 字符</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">暂无配置历史</div>
|
||||
<div class="muted">通过管控台或部署向导下发配置后,快照将自动保存在这里。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var selA = document.getElementById("version-a");
|
||||
var selB = document.getElementById("version-b");
|
||||
var diffResult = document.getElementById("diff-result");
|
||||
if (!selA || !selB) return;
|
||||
|
||||
function formatJSON(s) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2);
|
||||
} catch(e) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function compare() {
|
||||
var a = selA.value, b = selB.value;
|
||||
if (!a || !b) { diffResult.style.display = "none"; return; }
|
||||
var labelA = selA.options[selA.selectedIndex].text;
|
||||
var labelB = selB.options[selB.selectedIndex].text;
|
||||
document.getElementById("label-a").textContent = labelA;
|
||||
document.getElementById("label-b").textContent = labelB;
|
||||
|
||||
Promise.all([
|
||||
fetch("/devices/{{.Device.DeviceID}}/config-versions/"+a).then(function(r){return r.json()}),
|
||||
fetch("/devices/{{.Device.DeviceID}}/config-versions/"+b).then(function(r){return r.json()})
|
||||
]).then(function(results){
|
||||
var linesA = formatJSON(results[0].config_json).split("\n");
|
||||
var linesB = formatJSON(results[1].config_json).split("\n");
|
||||
var maxLen = Math.max(linesA.length, linesB.length);
|
||||
var outA = [], outB = [];
|
||||
for (var i = 0; i < maxLen; i++) {
|
||||
var la = linesA[i] || "", lb = linesB[i] || "";
|
||||
if (la === lb) {
|
||||
outA.push(la);
|
||||
outB.push(lb);
|
||||
} else {
|
||||
outA.push('\x1b[41m' + la + '\x1b[0m');
|
||||
outB.push('\x1b[42m' + lb + '\x1b[0m');
|
||||
}
|
||||
}
|
||||
var preA = document.getElementById("diff-a");
|
||||
var preB = document.getElementById("diff-b");
|
||||
function render(lines) {
|
||||
return lines.map(function(l){
|
||||
if (l.indexOf('\x1b[41m') >= 0) return '<span style="background:rgba(220,70,70,.25);display:block">' + l.replace(/\x1b\[41m/g,"").replace(/\x1b\[0m/g,"") + '</span>';
|
||||
if (l.indexOf('\x1b[42m') >= 0) return '<span style="background:rgba(70,180,70,.25);display:block">' + l.replace(/\x1b\[42m/g,"").replace(/\x1b\[0m/g,"") + '</span>';
|
||||
return '<span style="display:block">' + l + '</span>';
|
||||
}).join("");
|
||||
}
|
||||
preA.innerHTML = render(outA);
|
||||
preB.innerHTML = render(outB);
|
||||
diffResult.style.display = "";
|
||||
});
|
||||
}
|
||||
|
||||
selA.addEventListener("change", compare);
|
||||
selB.addEventListener("change", compare);
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@ -21,10 +21,10 @@
|
||||
<div class="tab-content" style="padding:0">
|
||||
|
||||
<div id="console-panel-device" class="card tab-pane active show" style="padding:16px;margin:0;border-top-left-radius:0">
|
||||
<form id="console-form" method="post" action="/ui/console" style="display:contents">
|
||||
<form id="console-form" method="post" action="/console" style="display:contents">
|
||||
|
||||
{{range $i, $cd := .ConsoleDevices}}
|
||||
<div class="console-device-row">
|
||||
<div class="console-device-row" data-device-id="{{$cd.Device.DeviceID}}">
|
||||
<div class="console-header">
|
||||
<span class="status-dot {{if $cd.Device.Online}}ok{{else}}bad{{end}}"></span>
|
||||
<span class="console-device-name">{{$cd.Device.DisplayName}}</span>
|
||||
@ -68,6 +68,7 @@
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="console-section">
|
||||
<div class="console-section-title">设备负载</div>
|
||||
<div class="console-metrics">
|
||||
@ -130,7 +131,7 @@
|
||||
</div>
|
||||
<div class="video-card-info">
|
||||
<div class="video-card-name">{{$ch.SourceName}}</div>
|
||||
<div class="video-card-device">{{$cd.Device.DisplayName}}</div>
|
||||
<div class="video-card-device">{{$cd.Device.DisplayName}} · {{$cd.Device.IP}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -142,7 +143,7 @@
|
||||
</div>
|
||||
<div style="padding:8px 10px">
|
||||
<div style="font-size:13px;font-weight:500;margin-bottom:2px">{{$s.Name}}</div>
|
||||
<a href="#" style="font-size:11px;color:var(--primary);text-decoration:none">+ 分配到设备</a>
|
||||
<a href="#" style="font-size:11px;color:var(--primary);text-decoration:none" onclick="event.preventDefault();showDevicePicker('{{$s.Name}}');">+ 分配到设备</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -152,7 +153,7 @@
|
||||
</div><!-- tab-content -->
|
||||
</div><!-- card-tabs -->
|
||||
|
||||
<script src="/ui/assets/vendor/hls.min.js"></script>
|
||||
<script src="/assets/vendor/hls.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
// Tab switching
|
||||
@ -193,14 +194,14 @@
|
||||
|
||||
var deviceIds = Object.keys(deviceChannels);
|
||||
deviceIds.forEach(function(did) {
|
||||
fetch('/ui/api/monitor/channels?device_id=' + encodeURIComponent(did))
|
||||
fetch('/api/monitor/channels?device_id=' + encodeURIComponent(did))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.channels) return;
|
||||
data.channels.forEach(function(ch) {
|
||||
var video = deviceChannels[did][ch.name];
|
||||
if (!video) return;
|
||||
var proxyUrl = '/ui/hls/' + encodeURIComponent(did) + '/hls/' + encodeURIComponent(ch.name) + '/index.m3u8';
|
||||
var proxyUrl = '/hls/' + encodeURIComponent(did) + '/hls/' + encodeURIComponent(ch.name) + '/index.m3u8';
|
||||
if (Hls.isSupported()) {
|
||||
var hls = new Hls();
|
||||
hls.loadSource(proxyUrl);
|
||||
@ -241,14 +242,34 @@
|
||||
}
|
||||
});
|
||||
if (visible.length === 0) { alert('暂无未分配视频源'); return; }
|
||||
var html = '<div class="source-assign-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center" onclick="this.remove()"><div style="background:var(--surface);border-radius:8px;padding:20px;min-width:300px" onclick="event.stopPropagation()"><div style="font-size:14px;font-weight:600;margin-bottom:12px">为设备添加视频源</div>';
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center';
|
||||
overlay.addEventListener('click',function(){overlay.remove()});
|
||||
var box = document.createElement('div');
|
||||
box.style.cssText = 'background:var(--surface);border-radius:8px;padding:20px;min-width:300px;max-width:400px';
|
||||
box.addEventListener('click',function(e){e.stopPropagation()});
|
||||
box.innerHTML = '<div style="font-size:14px;font-weight:600;margin-bottom:12px">为设备添加视频源 (' + visible.length + '路可用)</div>' +
|
||||
'<div style="max-height:60vh;overflow-y:auto;margin-bottom:0"></div>';
|
||||
var list = box.querySelector('div:last-child');
|
||||
visible.forEach(function(name) {
|
||||
html += '<div style="padding:8px 12px;cursor:pointer;border-radius:4px;margin-bottom:4px" onmouseover="this.style.background=\'var(--surface-soft)\'" onmouseout="this.style.background=\'transparent\'" onclick="addSourceToDeviceForm(\'' + deviceID + '\', \'' + name + '\');this.closest(\'.source-assign-overlay\').remove()">' + name + '</div>';
|
||||
var item = document.createElement('div');
|
||||
item.textContent = name;
|
||||
item.style.cssText = 'padding:8px 12px;cursor:pointer;border-radius:4px;margin-bottom:2px;font-size:13px';
|
||||
item.addEventListener('mouseover',function(){item.style.background='var(--surface-soft)'});
|
||||
item.addEventListener('mouseout',function(){item.style.background='transparent'});
|
||||
item.addEventListener('click',function(){
|
||||
addSourceToDeviceForm(deviceID, name);
|
||||
overlay.remove();
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
html += '<div style="margin-top:12px;text-align:right"><button class="btn" style="font-size:12px" onclick="this.closest(\'.source-assign-overlay\').remove()">取消</button></div></div></div>';
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
document.body.appendChild(div.firstElementChild);
|
||||
var footer = document.createElement('div');
|
||||
footer.style.cssText = 'margin-top:12px;text-align:right';
|
||||
footer.innerHTML = '<button class="btn" style="font-size:12px">取消</button>';
|
||||
footer.querySelector('button').addEventListener('click',function(){overlay.remove()});
|
||||
box.appendChild(footer);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
};
|
||||
|
||||
function addSourceToDeviceForm(deviceID, sourceName) {
|
||||
@ -261,28 +282,61 @@
|
||||
input.name = 'device_' + deviceID + '_source';
|
||||
input.value = sourceName;
|
||||
row.appendChild(input);
|
||||
// Hide source card
|
||||
// Visual badge on device row
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'console-source-badge';
|
||||
badge.textContent = sourceName;
|
||||
badge.style.cssText = 'display:inline-block;background:var(--primary-soft);color:var(--primary-strong);padding:1px 6px;border-radius:3px;font-size:10px;margin:2px 4px 2px 0';
|
||||
badge.setAttribute('data-source', sourceName);
|
||||
var header = row.querySelector('.console-header');
|
||||
if (header) header.appendChild(badge);
|
||||
// Mark source card as pending instead of hiding
|
||||
var card = document.querySelector('.console-source-assign[data-source="' + sourceName + '"]');
|
||||
if (card) {
|
||||
var sc = card.closest('.console-source-card');
|
||||
if (sc) sc.style.display = 'none';
|
||||
if (sc) {
|
||||
sc.style.opacity = '0.5';
|
||||
sc.style.borderStyle = 'dashed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showDevicePicker(sourceName) {
|
||||
var rows = document.querySelectorAll('.console-device-row');
|
||||
if (rows.length === 0) { alert('暂无可分配设备'); return; }
|
||||
var html = '<div class="source-assign-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center" onclick="this.remove()"><div style="background:var(--surface);border-radius:8px;padding:20px;min-width:300px;max-width:400px" onclick="event.stopPropagation()"><div style="font-size:14px;font-weight:600;margin-bottom:12px">分配 \'' + sourceName + '\' 到设备</div>';
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center';
|
||||
overlay.addEventListener('click',function(){overlay.remove()});
|
||||
var box = document.createElement('div');
|
||||
box.style.cssText = 'background:var(--surface);border-radius:8px;padding:20px;min-width:300px;max-width:400px';
|
||||
box.addEventListener('click',function(e){e.stopPropagation()});
|
||||
box.innerHTML = '<div style="font-size:14px;font-weight:600;margin-bottom:12px">分配 "' + sourceName + '" 到设备</div>' +
|
||||
'<div style="max-height:60vh;overflow-y:auto;margin-bottom:0"></div>';
|
||||
var list = box.querySelector('div:last-child');
|
||||
rows.forEach(function(row) {
|
||||
var did = row.getAttribute('data-device-id');
|
||||
var nameEl = row.querySelector('.console-device-name');
|
||||
var dname = nameEl ? nameEl.textContent : did;
|
||||
html += '<div style="padding:8px 12px;cursor:pointer;border-radius:4px;margin-bottom:4px" onmouseover="this.style.background=\'var(--surface-soft)\'" onmouseout="this.style.background=\'transparent\'" onclick="addSourceToDeviceForm(\'' + did + '\', \'' + sourceName + '\');this.closest(\'.source-assign-overlay\').remove()">' + dname + ' <span style="font-size:11px;color:var(--muted)">' + did + '</span></div>';
|
||||
var metaEl = row.querySelector('.console-device-meta');
|
||||
var ip = metaEl ? (metaEl.textContent.match(/[\d.]+/) || [''])[0] : '';
|
||||
var item = document.createElement('div');
|
||||
item.style.cssText = 'padding:8px 12px;cursor:pointer;border-radius:4px;margin-bottom:2px;font-size:13px';
|
||||
item.innerHTML = dname + ' <span style="font-size:11px;color:var(--muted)">' + (ip || did) + '</span>';
|
||||
item.addEventListener('mouseover',function(){item.style.background='var(--surface-soft)'});
|
||||
item.addEventListener('mouseout',function(){item.style.background='transparent'});
|
||||
item.addEventListener('click',function(){
|
||||
addSourceToDeviceForm(did, sourceName);
|
||||
overlay.remove();
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
html += '<div style="margin-top:12px;text-align:right"><button class="btn" style="font-size:12px" onclick="this.closest(\'.source-assign-overlay\').remove()">取消</button></div></div></div>';
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
document.body.appendChild(div.firstElementChild);
|
||||
var footer = document.createElement('div');
|
||||
footer.style.cssText = 'margin-top:12px;text-align:right';
|
||||
footer.innerHTML = '<button class="btn" style="font-size:12px">取消</button>';
|
||||
footer.querySelector('button').addEventListener('click',function(){overlay.remove()});
|
||||
box.appendChild(footer);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// ---- dirty state tracking ----
|
||||
@ -344,16 +398,23 @@
|
||||
var confirmHandler = function() {
|
||||
if (!isDirty()) return;
|
||||
var summary = '';
|
||||
var dirtyDevs = [];
|
||||
document.querySelectorAll('.console-device-row').forEach(function(row) {
|
||||
var did = row.getAttribute('data-device-id');
|
||||
var feats = [];
|
||||
row.querySelectorAll('input[type=checkbox]:checked').forEach(function(cb) { feats.push(cb.value); });
|
||||
if (feats.sort().join(',') === (initialFeatures[did]||[]).sort().join(',')) {
|
||||
// Check sources too - device may be dirty from source changes only
|
||||
var srcs = [];
|
||||
row.querySelectorAll('input[type=hidden][name^=device_]').forEach(function(h) { srcs.push(h.value); });
|
||||
if (srcs.sort().join(',') === (initialSources[did]||[]).sort().join(',')) return;
|
||||
}
|
||||
dirtyDevs.push(did);
|
||||
var nameEl = row.querySelector('.console-device-name');
|
||||
var dname = nameEl ? nameEl.textContent : did;
|
||||
summary += '<div style="font-weight:600;margin:8px 0 4px">' + dname + '</div>';
|
||||
row.querySelectorAll('input[type=checkbox]:checked').forEach(function(cb) {
|
||||
summary += '<div style="font-size:12px;padding-left:12px">☑ ' + cb.value + '</div>';
|
||||
});
|
||||
row.querySelectorAll('input[type=hidden][name^=device_]').forEach(function(h) {
|
||||
summary += '<div style="font-size:12px;padding-left:12px;color:var(--muted)">🎥 ' + h.value + '</div>';
|
||||
feats.forEach(function(f) {
|
||||
summary += '<div style="font-size:12px;padding-left:12px">☑ ' + f + '</div>';
|
||||
});
|
||||
});
|
||||
if (!summary) { alert('未选择任何检测功能或视频源'); return; }
|
||||
@ -365,7 +426,7 @@
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center';
|
||||
overlay.innerHTML = '<div style="background:var(--surface);border-radius:8px;padding:24px;min-width:360px;max-width:500px" onclick="event.stopPropagation()">' +
|
||||
'<div style="font-size:16px;font-weight:600;margin-bottom:4px">确认下发配置</div>' +
|
||||
'<div style="font-size:12px;color:var(--muted);margin-bottom:16px">以下配置将被渲染并下发到设备,media-server 将热更新。</div>' +
|
||||
'<div style="font-size:12px;color:var(--muted);margin-bottom:16px">以下配置将被渲染并下发到设备,edge-server 将热更新。</div>' +
|
||||
summary +
|
||||
'<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">' +
|
||||
'<button class="btn secondary" style="font-size:12px" onclick="this.closest(\'.confirm-overlay\').remove()">取消</button>' +
|
||||
@ -374,6 +435,12 @@
|
||||
overlay.addEventListener('click', function() { overlay.remove(); });
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById('confirm-deploy-btn').addEventListener('click', function() {
|
||||
if (dirtyDevs.length === 0) { overlay.remove(); return; }
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'dirty_device_ids';
|
||||
input.value = dirtyDevs.join(',');
|
||||
document.getElementById('console-form').appendChild(input);
|
||||
overlay.remove();
|
||||
document.getElementById('console-form').submit();
|
||||
}, {once: true});
|
||||
@ -386,7 +453,7 @@
|
||||
var msg = location.search.match(/[?&]msg=([^&]+)/);
|
||||
if (m) {
|
||||
document.getElementById('console-status').innerHTML = '⏳ 任务 ' + m[1].substring(0,8) + ' 执行中,' + (msg ? decodeURIComponent(msg[1]) : '页面将自动刷新...');
|
||||
setTimeout(function(){ location.href = '/ui/console'; }, 8000);
|
||||
setTimeout(function(){ location.href = '/console'; }, 8000);
|
||||
} else if (msg) {
|
||||
document.getElementById('console-status').innerHTML = decodeURIComponent(msg[1]);
|
||||
setTimeout(function(){ document.getElementById('console-status').innerHTML = ''; }, 5000);
|
||||
|
||||
@ -1,56 +1,88 @@
|
||||
{{define "dashboard"}}
|
||||
{{if .AlarmRecords}}
|
||||
<div class="card" style="border-left:3px solid var(--red)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<h3 class="title-with-icon" style="margin:0">{{icon "bell"}}<span>最近告警</span></h3>
|
||||
<a class="btn ghost" href="/ui/alarms" style="font-size:11px">查看全部</a>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||||
{{range .AlarmRecords}}
|
||||
<div style="padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);min-width:200px">
|
||||
<div style="font-weight:500;font-size:12px;margin-bottom:2px">{{ruleLabel .RuleName}}</div>
|
||||
<div class="muted small mono">{{.Timestamp}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if gt .StaleCount 0}}
|
||||
<div class="card" style="background:var(--amber-soft);border:1px solid var(--amber);margin-bottom:16px;padding:12px 16px;display:flex;align-items:center;justify-content:space-between">
|
||||
<span>⚠ 第三方服务已更新,<strong>{{.StaleCount}}</strong> 台在线设备需重新下发配置以生效</span>
|
||||
<a class="btn" href="/wizard" style="font-size:12px">前往部署向导 →</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="k">设备总数</div><div class="v">{{.DeviceCount}}</div><div class="hint">已纳管的边缘设备</div></div>
|
||||
<div class="stat"><div class="k">在线率</div><div class="v">{{.OnlineCount}} / {{.DeviceCount}}</div><div class="hint">在线设备占比</div></div>
|
||||
<div class="stat"><div class="k">今日告警</div><div class="v">{{.TodayAlarmCount}}</div><div class="hint">今日告警总数</div></div>
|
||||
<div class="stat"><div class="k">执行中任务</div><div class="v">{{.RunningTaskCount}}</div><div class="hint">正在下发或控制节点</div></div>
|
||||
<div class="stat accent-red">
|
||||
<div class="k">{{icon "bell"}}<span>今日告警</span></div>
|
||||
<div class="v">{{.TodayAlarmCount}}</div>
|
||||
<div class="hint">今日告警总数</div>
|
||||
</div>
|
||||
<div class="stat accent-amber">
|
||||
<div class="k">{{icon "bell"}}<span>未处理告警</span></div>
|
||||
<div class="v" {{if gt .UnacknowledgedAlarmCount 0}}style="color:var(--amber)"{{end}}>{{.UnacknowledgedAlarmCount}}</div>
|
||||
<div class="hint">待确认和关闭的告警</div>
|
||||
</div>
|
||||
<div class="stat accent-green">
|
||||
<div class="k">{{icon "devices"}}<span>在线设备</span></div>
|
||||
<div class="v">{{.OnlineCount}}<span style="font-size:14px;color:var(--muted)"> / {{.DeviceCount}}</span></div>
|
||||
<div class="hint">设备在线率</div>
|
||||
</div>
|
||||
<div class="stat accent-slate">
|
||||
<div class="k">{{icon "apply"}}<span>检测路数</span></div>
|
||||
<div class="v">{{.DetectionChannelCount}}</div>
|
||||
<div class="hint">已配置的检测通道</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="trend-card">
|
||||
<h3 class="title-with-icon" style="margin-top:0;margin-bottom:12px">{{icon "result"}}<span>近 7 日告警趋势</span></h3>
|
||||
<canvas id="alarm-trend-chart" style="width:100%;height:160px"></canvas>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card">
|
||||
<h2>异常设备</h2>
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
<table>
|
||||
<thead><tr><th>节点</th><th>状态</th><th>管理地址</th><th>最后心跳</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .AttentionDevices}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/devices/{{.DeviceID}}">{{.DeviceName}}</a><div class="muted small mono">{{.DeviceID}}</div></td>
|
||||
<td><span class="pill bad">离线</span></td>
|
||||
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
|
||||
<td>{{ago .LastSeenMs}}</td>
|
||||
<td><a class="btn ghost" href="/ui/devices/{{.DeviceID}}">查看</a></td>
|
||||
</tr>
|
||||
<div class="row">
|
||||
<div style="flex:1 1 480px">
|
||||
<div class="card" style="border-left:3px solid var(--red);min-height:200px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||
<h3 class="title-with-icon" style="margin:0">{{icon "bell"}}<span>实时告警</span></h3>
|
||||
<a class="btn ghost" href="/alarms" style="font-size:11px">查看全部 →</a>
|
||||
</div>
|
||||
<div id="live-alarms" style="display:flex;flex-direction:column;gap:6px">
|
||||
{{range .AlarmRecords}}
|
||||
<div class="live-alarm-card" onclick="location.href='/alarms'" style="padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);cursor:pointer;display:flex;align-items:center;gap:10px">
|
||||
<span class="pill {{severityClass .Severity}}" style="flex-shrink:0">{{severityLabel .Severity}}</span>
|
||||
<span style="font-weight:500;font-size:12px">{{ruleLabel .RuleName}}</span>
|
||||
<span class="muted small mono" style="margin-left:auto;flex-shrink:0">{{shortID .DeviceID}}</span>
|
||||
</div>
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="muted">暂无需要关注的节点。</td></tr>
|
||||
<div class="empty-state compact" style="border-style:solid">
|
||||
<div class="empty-title">暂无告警</div>
|
||||
<div class="muted">有新告警时将实时显示在这里。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1 1 380px">
|
||||
<div class="card" style="min-height:200px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||
<h3 style="margin:0">{{icon "online"}}<span style="margin-left:6px">异常设备</span></h3>
|
||||
</div>
|
||||
{{if .AttentionDevices}}
|
||||
{{range .AttentionDevices}}
|
||||
<div style="padding:8px 10px;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)">
|
||||
<span class="pill bad">离线</span>
|
||||
<a class="mono" href="/devices/{{.DeviceID}}" style="font-size:12px">{{.DeviceName}}</a>
|
||||
<span class="muted small" style="margin-left:auto">{{ago .LastSeenMs}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-state compact" style="border-style:solid">
|
||||
<div class="muted">所有设备运行正常。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<h2 style="margin:0">最近任务</h2>
|
||||
<a class="btn ghost" href="/ui/tasks" style="font-size:11px">更多 →</a>
|
||||
<a class="btn ghost" href="/tasks" style="font-size:11px">更多 →</a>
|
||||
</div>
|
||||
<div class="table-wrap" style="margin-top:10px">
|
||||
<table>
|
||||
@ -59,11 +91,11 @@
|
||||
{{range $i, $t := .Tasks}}
|
||||
{{if lt $i 5}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/tasks/{{$t.ID}}">{{shortID $t.ID}}</a></td>
|
||||
<td><a class="mono" href="/tasks/{{$t.ID}}">{{shortID $t.ID}}</a></td>
|
||||
<td>{{taskActionLabel $t.Type}}</td>
|
||||
<td><span class="{{taskStatusClass $t.Status}}">{{taskStatusLabel $t.Status}}</span></td>
|
||||
<td class="muted small">{{$t.CreatedAt}}</td>
|
||||
<td class="muted small">{{$t.CompletedAt}}</td>
|
||||
<td class="muted small">{{formatTime $t.CreatedAt}}</td>
|
||||
<td class="muted small">{{formatTime $t.CompletedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
@ -73,9 +105,132 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
if({{.RunningTaskCount}}>0) setTimeout(function(){location.reload()},6000);
|
||||
var alarmContainer = document.getElementById("live-alarms");
|
||||
var maxCards = 10;
|
||||
var emptyEl = alarmContainer.querySelector(".empty-state");
|
||||
|
||||
function addAlarmCard(alarm) {
|
||||
if (emptyEl) {
|
||||
emptyEl.remove();
|
||||
emptyEl = null;
|
||||
}
|
||||
var card = document.createElement("div");
|
||||
card.className = "live-alarm-card";
|
||||
card.style.cssText = "padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);cursor:pointer;display:flex;align-items:center;gap:10px;animation:fadeIn .3s ease";
|
||||
card.onclick = function(){ location.href="/alarms"; };
|
||||
|
||||
var sevMap = {critical:"严重",warning:"警告",info:"信息"};
|
||||
var sevClassMap = {critical:"bad",warning:"warn",info:"ok"};
|
||||
var sevLabel = sevMap[alarm.severity] || alarm.severity || "警告";
|
||||
var sevClass = sevClassMap[alarm.severity] || "warn";
|
||||
|
||||
var ruleMap = {
|
||||
"unknown_face":"陌生人员告警","known_person":"已登记人员",
|
||||
"non_compliant_workshoe":"未穿劳保鞋","non_black_shoe":"工鞋不合规"
|
||||
};
|
||||
var ruleLabel = ruleMap[alarm.rule_name] || alarm.rule_name;
|
||||
|
||||
var deviceID = alarm.device_id || "";
|
||||
var devShort = deviceID.length > 8 ? deviceID.substring(0,8) : deviceID;
|
||||
|
||||
card.innerHTML =
|
||||
'<span class="pill ' + sevClass + '" style="flex-shrink:0">' + sevLabel + '</span>' +
|
||||
'<span style="font-weight:500;font-size:12px">' + ruleLabel + '</span>' +
|
||||
'<span class="muted small mono" style="margin-left:auto;flex-shrink:0">' + devShort + '</span>';
|
||||
|
||||
alarmContainer.insertBefore(card, alarmContainer.firstChild);
|
||||
while (alarmContainer.children.length > maxCards) {
|
||||
alarmContainer.removeChild(alarmContainer.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
var es = new EventSource("/api/alarms/stream");
|
||||
es.onmessage = function(e) {
|
||||
try {
|
||||
var alarm = JSON.parse(e.data);
|
||||
addAlarmCard(alarm);
|
||||
} catch(ignored) {}
|
||||
};
|
||||
} catch(ignored) {}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.live-alarm-card:hover{background:var(--sidebar-hover)!important}
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var canvas = document.getElementById("alarm-trend-chart");
|
||||
if (!canvas) return;
|
||||
var ctx = canvas.getContext("2d");
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
|
||||
fetch("/api/alarms/daily-count?days=7", {credentials:"same-origin"})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(data){
|
||||
if (!data || data.length === 0) return;
|
||||
var values = data.map(function(d){return d.count});
|
||||
var labels = data.map(function(d){return d.date.substring(5)});
|
||||
var maxVal = Math.max.apply(null, values.concat([1]));
|
||||
var w = canvas.clientWidth;
|
||||
var h = 160;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
var pad = {top:16, bottom:24, left:4, right:4};
|
||||
var chartW = w - pad.left - pad.right;
|
||||
var chartH = h - pad.top - pad.bottom;
|
||||
var barW = Math.min(36, chartW / values.length - 8);
|
||||
var gap = (chartW - barW * values.length) / (values.length + 1);
|
||||
|
||||
var style = getComputedStyle(document.body);
|
||||
var textColor = style.getPropertyValue("--text").trim() || "#f4f4f4";
|
||||
var mutedColor = style.getPropertyValue("--muted").trim() || "#bcbcbc";
|
||||
var primary = style.getPropertyValue("--primary").trim() || "#ddd";
|
||||
var primaryStrong = style.getPropertyValue("--primary-strong").trim() || "#2b2b2b";
|
||||
|
||||
// Y-axis lines
|
||||
ctx.strokeStyle = "rgba(255,255,255,.06)";
|
||||
ctx.lineWidth = 1;
|
||||
for (var i = 0; i <= 3; i++) {
|
||||
var y = pad.top + chartH * (1 - i/3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, y);
|
||||
ctx.lineTo(pad.left + chartW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Bars
|
||||
values.forEach(function(v, i){
|
||||
var barH = v > 0 ? Math.max(2, chartH * v / maxVal) : 0;
|
||||
var x = pad.left + gap + i * (barW + gap);
|
||||
var y = pad.top + chartH - barH;
|
||||
ctx.fillStyle = primaryStrong;
|
||||
ctx.fillRect(x, y, barW, barH);
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = "10px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
if (v > 0) {
|
||||
ctx.fillText(v, x + barW/2, y - 4);
|
||||
}
|
||||
});
|
||||
|
||||
// Labels
|
||||
ctx.fillStyle = mutedColor;
|
||||
ctx.font = "10px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
labels.forEach(function(l, i){
|
||||
var x = pad.left + gap + i * (barW + gap) + barW/2;
|
||||
ctx.fillText(l, x, h - 4);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@ -8,9 +8,10 @@
|
||||
<div class="muted small">单设备查看、服务控制、设备分配应用、模型资源和日志指标都收敛在这里完成。</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/devices">返回设备列表</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览设备分配</span></a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
<a class="btn ghost" href="/devices">返回设备列表</a>
|
||||
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/config-preview"><span>预览设备分配</span></a>
|
||||
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/config-versions"><span>配置历史</span></a>
|
||||
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-strip control-summary">
|
||||
@ -48,7 +49,7 @@
|
||||
{{if .Device.Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div><span>设备名称</span><strong>{{displayDeviceName .Device .ConfigStatus}}</strong></div>
|
||||
<div><span>设备名称</span><strong style="display:flex;align-items:center;gap:6px"><span>{{displayDeviceName .Device .ConfigStatus}}</span><span class="btn-icon" style="font-size:12px;cursor:pointer;color:var(--primary);border:1px solid var(--border);border-radius:3px;padding:0 5px;line-height:18px" onclick="editDeviceName(this,'{{.Device.DeviceID}}')">✎</span></strong></div>
|
||||
<div><span>设备 ID</span><strong class="mono">{{.Device.DeviceID}}</strong></div>
|
||||
<div><span>管理地址</span><strong class="mono">{{.Device.IP}}:{{.Device.AgentPort}}</strong></div>
|
||||
<div><span>视频端口</span><strong class="mono">{{.Device.MediaPort}}</strong></div>
|
||||
@ -108,17 +109,17 @@
|
||||
<div><span>媒体服务</span><strong>{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}运行中{{else}}未确认{{end}}</strong></div>
|
||||
</div>
|
||||
<div class="actions service-actions-row">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_start" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="secondary">启动服务</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_restart" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="primary">重启服务</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_stop" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="danger">停止服务</button>
|
||||
@ -148,11 +149,11 @@
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="actions scene-actions-row">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/plan-apply">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/plan-apply">
|
||||
<button type="submit" class="primary">下发当前设备分配</button>
|
||||
</form>
|
||||
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览当前设备分配</span></a>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<a class="btn secondary" href="/devices/{{.Device.DeviceID}}/config-preview"><span>预览当前设备分配</span></a>
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="rollback" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="secondary">回滚上一版</button>
|
||||
@ -160,23 +161,82 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-block" id="device-overlays">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3 class="title-with-icon">{{icon "overlay"}}<span>调试参数覆盖</span></h3>
|
||||
</div>
|
||||
</div>
|
||||
{{if .AssetOverlays}}
|
||||
<form method="POST" action="/devices/{{.Device.DeviceID}}/overlays">
|
||||
{{range .AssetOverlays}}
|
||||
<label class="overlay-check-row">
|
||||
<input type="checkbox" name="overlay" value="{{.Name}}" {{if hasString $.SelectedOverlays .Name}}checked{{end}}>
|
||||
<span class="mono" style="min-width:170px">{{.Name}}</span>
|
||||
{{if .Description}}<span class="muted small">{{.Description}}</span>{{end}}
|
||||
</label>
|
||||
{{end}}
|
||||
<div style="margin-top:12px">
|
||||
<button type="submit" class="btn primary">保存覆盖参数</button>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="empty-state compact">
|
||||
<div class="muted">暂无可用的调试参数覆盖。在 <a href="/assets/overlays">调试参数管理</a> 中创建。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="panel-block" id="device-models">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3 class="title-with-icon">{{icon "assets"}}<span>模型与资源</span></h3>
|
||||
</div>
|
||||
<a class="btn secondary" href="/ui/models">进入模型管理</a>
|
||||
<a class="btn secondary" href="/models">模型管理</a>
|
||||
</div>
|
||||
|
||||
{{if .StandardModels}}
|
||||
<div class="info-list compact-list" style="margin-bottom:0">
|
||||
{{range $m := .StandardModels}}
|
||||
{{$status := "missing"}}
|
||||
{{range $d := $.DeviceModelStatuses}}{{if eq $d.Name $m.Name}}{{if eq $d.SHA256 $m.SHA256}}{{$status = "ok"}}{{else}}{{$status = "mismatch"}}{{end}}{{end}}{{end}}
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><span class="mono" title="{{$m.Name}}" style="margin-bottom:0">{{$m.Name}}</span>
|
||||
<strong style="flex-shrink:0">{{if eq $status "ok"}}<span class="pill ok">一致</span>{{else if eq $status "mismatch"}}<span class="pill warn">不一致</span>{{else}}<span class="pill bad">缺失</span>{{end}}</strong></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else if .DeviceModelStatuses}}
|
||||
<div class="info-list compact-list" style="margin-bottom:0">
|
||||
{{range $m := .DeviceModelStatuses}}
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><span class="mono" title="{{$m.Name}}" style="margin-bottom:0">{{$m.Name}}</span>
|
||||
<strong style="flex-shrink:0"><span class="pill ok">{{$m.SHA256}}</span></strong></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .StandardResources}}
|
||||
<div class="info-list compact-list">
|
||||
{{range $r := .StandardResources}}
|
||||
{{$status := "missing"}}
|
||||
{{range $d := $.DeviceResourceStatuses}}{{if eq $d.Name $r.Name}}{{if eq $d.SHA256 $r.SHA256}}{{$status = "ok"}}{{else}}{{$status = "mismatch"}}{{end}}{{end}}{{end}}
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><span class="mono" title="{{$r.Name}}" style="margin-bottom:0">{{$r.Name}} <span class="muted small">({{resourceTypeLabel $r.ResourceType}})</span></span>
|
||||
<strong style="flex-shrink:0">{{if eq $status "ok"}}<span class="pill ok">一致</span>{{else if eq $status "mismatch"}}<span class="pill warn">不一致</span>{{else}}<span class="pill bad">缺失</span>{{end}}</strong></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else if .DeviceResourceStatuses}}
|
||||
<div class="info-list compact-list">
|
||||
{{range $r := .DeviceResourceStatuses}}
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><span class="mono" title="{{$r.Name}}" style="margin-bottom:0">{{$r.Name}} <span class="muted small">({{resourceTypeLabel $r.ResourceType}})</span></span>
|
||||
<strong style="flex-shrink:0"><span class="pill ok">{{$r.SHA256}}</span></strong></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if not (or .StandardModels .StandardResources .DeviceModelStatuses .DeviceResourceStatuses)}}
|
||||
<div class="info-list compact-list">
|
||||
<div><span>模型入口</span><strong>通过模型管理页上传到设备</strong></div>
|
||||
<div><span>人脸库</span><strong>通过资源管理与基础配置维护</strong></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/reload">
|
||||
<button type="submit" class="secondary">重载人脸库</button>
|
||||
</form>
|
||||
<a class="btn ghost" href="/ui/assets">查看基础配置</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="panel-block" id="device-observability">
|
||||
@ -186,9 +246,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions stack">
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/graphs">运行指标</a>
|
||||
<a class="btn ghost" href="/ui/api">高级调试</a>
|
||||
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/graphs">运行指标</a>
|
||||
<a class="btn ghost" href="/api">高级调试</a>
|
||||
</div>
|
||||
{{if .ResultTitle}}<div class="muted small" style="margin-top:12px">{{.ResultTitle}}</div>{{end}}
|
||||
{{if .Message}}<div class="msg">{{.Message}}</div>{{end}}
|
||||
@ -196,4 +256,38 @@
|
||||
{{if .RawText}}<pre style="margin-top:10px">{{.RawText}}</pre>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
function editDeviceName(el, deviceID) {
|
||||
var strong = el.parentNode;
|
||||
var nameSpan = strong.querySelector('span:first-child');
|
||||
var current = nameSpan.textContent.trim();
|
||||
var input = document.createElement('input');
|
||||
input.value = current;
|
||||
input.style.cssText = 'width:200px;padding:2px 6px;font-size:inherit;font-weight:400;border:1px solid var(--primary);border-radius:4px';
|
||||
nameSpan.style.display = 'none';
|
||||
el.style.display = 'none';
|
||||
strong.insertBefore(input, strong.firstChild);
|
||||
input.focus();
|
||||
input.select();
|
||||
function save() {
|
||||
var name = input.value.trim();
|
||||
input.remove();
|
||||
nameSpan.style.display = '';
|
||||
el.style.display = '';
|
||||
if (!name || name === current) return;
|
||||
var form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/devices/' + deviceID + '/alias';
|
||||
form.style.display = 'none';
|
||||
var inp = document.createElement('input');
|
||||
inp.name = 'device_alias';
|
||||
inp.value = name;
|
||||
form.appendChild(inp);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
input.addEventListener('blur', save);
|
||||
input.addEventListener('keydown', function(e) { if (e.key === 'Enter') save(); if (e.key === 'Escape') { input.remove(); nameSpan.style.display = ''; el.style.display = ''; } });
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{{define "device_add"}}
|
||||
<div class="card">
|
||||
<h2>新增设备</h2>
|
||||
<form method="post" action="/ui/devices-add" style="margin-top:16px">
|
||||
<form method="post" action="/devices-add" style="margin-top:16px">
|
||||
<div class="row" style="gap:16px">
|
||||
<div>
|
||||
<div class="muted small">节点标识 <span class="muted">*</span></div>
|
||||
@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<div style="margin-top:16px">
|
||||
<button type="submit">新增设备</button>
|
||||
<a href="/ui/devices" style="margin-left:12px">返回设备列表</a>
|
||||
<a href="/devices" style="margin-left:12px">返回设备列表</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
{{if .DeviceAssignmentBoard}}
|
||||
<div class="assignment-kpis">
|
||||
<div class="assignment-kpi">
|
||||
<span>视频通道</span>
|
||||
<span>检测通道</span>
|
||||
<strong>{{.DeviceAssignmentBoard.Stats.TotalUnits}}</strong>
|
||||
</div>
|
||||
<div class="assignment-kpi">
|
||||
@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/device-assignments" id="assignment-board-form">
|
||||
<form method="post" action="/device-assignments" id="assignment-board-form">
|
||||
<input type="hidden" name="board_state_json" id="assignment-board-state" value="" />
|
||||
<div class="assignment-action-bar">
|
||||
<label class="assignment-slider">
|
||||
@ -75,7 +75,7 @@
|
||||
{{if lt .AssignedCount .MaxUnits}}
|
||||
<div class="assignment-device-add">
|
||||
<select data-add-select="{{.DeviceID}}">
|
||||
<option value="">添加视频通道</option>
|
||||
<option value="">添加检测通道</option>
|
||||
</select>
|
||||
<button type="button" class="btn secondary" data-add-button="{{.DeviceID}}">加入</button>
|
||||
</div>
|
||||
@ -87,7 +87,7 @@
|
||||
<section class="assignment-unassigned">
|
||||
<div class="section-title compact">
|
||||
<div>
|
||||
<h3>未部署视频通道</h3>
|
||||
<h3>未部署检测通道</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="assignment-chip-list" id="assignment-unassigned-list">
|
||||
@ -273,7 +273,7 @@
|
||||
const addControls = card.refs.length < state.max ? `
|
||||
<div class="assignment-device-add">
|
||||
<select data-add-select="${card.deviceID}">
|
||||
<option value="">添加视频通道</option>
|
||||
<option value="">添加检测通道</option>
|
||||
${availableRefs.map(ref => `<option value="${ref}">${unitLabel(state.units[ref])}</option>`).join('')}
|
||||
</select>
|
||||
<button type="button" class="btn secondary" data-add-button="${card.deviceID}">加入</button>
|
||||
@ -290,6 +290,9 @@
|
||||
<span class="pill">${card.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-sync-indicator ${card.config_synced ? 'synced' : 'pending'}">
|
||||
${card.config_synced ? '✅ 配置已同步' : '⚠ 配置待更新'}
|
||||
</div>
|
||||
<div class="assignment-chip-list"></div>
|
||||
${addControls}
|
||||
`;
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="{{.SelectedDevicesURL}}#batch-config">返回设备列表</a>
|
||||
<a class="btn secondary" href="/ui/devices">重新选择</a>
|
||||
<a class="btn secondary" href="/devices">重新选择</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
@ -32,7 +32,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/devices/batch-config">
|
||||
<form method="post" action="/devices/batch-config">
|
||||
{{range .SelectedDeviceIDs}}<input type="hidden" name="device_id" value="{{.}}" />{{end}}
|
||||
<div class="actions">
|
||||
<button type="submit" class="primary">创建下发任务</button>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<h2>单设备配置已并入设备详情</h2>
|
||||
<div class="muted small">配置查看、服务控制和回滚,都已经收敛到设备详情工作台。</div>
|
||||
</div>
|
||||
<a class="btn ghost" href="/ui/devices">去设备列表</a>
|
||||
<a class="btn ghost" href="/devices">去设备列表</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
<h2>设备入口</h2>
|
||||
<div class="device-selector-grid" style="margin-top:12px">
|
||||
{{range .Devices}}
|
||||
<a class="selector-card" href="/ui/devices/{{.DeviceID}}#device-config">
|
||||
<a class="selector-card" href="/devices/{{.DeviceID}}#device-config">
|
||||
<div class="selector-head">
|
||||
<div>
|
||||
<div class="selector-title">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</div>
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
{{if .Device.Online}}<span class="pill ok">可操作</span>{{else}}<span class="pill bad">设备离线</span>{{end}}
|
||||
</div>
|
||||
<div class="actions" style="margin-bottom:12px">
|
||||
<a class="btn secondary" href="/ui/device-config">返回设备选择</a>
|
||||
<a class="btn secondary" href="/device-config">返回设备选择</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-strip control-summary">
|
||||
@ -34,7 +34,7 @@
|
||||
<div>
|
||||
<h3 class="title-with-icon">{{icon "preview"}}<span>配置预览</span></h3>
|
||||
</div>
|
||||
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview">{{icon "preview"}}<span>打开预览器</span></a>
|
||||
<a class="btn secondary" href="/devices/{{.Device.DeviceID}}/config-preview">{{icon "preview"}}<span>打开预览器</span></a>
|
||||
</div>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>当前模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
|
||||
@ -54,11 +54,11 @@
|
||||
<div><span>上一份配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/config-candidate/apply">
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="primary">应用待应用配置</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="rollback" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="secondary">回滚到上一份</button>
|
||||
@ -73,17 +73,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions stack">
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_start" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="secondary">启动服务</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_restart" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="primary">重启服务</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
|
||||
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
|
||||
<input type="hidden" name="action" value="media_stop" />
|
||||
<input type="hidden" name="return_to" value="config" />
|
||||
<button type="submit" class="danger">停止服务</button>
|
||||
|
||||
@ -32,9 +32,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ui/devices/batch-action">
|
||||
{{if .SelectedDeviceIDs}}
|
||||
<div class="batch-toolbar" id="batch-config">
|
||||
<form method="post" action="/devices/batch-action">
|
||||
<div class="batch-toolbar" id="batch-config"{{if not .SelectedDeviceIDs}} style="display:none"{{end}}>
|
||||
<div>
|
||||
<div class="batch-toolbar-count">已选 {{len .SelectedDeviceIDs}} 台</div>
|
||||
</div>
|
||||
@ -44,12 +43,16 @@
|
||||
<button type="submit" name="action" value="media_stop" class="danger">停止服务</button>
|
||||
<button type="submit" name="action" value="reload" class="secondary" {{if .ReloadSummary}}onclick='return confirm("将重载当前运行配置:{{.ReloadSummary}}")'{{end}}>重载运行配置</button>
|
||||
<button type="submit" name="action" value="rollback" class="secondary" {{if .RollbackSummary}}onclick='return confirm("将回滚到上一版运行配置:{{.RollbackSummary}}")'{{end}}>回滚运行配置</button>
|
||||
{{$allOffline := true}}{{range .SelectedDevices}}{{if .Online}}{{$allOffline = false}}{{end}}{{end}}
|
||||
{{if $allOffline}}
|
||||
<button type="submit" id="cleanup-btn" name="action" value="cleanup_offline" class="danger" style="display:none" onclick="var n=document.querySelectorAll('input[name=device_id]:checked').length;return confirm('确认清理选中的 '+n+' 台离线设备及其全部关联数据?此操作不可恢复。')">清理选中离线设备</button>
|
||||
{{end}}
|
||||
<button type="submit" id="time-sync-btn" name="action" value="time_sync" class="secondary" style="display:none">⏱ 时间同步</button>
|
||||
<a class="btn secondary" href="{{.BatchConfigURL}}">下发设备分配</a>
|
||||
<a class="btn secondary" href="/ui/devices">清空选择</a>
|
||||
<a class="btn secondary" href="/devices/export-channels">导出视频地址</a>
|
||||
<a class="btn secondary" href="/devices">清空选择</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="device-list">
|
||||
<thead>
|
||||
@ -57,13 +60,14 @@
|
||||
<th style="width:52px">选中</th>
|
||||
<th>设备</th>
|
||||
<th>状态</th>
|
||||
<th>当前配置</th>
|
||||
<th>配置模板</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .DeviceRows}}
|
||||
<tr>
|
||||
{{range .DeviceRows}}
|
||||
{{$rowWarn := and .Device.Online (or (and .ConfigStatus (not .ConfigStatus.MediaServer.Running)) (not .ConfigStatus))}}
|
||||
<tr {{if $rowWarn}}class="device-row-warn"{{end}}>
|
||||
<td class="select-cell">
|
||||
<input type="checkbox" name="device_id" value="{{.Device.DeviceID}}" {{if hasString $.SelectedDeviceIDs .Device.DeviceID}}checked{{end}} />
|
||||
</td>
|
||||
@ -75,8 +79,6 @@
|
||||
<div class="device-meta-line">
|
||||
{{if displayDeviceTechnicalName .Device}}<span>{{displayDeviceTechnicalName .Device}}</span>{{end}}
|
||||
<span class="mono">{{.Device.IP}}</span>
|
||||
{{if .Device.Version}}<span class="mono">{{.Device.Version}}</span>{{end}}
|
||||
{{if .Device.BuildID}}<span class="mono">#{{shortHash .Device.BuildID}}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -92,23 +94,27 @@
|
||||
{{else}}
|
||||
<span class="pill bad">未知</span>
|
||||
{{end}}
|
||||
{{if .IntegrationStale}}<span class="pill warn">需重新下发</span>{{end}}
|
||||
{{if gt .TimeDriftSec $.TimeSyncThreshold}}<span class="pill bad">⏱ 偏差{{.TimeDriftSec}}秒</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="config-inline">
|
||||
{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}
|
||||
{{if and .ConfigStatus .ConfigStatus.Metadata.Template}}
|
||||
<div class="mono">{{.ConfigStatus.Metadata.Template}}</div>
|
||||
{{else if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}
|
||||
<div class="mono">{{.ConfigStatus.Metadata.ConfigID}}</div>
|
||||
{{else if .ConfigStatusErr}}
|
||||
<div class="muted small">未取到配置摘要</div>
|
||||
{{else}}
|
||||
<div class="muted small">暂无配置摘要</div>
|
||||
<div class="muted small">暂无配置</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">{{icon "detail"}}<span>详情</span></a>
|
||||
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}">{{icon "detail"}}<span>详情</span></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -150,7 +156,41 @@
|
||||
}
|
||||
}
|
||||
const next = `${url.pathname}${url.searchParams.toString() ? `?${url.searchParams.toString()}` : ''}`;
|
||||
window.location.assign(next);
|
||||
history.replaceState(null, '', next);
|
||||
// Toggle batch toolbar visibility without full page reload
|
||||
const toolbar = document.getElementById('batch-config');
|
||||
const checked = document.querySelectorAll('input[type="checkbox"][name="device_id"]:checked');
|
||||
if (toolbar) {
|
||||
toolbar.style.display = checked.length > 0 ? '' : 'none';
|
||||
const countEl = toolbar.querySelector('.batch-toolbar-count');
|
||||
if (countEl) countEl.textContent = `已选 ${checked.length} 台`;
|
||||
}
|
||||
// Show/hide cleanup button based on whether ALL selected devices are offline
|
||||
const cleanupBtn = document.getElementById('cleanup-btn');
|
||||
if (cleanupBtn) {
|
||||
let allOffline = checked.length > 0;
|
||||
checked.forEach(cb => {
|
||||
const row = cb.closest('tr');
|
||||
if (row) {
|
||||
const statusCell = row.querySelector('td:nth-child(3)');
|
||||
if (statusCell && !statusCell.textContent.includes('离线')) allOffline = false;
|
||||
}
|
||||
});
|
||||
cleanupBtn.style.display = allOffline ? '' : 'none';
|
||||
}
|
||||
// Show/hide time sync button if any selected device has time drift
|
||||
const syncBtn = document.getElementById('time-sync-btn');
|
||||
if (syncBtn) {
|
||||
let hasDrift = false;
|
||||
checked.forEach(cb => {
|
||||
const row = cb.closest('tr');
|
||||
if (row) {
|
||||
const driftEl = row.querySelector('.pill.bad');
|
||||
if (driftEl && driftEl.textContent.includes('偏差')) hasDrift = true;
|
||||
}
|
||||
});
|
||||
syncBtn.style.display = hasDrift ? '' : 'none';
|
||||
}
|
||||
};
|
||||
for (const box of selectedBoxes) {
|
||||
box.addEventListener('change', syncSelected);
|
||||
|
||||
@ -79,15 +79,15 @@
|
||||
{{range .Devices}}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="mono" href="/ui/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a>
|
||||
<a class="mono" href="/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a>
|
||||
<div class="muted small mono">{{.DeviceID}}</div>
|
||||
</td>
|
||||
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
|
||||
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/graphs">运行指标</a>
|
||||
<a class="btn ghost" href="/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
<a class="btn ghost" href="/devices/{{.DeviceID}}/graphs">运行指标</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -13,17 +13,24 @@
|
||||
<span class="muted small">{{len .FaceGalleryPersons}} 人</span>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<input type="search" id="person-search" class="input" placeholder="搜索人员..." style="width:180px" oninput="filterPersons()">
|
||||
<button class="btn secondary" type="button" onclick="showImport()">批量导入</button>
|
||||
<button class="btn" type="button" onclick="showAdd()">新增人员</button>
|
||||
{{$built := false}}{{range .StandardResources}}{{if eq .ResourceType "face_gallery"}}{{$built = true}}{{end}}{{end}}
|
||||
<form method="post" action="/face-gallery/build" class="inline-form" {{if $built}}onsubmit="return confirm('已存在构建记录,重新构建将覆盖。确定继续?')"{{else}}onsubmit="return confirm('确认从已导入的照片构建人脸库?')"{{end}}>
|
||||
<button class="btn {{if $built}}secondary{{end}}" type="submit">{{if $built}}重新构建人脸库{{else}}构建人脸库{{end}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Message}}<div class="msg {{if .Error}}error{{end}}">{{.Message}}</div>{{end}}
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
{{if .FaceGalleryPersons}}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px;margin-top:12px">
|
||||
<div class="person-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px;margin-top:12px">
|
||||
{{range .FaceGalleryPersons}}
|
||||
<div class="card" style="padding:8px;max-width:200px">
|
||||
<div class="photo-viewer" data-photos='[{{range $i,$p := .Photos}}{{if $i}},{{end}}"/ui/face-photo/{{$p}}"{{end}}]' style="width:100%;aspect-ratio:3/4;background:var(--surface-soft);border-radius:var(--radius);overflow:hidden;position:relative">
|
||||
<div class="card person-card" data-name="{{.Name}}" style="padding:8px;max-width:200px">
|
||||
<div class="photo-viewer" data-photos='[{{range $i,$p := .Photos}}{{if $i}},{{end}}"/face-photo/{{$p}}"{{end}}]' style="width:100%;aspect-ratio:3/4;background:var(--surface-soft);border-radius:var(--radius);overflow:hidden;position:relative">
|
||||
{{if .Photos}}
|
||||
<img src="/ui/face-photo/{{index .Photos 0}}" style="width:100%;height:100%;object-fit:cover" id="img-{{.ID}}" />
|
||||
<img src="/face-photo/{{index .Photos 0}}" style="width:100%;height:100%;object-fit:cover" id="img-{{.ID}}" />
|
||||
{{if gt .PhotoCount 1}}
|
||||
<span class="btn-icon ghost nav-btn" id="prev-{{.ID}}" style="position:absolute;left:2px;top:50%;transform:translateY(-50%);font-size:10px;opacity:0.7;display:none" onclick="navPhoto({{.ID}},-1)">◀</span>
|
||||
<span class="btn-icon ghost nav-btn" id="next-{{.ID}}" style="position:absolute;right:2px;top:50%;transform:translateY(-50%);font-size:10px;opacity:0.7" onclick="navPhoto({{.ID}},1)">▶</span>
|
||||
@ -33,10 +40,10 @@
|
||||
{{end}}
|
||||
</div>
|
||||
<div style="margin-top:6px;display:flex;justify-content:space-between;align-items:center">
|
||||
<div><span style="font-weight:500;font-size:13px">{{.Name}}</span> <span class="muted small">{{.PhotoCount}}张</span></div>
|
||||
<div><span style="font-weight:500;font-size:13px">{{.Name}}</span> <span class="muted small">{{.PhotoCount}}张</span>{{$quality := index $.FaceGalleryQuality .Name}}{{if $quality}} <span class="pill ok" style="font-size:10px">{{$quality}}通过</span>{{end}}</div>
|
||||
<div class="actions compact" style="gap:2px">
|
||||
<span class="btn-icon ghost edit-btn" title="编辑" data-id="{{.ID}}" data-name="{{.Name}}" data-photos='[{{$person := .}}{{range $i,$p := .Photos}}{{if $i}},{{end}}{"p":"/ui/face-photo/{{$p}}","id":{{index $person.PhotoIDs $i}}}{{end}}]'>✎</span>
|
||||
<form method="post" action="/ui/face-gallery/delete" style="display:inline" onsubmit="return confirm('确定删除 {{.Name}}?')">
|
||||
<span class="btn-icon ghost edit-btn" title="编辑" data-id="{{.ID}}" data-name="{{.Name}}" data-photos='[{{$person := .}}{{range $i,$p := .Photos}}{{if $i}},{{end}}{"p":"/face-photo/{{$p}}","id":{{index $person.PhotoIDs $i}}}{{end}}]'>✎</span>
|
||||
<form method="post" action="/face-gallery/delete" style="display:inline" onsubmit="return confirm('确定删除 {{.Name}}?')">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<span class="btn-icon ghost" style="color:var(--danger-soft-text)" title="删除" onclick="this.closest('form').requestSubmit()">✕</span>
|
||||
</form>
|
||||
@ -50,6 +57,58 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{$board := .ResourceStatusBoard}}
|
||||
{{if and $board (gt (len .StandardResources) 0)}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h3 class="title-with-icon">{{icon "devices"}}<span>设备同步状态</span></h3>
|
||||
<div class="form-hint">将人脸库下发到 Edge 设备,SHA256 比对显示同步状态。</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<form method="post" action="/face-gallery/sync">
|
||||
{{range .Devices}}{{if .Online}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}
|
||||
<button class="btn" type="submit">同步到全部设备</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="models-status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>设备</th>
|
||||
{{range .StandardResources}}<th class="model-status-col" title="{{.Name}}">{{shortHash .SHA256}}</th>{{end}}
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $board.Rows}}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-key">{{.DeviceName}}</div>
|
||||
<div class="muted small mono">{{.DeviceIP}}</div>
|
||||
</td>
|
||||
{{range .Cells}}
|
||||
<td>
|
||||
{{if eq .Status "ok"}}<span class="pill ok">一致</span>
|
||||
{{else if eq .Status "mismatch"}}<span class="pill warn">不一致</span>
|
||||
{{else}}<span class="pill bad">缺失</span>{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
<td>
|
||||
<form method="post" action="/face-gallery/sync" style="display:inline">
|
||||
<input type="hidden" name="device_id" value="{{.DeviceID}}">
|
||||
<button class="btn ghost small" type="submit">同步</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
var photoData={};
|
||||
document.querySelectorAll('.photo-viewer').forEach(function(el){
|
||||
@ -65,16 +124,16 @@ function navPhoto(id,dir){
|
||||
document.getElementById('next-'+id).style.display=d.idx===d.photos.length-1?'none':'';
|
||||
}
|
||||
function showImport(){
|
||||
document.getElementById('modal-body').innerHTML='<h3>批量导入</h3><div class="form-hint" style="margin-bottom:12px">选择按姓名分目录的照片根目录</div><form method="post" action="/ui/face-gallery/import" enctype="multipart/form-data"><label class="full" style="margin-bottom:12px;display:block"><span>选择照片目录</span><input type="file" name="photos" webkitdirectory multiple required /></label><div class="actions"><button class="btn" type="submit">导入</button></div></form>';
|
||||
document.getElementById('modal-body').innerHTML='<h3>批量导入</h3><div class="form-hint" style="margin-bottom:12px">选择按姓名分目录的照片根目录</div><form method="post" action="/face-gallery/import" enctype="multipart/form-data"><label class="full" style="margin-bottom:12px;display:block"><span>选择照片目录</span><input type="file" name="photos" webkitdirectory multiple required /></label><div class="actions"><button class="btn" type="submit">导入</button></div></form>';
|
||||
document.getElementById('modal').style.display='flex';
|
||||
}
|
||||
function showAdd(){
|
||||
document.getElementById('modal-body').innerHTML='<h3>新增人员</h3><form method="post" action="/ui/face-gallery/add" enctype="multipart/form-data"><div class="field-grid"><label><span>姓名</span><input type="text" name="name" required /></label><label><span>照片</span><input type="file" name="photo" accept="image/*" required /></label></div><div class="actions"><button class="btn" type="submit">新增</button></div></form>';
|
||||
document.getElementById('modal-body').innerHTML='<h3>新增人员</h3><form method="post" action="/face-gallery/add" enctype="multipart/form-data"><div class="field-grid"><label><span>姓名</span><input type="text" name="name" required /></label><label><span>照片</span><input type="file" name="photo" accept="image/*" required /></label></div><div class="actions"><button class="btn" type="submit">新增</button></div></form>';
|
||||
document.getElementById('modal').style.display='flex';
|
||||
}
|
||||
function showEdit(id,name,photos){
|
||||
var h='<h3>编辑人员</h3>';
|
||||
h+='<form method="post" action="/ui/face-gallery/rename" style="margin-bottom:12px;display:flex;gap:8px;align-items:flex-end">';
|
||||
h+='<form method="post" action="/face-gallery/rename" style="margin-bottom:12px;display:flex;gap:8px;align-items:flex-end">';
|
||||
h+='<input type="hidden" name="id" value="'+id+'"><label style="flex:1"><span class="small muted">姓名</span><input type="text" name="name" value="'+esc(name)+'" required /></label>';
|
||||
h+='<button class="btn" type="submit">保存</button></form>';
|
||||
h+='<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px">';
|
||||
@ -84,7 +143,7 @@ function showEdit(id,name,photos){
|
||||
h+='</div>';
|
||||
});
|
||||
h+='</div>';
|
||||
h+='<form method="post" action="/ui/face-gallery/add-photo" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:flex-end">';
|
||||
h+='<form method="post" action="/face-gallery/add-photo" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:flex-end">';
|
||||
h+='<input type="hidden" name="id" value="'+id+'"><label style="flex:1"><span class="small muted">添加照片</span><input type="file" name="photo" accept="image/*" /></label>';
|
||||
h+='<button class="btn ghost" type="submit">上传</button></form>';
|
||||
document.getElementById('modal-body').innerHTML=h;
|
||||
@ -92,11 +151,23 @@ function showEdit(id,name,photos){
|
||||
}
|
||||
function delPhoto(id){
|
||||
if(!confirm('确认删除这张照片?'))return;
|
||||
var f=document.createElement('form');f.method='post';f.action='/ui/face-gallery/delete-photo';
|
||||
var f=document.createElement('form');f.method='post';f.action='/face-gallery/delete-photo';
|
||||
var i=document.createElement('input');i.type='hidden';i.name='id';i.value=id;f.appendChild(i);
|
||||
document.body.appendChild(f);f.submit();
|
||||
}
|
||||
function closeModal(){document.getElementById('modal').style.display='none';}
|
||||
function filterPersons(){
|
||||
var q=(document.getElementById('person-search').value||'').toLowerCase();
|
||||
var cards=document.querySelectorAll('.person-card');
|
||||
var visible=0;
|
||||
cards.forEach(function(c){
|
||||
var name=(c.dataset.name||'').toLowerCase();
|
||||
if(!q||name.indexOf(q)!==-1){c.style.display='';visible++;}
|
||||
else{c.style.display='none';}
|
||||
});
|
||||
var grid=document.querySelector('.person-grid');
|
||||
if(grid)grid.dataset.visible=visible;
|
||||
}
|
||||
document.addEventListener('click',function(e){var b=e.target.closest('.edit-btn');if(b){showEdit(b.dataset.id,b.dataset.name,JSON.parse(b.dataset.photos));}});
|
||||
function esc(s){return(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
</script>
|
||||
|
||||
@ -5,9 +5,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="/ui/assets/vendor/tabler.min.css" />
|
||||
<link rel="stylesheet" href="/ui/assets/style.css?v=20260508-ia06" />
|
||||
<link rel="stylesheet" href="/ui/assets/graph_editor.css?v=20260429-ia01" />
|
||||
<link rel="stylesheet" href="/assets/vendor/tabler.min.css" />
|
||||
<link rel="stylesheet" href="/assets/style.css?v=ia13" />
|
||||
<link rel="stylesheet" href="/assets/graph_editor.css?v=20260720-ia01" />
|
||||
</head>
|
||||
<body data-theme="blue-dark">
|
||||
<div class="app-shell">
|
||||
@ -19,33 +19,45 @@
|
||||
</div>
|
||||
</div>
|
||||
<nav class="side-nav" aria-label="主导航">
|
||||
<div class="nav-section">主模块</div>
|
||||
<a href="/ui/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>总览</span></a>
|
||||
<a href="/ui/console"><span class="nav-icon">{{icon "apply"}}</span><span>管控台</span></a>
|
||||
<a href="/ui/alarms"><span class="nav-icon">{{icon "bell"}}</span><span>告警中心</span></a>
|
||||
<a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备</span></a>
|
||||
<a href="/ui/monitor"><span class="nav-icon">{{icon "devices"}}</span><span>视频监控</span></a>
|
||||
<a href="/ui/face-gallery"><span class="nav-icon">{{icon "profile"}}</span><span>人脸库</span></a>
|
||||
<a href="/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>首页</span></a>
|
||||
<a href="/alarms"><span class="nav-icon">{{icon "bell"}}</span><span>告警中心</span></a>
|
||||
<a href="/monitor"><span class="nav-icon">{{icon "preview"}}</span><span>视频监控</span></a>
|
||||
<a href="/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备管理</span></a>
|
||||
<a href="/console"><span class="nav-icon">{{icon "apply"}}</span><span>运行看板</span></a>
|
||||
<a href="/face-gallery"><span class="nav-icon">{{icon "profile"}}</span><span>人脸库</span></a>
|
||||
<a href="/wizard"><span class="nav-icon">{{icon "control"}}</span><span>部署向导</span></a>
|
||||
<details class="nav-group" id="system-nav-group">
|
||||
<summary>
|
||||
<span class="nav-icon">{{icon "system"}}</span>
|
||||
<span>系统设置</span>
|
||||
</summary>
|
||||
<div class="nav-group-items">
|
||||
<a class="nav-subitem" href="/ui/system"><span class="nav-icon nav-subicon">{{icon "heartbeat"}}</span><span>系统状态</span></a>
|
||||
<a class="nav-subitem" href="/ui/diagnostics"><span class="nav-icon nav-subicon">{{icon "logs"}}</span><span>日志审计</span></a>
|
||||
<a class="nav-subitem" href="/ui/tasks"><span class="nav-icon nav-subicon">{{icon "task"}}</span><span>任务中心</span></a>
|
||||
<a class="nav-subitem" href="/ui/scene-templates"><span class="nav-icon nav-subicon">{{icon "profile"}}</span><span>场景管理</span></a>
|
||||
<a class="nav-subitem" href="/ui/recognition-units"><span class="nav-icon nav-subicon">{{icon "device"}}</span><span>视频通道</span></a>
|
||||
<a class="nav-subitem" href="/ui/device-assignments"><span class="nav-icon nav-subicon">{{icon "apply"}}</span><span>通道部署</span></a>
|
||||
<a class="nav-subitem" href="/ui/assets"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>配置中心</span></a>
|
||||
<a class="nav-subitem" href="/ui/models"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>模型管理</span></a>
|
||||
<a class="nav-subitem" href="/ui/resources"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资源管理</span></a>
|
||||
<a class="nav-subitem" href="/system"><span class="nav-icon nav-subicon">{{icon "heartbeat"}}</span><span>系统状态</span></a>
|
||||
<a class="nav-subitem" href="/diagnostics"><span class="nav-icon nav-subicon">{{icon "logs"}}</span><span>日志审计</span></a>
|
||||
<a class="nav-subitem" href="/tasks"><span class="nav-icon nav-subicon">{{icon "task"}}</span><span>任务中心</span></a>
|
||||
<details class="nav-subgroup expert-only" id="detection-config-group">
|
||||
<summary>
|
||||
<span class="nav-icon nav-subicon">{{icon "config"}}</span>
|
||||
<span>检测配置</span>
|
||||
</summary>
|
||||
<div class="nav-subgroup-items">
|
||||
<a class="nav-subitem" href="/scene-templates"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>场景模板</span></a>
|
||||
<a class="nav-subitem" href="/recognition-units"><span class="nav-icon nav-subicon">{{icon "device"}}</span><span>检测通道</span></a>
|
||||
<a class="nav-subitem" href="/device-assignments"><span class="nav-icon nav-subicon">{{icon "apply"}}</span><span>通道部署</span></a>
|
||||
</div>
|
||||
</details>
|
||||
<a class="nav-subitem expert-only" href="/assets"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资产管理</span></a>
|
||||
<a class="nav-subitem expert-only" href="/models"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>模型管理</span></a>
|
||||
</div>
|
||||
</details>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div>北京泰奥理科技有限公司</div>
|
||||
<div class="mode-toggle" id="mode-toggle" role="switch" tabindex="0" aria-label="切换操作模式">
|
||||
<span class="mode-label mode-label-left">标准</span>
|
||||
<span class="mode-track"><span class="mode-thumb"></span></span>
|
||||
<span class="mode-label mode-label-right">专家</span>
|
||||
</div>
|
||||
<div>{{if .SystemCompanyName}}{{.SystemCompanyName}}{{else}}SafeSight{{end}}</div>
|
||||
<div>© {{.Year}} V{{.Version}}</div>
|
||||
</div>
|
||||
</aside>
|
||||
@ -65,11 +77,12 @@
|
||||
<button type="button" data-theme-option="graphite-gold">石墨金色</button>
|
||||
</div>
|
||||
</div>
|
||||
<a class="topbar-icon-btn" href="/ui/diagnostics" aria-label="日志审计" title="日志审计">
|
||||
<a class="topbar-icon-btn" href="/alarms" aria-label="告警中心" title="告警中心" id="topbar-alarm-btn">
|
||||
{{icon "bell"}}
|
||||
<span class="topbar-dot" aria-hidden="true"></span>
|
||||
<span class="topbar-dot" id="alarm-dot" {{if eq .UnacknowledgedAlarmCount 0}}hidden{{end}} aria-hidden="true"></span>
|
||||
<span class="topbar-badge" id="alarm-badge" {{if eq .UnacknowledgedAlarmCount 0}}hidden{{end}}>{{if gt .UnacknowledgedAlarmCount 99}}99+{{else}}{{.UnacknowledgedAlarmCount}}{{end}}</span>
|
||||
</a>
|
||||
<a class="topbar-icon-btn" href="/ui/system" aria-label="系统" title="系统">
|
||||
<a class="topbar-icon-btn" href="/system" aria-label="系统" title="系统">
|
||||
{{icon "system"}}
|
||||
</a>
|
||||
</div>
|
||||
@ -81,7 +94,8 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/ui/assets/vendor/tabler.min.js"></script>
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
<script src="/assets/vendor/tabler.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const themeKey = "3588-admin-theme";
|
||||
@ -326,21 +340,103 @@
|
||||
if (systemNavGroup) {
|
||||
const path = window.location.pathname || "";
|
||||
if (
|
||||
path === "/ui/system" ||
|
||||
path === "/ui/diagnostics" ||
|
||||
path === "/ui/tasks" ||
|
||||
path === "/ui/scene-templates" ||
|
||||
path === "/ui/recognition-units" ||
|
||||
path === "/ui/device-assignments" ||
|
||||
path === "/ui/assets" ||
|
||||
path === "/ui/models" ||
|
||||
path === "/ui/resources" ||
|
||||
path === "/ui/audit" ||
|
||||
path === "/ui/api"
|
||||
path === "/system" ||
|
||||
path === "/diagnostics" ||
|
||||
path === "/tasks" ||
|
||||
path === "/scene-templates" ||
|
||||
path === "/recognition-units" ||
|
||||
path === "/device-assignments" ||
|
||||
path === "/assets" ||
|
||||
path === "/models" ||
|
||||
path === "/audit" ||
|
||||
path === "/api"
|
||||
) {
|
||||
systemNavGroup.open = true;
|
||||
}
|
||||
}
|
||||
const detectionGroup = document.getElementById("detection-config-group");
|
||||
if (detectionGroup) {
|
||||
const path = window.location.pathname || "";
|
||||
if (
|
||||
path === "/scene-templates" ||
|
||||
path === "/recognition-units" ||
|
||||
path === "/device-assignments"
|
||||
) {
|
||||
detectionGroup.open = true;
|
||||
}
|
||||
}
|
||||
|
||||
var modeKey = "safesight-mode";
|
||||
function applyMode(mode, persist) {
|
||||
var nextMode = mode === "expert" ? "expert" : "standard";
|
||||
document.body.setAttribute("data-mode", nextMode);
|
||||
if (persist) {
|
||||
localStorage.setItem(modeKey, nextMode);
|
||||
}
|
||||
}
|
||||
applyMode(localStorage.getItem(modeKey) || "standard", false);
|
||||
document.getElementById("mode-toggle").addEventListener("click", function () {
|
||||
var current = document.body.getAttribute("data-mode") || "standard";
|
||||
applyMode(current === "expert" ? "standard" : "expert", true);
|
||||
});
|
||||
|
||||
var originalTitle = document.title;
|
||||
var alarmDot = document.getElementById("alarm-dot");
|
||||
var alarmBadge = document.getElementById("alarm-badge");
|
||||
|
||||
function updateAlarmBadge(count) {
|
||||
if (!alarmDot || !alarmBadge) return;
|
||||
if (count > 0) {
|
||||
alarmDot.hidden = false;
|
||||
alarmBadge.hidden = false;
|
||||
alarmBadge.textContent = count > 99 ? "99+" : count;
|
||||
document.title = "(" + count + ") " + originalTitle;
|
||||
} else {
|
||||
alarmDot.hidden = true;
|
||||
alarmBadge.hidden = true;
|
||||
document.title = originalTitle;
|
||||
}
|
||||
}
|
||||
|
||||
function pollAlarmCount() {
|
||||
fetch("/api/alarms/unacknowledged-count", {credentials:"same-origin"})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(data){updateAlarmBadge(data.count||0)})
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
setInterval(pollAlarmCount, 15000);
|
||||
|
||||
var container = document.getElementById("toast-container");
|
||||
if (container) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
function showToast(text, type) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "toast " + type;
|
||||
el.textContent = text;
|
||||
container.appendChild(el);
|
||||
setTimeout(function(){
|
||||
el.classList.add("out");
|
||||
setTimeout(function(){el.remove()}, 250);
|
||||
}, 4000);
|
||||
}
|
||||
var msg = params.get("msg");
|
||||
var err = params.get("error");
|
||||
if (msg || err) {
|
||||
var staticEl = document.querySelector("main > .msg, main > .error");
|
||||
if (staticEl) staticEl.style.display = "none";
|
||||
}
|
||||
if (msg) showToast(decodeURIComponent(msg), "success");
|
||||
if (err) showToast(decodeURIComponent(err), "error");
|
||||
if (msg || err) {
|
||||
params.delete("msg");
|
||||
params.delete("error");
|
||||
var newUrl = window.location.pathname;
|
||||
var qs = params.toString();
|
||||
if (qs) newUrl += "?" + qs;
|
||||
history.replaceState(null, "", newUrl);
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<h2>日志分析</h2>
|
||||
<div class="muted small">按设备查看诊断日志、运行指标,并进入高级调试。</div>
|
||||
</div>
|
||||
<a class="btn ghost" href="/ui/api">高级调试</a>
|
||||
<a class="btn ghost" href="/api">高级调试</a>
|
||||
</div>
|
||||
<div class="model-summary">
|
||||
<div class="summary-item"><div class="summary-label">日志筛选</div><div class="summary-value">设备/数量</div><div class="summary-hint">首版按设备和 limit 查看</div></div>
|
||||
@ -23,13 +23,13 @@
|
||||
<tbody>
|
||||
{{range .Devices}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a><div class="muted small mono">{{.DeviceID}}</div></td>
|
||||
<td><a class="mono" href="/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a><div class="muted small mono">{{.DeviceID}}</div></td>
|
||||
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
|
||||
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/graphs">运行指标</a>
|
||||
<a class="btn ghost" href="/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
|
||||
<a class="btn ghost" href="/devices/{{.DeviceID}}/graphs">运行指标</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
<div class="form-hint">统一维护标准模型目录,设备页只看当前生效状态,更新动作统一走任务。</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<button class="btn secondary" type="button">上传新模型</button>
|
||||
<form method="post" action="/ui/models/sync">
|
||||
{{range .Devices}}{{if .Online}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}
|
||||
<button class="btn secondary" type="button" onclick="showUpload()">上传新模型</button>
|
||||
<form method="post" action="/models/sync">
|
||||
{{if $board}}{{range $board.Rows}}{{if .NeedsSync}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}{{end}}
|
||||
<input type="hidden" name="action" value="model_sync_all">
|
||||
<button class="btn" type="submit">更新全部模型</button>
|
||||
</form>
|
||||
@ -87,12 +87,13 @@
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-key">{{.DeviceName}}</div>
|
||||
<div class="muted small mono">{{.DeviceID}}</div>
|
||||
<div class="muted small mono">{{.DeviceIP}}</div>
|
||||
</td>
|
||||
{{range .Cells}}
|
||||
<td>
|
||||
{{if eq .Status "ok"}}<span class="pill ok">一致</span>
|
||||
{{else if eq .Status "mismatch"}}<span class="pill warn">不一致</span>
|
||||
{{else if eq .Status "na"}}<span class="pill muted-pill">不适用</span>
|
||||
{{else}}<span class="pill bad">缺失</span>{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
@ -119,4 +120,45 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal" class="modal-overlay" style="display:none">
|
||||
<div class="card" style="max-width:480px;width:90%;position:relative">
|
||||
<span class="btn-icon ghost" style="position:absolute;top:8px;right:8px" onclick="closeModal()">×</span>
|
||||
<div id="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showUpload(){
|
||||
document.getElementById('modal-body').innerHTML=
|
||||
'<h3>上传新模型</h3>'+
|
||||
'<form method="post" action="/models/upload" enctype="multipart/form-data">'+
|
||||
'<div class="field-grid">'+
|
||||
'<label><span>模型名称</span><input type="text" name="name" required placeholder="如 face_det_v2" /></label>'+
|
||||
'<label><span>模型类型</span><select name="model_type">'+
|
||||
'<option value="face_detection">人脸检测</option>'+
|
||||
'<option value="face_recognition">人脸识别</option>'+
|
||||
'<option value="ppe_detection">PPE检测</option>'+
|
||||
'<option value="shoe_detection">工鞋检测</option>'+
|
||||
'<option value="object_detection">通用检测</option>'+
|
||||
'<option value="other">其他</option>'+
|
||||
'</select></label>'+
|
||||
'<label><span>版本号 <span class="muted small">(可选,留空自动生成)</span></span><input type="text" name="version" placeholder="自动生成" /></label>'+
|
||||
'<label><span>描述 <span class="muted small">(可选)</span></span><input type="text" name="description" placeholder="" /></label>'+
|
||||
'<label><span>模型文件 (.rknn)</span><input type="file" name="file" accept=".rknn" required /></label>'+
|
||||
'</div>'+
|
||||
'<label style="display:flex;align-items:center;gap:8px;margin:12px 0">'+
|
||||
'<input type="checkbox" name="auto_sync" value="1" checked />'+
|
||||
'<span class="small">上传后自动分发到所有在线设备</span>'+
|
||||
'</label>'+
|
||||
'<div class="actions"><button class="btn" type="submit">上传</button></div>'+
|
||||
'</form>';
|
||||
document.getElementById('modal').style.display='flex';
|
||||
}
|
||||
function closeModal(){document.getElementById('modal').style.display='none';}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:1000;align-items:center;justify-content:center}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
<div class="card muted" style="text-align:center;grid-column:1/-1">加载中…</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
<script src="/assets/vendor/hls.min.js"></script>
|
||||
<script>
|
||||
function loadAll() {
|
||||
var wall = document.getElementById("video-wall");
|
||||
@ -32,7 +32,7 @@ function loadAll() {
|
||||
}
|
||||
|
||||
var promises = devices.map(function(dev) {
|
||||
return fetch('/ui/api/monitor/channels?device_id=' + encodeURIComponent(dev.id))
|
||||
return fetch('/api/monitor/channels?device_id=' + encodeURIComponent(dev.id))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) { return (data.channels||[]).map(function(ch) { ch._dev = dev.name; ch._devId = dev.id; return ch; }); })
|
||||
.catch(function() { return []; });
|
||||
@ -49,7 +49,7 @@ function loadAll() {
|
||||
wall.style.gridTemplateColumns = "repeat(" + cols + ",minmax(0,1fr))";
|
||||
var html = "";
|
||||
all.forEach(function(ch, i) {
|
||||
var proxyUrl = '/ui/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
|
||||
var proxyUrl = '/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
|
||||
html += '<div class="card" style="padding:8px">';
|
||||
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">';
|
||||
html += '<span style="font-size:12px;font-weight:500">' + esc(ch._dev) + ' · ' + esc(ch.name) + '</span>';
|
||||
@ -65,7 +65,7 @@ function loadAll() {
|
||||
// Initialize HLS players
|
||||
all.forEach(function(ch, i) {
|
||||
if (!ch.hls_url || !ch._devId) return;
|
||||
var proxyUrl = '/ui/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
|
||||
var proxyUrl = '/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
|
||||
var video = document.getElementById('v' + i);
|
||||
if (!video) return;
|
||||
if (Hls.isSupported()) {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<h2>识别配置</h2>
|
||||
<div class="muted small">管理识别方案、视频通道、模板参数和推理配置。</div>
|
||||
</div>
|
||||
<a class="btn ghost" href="/ui/templates">模板库</a>
|
||||
<a class="btn ghost" href="/templates">模板库</a>
|
||||
</div>
|
||||
<div class="model-summary">
|
||||
<div class="summary-item"><div class="summary-label">识别方案</div><div class="summary-value">按设备配置</div><div class="summary-hint">进入单台设备维护方案</div></div>
|
||||
@ -23,13 +23,13 @@
|
||||
<tbody>
|
||||
{{range .Devices}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a><div class="muted small mono">{{.DeviceID}}</div></td>
|
||||
<td><a class="mono" href="/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a><div class="muted small mono">{{.DeviceID}}</div></td>
|
||||
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
|
||||
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/config-friendly">编辑视频通道</a>
|
||||
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/config-ui">高级 JSON</a>
|
||||
<a class="btn ghost" href="/devices/{{.DeviceID}}/config-friendly">编辑视频通道</a>
|
||||
<a class="btn ghost" href="/devices/{{.DeviceID}}/config-ui">高级 JSON</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频通道列表</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>检测通道列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/recognition-units?new=1">{{icon "apply"}}<span>新增视频通道</span></a>
|
||||
<a class="btn secondary" href="/recognition-units?new=1">{{icon "apply"}}<span>新增检测通道</span></a>
|
||||
{{if .SelectedRecognitionUnit}}
|
||||
<a class="btn secondary" href="/ui/recognition-units?ref={{.SelectedRecognitionUnit}}&edit=1">编辑</a>
|
||||
<form method="post" action="/ui/recognition-units/delete" onsubmit="return confirm('确认删除这个视频通道吗?');">
|
||||
<a class="btn secondary" href="/recognition-units?ref={{.SelectedRecognitionUnit}}&edit=1">编辑</a>
|
||||
<form method="post" action="/recognition-units/delete" onsubmit="return confirm('确认删除这个检测通道吗?');">
|
||||
<input type="hidden" name="ref" value="{{.SelectedRecognitionUnit}}" />
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
@ -19,22 +19,26 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>视频通道</th>
|
||||
<th>检测通道</th>
|
||||
<th>场景</th>
|
||||
<th>视频源</th>
|
||||
<th>输出频道号</th>
|
||||
<th>RTSP</th>
|
||||
<th>HLS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecognitionUnits}}
|
||||
<tr data-nav-row data-nav-href="/ui/recognition-units?ref={{.Ref}}" {{if eq $.SelectedRecognitionUnit .Ref}}class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/recognition-units?ref={{.Ref}}">{{.Name}}</a>{{if .DisplayName}}<div class="stacked-meta"><span>{{.DisplayName}}</span></div>{{end}}</td>
|
||||
<tr data-nav-row data-nav-href="/recognition-units?ref={{.Ref}}" {{if eq $.SelectedRecognitionUnit .Ref}}class="selected"{{end}}>
|
||||
<td><a class="mono" href="/recognition-units?ref={{.Ref}}">{{.Name}}</a>{{if .DisplayName}}<div class="stacked-meta"><span>{{.DisplayName}}</span></div>{{end}}</td>
|
||||
<td class="mono">{{.SceneTemplateName}}</td>
|
||||
<td class="mono">{{if .VideoSourceRef}}{{.VideoSourceRef}}{{else}}-{{end}}</td>
|
||||
<td class="mono">{{if .OutputChannel}}{{.OutputChannel}}{{else}}-{{end}}</td>
|
||||
<td class="mono small">rtsp://<IP>:8555/live/{{.Name}}</td>
|
||||
<td class="mono small">http://<IP>:9000/hls/{{.Name}}/index.m3u8</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有视频通道</div></div></td></tr>
|
||||
<tr><td colspan="6"><div class="empty-state compact"><div class="empty-title">还没有检测通道</div></div></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -42,16 +46,16 @@
|
||||
</div>
|
||||
|
||||
{{if .RecognitionUnit}}
|
||||
<form method="post" action="/ui/recognition-units">
|
||||
<form method="post" action="/recognition-units">
|
||||
<input type="hidden" name="original_ref" value="{{.SelectedRecognitionUnit}}" />
|
||||
<div class="card editor-state {{if .RecognitionUnitEditing}}editing{{else}}readonly{{end}}">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>视频通道{{if .RecognitionUnit.Name}} · {{.RecognitionUnit.Name}}{{end}}</span></h2>
|
||||
<h2 class="title-with-icon">{{icon "device"}}<span>检测通道{{if .RecognitionUnit.Name}} · {{.RecognitionUnit.Name}}{{end}}</span></h2>
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .RecognitionUnitEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>一路视频对应一个视频通道,由通道部署决定最终在哪台设备上运行。</span>
|
||||
<span>一路视频对应一个检测通道,由通道部署决定最终在哪台设备上运行。</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
|
||||
@ -60,14 +64,14 @@
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
{{if .RecognitionUnitEditing}}
|
||||
<button type="submit">{{icon "apply"}}<span>保存视频通道</span></button>
|
||||
<a class="btn secondary" href="/ui/recognition-units{{if .SelectedRecognitionUnit}}?ref={{.SelectedRecognitionUnit}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
<button type="submit">{{icon "apply"}}<span>保存检测通道</span></button>
|
||||
<a class="btn secondary" href="/recognition-units{{if .SelectedRecognitionUnit}}?ref={{.SelectedRecognitionUnit}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .RecognitionUnitEditing}}
|
||||
<div class="field-grid">
|
||||
<label><span>视频通道名称<span class="required-mark">*</span></span><input name="name" value="{{.RecognitionUnit.Name}}" {{if not .SelectedRecognitionUnit}}autofocus{{end}} /></label>
|
||||
<label><span>检测通道名称<span class="required-mark">*</span></span><input name="name" value="{{.RecognitionUnit.Name}}" {{if not .SelectedRecognitionUnit}}autofocus{{end}} /></label>
|
||||
<label>
|
||||
<span>场景<span class="required-mark">*</span></span>
|
||||
<select name="scene_template_name">
|
||||
@ -92,13 +96,15 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="detail-sheet">
|
||||
<div class="detail-item"><span>视频通道名称</span><strong class="mono">{{if .RecognitionUnit.Name}}{{.RecognitionUnit.Name}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>检测通道名称</span><strong class="mono">{{if .RecognitionUnit.Name}}{{.RecognitionUnit.Name}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>场景</span><strong class="mono">{{if .RecognitionUnit.SceneTemplateName}}{{.RecognitionUnit.SceneTemplateName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>通道显示名</span><strong>{{if .RecognitionUnit.DisplayName}}{{.RecognitionUnit.DisplayName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>站点名</span><strong>{{if .RecognitionUnit.SiteName}}{{.RecognitionUnit.SiteName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>视频源</span><strong class="mono">{{if .RecognitionUnit.VideoSourceRef}}{{.RecognitionUnit.VideoSourceRef}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>输出频道号</span><strong class="mono">{{if .RecognitionUnit.OutputChannel}}{{.RecognitionUnit.OutputChannel}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>RTSP 端口</span><strong class="mono">{{if .RecognitionUnit.RTSPPort}}{{.RecognitionUnit.RTSPPort}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item full"><span>RTSP 地址</span><strong class="mono">rtsp://<IP>:{{if .RecognitionUnit.RTSPPort}}{{.RecognitionUnit.RTSPPort}}{{else}}8555{{end}}/live/{{.RecognitionUnit.Name}}</strong></div>
|
||||
<div class="detail-item full"><span>HLS 地址</span><strong class="mono">http://<IP>:9000/hls/{{.RecognitionUnit.Name}}/index.m3u8</strong></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<div class="form-hint">统一维护标准资源,设备侧通过任务同步。当前支持人脸库等资源类型。</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<form method="post" action="/ui/resources/sync">
|
||||
<form method="post" action="/resources/sync">
|
||||
{{range .Devices}}{{if .Online}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}
|
||||
<input type="hidden" name="action" value="resource_sync_all">
|
||||
<button class="btn" type="submit">同步全部资源</button>
|
||||
@ -85,7 +85,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<div class="table-key">{{.DeviceName}}</div>
|
||||
<div class="muted small mono">{{.DeviceID}}</div>
|
||||
<div class="muted small mono">{{.DeviceIP}}</div>
|
||||
</td>
|
||||
{{range .Cells}}
|
||||
<td>
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>场景列表</span></h2>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<a class="btn secondary" href="/ui/scene-templates?new=1">{{icon "apply"}}<span>新建场景</span></a>
|
||||
<a class="btn secondary" href="/scene-templates?new=1">{{icon "apply"}}<span>新建场景</span></a>
|
||||
{{if .SelectedProfile}}
|
||||
<a class="btn secondary" href="/ui/scene-templates?name={{.SelectedProfile}}&edit=1">编辑</a>
|
||||
<form method="post" action="/ui/scene-templates/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景吗?');">
|
||||
<a class="btn secondary" href="/scene-templates?name={{.SelectedProfile}}&edit=1">编辑</a>
|
||||
<form method="post" action="/scene-templates/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景吗?');">
|
||||
<button class="btn secondary" type="submit">删除</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@ -21,13 +21,13 @@
|
||||
<th>场景</th>
|
||||
<th>识别模板</th>
|
||||
<th>调试参数</th>
|
||||
<th>视频通道</th>
|
||||
<th>检测通道</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AssetProfiles}}
|
||||
<tr data-nav-row data-nav-href="/ui/scene-templates?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
|
||||
<td><a class="mono" href="/ui/scene-templates?name={{.Name}}">{{.Name}}</a></td>
|
||||
<tr data-nav-row data-nav-href="/scene-templates?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
|
||||
<td><a class="mono" href="/scene-templates?name={{.Name}}">{{.Name}}</a></td>
|
||||
<td>{{if index .Raw "primary_template_name"}}{{index .Raw "primary_template_name"}}{{else if .Instances}}{{(index .Instances 0).Template}}{{else}}-{{end}}</td>
|
||||
<td>{{if $.AssetProfileEditor}}{{if and (eq $.AssetProfileEditor.Name .Name) $.AssetProfileEditor.OverlayName}}{{$.AssetProfileEditor.OverlayName}}{{else}}-{{end}}{{else}}-{{end}}</td>
|
||||
<td>{{len .Instances}}</td>
|
||||
@ -49,7 +49,7 @@
|
||||
<div class="form-hint form-state-hint">
|
||||
{{if .AssetProfileEditing}}
|
||||
<span class="pill run">编辑模式</span>
|
||||
<span>当前内容只包含场景级信息,视频通道请到“视频通道”页面维护。</span>
|
||||
<span>当前内容只包含场景级信息,检测通道请到“检测通道”页面维护。</span>
|
||||
{{else}}
|
||||
<span class="pill">查看模式</span>
|
||||
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
|
||||
@ -59,9 +59,9 @@
|
||||
<div class="actions compact">
|
||||
{{if .AssetProfileEditing}}
|
||||
<button type="submit">{{icon "apply"}}<span>保存场景</span></button>
|
||||
<a class="btn secondary" href="/ui/scene-templates{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
<a class="btn secondary" href="/scene-templates{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
|
||||
{{end}}
|
||||
<button type="button" class="btn secondary js-export-json" data-export-url="/ui/scene-templates/{{.AssetProfileEditor.Name}}/export" data-default-filename="{{.AssetProfileEditor.Name}}.json">{{icon "apply"}}<span>导出为 JSON</span></button>
|
||||
<button type="button" class="btn secondary js-export-json" data-export-url="/scene-templates/{{.AssetProfileEditor.Name}}/export" data-default-filename="{{.AssetProfileEditor.Name}}.json">{{icon "apply"}}<span>导出为 JSON</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -95,7 +95,7 @@
|
||||
<div class="detail-item"><span>业务名称</span><strong>{{if .AssetProfileEditor.BusinessName}}{{.AssetProfileEditor.BusinessName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>调试参数</span><strong>{{if .AssetProfileEditor.OverlayName}}{{.AssetProfileEditor.OverlayName}}{{else}}不使用{{end}}</strong></div>
|
||||
<div class="detail-item"><span>站点名</span><strong>{{if .AssetProfileEditor.SiteName}}{{.AssetProfileEditor.SiteName}}{{else}}-{{end}}</strong></div>
|
||||
<div class="detail-item"><span>视频通道</span><strong>{{len .AssetProfileEditor.Instances}} 路</strong></div>
|
||||
<div class="detail-item"><span>检测通道</span><strong>{{len .AssetProfileEditor.Instances}} 路</strong></div>
|
||||
<div class="detail-item full"><span>描述</span><strong>{{if .AssetProfileEditor.Description}}{{.AssetProfileEditor.Description}}{{else}}-{{end}}</strong></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@ -1,25 +1,9 @@
|
||||
{{define "system"}}
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "audit"}}<span>数据备份</span></h2>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>数据库文件</span><strong class="mono">{{if .DBPath}}{{.DBPath}}{{else}}未配置{{end}}</strong></div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<button type="button" class="btn ghost js-export-db" data-export-url="/ui/system/db-backup" data-default-filename="app.db">备份数据库</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "apply"}}<span>数据恢复</span></h2>
|
||||
<form method="post" action="/ui/system/db-restore" enctype="multipart/form-data">
|
||||
<div class="field-grid">
|
||||
<label class="full"><span>选择备份文件</span><input type="file" name="file" accept=".db,application/octet-stream" required /></label>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<button type="submit" class="secondary">恢复数据库</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "heartbeat"}}<span>本机时间</span></h2>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>当前时间</span><strong id="local-time">--</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -34,6 +18,42 @@
|
||||
<button class="btn ghost" type="button" id="btn-refresh">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "system"}}<span>系统配置</span></h2>
|
||||
<form method="post" action="/system/config">
|
||||
<div class="field-grid">
|
||||
<label><span>告警保留天数</span><input type="number" name="alarm_retention_days" value="{{.SystemAlarmRetention}}" min="0" /><div class="form-hint">0=永久保留</div></label>
|
||||
<label><span>公司名称</span><input type="text" name="company_name" value="{{.SystemCompanyName}}" placeholder="将在页面底部显示" /></label>
|
||||
<label><span>时间同步阈值(秒)</span><input type="number" name="time_sync_threshold_sec" value="{{.TimeSyncThreshold}}" min="1" /><div class="form-hint">偏差超此值显示角标,默认5秒</div></label>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<button type="submit" class="secondary">保存配置</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "audit"}}<span>数据备份</span></h2>
|
||||
<div class="info-list compact-list">
|
||||
<div><span>数据库文件</span><strong class="mono">{{if .DBPath}}{{.DBPath}}{{else}}未配置{{end}}</strong></div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<button type="button" class="btn ghost js-export-db" data-export-url="/system/db-backup" data-default-filename="app.db">备份数据库</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="title-with-icon">{{icon "apply"}}<span>数据恢复</span></h2>
|
||||
<form method="post" action="/system/db-restore" enctype="multipart/form-data">
|
||||
<div class="field-grid">
|
||||
<label class="full"><span>选择备份文件</span><input type="file" name="file" accept=".db,application/octet-stream" required /></label>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:12px">
|
||||
<button type="submit" class="secondary">恢复数据库</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@ -46,7 +66,6 @@
|
||||
svcEl.style.color = "";
|
||||
apiEl.textContent = "检测中…";
|
||||
apiEl.style.color = "";
|
||||
|
||||
fetch("/health")
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(t) {
|
||||
@ -62,7 +81,6 @@
|
||||
svcEl.textContent = "无法连接";
|
||||
svcEl.style.color = "var(--danger-soft-text)";
|
||||
});
|
||||
|
||||
fetch("/openapi.json")
|
||||
.then(function(r) {
|
||||
if (r.ok) {
|
||||
@ -78,9 +96,21 @@
|
||||
apiEl.style.color = "var(--danger-soft-text)";
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("btn-refresh").addEventListener("click", check);
|
||||
check();
|
||||
|
||||
function updateTime() {
|
||||
var now = new Date();
|
||||
var s = now.getFullYear() + '-' +
|
||||
String(now.getMonth()+1).padStart(2,'0') + '-' +
|
||||
String(now.getDate()).padStart(2,'0') + ' ' +
|
||||
String(now.getHours()).padStart(2,'0') + ':' +
|
||||
String(now.getMinutes()).padStart(2,'0') + ':' +
|
||||
String(now.getSeconds()).padStart(2,'0');
|
||||
document.getElementById('local-time').textContent = s;
|
||||
}
|
||||
updateTime();
|
||||
setInterval(updateTime, 1000);
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{{define "task"}}
|
||||
<div class="card">
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="/ui/tasks">{{icon "devices"}}<span>返回任务列表</span></a>
|
||||
<a class="btn ghost" href="/tasks">{{icon "devices"}}<span>返回任务列表</span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
{{range .Tasks}}
|
||||
<tr>
|
||||
<td>
|
||||
<div><a class="mono" href="/ui/tasks/{{.ID}}">{{shortID .ID}}</a></div>
|
||||
<div><a class="mono" href="/tasks/{{.ID}}">{{shortID .ID}}</a></div>
|
||||
<div class="muted small">{{taskActionLabel .Type}}</div>
|
||||
</td>
|
||||
<td>
|
||||
@ -20,8 +20,8 @@
|
||||
<td>
|
||||
<span class="{{taskStatusClass .Status}}">{{taskStatusLabel .Status}}</span>
|
||||
</td>
|
||||
<td class="muted small">{{.CreatedAt}}</td>
|
||||
<td class="muted small">{{.CompletedAt}}</td>
|
||||
<td class="muted small">{{formatTime .CreatedAt}}</td>
|
||||
<td class="muted small">{{formatTime .CompletedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{{define "template"}}
|
||||
<div class="card">
|
||||
<div><a href="/ui/templates">返回识别配置模板</a></div>
|
||||
<div><a href="/templates">返回识别配置模板</a></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
105
internal/web/ui/templates/template_settings.html
Normal file
105
internal/web/ui/templates/template_settings.html
Normal file
@ -0,0 +1,105 @@
|
||||
{{define "template_settings"}}
|
||||
{{template "asset_tabs" .}}
|
||||
{{if .Error}}
|
||||
<div class="card error">{{.Error}}</div>
|
||||
{{end}}
|
||||
{{if .TuningItems}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<h2 class="title-with-icon">{{icon "edit"}}<span>{{.AssetTemplate.Name}} · 参数调整</span></h2>
|
||||
</div>
|
||||
<p class="hint">修改参数后,点击「保存并下发」将更新所有使用此模板的设备。</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/assets/templates/{{.AssetTemplate.Name}}/settings">
|
||||
{{range $group, $items := .TuningGroups}}
|
||||
<div class="card">
|
||||
<h3 class="title-with-icon">{{icon "edit"}}<span>{{$group}}</span></h3>
|
||||
<div class="tuning-list">
|
||||
{{range $items}}
|
||||
<div class="tuning-item">
|
||||
<div class="tuning-label-wrap">
|
||||
<label class="tuning-label">{{.Label}}</label>
|
||||
{{if .Desc}}<span class="tuning-desc">{{.Desc}}</span>{{end}}
|
||||
</div>
|
||||
<div class="tuning-control">
|
||||
<input type="range" name="tune_{{.Path}}" value="{{printf "%v" .Value}}" min="{{.Min}}" max="{{.Max}}" step="{{printf "%v" .Step}}"
|
||||
data-step="{{printf "%v" .Step}}" data-unit="{{.Unit}}"
|
||||
oninput="var s=Number(this.dataset.step);var d=s>=1?0:2;var u=this.dataset.unit||'';this.nextElementSibling.textContent=Number(this.value).toFixed(d)+(u?' '+u:'')">
|
||||
<output>{{formatTuneValue .Value .Step}}{{if .Unit}} {{.Unit}}{{end}}</output>
|
||||
{{if .Hint}}<span class="muted small">({{.Hint}})</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card" id="affected-devices" style="display:none">
|
||||
<h3 class="title-with-icon">{{icon "device"}}<span>受影响设备 (<span id="affected-count">0</span>)</span></h3>
|
||||
<div id="affected-list" class="device-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="card actions">
|
||||
<button type="submit" class="btn primary" name="action" value="save">保存参数</button>
|
||||
<button type="submit" class="btn primary" name="action" value="deploy" id="btn-deploy" disabled>保存并下发</button>
|
||||
<a class="btn secondary" href="/assets/templates/{{.AssetTemplate.Name}}">返回</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.querySelector('form').addEventListener('submit', async function(e) {
|
||||
const btn = e.submitter;
|
||||
if (!btn || btn.value !== 'deploy') return;
|
||||
e.preventDefault();
|
||||
|
||||
const form = this;
|
||||
const fd = new FormData(form);
|
||||
fd.set('action', 'preview');
|
||||
|
||||
const resp = await fetch(form.action, { method: 'POST', body: fd });
|
||||
const data = await resp.json();
|
||||
|
||||
const div = document.getElementById('affected-devices');
|
||||
const count = document.getElementById('affected-count');
|
||||
const list = document.getElementById('affected-list');
|
||||
const deployBtn = document.getElementById('btn-deploy');
|
||||
|
||||
div.style.display = 'block';
|
||||
count.textContent = data.devices ? data.devices.length : 0;
|
||||
|
||||
if (data.devices && data.devices.length > 0) {
|
||||
deployBtn.disabled = false;
|
||||
list.innerHTML = data.devices.map(function(d) {
|
||||
return '<span class="pill ok device-item">' + d + '</span>';
|
||||
}).join('');
|
||||
} else {
|
||||
deployBtn.disabled = true;
|
||||
list.innerHTML = '<p class="hint">没有设备使用此模板。请先在运行看板中为设备分配此模板。</p>';
|
||||
}
|
||||
|
||||
// Store affected devices for final submit
|
||||
document.getElementById('affected-ids').value = (data.devices || []).join(',');
|
||||
document.getElementById('deploy-flag').value = '1';
|
||||
});
|
||||
|
||||
document.getElementById('btn-deploy').addEventListener('click', function() {
|
||||
if (document.getElementById('affected-ids').value) {
|
||||
document.getElementById('deploy-flag').value = '1';
|
||||
document.querySelector('form').submit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<input type="hidden" name="affected_ids" id="affected-ids" value="">
|
||||
<input type="hidden" name="do_deploy" id="deploy-flag" value="0">
|
||||
|
||||
{{else}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<h2 class="title-with-icon">{{icon "edit"}}<span>{{.AssetTemplate.Name}} · 参数调整</span></h2>
|
||||
</div>
|
||||
<p class="hint">该模板没有定义可调参数。如需添加,请在模板 JSON 中添加 tuning 段。</p>
|
||||
<a class="btn secondary" href="/assets/templates/{{.AssetTemplate.Name}}">返回</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@ -9,7 +9,7 @@
|
||||
<tbody>
|
||||
{{range .Templates}}
|
||||
<tr>
|
||||
<td><a class="mono" href="/ui/templates/{{.Name}}">{{.Name}}</a></td>
|
||||
<td><a class="mono" href="/templates/{{.Name}}">{{.Name}}</a></td>
|
||||
<td><pre style="margin:0">{{json .Schema}}</pre></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
382
internal/web/ui/templates/wizard.html
Normal file
382
internal/web/ui/templates/wizard.html
Normal file
@ -0,0 +1,382 @@
|
||||
{{define "wizard"}}
|
||||
<div class="hero-band">
|
||||
<div>
|
||||
<div class="crumb">部署</div>
|
||||
<h2>部署向导</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="wizard-container">
|
||||
<div class="wizard-stepper" style="display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:20px;padding:0 40px">
|
||||
<div class="wizard-step active" data-step="1">
|
||||
<span class="wizard-step-num">1</span>
|
||||
<span>选择设备</span>
|
||||
</div>
|
||||
<div class="wizard-step-line"></div>
|
||||
<div class="wizard-step" data-step="2">
|
||||
<span class="wizard-step-num">2</span>
|
||||
<span>配置检测</span>
|
||||
</div>
|
||||
<div class="wizard-step-line"></div>
|
||||
<div class="wizard-step" data-step="3">
|
||||
<span class="wizard-step-num">3</span>
|
||||
<span>确认下发</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wizard-step-1" class="wizard-panel">
|
||||
<h3 style="margin-top:0">选择要部署的设备</h3>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px" id="device-grid">
|
||||
{{$hasOnline := false}}
|
||||
{{range .Devices}}
|
||||
{{if .Online}}
|
||||
{{$hasOnline = true}}
|
||||
<div class="wizard-device-card" data-device-id="{{.DeviceID}}" data-device-name="{{.DeviceName}}" data-device-ip="{{.IP}}">
|
||||
<div class="wizard-device-status online">在线{{if index $.WizardStale .DeviceID}}<span style="color:var(--amber);margin-left:4px">⚠ 需更新</span>{{end}}</div>
|
||||
<div class="wizard-device-name">{{.DeviceName}}</div>
|
||||
<div class="wizard-device-ip mono small">{{.IP}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if not $hasOnline}}
|
||||
<div class="empty-state compact">
|
||||
<div class="empty-title">暂无在线设备</div>
|
||||
<div class="muted">请先在"设备"页面发现并添加设备。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div style="margin-top:20px;display:flex;justify-content:flex-end">
|
||||
<button class="btn primary" id="btn-next-step" disabled>下一步 →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wizard-step-2" class="wizard-panel" style="display:none">
|
||||
<h3 style="margin-top:0">配置 <span id="step2-device-name">-</span> 的检测任务</h3>
|
||||
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:8px">已配置的摄像头</div>
|
||||
<div id="assigned-sources" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px">
|
||||
<div class="empty-state compact" id="no-assigned-sources">
|
||||
<div class="muted">该设备尚未配置摄像头。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding:12px;border:1px dashed var(--border);border-radius:var(--radius);margin-bottom:16px">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:8px">+ 添加摄像头</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
|
||||
<select id="existing-source-select" style="width:200px;font-size:12px">
|
||||
<option value="">选择已有视频源...</option>
|
||||
{{range .ConsoleAllVideoSources}}
|
||||
<option value="{{.Name}}|{{.Config.URL}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button class="btn" id="btn-add-existing-source" style="white-space:nowrap">添加此摄像头</button>
|
||||
</div>
|
||||
<div style="color:var(--muted);font-size:11px;margin:8px 0">或手动输入新摄像头</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input type="text" id="new-source-name" placeholder="摄像头名称(如:车间东门)" style="width:160px;font-size:12px">
|
||||
<input type="text" id="new-source-url" placeholder="RTSP 地址(rtsp://...)" style="flex:1;min-width:260px;font-size:12px;font-family:monospace">
|
||||
<button class="btn" id="btn-add-source" style="white-space:nowrap">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:8px">检测功能</div>
|
||||
<div id="feature-list" style="display:flex;gap:12px;flex-wrap:wrap">
|
||||
<span class="muted small" id="feature-placeholder">加载设备能力中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<button class="btn ghost" id="btn-prev-step">← 上一步</button>
|
||||
<button class="btn primary" id="btn-goto-step3">下一步 →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wizard-step-3" class="wizard-panel" style="display:none">
|
||||
<h3 style="margin-top:0">确认部署</h3>
|
||||
<div style="padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);margin-bottom:12px">
|
||||
<div style="font-size:12px;color:var(--muted);margin-bottom:6px">设备</div>
|
||||
<div style="font-weight:600" id="summary-device">-</div>
|
||||
</div>
|
||||
<div style="padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);margin-bottom:12px">
|
||||
<div style="font-size:12px;color:var(--muted);margin-bottom:6px">检测摄像头</div>
|
||||
<div id="summary-sources" style="font-weight:600">-</div>
|
||||
</div>
|
||||
<div style="padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);margin-bottom:12px">
|
||||
<div style="font-size:12px;color:var(--muted);margin-bottom:6px">检测功能</div>
|
||||
<div id="summary-features" style="font-weight:600">-</div>
|
||||
</div>
|
||||
<div id="deploy-result" style="display:none;margin-top:12px"></div>
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<button class="btn ghost" id="btn-prev-step3">← 上一步</button>
|
||||
<button class="btn primary" id="btn-deploy">确认下发</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wizard-stepper{margin:0 auto;max-width:500px}
|
||||
.wizard-step{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);flex-shrink:0}
|
||||
.wizard-step.active{color:var(--text)}
|
||||
.wizard-step.done{color:var(--green)}
|
||||
.wizard-step-num{width:24px;height:24px;border-radius:50%;border:1.5px solid var(--border);display:grid;place-items:center;font-size:11px;font-weight:600}
|
||||
.wizard-step.active .wizard-step-num{border-color:var(--primary);background:var(--primary-strong);color:#fff}
|
||||
.wizard-step.done .wizard-step-num{border-color:var(--green);background:var(--green);color:#fff}
|
||||
.wizard-step-line{flex:1;height:1.5px;background:var(--border);margin:0 8px;min-width:20px}
|
||||
.wizard-step.done+.wizard-step-line,.wizard-step.active+.wizard-step-line{background:var(--primary)}
|
||||
.wizard-device-card{padding:14px;border:2px solid var(--border);border-radius:var(--radius);cursor:pointer;transition:border-color .16s ease}
|
||||
.wizard-device-card:hover{border-color:var(--primary)}
|
||||
.wizard-device-card.selected{border-color:var(--primary);background:var(--surface-soft)}
|
||||
.wizard-device-status{font-size:10px;font-weight:600;margin-bottom:6px}
|
||||
.wizard-device-status.online{color:var(--green)}
|
||||
.wizard-device-name{font-size:13px;font-weight:600}
|
||||
.wizard-device-ip{margin-top:4px;color:var(--muted)}
|
||||
.source-row{display:flex;align-items:center;gap:8px;padding:10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
|
||||
.source-row-name{font-weight:500;font-size:12px;min-width:100px}
|
||||
.source-row-url{font-size:11px;color:var(--muted);flex:1}
|
||||
.feature-check{display:flex;align-items:center;gap:6px;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;user-select:none}
|
||||
.feature-check.enabled{border-color:var(--primary);background:var(--surface-soft)}
|
||||
.feature-check input{width:14px;height:14px;margin:0}
|
||||
.feature-check label{font-size:12px;cursor:pointer}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var selectedDevice = null;
|
||||
var selectedDeviceName = "";
|
||||
var allVideoSources = [];
|
||||
var step2Assigned = [];
|
||||
var step2Added = [];
|
||||
var step2Features = [];
|
||||
|
||||
function loadAllVideoSources() {
|
||||
return {{.WizardVideoSourcesJSON}} || [];
|
||||
}
|
||||
allVideoSources = loadAllVideoSources();
|
||||
|
||||
var currentStep = 1;
|
||||
|
||||
function setStep(n) {
|
||||
currentStep = n;
|
||||
document.querySelectorAll(".wizard-step").forEach(function(el){
|
||||
var s = parseInt(el.getAttribute("data-step"));
|
||||
el.classList.remove("active","done");
|
||||
if (s < n) el.classList.add("done");
|
||||
if (s === n) el.classList.add("active");
|
||||
});
|
||||
document.querySelectorAll(".wizard-panel").forEach(function(el){el.style.display="none"});
|
||||
document.getElementById("wizard-step-"+n).style.display="";
|
||||
}
|
||||
|
||||
function featuresToLabel(key) {
|
||||
var map = {face:"人脸识别",shoe:"劳保鞋检测",helmet:"安全帽检测",smoking:"抽烟检测",intrusion:"区域入侵"};
|
||||
return map[key] || key;
|
||||
}
|
||||
|
||||
document.getElementById("device-grid").addEventListener("click", function(e){
|
||||
var card = e.target.closest(".wizard-device-card");
|
||||
if (!card) return;
|
||||
document.querySelectorAll(".wizard-device-card").forEach(function(c){c.classList.remove("selected")});
|
||||
card.classList.add("selected");
|
||||
selectedDevice = card.getAttribute("data-device-id");
|
||||
selectedDeviceName = card.getAttribute("data-device-name");
|
||||
document.getElementById("btn-next-step").disabled = false;
|
||||
});
|
||||
|
||||
document.getElementById("btn-next-step").addEventListener("click", function(){
|
||||
if (!selectedDevice) return;
|
||||
document.getElementById("step2-device-name").textContent = selectedDeviceName;
|
||||
setStep(2);
|
||||
|
||||
document.getElementById("feature-placeholder").textContent = "加载设备能力中...";
|
||||
document.getElementById("feature-placeholder").style.display = "";
|
||||
step2Assigned = [];
|
||||
step2Added = [];
|
||||
step2Features = [];
|
||||
renderAssignedSources();
|
||||
renderFeatureList();
|
||||
|
||||
fetch("/wizard/device-info?device_id=" + encodeURIComponent(selectedDevice))
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(info){
|
||||
var channels = info.channels || [];
|
||||
step2Assigned = channels.map(function(ch){return {name: ch.display || ch.name, url: ch.rtsp_url || ""}});
|
||||
renderAssignedSources();
|
||||
|
||||
step2Features = [];
|
||||
var caps = (info.capabilities || []).filter(function(c){return c.available});
|
||||
// Pre-check features already enabled on this device
|
||||
var enabled = info.enabled_features || [];
|
||||
caps.forEach(function(c){ if (enabled.indexOf(c.key) >= 0) step2Features.push(c.key); });
|
||||
document.getElementById("feature-placeholder").style.display = "none";
|
||||
renderFeatures(caps);
|
||||
})
|
||||
.catch(function(){
|
||||
document.getElementById("feature-placeholder").textContent = "无法获取设备信息";
|
||||
});
|
||||
});
|
||||
|
||||
function renderAssignedSources() {
|
||||
var container = document.getElementById("assigned-sources");
|
||||
var emptyEl = document.getElementById("no-assigned-sources");
|
||||
var allSources = step2Assigned.concat(step2Added);
|
||||
|
||||
var existingCards = container.querySelectorAll(".assigned-source-card");
|
||||
existingCards.forEach(function(c){c.remove()});
|
||||
|
||||
allSources.forEach(function(src){
|
||||
var div = document.createElement("div");
|
||||
div.className = "assigned-source-card source-row";
|
||||
div.innerHTML =
|
||||
'<span class="source-row-name">'+src.name+'</span>'+
|
||||
'<span class="source-row-url mono">'+src.url+'</span>'+
|
||||
'<button class="btn ghost icon-only" style="min-height:22px;width:22px;height:22px;font-size:12px;color:var(--red);flex-shrink:0" data-remove="'+src.name+'">×</button>';
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
if (allSources.length > 0) {
|
||||
if (emptyEl) emptyEl.style.display = "none";
|
||||
} else {
|
||||
if (emptyEl) emptyEl.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("assigned-sources").addEventListener("click", function(e){
|
||||
var btn = e.target.closest("[data-remove]");
|
||||
if (!btn) return;
|
||||
var name = btn.getAttribute("data-remove");
|
||||
step2Assigned = step2Assigned.filter(function(s){return s.name !== name});
|
||||
step2Added = step2Added.filter(function(s){return s.name !== name});
|
||||
renderAssignedSources();
|
||||
});
|
||||
|
||||
function renderFeatures(caps) {
|
||||
var featDiv = document.getElementById("feature-list");
|
||||
featDiv.innerHTML = "";
|
||||
if (!caps || caps.length === 0) {
|
||||
featDiv.innerHTML = '<span class="muted small">该设备无可用的检测功能。</span>';
|
||||
return;
|
||||
}
|
||||
caps.forEach(function(f){
|
||||
var enabled = step2Features.indexOf(f.key) >= 0;
|
||||
var label = document.createElement("label");
|
||||
label.className = "feature-check" + (enabled ? " enabled" : "");
|
||||
label.innerHTML =
|
||||
'<input type="checkbox" value="'+f.key+'" '+(enabled?'checked':'')+' style="width:14px;height:14px">'+
|
||||
'<span>'+f.label+'</span>';
|
||||
label.querySelector("input").addEventListener("change", function(){
|
||||
var key = this.value;
|
||||
if (this.checked) {
|
||||
if (step2Features.indexOf(key) < 0) step2Features.push(key);
|
||||
} else {
|
||||
step2Features = step2Features.filter(function(k){return k !== key});
|
||||
}
|
||||
renderFeatures(caps);
|
||||
});
|
||||
featDiv.appendChild(label);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFeatureList() {
|
||||
var featDiv = document.getElementById("feature-list");
|
||||
featDiv.innerHTML = '<span class="muted small" id="feature-placeholder">加载设备能力中...</span>';
|
||||
}
|
||||
|
||||
document.getElementById("btn-add-existing-source").addEventListener("click", function(){
|
||||
var sel = document.getElementById("existing-source-select");
|
||||
var val = sel.value;
|
||||
if (!val) return;
|
||||
var parts = val.split("|");
|
||||
var name = parts[0];
|
||||
var url = parts.slice(1).join("|");
|
||||
if (step2Assigned.find(function(s){return s.name===name}) || step2Added.find(function(s){return s.name===name})) {
|
||||
return;
|
||||
}
|
||||
step2Added.push({name: name, url: url});
|
||||
sel.value = "";
|
||||
renderAssignedSources();
|
||||
});
|
||||
|
||||
document.getElementById("btn-add-source").addEventListener("click", function(){
|
||||
var nameEl = document.getElementById("new-source-name");
|
||||
var urlEl = document.getElementById("new-source-url");
|
||||
var name = nameEl.value.trim();
|
||||
var url = urlEl.value.trim();
|
||||
if (!name || !url) return;
|
||||
if (step2Assigned.find(function(s){return s.name===name}) || step2Added.find(function(s){return s.name===name})) {
|
||||
return;
|
||||
}
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "添加中...";
|
||||
var formData = new URLSearchParams();
|
||||
formData.append("name", name);
|
||||
formData.append("url", url);
|
||||
fetch("/wizard/create-source", {method:"POST",body:formData,headers:{"Content-Type":"application/x-www-form-urlencoded"}})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(data){
|
||||
step2Added.push({name: name, url: url});
|
||||
allVideoSources.push({name: name, url: url});
|
||||
nameEl.value = "";
|
||||
urlEl.value = "";
|
||||
renderAssignedSources();
|
||||
btn.disabled = false;
|
||||
btn.textContent = "添加";
|
||||
})
|
||||
.catch(function(){
|
||||
btn.disabled = false;
|
||||
btn.textContent = "添加";
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("btn-prev-step").addEventListener("click", function(){setStep(1)});
|
||||
document.getElementById("btn-prev-step3").addEventListener("click", function(){setStep(2)});
|
||||
|
||||
document.getElementById("btn-goto-step3").addEventListener("click", function(){
|
||||
setStep(3);
|
||||
var allSources = step2Assigned.concat(step2Added);
|
||||
var sourceNames = allSources.map(function(s){return s.name});
|
||||
document.getElementById("summary-device").textContent = selectedDeviceName;
|
||||
document.getElementById("summary-sources").textContent = sourceNames.length > 0 ? sourceNames.join("、") : "未选择";
|
||||
document.getElementById("summary-features").textContent = step2Features.length > 0 ? step2Features.map(featuresToLabel).join("、") : "未选择";
|
||||
});
|
||||
|
||||
document.getElementById("btn-deploy").addEventListener("click", function(){
|
||||
var allSources = step2Assigned.concat(step2Added);
|
||||
var sourceNames = allSources.map(function(s){return s.name});
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "下发中...";
|
||||
fetch("/wizard/apply", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type":"application/json"},
|
||||
body: JSON.stringify({device_id:selectedDevice, features:step2Features, source_names:sourceNames})
|
||||
})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(result){
|
||||
var resultDiv = document.getElementById("deploy-result");
|
||||
resultDiv.style.display = "";
|
||||
if (result.error) {
|
||||
resultDiv.innerHTML = '<div class="error">部署失败:'+result.error+'</div>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = "重试";
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div class="card" style="border-left:3px solid var(--green)"><div class="metric-label">'+
|
||||
'<span class="status-dot ok"></span><strong>部署成功</strong></div>'+
|
||||
'<div class="muted small" style="margin-top:6px">模板:'+result.template_name+' | 检测通道:'+result.units_created+' 个</div></div>';
|
||||
btn.style.display = "none";
|
||||
currentStep = 3;
|
||||
document.querySelectorAll(".wizard-step").forEach(function(el){el.classList.add("done")});
|
||||
}
|
||||
})
|
||||
.catch(function(err){
|
||||
document.getElementById("deploy-result").innerHTML = '<div class="error">请求失败:'+err.message+'</div>';
|
||||
document.getElementById("deploy-result").style.display = "";
|
||||
btn.disabled = false;
|
||||
btn.textContent = "重试";
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "3588adminbackend",
|
||||
"name": "safesight-control",
|
||||
"version": "1.0.0",
|
||||
"description": "`managerd` 是 RK3588 管理端后端服务,负责设备发现、设备注册表维护、代理访问设备端 `rk3588-agent`,并提供内嵌 Web UI、OpenAPI 页面和 HTTP API。",
|
||||
"main": "index.js",
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://10.0.0.99:4000/Doni/3588AdminBackend.git"
|
||||
"url": "http://10.0.0.99:4000/Doni/safesight-control.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
BIN
resources/standard_resources/face_gallery/face_gallery.db
Normal file
BIN
resources/standard_resources/face_gallery/face_gallery.db
Normal file
Binary file not shown.
BIN
safesightd-linux-arm64
Normal file
BIN
safesightd-linux-arm64
Normal file
Binary file not shown.
@ -8,8 +8,10 @@
|
||||
"data_dir": "data",
|
||||
"db_path": "data/app.db",
|
||||
"log_dir": "data/logs",
|
||||
"alarm_retention_days": 30,
|
||||
"company_name": "金航数码有限公司",
|
||||
"time_sync_threshold_sec": 10,
|
||||
"device_aliases": {
|
||||
"d12a4719c91641df91b76ab271280797": "盒子A"
|
||||
},
|
||||
"alarm_retention_days": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user