Compare commits

..

No commits in common. "master" and "p0-dashboard" have entirely different histories.

117 changed files with 1302 additions and 33597 deletions

4
.gitignore vendored
View File

@ -5,8 +5,6 @@ bin/
dist/
tmp/
*.exe
*.exe~
*~
*.test
*.out
@ -41,5 +39,3 @@ nul
dataset/
__pycache__/
managerd.db
assets-import.tar.gz
deps/

127
AGENTS.md
View File

@ -1,127 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 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`
- 查看服务状态:`pwsh -File scripts/managerd.ps1 status`
- Test all: `go test ./...`
- Test single package: `go test ./internal/service/...`
- 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)
```
cmd/managerd/main.go — Composition root: builds config, services, storage, router
internal/api/ — Thin HTTP handlers that delegate to services (chi router, no framework)
internal/web/ — Embedded HTML UI (Go html/template + layout.html pattern)
internal/service/ — Business logic (discovery, registry, tasks, config assets, preview rendering, template slots)
internal/storage/ — SQLite persistence (raw SQL via database/sql, pure-Go modernc.org/sqlite driver)
internal/config/ — JSON config file loading (managerd.json)
internal/models/ — Core domain types (Device, Task, DeviceTaskStatus, etc.)
templates/ — Local JSON template files loaded at runtime
templates/standard_templates/ — "Standard" templates (std_ prefix) imported into SQLite on startup
```
### Dependency Flow (all wired in main.go)
```
config → service (agent client, registry, discovery)
config + service → storage (opens SQLite, creates repos)
storage → service (task service, config preview, etc.)
service + storage → api + web (HTTP handlers)
chi.Router → mounts api routes + UI
```
### Device Communication
- `AgentClient` proxies HTTP requests to device-side `rk3588-agent` via the device's IP/port.
- Two timeout tiers: 3s for normal requests, 120s for long operations (config apply, media server, model uploads).
- All proxied requests carry `X-RK-Token` header from config.
- Generic passthrough: `r.Handle("/devices/{id}/v1/*", proxyAgentV1)` covers agent sub-routes.
### Config Asset Management
The system manages four asset types stored as JSON blobs in SQLite:
| Asset | Table | Key Concept |
|---|---|---|
| Templates | `templates` | Base config template with bindings/variables |
| Profiles | `profiles` | Template-specific configuration (references `primary_template_name`) |
| Overlays | `overlays` | Named config overrides that can be appended to any profile |
| Integration Services | `integration_services` | Third-party service definitions (e.g., RTMP, HTTP push) |
| Video Sources | `video_sources` | Camera/stream source definitions |
Naming convention: names starting with `std_` are read-only standard templates, imported on startup from `templates/standard_templates/`.
The config preview rendering pipeline: merge template + profile + overlays → resolve binding references (video sources, integration services) → expand slot tokens (`${slot:name.field}`) → compute SHA256 hash.
### Background Goroutines
- RegistryService: 2s tick for offline pruning, 30s tick for graph polling
- DiscoveryService: UDP broadcast listener (blocking goroutine per discovery request)
- TaskService: concurrent task execution gated by a semaphore channel
### Key Database Tables (SQLite, migrations in storage/migrate.go)
- `devices` — device registry (device_id PK, ip, port, online status, last_seen)
- `templates`, `profiles`, `overlays` — config assets
- `integration_services`, `video_sources` — third-party service and video source definitions
- `tasks` + `task_devices` — batch task execution (type, status, per-device status)
- `device_config_state` — per-device applied config state
- `audit_logs` — action audit trail (actor, action, target, details)
### Test Patterns
- Test files co-located with source: `*_test.go`
- Services constructed with nil/real dependencies (no mocking framework)
- HTTP tests use `httptest.NewRequest` + `httptest.NewRecorder`
- Storage tests use in-memory SQLite (tests seed their own data via repo methods)
- Tests assert on status codes, redirect URLs, and response body content
## Coding Rules
- **Never use `sed`** on Windows (Git Bash). It produces stray `t` characters, broken indentation, and corrupted files.
- **Use `rg` and `fd`** for searching (not `grep`/`find`). Both are installed.
- **File editing**: use the `write` tool for new/rewritten files and `edit` tool for precise changes. For complex find-and-replace, write a small temporary Go program.
- **No tabs**: use spaces for indentation in all source files.
- **No `fetch()` in templates** for page data — all API calls must go through Go server-side handlers. JS is only for UI interactivity (modals, carousels, confirm dialogs).
- **No inline `onclick` with complex quoting** — use `data-*` attributes + event delegation instead.
- **Use `pwsh`** for PowerShell commands (v7.6.1 in PATH, not `powershell.exe`).
- **重启服务**:统一用 `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)

View File

@ -1,6 +1,6 @@
# safesightd
# managerd
`safesightd` 是 SafeSight Control 管理端后端服务,负责设备发现、设备注册表维护、代理访问设备端 `rk3588-agent`,并提供内嵌 Web UI、OpenAPI 页面和 HTTP API。
`managerd` 是 RK3588 管理端后端服务,负责设备发现、设备注册表维护、代理访问设备端 `rk3588-agent`,并提供内嵌 Web UI、OpenAPI 页面和 HTTP API。
## 当前状态
@ -16,37 +16,37 @@
默认通过单个可执行文件运行:
```bash
safesightd
managerd
```
也可以指定配置文件路径:
```bash
safesightd path/to/safesightd.json
managerd path/to/managerd.json
```
在当前仓库的 Windows 开发环境中,推荐使用统一脚本入口:
```bat
scripts\safesightd.bat build
scripts\safesightd.bat start
scripts\safesightd.bat stop
scripts\safesightd.bat restart
scripts\safesightd.bat status
scripts\managerd.bat build
scripts\managerd.bat start
scripts\managerd.bat stop
scripts\managerd.bat restart
scripts\managerd.bat status
```
各动作说明:
- `build`: 编译 `.\cmd\safesightd`,生成根目录下的 `safesightd.exe`
- `start`: 启动当前仓库中的 `safesightd.exe`,并检查 `/health`
- `stop`: 停止当前仓库对应的 `safesightd.exe`
- `build`: 编译 `.\cmd\managerd`,生成根目录下的 `managerd.exe`
- `start`: 启动当前仓库中的 `managerd.exe`,并检查 `/health`
- `stop`: 停止当前仓库对应的 `managerd.exe`
- `restart`: 先停止再启动
- `status`: 查看进程状态与健康检查结果
脚本实际入口文件:
- `scripts/safesightd.bat`
- `scripts/safesightd.ps1`
- `scripts/managerd.bat`
- `scripts/managerd.ps1`
程序启动后:
@ -192,7 +192,7 @@ Response:
### 设备代理接口
以下接口由 safesightd 转发到设备端 agent
以下接口由 managerd 转发到设备端 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`: 二进制文件
行为:safesightd 读取上传文件,并转发为设备端 agent 的 `PUT /v1/models/{name}`
行为:managerd 读取上传文件,并转发为设备端 agent 的 `PUT /v1/models/{name}`
### Templates
@ -288,7 +288,7 @@ SSE 事件名为 `device_update`,数据格式:
## 配置文件
`safesightd.json` 示例:
`managerd.json` 示例:
```json
{
@ -303,7 +303,7 @@ SSE 事件名为 `device_update`,数据格式:
字段说明:
- `listen`: safesightd 监听地址
- `listen`: managerd 监听地址
- `discovery_port`: 设备发现广播端口
- `discovery_timeout_ms`: 搜索超时
- `offline_after_ms`: 多久未更新则视为离线
@ -312,7 +312,7 @@ SSE 事件名为 `device_update`,数据格式:
## 目录说明
- `cmd/safesightd`: 程序入口
- `cmd/managerd`: 程序入口
- `internal/api`: API 路由与处理器
- `internal/service`: discovery、registry、task、template、agent client
- `internal/web`: 内嵌 Web UI

View File

@ -7,11 +7,11 @@ import (
"os"
"path/filepath"
"safesight-control/internal/api"
"safesight-control/internal/config"
"safesight-control/internal/service"
"safesight-control/internal/storage"
"safesight-control/internal/web"
"3588AdminBackend/internal/api"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/service"
"3588AdminBackend/internal/storage"
"3588AdminBackend/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@ -19,10 +19,10 @@ import (
)
func main() {
cfgPath := "safesightd.json"
if len(os.Args) > 1 {
cfgPath = os.Args[1]
}
cfgPath := "managerd.json"
if len(os.Args) > 1 {
cfgPath = os.Args[1]
}
cfg, err := config.LoadConfig(cfgPath)
if err != nil {
@ -42,16 +42,15 @@ func main() {
taskRepo := storage.NewTasksRepo(store.DB())
assetsRepo := storage.NewAssetsRepo(store.DB())
modelsRepo := storage.NewModelsRepo(store.DB())
// 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.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)
}
if err := service.EnsureDefaultIntegrations(assetsRepo); err != nil {
log.Printf("ensure default integrations: %v", err)
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)
}
standardModelsDir := filepath.Join("models", "standard_models")
modelSvc := service.NewModelManagementService(modelsRepo)
@ -75,7 +74,9 @@ func main() {
log.Printf("load persisted tasks: %v", err)
}
alarmCollector := service.NewAlarmCollector(store.DB(), agentClient, regSvc)
alarmCollector.SetRetention(cfg.AlarmRetentionDays)
if cfg.AlarmRetentionDays > 0 {
alarmCollector.SetRetention(cfg.AlarmRetentionDays)
}
alarmCollector.Start()
tplSvc := service.NewTemplateService(cfg)
h := api.NewHandler(discoSvc, regSvc, agentClient, taskSvc, tplSvc)
@ -95,7 +96,7 @@ func main() {
})
r.Get("/openapi.json", api.OpenAPI)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/dashboard", http.StatusFound)
http.Redirect(w, r, "/ui", http.StatusFound)
})
previewSvc := service.NewConfigPreviewService(cfg, assetsRepo)
@ -106,20 +107,15 @@ 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("/", uiRouter)
ui.RegisterAlarmRoutes(r)
r.Mount("/ui", http.StripPrefix("/ui", uiRouter))
// API Routes
r.Route("/api", func(r chi.Router) {
@ -140,10 +136,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}/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)
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)
// Generic passthrough for device agent /v1/* (covers config/ui/*, face-gallery, etc.)
r.Handle("/devices/{id}/v1/*", http.HandlerFunc(h.ProxyAgentV1))
@ -161,8 +157,8 @@ func main() {
r.Post("/devices/{id}/models/upload", h.UploadModel)
})
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)
}
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)
}
}

27924
deps/get-pip.py vendored

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -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/safesightd/main.go` | 修改:注入新服务 |
| 后端 | `cmd/managerd/main.go` | 修改:注入新服务 |

View File

@ -1,86 +0,0 @@
# 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`

View File

@ -1,25 +0,0 @@
# 设备发现与在线状态设计原则
## 核心划分
| 机制 | 职责 | 使用场景 |
|------|------|---------|
| **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 请求的真实结果才是唯一可靠的在线判断。

View File

@ -1,228 +0,0 @@
# 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. 模型管理简化
**现状**:模型通过目录扫描同步到 DBTask 系统支持 `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-5HLS 修复)
进行中P0-1告警增强 → P0-2仪表盘重做
待开始P0-3配置向导 → P1体验打磨 → P0-4登录 → P2长期优化
```
## 技术债务同步处理
在实现上述功能时,顺手清理:
- 提取模板函数到独立文件(当前约 300 行挤在一个函数里)
- 统一设备健康检查的 timeout 策略
- 为所有新增 API 建立测试用例
- `.gitignore` 覆盖所有构建产物

View File

@ -1,7 +1,7 @@
# 产品化评估与后续规划
> 评估日期2026-05-06
> 范围视觉识别服务OrangePi3588Media+ 管理后台(SafeSight Control
> 范围视觉识别服务OrangePi3588Media+ 管理后台(3588AdminBackend
---
@ -75,7 +75,7 @@
两个项目的架构基础扎实:
- **media-server**:插件化 DAG 流水线JSON 配置驱动,热更新/回滚,硬件解耦抽象清晰
- **safesightd**分层架构API→Service→StorageRESTfulHTML/template 服务端渲染,无框架依赖
- **managerd**分层架构API→Service→StorageRESTfulHTML/template 服务端渲染,无框架依赖
从产品化角度看,两边都有显著缺口,下面按优先级列出。
@ -107,7 +107,7 @@
---
## 三、管理后台(safesightd
## 三、管理后台(managerd
### ✅ 优势
@ -249,7 +249,7 @@
| 项目 | 说明 |
|------|------|
| `cmd/safesightd` 目录名歧义 | 既是源码目录又被 gitignore 匹配为二进制 |
| `cmd/managerd` 目录名歧义 | 既是源码目录又被 gitignore 匹配为二进制 |
| 模板函数分散 | `modelTypeLabel`、`resourceTypeLabel` 等散落在 NewUI 中,应集中管理 |
| agent 配置示例过时 | `agent.config.example.json` 路径与生产环境不一致 |
| deploy.sh 交互选择 | 应支持完全无交互部署CI/CD 友好) |

View File

@ -1,165 +0,0 @@
# 产品化改进实施跟踪
## 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` handlermultipart 接收 → 存盘 `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/1593.3%** | **剩余P0-4登录** | **推迟P2-11分组**

View File

@ -1,174 +0,0 @@
# 模型管理 & 人脸库管理 改进实施方案
> 关联文档:[产品化改进计划](./产品化改进计划.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` | 可选:解析质量分 |

View File

@ -1,4 +1,4 @@
# SafeSight Control 离线部署文档
# 3588AdminBackend 离线部署文档
> **目标环境**: Ubuntu 22.04.5 LTS (离线环境,无法连接互联网)
> **部署方式**: 在 Windows 上交叉编译,上传安装包到 Ubuntu 进行离线部署
@ -21,7 +21,7 @@
### 1.1 项目简介
SafeSight Control 是 Orange Pi 3588 媒体服务器的后台管理端,提供以下功能:
3588AdminBackend 是 Orange Pi 3588 媒体服务器的后台管理端,提供以下功能:
- 设备发现与注册管理
- 设备配置下发与批量任务管理
@ -47,7 +47,7 @@ SafeSight Control 是 Orange Pi 3588 媒体服务器的后台管理端,提供
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 1. 安装 Go 1.23+ │ │
│ │ 2. 运行 build-windows.ps1 │ │
│ │ 3. 生成 SafeSight Control-离线部署包-YYYYMMDD.tar.gz │ │
│ │ 3. 生成 3588AdminBackend-离线部署包-YYYYMMDD.tar.gz │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
@ -56,7 +56,7 @@ SafeSight Control 是 Orange Pi 3588 媒体服务器的后台管理端,提供
│ Ubuntu生产环境
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 无需 Go 运行时!直接运行编译好的静态二进制文件 │ │
│ │ - tar -xzf SafeSight Control-离线部署包-*.tar.gz │ │
│ │ - tar -xzf 3588AdminBackend-离线部署包-*.tar.gz │ │
│ │ - sudo ./scripts/3588admin install │ │
│ │ - sudo ./scripts/3588admin start │ │
│ └──────────────────────────────────────────────────────────────┘ │
@ -96,14 +96,14 @@ SafeSight Control 是 Orange Pi 3588 媒体服务器的后台管理端,提供
将项目源码复制到 Windows 机器上,例如:
```
C:\Users\YourName\SafeSight Control\
C:\Users\YourName\3588AdminBackend\
```
目录结构应包含:
```
SafeSight Control/
3588AdminBackend/
├── cmd/
│ └── safesightd/
│ └── managerd/
├── internal/
├── scripts/
│ └── deploy/
@ -125,7 +125,7 @@ SafeSight Control/
```powershell
# 进入项目目录
cd C:\Users\YourName\SafeSight Control\scripts\deploy
cd C:\Users\YourName\3588AdminBackend\scripts\deploy
# 执行构建脚本
.\build-windows.ps1
@ -133,21 +133,21 @@ cd C:\Users\YourName\SafeSight Control\scripts\deploy
或一行命令:
```powershell
& "C:<Users>YourName<SafeSight Control<scripts>deploy<build-windows.ps1"
& "C:<Users>YourName<3588AdminBackend<scripts>deploy<build-windows.ps1"
```
### 3.2 构建输出
构建完成后,会在 `build\` 目录下生成:
```
SafeSight Control-离线部署包-YYYYMMDD.tar.gz
3588AdminBackend-离线部署包-YYYYMMDD.tar.gz
```
构建输出示例:
```
========== SafeSight Control Windows 构建 ==========
项目目录: C:<Users>YourName<SafeSight Control
构建目录: C:<Users>YourName<SafeSight Control<build
========== 3588AdminBackend Windows 构建 ==========
项目目录: C:<Users>YourName<3588AdminBackend
构建目录: C:<Users>YourName<3588AdminBackend<build
Go 版本: go version go1.23.3 windows/amd64
[1/6] 清理旧构建...
@ -163,12 +163,12 @@ Go 版本: go version go1.23.3 windows/amd64
========== 构建完成 ==========
输出文件:
SafeSight Control-离线部署包-20260225.tar.gz (12.34 MB)
3588AdminBackend-离线部署包-20260225.tar.gz (12.34 MB)
部署包位置: C:<Users>YourName<SafeSight Control<build<SafeSight Control-离线部署包-20260225.tar.gz
部署包位置: C:<Users>YourName<3588AdminBackend<build<3588AdminBackend-离线部署包-20260225.tar.gz
使用说明:
1. 将 SafeSight Control-离线部署包-20260225.tar.gz 上传到 Ubuntu 服务器
1. 将 3588AdminBackend-离线部署包-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<SafeSight Control
cd C:<Users>YourName<3588AdminBackend
# 2. 交叉编译 Linux 版本
$env:GOOS = "linux"
$env:GOARCH = "amd64"
go build -ldflags="-s -w" -o safesightd-linux-amd64 .\cmd\safesightd
go build -ldflags="-s -w" -o managerd-linux-amd64 .\cmd\managerd
# 3. 创建部署目录
mkdir build<3588admin-deploy<bin
@ -191,14 +191,14 @@ mkdir build<3588admin-deploy<config
mkdir build<3588admin-deploy<scripts
# 4. 复制文件
copy safesightd-linux-amd64 build<3588admin-deploy<bin<safesightd
copy managerd-linux-amd64 build<3588admin-deploy<bin<managerd
copy scripts>deploy<3588admin build<3588admin-deploy<scripts<
copy scripts>deploy<3588admin.service build<3588admin-deploy<scripts<
copy safesightd.json build<3588admin-deploy<config<safesightd.json.example
copy managerd.json build<3588admin-deploy<config<managerd.json.example
# 5. 打包
cd build
tar -czf SafeSight Control-离线部署包-$(Get-Date -Format 'yyyyMMdd').tar.gz 3588admin-deploy<
tar -czf 3588AdminBackend-离线部署包-$(Get-Date -Format 'yyyyMMdd').tar.gz 3588admin-deploy<
```
### 3.4 上传到 Ubuntu 服务器
@ -207,7 +207,7 @@ tar -czf SafeSight Control-离线部署包-$(Get-Date -Format 'yyyyMMdd').tar.gz
```powershell
# 使用 scp如果 Windows 有 OpenSSH
scp .\build<SafeSight Control-离线部署包-*.tar.gz user@ubuntu-server:/tmp/
scp .\build<3588AdminBackend-离线部署包-*.tar.gz user@ubuntu-server:/tmp/
# 或使用 WinSCP、FileZilla 等图形化工具
```
@ -225,10 +225,10 @@ scp .\build<SafeSight Control-离线部署包-*.tar.gz user@ubuntu-server:/tmp/
cd /tmp
# 解压
tar -xzf SafeSight Control-离线部署包-*.tar.gz
tar -xzf 3588AdminBackend-离线部署包-*.tar.gz
# 进入部署目录
cd SafeSight Control-离线部署包-*/
cd 3588AdminBackend-离线部署包-*/
# 查看内容
ls -la
@ -283,7 +283,7 @@ sudo systemctl status 3588admin
```bash
# 编辑配置文件
sudo nano /opt/3588admin/config/safesightd.json
sudo nano /opt/3588admin/config/managerd.json
```
**关键配置项说明**
@ -346,7 +346,7 @@ sudo systemctl status 3588admin
```bash
# 检查进程
ps aux | grep safesightd
ps aux | grep managerd
# 检查端口监听
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/safesightd.log
tail -f /opt/3588admin/logs/managerd.log
```
### 5.4 防火墙配置
@ -403,14 +403,14 @@ Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process
```bash
# 1. 检查二进制文件架构是否匹配
file /opt/3588admin/bin/safesightd
file /opt/3588admin/bin/managerd
# 应输出: ELF 64-bit LSB executable, x86-64
# 2. 直接运行查看错误信息
/opt/3588admin/bin/safesightd -config /opt/3588admin/config/safesightd.json
/opt/3588admin/bin/managerd -config /opt/3588admin/config/managerd.json
# 3. 检查配置文件格式
python3 -m json.tool /opt/3588admin/config/safesightd.json
python3 -m json.tool /opt/3588admin/config/managerd.json
```
### 6.3 端口被占用
@ -426,7 +426,7 @@ sudo lsof -i :18080
sudo netstat -tlnp | grep 18080
# 修改配置文件使用其他端口
sudo nano /opt/3588admin/config/safesightd.json
sudo nano /opt/3588admin/config/managerd.json
# 修改 "listen" 为 "127.0.0.1:18081" 或其他端口
```
@ -457,7 +457,7 @@ sudo ufw status | grep 35688
```bash
# 修复权限
sudo chmod +x /opt/3588admin/bin/safesightd
sudo chmod +x /opt/3588admin/bin/managerd
sudo chmod +x /opt/3588admin/scripts/3588admin
# 确保以 root 运行
@ -468,13 +468,13 @@ sudo 3588admin start
```bash
# 实时查看日志
tail -f /opt/3588admin/logs/safesightd.log
tail -f /opt/3588admin/logs/managerd.log
# 查看最近 100 行
tail -n 100 /opt/3588admin/logs/safesightd.log
tail -n 100 /opt/3588admin/logs/managerd.log
# 搜索错误信息
grep -i error /opt/3588admin/logs/safesightd.log
grep -i error /opt/3588admin/logs/managerd.log
```
---
@ -485,11 +485,11 @@ grep -i error /opt/3588admin/logs/safesightd.log
**Windows 构建机**
```
C:<Users>YourName<SafeSight Control<
C:<Users>YourName<3588AdminBackend<
├── build< # 构建输出
│ └── SafeSight Control-离线部署包-20260225<
│ └── 3588AdminBackend-离线部署包-20260225<
│ ├── bin<
│ │ └── safesightd # Linux 静态二进制文件
│ │ └── managerd # Linux 静态二进制文件
│ ├── config<
│ ├── scripts<
│ │ └── 3588admin # 管理脚本
@ -508,11 +508,11 @@ C:<Users>YourName<SafeSight Control<
```
/opt/3588admin/
├── bin/
│ └── safesightd # 主程序Windows 交叉编译生成)
│ └── managerd # 主程序Windows 交叉编译生成)
├── config/
│ └── safesightd.json # 配置文件
│ └── managerd.json # 配置文件
├── logs/
│ └── safesightd.log # 运行日志
│ └── managerd.log # 运行日志
├── scripts/
│ ├── 3588admin # 统一管理脚本
│ └── 3588admin.service # 服务文件(复制用)
@ -555,7 +555,7 @@ sudo 3588admin stop
sudo systemctl stop 3588admin
# 2. 备份配置
cp /opt/3588admin/config/safesightd.json ~/safesightd.json.bak
cp /opt/3588admin/config/managerd.json ~/managerd.json.bak
# 3. 在 Windows 上重新构建新版本
# 运行 build-windows.ps1
@ -589,4 +589,4 @@ echo "卸载完成"
**文档版本**: 2.1 (Windows 构建版)
**最后更新**: 2026-02-25
**适用版本**: SafeSight Control V1.0+
**适用版本**: 3588AdminBackend V1.0+

5
go.mod
View File

@ -1,6 +1,6 @@
module safesight-control
module 3588AdminBackend
go 1.25.0
go 1.23.3
require (
github.com/go-chi/chi/v5 v5.2.3
@ -14,7 +14,6 @@ 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
View File

@ -15,10 +15,6 @@ 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=

View File

@ -8,9 +8,9 @@ import (
"strings"
"time"
"safesight-control/internal/models"
"safesight-control/internal/service"
"github.com/go-chi/chi/v5"
"3588AdminBackend/internal/models"
"3588AdminBackend/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/edge-server/reload"
agentPath = "/v1/media-server/reload"
method = "POST"
case r.URL.Path == fmt.Sprintf("/api/devices/%s/rollback", id):
agentPath = "/v1/edge-server/rollback"
agentPath = "/v1/media-server/rollback"
method = "POST"
case r.URL.Path == fmt.Sprintf("/api/devices/%s/edge-server/start", id):
agentPath = "/v1/edge-server/start"
case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/start", id):
agentPath = "/v1/media-server/start"
method = "POST"
case r.URL.Path == fmt.Sprintf("/api/devices/%s/edge-server/restart", id):
agentPath = "/v1/edge-server/restart"
case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/restart", id):
agentPath = "/v1/media-server/restart"
method = "POST"
case r.URL.Path == fmt.Sprintf("/api/devices/%s/edge-server/stop", id):
agentPath = "/v1/edge-server/stop"
case r.URL.Path == fmt.Sprintf("/api/devices/%s/media-server/stop", id):
agentPath = "/v1/media-server/stop"
method = "POST"
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/media-server/status", id):
agentPath = "/v1/media-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):

View File

@ -1,18 +1,18 @@
package api
import (
"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"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/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/edge-server/rollback" {
t.Fatalf("expected /v1/edge-server/rollback, got %s", r.URL.Path)
if r.URL.Path != "/v1/media-server/rollback" {
t.Fatalf("expected /v1/media-server/rollback, got %s", r.URL.Path)
}
time.Sleep(3500 * time.Millisecond)
w.Header().Set("Content-Type", "application/json")

View File

@ -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": "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{}}}},
},
},
},
}
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{}}}},
},
},
},
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(spec)

View File

@ -19,7 +19,6 @@ 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"`
DeviceAliases map[string]string `json:"device_aliases,omitempty"`
path string
}

View File

@ -9,7 +9,7 @@ import (
"strings"
"time"
"safesight-control/internal/config"
"3588AdminBackend/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/edge-server/rollback" {
if method == http.MethodPost && path == "/v1/media-server/rollback" {
return true
}
if strings.HasPrefix(path, "/v1/config/ui/") {

View File

@ -8,28 +8,22 @@ import (
"sync"
"time"
"safesight-control/internal/models"
"3588AdminBackend/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"`
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"`
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"`
}
type AlarmCollector struct {
@ -37,13 +31,12 @@ type AlarmCollector struct {
agent *AgentClient
registry *RegistryService
mu sync.Mutex
lastID string
retentionDays int
subscribers map[chan AlarmRecord]struct{}
lastID string // last alarm ID seen, to avoid duplicates
retentionDays int // auto-delete alarms older than N days, 0=keep
}
func NewAlarmCollector(db *sql.DB, agent *AgentClient, registry *RegistryService) *AlarmCollector {
return &AlarmCollector{db: db, agent: agent, registry: registry, subscribers: make(map[chan AlarmRecord]struct{})}
return &AlarmCollector{db: db, agent: agent, registry: registry}
}
func (c *AlarmCollector) Start() {
@ -56,13 +49,6 @@ 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)
@ -72,11 +58,6 @@ 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
@ -220,46 +201,39 @@ 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, status, severity)
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)
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, "unacknowledged", "warning")
`, alarm.ID, alarm.DeviceID, alarm.Channel, alarm.Timestamp, alarm.RuleName, alarm.RuleType, alarm.ObjectLabel, alarm.Confidence, alarm.SnapshotURL, alarm.ClipURL, alarm.DurationMs, alarm.CollectedAt)
if err != nil {
log.Printf("alarm collector: save error: %v", err)
}
c.broadcast(alarm)
}
// 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) {
// 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) {
if c.db == nil {
return nil, 0
}
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)
}
// Count total
var total int
c.db.QueryRow(`SELECT COUNT(*) FROM alarm_records `+cond, args...).Scan(&total)
c.db.QueryRow(`
SELECT COUNT(*) FROM alarm_records
WHERE timestamp >= ? AND timestamp <= ?
`, fromTS, toTS).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 + `
// 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 <= ?
ORDER BY timestamp DESC
LIMIT ? OFFSET ?`
fullArgs := append(args, limit, offset)
rows, err := c.db.Query(fullQuery, fullArgs...)
LIMIT ? OFFSET ?
`, fromTS, toTS, limit, offset)
if err != nil {
return nil, total
}
@ -268,7 +242,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, &a.Status, &a.Severity, &a.AcknowledgedAt, &a.AcknowledgedBy, &a.ResolvedAt, &a.ResolvedBy); 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); err != nil {
continue
}
alarms = append(alarms, a)
@ -282,7 +256,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, status, severity, acknowledged_at, acknowledged_by, resolved_at, resolved_by
SELECT id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at
FROM alarm_records
ORDER BY timestamp DESC
LIMIT ?
@ -295,178 +269,7 @@ 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, &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 {
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 {
continue
}
alarms = append(alarms, a)

View File

@ -7,8 +7,6 @@ import (
"fmt"
"sort"
"strings"
"safesight-control/internal/storage"
)
// FeatureDef describes a detection feature that users can toggle on/off.
@ -323,10 +321,9 @@ type AutoConfigResult struct {
// AutoConfigService orchestrates the full pipeline from user intent to deployed config.
type AutoConfigService struct {
preview *ConfigPreviewService
tasks *TaskService
registry *FeatureRegistry
configVersions *storage.ConfigVersionsRepo
preview *ConfigPreviewService
tasks *TaskService
registry *FeatureRegistry
}
// NewAutoConfigService creates a new AutoConfigService.
@ -338,10 +335,6 @@ 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
@ -400,16 +393,8 @@ 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,
@ -487,18 +472,11 @@ func (s *AutoConfigService) BuildPipeline(req AutoConfigRequest, deploy bool) (*
}
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 {
@ -606,127 +584,24 @@ 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 unique hash-based name is generated.
// valid, it's used as-is. Otherwise, a sanitised version is generated.
func deriveSafeInstanceName(srcName string) string {
// If already valid per validateConfigName rules, use as-is.
if safeConfigName.MatchString(srcName) {
return srcName
}
// 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").
// 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.
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
}
}

View File

@ -8,8 +8,8 @@ import (
"sort"
"strings"
"safesight-control/internal/models"
"safesight-control/internal/storage"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
)
var legacyBuiltinTemplateAliases = map[string]string{
@ -80,7 +80,6 @@ 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"`
@ -166,7 +165,6 @@ 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"`
}
@ -1227,7 +1225,6 @@ 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 {
@ -1240,9 +1237,7 @@ 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),
@ -1643,7 +1638,6 @@ 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)

View File

@ -7,9 +7,9 @@ import (
"strings"
"testing"
"safesight-control/internal/config"
"safesight-control/internal/models"
"safesight-control/internal/storage"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
)
func mustSaveTemplateRecord(t *testing.T, repo *storage.AssetsRepo, name string, description string, body string) {

View File

@ -12,8 +12,8 @@ import (
"strings"
"time"
"safesight-control/internal/config"
"safesight-control/internal/storage"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/storage"
)
var safeConfigName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
@ -251,56 +251,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": "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
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
}
func (s *ConfigPreviewService) resolveSceneBindings(raw map[string]any) (map[string]any, error) {
@ -427,13 +427,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 "safesightd"
if strings.HasPrefix(templatePath, "sqlite:") || strings.HasPrefix(profilePath, "sqlite:") {
return "SQLite"
}
if dir := filepath.Dir(templatePath); strings.TrimSpace(dir) != "" && dir != "." {
return dir
}
return "managerd"
}
func writeResolvedConfigFile(pattern string, raw map[string]any) (string, error) {

View File

@ -7,8 +7,8 @@ import (
"strings"
"testing"
"safesight-control/internal/config"
"safesight-control/internal/storage"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/storage"
)
func mustImportAssetsFromMediaRepo(t *testing.T, svc *ConfigPreviewService) {
@ -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 != "safesightd" {
t.Fatalf("expected safesightd 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 != "managerd" {
t.Fatalf("expected managerd renderer metadata, got %#v", metadata)
}
}
func TestConfigPreviewServiceRenderProfileEditorAllowsUnboundOptionalServiceSlot(t *testing.T) {

View File

@ -6,9 +6,9 @@ import (
"strings"
"time"
"safesight-control/internal/config"
"safesight-control/internal/models"
"github.com/google/uuid"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"github.com/google/uuid"
)
const discoveryMagicV1 = "RK3588SYS_DISCOVERY_V1"

View File

@ -10,10 +10,9 @@ import (
"path/filepath"
"sort"
"strings"
"time"
"safesight-control/internal/models"
"safesight-control/internal/storage"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
)
type ModelManagementService struct {
@ -38,9 +37,7 @@ 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"`
@ -90,7 +87,7 @@ func (s *ModelManagementService) SyncStandardModelsFromDirectory(dir string) err
record := storage.StandardModelRecord{
Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())),
FileName: entry.Name(),
Version: fileVersion(fullPath),
Version: "auto",
SHA256: sum,
SizeBytes: size,
ModelType: inferModelType(entry.Name()),
@ -135,25 +132,7 @@ func inferModelType(fileName string) string {
}
}
// 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 {
func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices []*models.Device, installed map[string][]InstalledModelStatus) ModelStatusBoard {
board := ModelStatusBoard{
Summary: ModelStatusSummary{
StandardModels: len(standardModels),
@ -176,7 +155,6 @@ 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),
@ -199,19 +177,7 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices
hasMismatch = true
}
} else {
// 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
}
hasMissing = true
}
if cell.Status == "missing" {
hasMissing = true
@ -228,7 +194,6 @@ 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++
@ -264,13 +229,3 @@ 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")
}

View File

@ -5,8 +5,8 @@ import (
"path/filepath"
"testing"
"safesight-control/internal/models"
"safesight-control/internal/storage"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
)
func TestSyncStandardModelsFromDirectory(t *testing.T) {
@ -58,7 +58,7 @@ func TestBuildModelStatusBoardMarksMissingAndMismatch(t *testing.T) {
},
}
board := BuildModelStatusBoard(modelsList, devices, installed, nil)
board := BuildModelStatusBoard(modelsList, devices, installed)
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, nil)
board := BuildModelStatusBoard(modelsList, devices, installed)
if len(board.Rows) != 1 {
t.Fatalf("unexpected board rows: %#v", board.Rows)

View File

@ -2,13 +2,12 @@ package service
import (
"encoding/json"
"sort"
"strings"
"sync"
"time"
"safesight-control/internal/config"
"safesight-control/internal/models"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
)
type RegistryService struct {
@ -145,12 +144,6 @@ 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()
@ -159,12 +152,6 @@ 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
}

View File

@ -1,12 +1,12 @@
package service
import (
"safesight-control/internal/config"
"safesight-control/internal/models"
"safesight-control/internal/storage"
"path/filepath"
"testing"
"time"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
"path/filepath"
"testing"
"time"
)
func TestRegistryService_UpdateAndGet(t *testing.T) {

View File

@ -10,8 +10,8 @@ import (
"sync"
"time"
"safesight-control/internal/models"
"safesight-control/internal/storage"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
)
type ResourceManagementService struct {
@ -36,7 +36,6 @@ 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"`
@ -60,27 +59,6 @@ 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")
@ -106,7 +84,7 @@ func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir strin
continue
}
for _, sub := range subEntries {
if sub.IsDir() || sub.Name() == "build_meta.json" {
if sub.IsDir() {
continue
}
fullPath := filepath.Join(subDir, sub.Name())
@ -117,7 +95,7 @@ func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir strin
record := storage.StandardResourceRecord{
Name: strings.TrimSuffix(sub.Name(), filepath.Ext(sub.Name())),
ResourceType: resourceType,
Version: fileVersion(fullPath),
Version: "auto",
SHA256: sum,
SizeBytes: size,
FilePath: fullPath,
@ -136,7 +114,7 @@ func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir strin
}
record := storage.StandardResourceRecord{
Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())),
Version: fileVersion(fullPath),
Version: "auto",
SHA256: sum,
SizeBytes: size,
FilePath: fullPath,
@ -191,7 +169,6 @@ 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),
@ -271,7 +248,7 @@ func FetchAndBuildResourceBoard(agent *AgentClient, devices []*models.Device, st
}()
for _, device := range devices {
if device == nil {
if device == nil || !device.Online {
continue
}
wg.Add(1)

View File

@ -5,8 +5,8 @@ import (
"path/filepath"
"testing"
"safesight-control/internal/models"
"safesight-control/internal/storage"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
)
func TestSyncStandardResourcesFromDirectory(t *testing.T) {

View File

@ -3,99 +3,13 @@ package service
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"safesight-control/internal/storage"
"3588AdminBackend/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")
@ -227,40 +141,3 @@ 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
}

View File

@ -7,15 +7,13 @@ import (
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"safesight-control/internal/config"
"safesight-control/internal/models"
"safesight-control/internal/storage"
"github.com/google/uuid"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
"github.com/google/uuid"
)
type TaskRepository interface {
@ -122,10 +120,6 @@ 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
}
@ -215,7 +209,6 @@ 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)
}
@ -297,6 +290,11 @@ 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)
@ -323,7 +321,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/edge-server/reload", nil, "", 0)
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/reload", nil, "", 0)
if err != nil {
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
return
@ -336,7 +334,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/edge-server/rollback", nil, "", 0)
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/rollback", nil, "", 0)
if err != nil {
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
return
@ -354,7 +352,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/edge-server/start", bodyR, "", bodyLen)
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/start", bodyR, "", bodyLen)
if err != nil {
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
return
@ -372,7 +370,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/edge-server/restart", bodyR, "", bodyLen)
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/restart", bodyR, "", bodyLen)
if err != nil {
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
return
@ -385,7 +383,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/edge-server/stop", nil, "", 0)
_, code, err := s.agent.DoStream("POST", dev.IP, dev.AgentPort, "/v1/media-server/stop", nil, "", 0)
if err != nil {
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
return
@ -429,19 +427,6 @@ 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 "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")
}

View File

@ -1,23 +1,23 @@
package service
import (
"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"
"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"
)
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/edge-server/start" {
t.Fatalf("expected path /v1/edge-server/start, got %s", r.URL.Path)
if r.URL.Path != "/v1/media-server/start" {
t.Fatalf("expected path /v1/media-server/start, got %s", r.URL.Path)
}
bodyBytes, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)

View File

@ -5,7 +5,7 @@ import (
"os"
"path/filepath"
"safesight-control/internal/config"
"3588AdminBackend/internal/config"
)
type Template struct {

View File

@ -1,9 +1,9 @@
package service
import (
"safesight-control/internal/config"
"os"
"testing"
"3588AdminBackend/internal/config"
"os"
"testing"
)
func TestTemplateService(t *testing.T) {

View File

@ -1,63 +0,0 @@
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
}

View File

@ -5,7 +5,7 @@ import (
"encoding/json"
"time"
"safesight-control/internal/models"
"3588AdminBackend/internal/models"
)
type DevicesRepo struct {

View File

@ -3,7 +3,7 @@ package storage
import (
"testing"
"safesight-control/internal/models"
"3588AdminBackend/internal/models"
)
func TestDevicesRepoUpsertsRuntimeSnapshot(t *testing.T) {

View File

@ -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 created_at DESC`)
rows, err := db.Query(`SELECT id, name, created_at FROM face_persons ORDER BY name`)
if err != nil {
return nil, err
}
@ -180,4 +180,3 @@ func (r *FaceGalleryRepo) DeletePhoto(photoID int) error {
if path != "" { os.Remove(filepath.Join("dataset", path)) }
return nil
}

View File

@ -189,13 +189,7 @@ func migrate(db *sql.DB) error {
if err := migrateProfilePrimaryTemplateName(db); err != nil {
return err
}
if err := migrateProfilesToSceneTemplates(db); err != nil {
return err
}
if err := migrateAlarmWorkflow(db); err != nil {
return err
}
return migrateConfigVersions(db)
return migrateProfilesToSceneTemplates(db)
}
func migrateProfilePrimaryTemplateName(db *sql.DB) error {
@ -437,54 +431,6 @@ 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 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]))

View File

@ -5,7 +5,7 @@ import (
"encoding/json"
"time"
"safesight-control/internal/models"
"3588AdminBackend/internal/models"
)
type TasksRepo struct {

View File

@ -4,7 +4,7 @@ import (
"path/filepath"
"testing"
"safesight-control/internal/models"
"3588AdminBackend/internal/models"
)
func openTestStore(t *testing.T) *Store {

File diff suppressed because it is too large Load Diff

View File

@ -129,21 +129,12 @@ 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)}
@ -158,14 +149,6 @@ 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)}
@ -204,8 +187,6 @@ 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)}
@ -228,7 +209,6 @@ 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,
@ -261,7 +241,6 @@ 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}
@ -422,9 +401,6 @@ 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)}
@ -456,16 +432,4 @@ 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;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}
.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}

View File

@ -1,34 +1,15 @@
{{define "alarms"}}
<div class="card">
<form method="get" action="/alarms" style="display:flex;gap:8px;align-items:center;margin-bottom:12px;justify-content:space-between;flex-wrap:wrap">
<form method="get" action="/ui/alarms" style="display:flex;gap:8px;align-items:center;margin-bottom:12px;justify-content:space-between">
<span style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="date" name="from" value="{{.SelectedFrom}}" style="width:130px;font-size:12px">
<input type="date" name="from" value="{{.SelectedFrom}}" style="width:140px;font-size:12px">
<span class="muted small"></span>
<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}}>{{shortID .}}</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}}
<input type="date" name="to" value="{{.SelectedTo}}" style="width:140px;font-size:12px">
<button class="btn" style="font-size:12px;padding:4px 12px">筛选</button>
<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}}
<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>
</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>
@ -49,17 +30,15 @@
<th>规则</th>
<th>目标</th>
<th>置信度</th>
<th>等级</th>
<th>状态</th>
<th>操作</th>
<th>截图</th>
</tr>
</thead>
<tbody>
{{range .AlarmRecords}}
<tr id="alarm-{{.ID}}">
<tr>
<td class="mono small">{{.Timestamp}}</td>
<td>
<a href="/devices/{{.DeviceID}}">{{shortID .DeviceID}}</a>
<a href="/ui/devices/{{.DeviceID}}">{{shortID .DeviceID}}</a>
</td>
<td>{{.Channel}}</td>
<td>
@ -68,27 +47,12 @@
<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" style="font-size:10px;padding:2px 6px;min-height:22px">截图</a>
{{end}}
<a href="{{.SnapshotURL}}" target="_blank" class="btn ghost">查看截图</a>
{{else}}-{{end}}
{{if .ClipURL}}
<a href="{{.ClipURL}}" target="_blank" class="btn ghost" style="font-size:10px;padding:2px 6px;min-height:22px">视频</a>
<a href="{{.ClipURL}}" target="_blank" class="btn ghost">视频片段</a>
{{end}}
</span>
</td>
</tr>
{{end}}
@ -97,21 +61,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}}{{$dev := $.SelectedAlarmDevice}}{{$rt := $.SelectedAlarmRuleType}}
{{$from := $.SelectedFrom}}{{$to := $.SelectedTo}}{{$pp := $.PerPage}}
{{if gt .CurrentPage 1}}
<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>
<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>
{{end}}
{{range .PageNumbers}}
{{if eq . -1}}
<span class="muted small" style="padding:2px 4px"></span>
{{else}}
<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>
<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>
{{end}}
{{end}}
{{if lt .CurrentPage .TotalAlarmPages}}
<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>
<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>
{{end}}
</div>
{{end}}
@ -123,50 +87,4 @@
{{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}}

View File

@ -6,7 +6,7 @@
<div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>{{.AssetOverlay.Name}}</span></h2>
</div>
<a class="btn secondary" href="/assets/overlays">返回调试参数列表</a>
<a class="btn secondary" href="/ui/assets/overlays">返回调试参数列表</a>
</div>
<div class="info-list">
<div><span>调试参数</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>

View File

@ -9,12 +9,12 @@
<div class="actions compact">
{{if .AssetOverlay}}
{{if .AssetOverlay.ReadOnly}}
<form method="post" action="/assets/overlays/{{.AssetOverlay.Name}}/clone">
<form method="post" action="/ui/assets/overlays/{{.AssetOverlay.Name}}/clone">
<button type="submit" class="btn secondary">{{icon "add"}}<span>复制标准调试参数</span></button>
</form>
{{else}}
<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('确认删除这个调试参数吗?');">
<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('确认删除这个调试参数吗?');">
<button type="submit" class="btn secondary">删除</button>
</form>
{{end}}
@ -32,8 +32,8 @@
</thead>
<tbody>
{{range .AssetOverlays}}
<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>
<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>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td>{{if .ReadOnly}}标准参数{{else}}用户参数{{end}}</td>
</tr>
@ -64,13 +64,13 @@
<button
type="button"
class="btn secondary js-export-json"
data-export-url="/assets/overlays/{{.AssetOverlay.Name}}/export"
data-export-url="/ui/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="/assets/overlays">
<form method="post" action="/ui/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 +78,7 @@
</div>
<div class="actions">
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/assets/overlays">{{icon "close"}}<span>取消</span></a>
<a class="btn secondary" href="/ui/assets/overlays">{{icon "close"}}<span>取消</span></a>
</div>
</form>
{{else}}

View File

@ -5,10 +5,10 @@
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/plans?new=1">{{icon "apply"}}<span>新建场景</span></a>
<a class="btn secondary" href="/ui/plans?new=1">{{icon "apply"}}<span>新建场景</span></a>
{{if .SelectedProfile}}
<a class="btn secondary" href="/plans?name={{.SelectedProfile}}&edit=1">编辑</a>
<form method="post" action="/plans/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景配置吗?');">
<a class="btn secondary" href="/ui/plans?name={{.SelectedProfile}}&edit=1">编辑</a>
<form method="post" action="/ui/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="/plans?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
<td><a class="mono" href="/plans?name={{.Name}}">{{.Name}}</a></td>
<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>
<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="/plans{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
<a class="btn secondary" href="/ui/plans{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
{{end}}
<button
type="button"
class="btn secondary js-export-json"
data-export-url="/plans/{{.AssetProfileEditor.Name}}/export"
data-export-url="/ui/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>

View File

@ -6,7 +6,7 @@
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>{{.AssetTemplate.Name}}</span></h2>
</div>
<a class="btn secondary" href="/assets/templates">返回模板列表</a>
<a class="btn secondary" href="/ui/assets/templates">返回模板列表</a>
</div>
<div class="info-list">
<div><span>模板名</span><strong class="mono">{{.AssetTemplate.Name}}</strong></div>

View File

@ -8,16 +8,16 @@
</div>
<div class="actions compact">
{{if not .AssetTemplate.ReadOnly}}
<form class="graph-save-form" method="post" action="/assets/templates/{{.AssetTemplate.Name}}/graph">
<form class="graph-save-form" method="post" action="/ui/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="/assets/templates?clone={{.AssetTemplate.Name}}">{{icon "add"}}<span>复制后编辑</span></a>
<a class="btn secondary" href="/ui/assets/templates?clone={{.AssetTemplate.Name}}">{{icon "add"}}<span>复制后编辑</span></a>
{{end}}
<a class="btn ghost" href="/assets/templates{{if .TemplateCloneSource}}?clone_source={{.TemplateCloneSource}}&clone_name={{.AssetTemplate.Name}}{{else}}?name={{.AssetTemplate.Name}}{{end}}">返回模板</a>
<a class="btn ghost" href="/ui/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="/api/graph-node-types"></div>
<div class="graph-node-palette-list" data-catalog-url="/ui/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="/assets/graph_editor.js"></script>
<script src="/ui/assets/graph_editor.js"></script>
</div>
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -10,12 +10,12 @@
{{if .AssetTemplate}}
{{if .TemplateCloneSource}}
{{else if .AssetTemplate.ReadOnly}}
<form method="post" action="/assets/templates/{{.AssetTemplate.Name}}/clone">
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/clone">
<button type="submit" class="btn secondary">{{icon "add"}}<span>复制标准模板</span></button>
</form>
{{else}}
<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('确认删除这个用户模板吗?');">
<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('确认删除这个用户模板吗?');">
<button type="submit" class="btn secondary">删除</button>
</form>
{{end}}
@ -34,8 +34,8 @@
</thead>
<tbody>
{{range .AssetTemplates}}
<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>
<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>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td>{{.NodeCount}}节点 / {{.EdgeCount}}连线</td>
<td>{{if .ReadOnly}}标准模板{{else}}用户模板{{end}}</td>
@ -64,19 +64,19 @@
</div>
</div>
<div class="actions compact">
<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>
<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>
{{if not .TemplateCloneSource}}
<button
type="button"
class="btn secondary js-export-json"
data-export-url="/assets/templates/{{.AssetTemplate.Name}}/export"
data-export-url="/ui/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}}/assets/templates/create{{else}}/assets/templates/{{.AssetTemplate.Name}}/rename{{end}}">
<form method="post" action="{{if .TemplateCloneSource}}/ui/assets/templates/create{{else}}/ui/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 +98,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="/assets/templates">{{icon "close"}}<span>取消</span></a>
<a class="btn secondary" href="/ui/assets/templates">{{icon "close"}}<span>取消</span></a>
</div>
</form>
{{else}}

View File

@ -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="/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="/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>
</li>
<li class="nav-item" role="presentation">
<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>
<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>
</li>
<li class="nav-item" role="presentation">
<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>
<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>
</li>
<li class="nav-item" role="presentation">
<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>
<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>
</li>
<li class="nav-item" role="presentation">
<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>
<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>
</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="/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">
<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">
<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="/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>
<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>
<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="/assets/integrations">
<form method="post" action="/ui/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}}" pattern="[A-Za-z0-9_.-]+" title="只允许英文、数字和 ._- " required autofocus /></label>
<label><span>服务名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetIntegration.Name}}" 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="/assets/integrations?name={{.AssetIntegration.Name}}">{{icon "close"}}<span>取消</span></a>
<a class="btn secondary" href="/ui/assets/integrations?name={{.AssetIntegration.Name}}">{{icon "close"}}<span>取消</span></a>
</div>
{{else}}
<div class="detail-sheet">
@ -183,19 +183,14 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
<h2 class="title-with-icon">{{icon "device"}}<span>视频源列表</span></h2>
</div>
<div class="actions compact">
<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.Name}}/delete">
<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">
<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>
@ -212,8 +207,8 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</thead>
<tbody>
{{range .AssetVideoSources}}
<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>
<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>
<td>{{.SourceTypeLabel}}</td>
<td>{{if .Area}}{{.Area}}{{else}}-{{end}}</td>
<td class="mono">{{if .Config.URL}}{{.Config.URL}}{{else}}-{{end}}</td>
@ -229,7 +224,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
</div>
{{if .AssetVideoSource}}
<form method="post" action="/assets/video-sources">
<form method="post" action="/ui/assets/video-sources">
<div class="card editor-state {{if .AssetVideoSourceEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
@ -272,7 +267,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
<div class="actions">
<button type="submit">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/assets/video-sources?name={{.AssetVideoSource.Name}}">{{icon "close"}}<span>取消</span></a>
<a class="btn secondary" href="/ui/assets/video-sources?name={{.AssetVideoSource.Name}}">{{icon "close"}}<span>取消</span></a>
</div>
{{else}}
<div class="detail-sheet">
@ -326,7 +321,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
<div class="asset-list">
{{range .AssetTemplates}}
<a class="asset-row asset-link" href="/assets/templates/{{.Name}}">
<a class="asset-row asset-link" href="/ui/assets/templates/{{.Name}}">
<span class="mono">{{.Name}}</span>
<span class="muted small">{{.NodeCount}}节点 / {{.EdgeCount}}连线</span>
</a>
@ -346,7 +341,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
<div class="asset-list">
{{range .AssetVideoSources}}
<a class="asset-row asset-link" href="/assets/video-sources?name={{.Name}}">
<a class="asset-row asset-link" href="/ui/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>
@ -366,7 +361,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
<div class="asset-list">
{{range .AssetIntegrations}}
<a class="asset-row asset-link" href="/assets/integrations">
<a class="asset-row asset-link" href="/ui/assets/integrations">
<span>{{.Name}}</span>
<span class="muted small">{{if .AddressSummary}}{{.AddressSummary}}{{else}}{{.TypeLabel}}{{end}}</span>
</a>
@ -386,7 +381,7 @@ toggleIntegrationFields('{{.AssetIntegration.Type}}');
</div>
<div class="asset-list">
{{range .AssetOverlays}}
<a class="asset-row asset-link" href="/assets/overlays/{{.Name}}">
<a class="asset-row asset-link" href="/ui/assets/overlays/{{.Name}}">
<span>{{.Name}}</span>
<span class="muted small">{{.OverrideTargetNum}} 个目标</span>
</a>

View File

@ -2,7 +2,7 @@
{{template "device_nav" .}}
<div class="card">
<h2>识别方案配置</h2>
<div class="muted small">节点:<a class="mono" href="/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}})。</div>
<div class="muted small">节点:<a class="mono" href="/ui/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="/devices">去设备列表重新扫描</a>
<a href="/devices/{{.Device.DeviceID}}/config-ui">打开高级 JSON</a>
<a href="/ui/devices">去设备列表重新扫描</a>
<a href="/ui/devices/{{.Device.DeviceID}}/config-ui">打开高级 JSON</a>
</div>
</div>
@ -135,7 +135,7 @@
const idFromPath = (() => {
try{
const parts = String(location.pathname || '').split('/');
// /devices/{id}/config-friendly
// /ui/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/schema`;
const stateUrl = `${apiBase}/config/state`;
const schemaUrl = `${apiBase}/config/ui/schema`;
const stateUrl = `${apiBase}/config/ui/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/plan`, payload);
const out = await postJson(`${apiBase}/config/ui/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/apply`, payload);
const out = await postJson(`${apiBase}/config/ui/apply`, payload);
resultEl.style.display = '';
resultPre.textContent = pretty(out);
}catch(e){

View File

@ -8,7 +8,7 @@
</div>
</div>
<form method="post" action="/devices/{{.Device.DeviceID}}/config-preview">
<form method="post" action="/ui/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="/devices/{{.Device.DeviceID}}#device-config">返回设备详情</a>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}#device-config">返回设备详情</a>
</div>
</form>
</div>

View File

@ -2,7 +2,7 @@
{{template "device_nav" .}}
<div class="card">
<h2>高级识别配置</h2>
<div class="muted small">节点:<a class="mono" href="/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}})。</div>
<div class="muted small">节点:<a class="mono" href="/ui/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="/devices/{{.Device.DeviceID}}/config-ui/plan">
<form method="post" action="/ui/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="/devices/{{.Device.DeviceID}}/config-ui/apply">部署到设备</button>
<button type="submit" formaction="/ui/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="/devices/{{.Device.DeviceID}}/face-gallery/upload" enctype="multipart/form-data" class="row">
<form method="post" action="/ui/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="/devices/{{.Device.DeviceID}}/face-gallery/reload" style="align-self:end">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/reload" style="align-self:end">
<button type="submit">重新加载</button>
</form>
</div>

View File

@ -1,128 +0,0 @@
{{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}}">{{.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}}">{{.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">{{.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}}

View File

@ -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="/console" style="display:contents">
<form id="console-form" method="post" action="/ui/console" style="display:contents">
{{range $i, $cd := .ConsoleDevices}}
<div class="console-device-row" data-device-id="{{$cd.Device.DeviceID}}">
<div class="console-device-row">
<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>
@ -130,7 +130,7 @@
</div>
<div class="video-card-info">
<div class="video-card-name">{{$ch.SourceName}}</div>
<div class="video-card-device">{{$cd.Device.DisplayName}} · {{$cd.Device.IP}}</div>
<div class="video-card-device">{{$cd.Device.DisplayName}}</div>
</div>
</div>
{{end}}
@ -142,7 +142,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" onclick="event.preventDefault();showDevicePicker('{{$s.Name}}');"> 分配到设备</a>
<a href="#" style="font-size:11px;color:var(--primary);text-decoration:none"> 分配到设备</a>
</div>
</div>
{{end}}
@ -152,7 +152,7 @@
</div><!-- tab-content -->
</div><!-- card-tabs -->
<script src="/assets/vendor/hls.min.js"></script>
<script src="/ui/assets/vendor/hls.min.js"></script>
<script>
(function() {
// Tab switching
@ -193,14 +193,14 @@
var deviceIds = Object.keys(deviceChannels);
deviceIds.forEach(function(did) {
fetch('/api/monitor/channels?device_id=' + encodeURIComponent(did))
fetch('/ui/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 = '/hls/' + encodeURIComponent(did) + '/hls/' + encodeURIComponent(ch.name) + '/index.m3u8';
var proxyUrl = '/ui/hls/' + encodeURIComponent(did) + '/hls/' + encodeURIComponent(ch.name) + '/index.m3u8';
if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(proxyUrl);
@ -241,34 +241,14 @@
}
});
if (visible.length === 0) { alert('暂无未分配视频源'); return; }
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');
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>';
visible.forEach(function(name) {
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="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 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);
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);
};
function addSourceToDeviceForm(deviceID, sourceName) {
@ -281,61 +261,28 @@
input.name = 'device_' + deviceID + '_source';
input.value = sourceName;
row.appendChild(input);
// 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
// Hide source card
var card = document.querySelector('.console-source-assign[data-source="' + sourceName + '"]');
if (card) {
var sc = card.closest('.console-source-card');
if (sc) {
sc.style.opacity = '0.5';
sc.style.borderStyle = 'dashed';
}
if (sc) sc.style.display = 'none';
}
}
function showDevicePicker(sourceName) {
var rows = document.querySelectorAll('.console-device-row');
if (rows.length === 0) { alert('暂无可分配设备'); return; }
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');
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>';
rows.forEach(function(row) {
var did = row.getAttribute('data-device-id');
var nameEl = row.querySelector('.console-device-name');
var dname = nameEl ? nameEl.textContent : did;
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="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 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);
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);
}
// ---- dirty state tracking ----
@ -397,23 +344,16 @@
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>';
feats.forEach(function(f) {
summary += '<div style="font-size:12px;padding-left:12px">☑ ' + f + '</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>';
});
});
if (!summary) { alert('未选择任何检测功能或视频源'); return; }
@ -425,7 +365,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">以下配置将被渲染并下发到设备,edge-server 将热更新。</div>' +
'<div style="font-size:12px;color:var(--muted);margin-bottom:16px">以下配置将被渲染并下发到设备,media-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>' +
@ -434,12 +374,6 @@
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});
@ -452,7 +386,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 = '/console'; }, 8000);
setTimeout(function(){ location.href = '/ui/console'; }, 8000);
} else if (msg) {
document.getElementById('console-status').innerHTML = decodeURIComponent(msg[1]);
setTimeout(function(){ document.getElementById('console-status').innerHTML = ''; }, 5000);

View File

@ -1,88 +1,56 @@
{{define "dashboard"}}
{{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>
{{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>
</div>
{{end}}
<div class="stats">
<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 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>
<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="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>
<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>
{{else}}
<div class="empty-state compact" style="border-style:solid">
<div class="empty-title">暂无告警</div>
<div class="muted">有新告警时将实时显示在这里。</div>
</div>
<tr><td colspan="5" class="muted">暂无需要关注的节点。</td></tr>
{{end}}
</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>
</tbody>
</table>
</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="/tasks" style="font-size:11px">更多 →</a>
<a class="btn ghost" href="/ui/tasks" style="font-size:11px">更多 →</a>
</div>
<div class="table-wrap" style="margin-top:10px">
<table>
@ -91,7 +59,7 @@
{{range $i, $t := .Tasks}}
{{if lt $i 5}}
<tr>
<td><a class="mono" href="/tasks/{{$t.ID}}">{{shortID $t.ID}}</a></td>
<td><a class="mono" href="/ui/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>
@ -105,132 +73,9 @@
</table>
</div>
</div>
<script>
(function(){
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);
});
});
if({{.RunningTaskCount}}>0) setTimeout(function(){location.reload()},6000);
})();
</script>

View File

@ -8,10 +8,9 @@
<div class="muted small">单设备查看、服务控制、设备分配应用、模型资源和日志指标都收敛在这里完成。</div>
</div>
<div class="actions">
<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>
<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>
</div>
</div>
<div class="summary-strip control-summary">
@ -49,7 +48,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 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>设备名称</span><strong>{{displayDeviceName .Device .ConfigStatus}}</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>
@ -109,17 +108,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="/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/ui/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="/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/ui/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="/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/ui/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>
@ -149,11 +148,11 @@
</div>
{{end}}
<div class="actions scene-actions-row">
<form method="post" action="/devices/{{.Device.DeviceID}}/plan-apply">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/plan-apply">
<button type="submit" class="primary">下发当前设备分配</button>
</form>
<a class="btn secondary" href="/devices/{{.Device.DeviceID}}/config-preview"><span>预览当前设备分配</span></a>
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览当前设备分配</span></a>
<form method="post" action="/ui/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>
@ -166,51 +165,18 @@
<div>
<h3 class="title-with-icon">{{icon "assets"}}<span>模型与资源</span></h3>
</div>
<a class="btn secondary" href="/models">模型管理</a>
<a class="btn secondary" href="/ui/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>
{{end}}
<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>
</section>
<section class="panel-block" id="device-observability">
@ -220,9 +186,9 @@
</div>
</div>
<div class="actions stack">
<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>
<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>
</div>
{{if .ResultTitle}}<div class="muted small" style="margin-top:12px">{{.ResultTitle}}</div>{{end}}
{{if .Message}}<div class="msg">{{.Message}}</div>{{end}}
@ -230,38 +196,4 @@
{{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}}

View File

@ -1,7 +1,7 @@
{{define "device_add"}}
<div class="card">
<h2>新增设备</h2>
<form method="post" action="/devices-add" style="margin-top:16px">
<form method="post" action="/ui/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="/devices" style="margin-left:12px">返回设备列表</a>
<a href="/ui/devices" style="margin-left:12px">返回设备列表</a>
</div>
</form>
</div>

View File

@ -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="/device-assignments" id="assignment-board-form">
<form method="post" action="/ui/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,9 +290,6 @@
<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}
`;

View File

@ -6,7 +6,7 @@
</div>
<div class="actions compact">
<a class="btn secondary" href="{{.SelectedDevicesURL}}#batch-config">返回设备列表</a>
<a class="btn secondary" href="/devices">重新选择</a>
<a class="btn secondary" href="/ui/devices">重新选择</a>
</div>
</div>
<div class="info-list">
@ -32,7 +32,7 @@
</div>
</div>
<form method="post" action="/devices/batch-config">
<form method="post" action="/ui/devices/batch-config">
{{range .SelectedDeviceIDs}}<input type="hidden" name="device_id" value="{{.}}" />{{end}}
<div class="actions">
<button type="submit" class="primary">创建下发任务</button>

View File

@ -5,7 +5,7 @@
<h2>单设备配置已并入设备详情</h2>
<div class="muted small">配置查看、服务控制和回滚,都已经收敛到设备详情工作台。</div>
</div>
<a class="btn ghost" href="/devices">去设备列表</a>
<a class="btn ghost" href="/ui/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="/devices/{{.DeviceID}}#device-config">
<a class="selector-card" href="/ui/devices/{{.DeviceID}}#device-config">
<div class="selector-head">
<div>
<div class="selector-title">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</div>

View File

@ -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="/device-config">返回设备选择</a>
<a class="btn secondary" href="/ui/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="/devices/{{.Device.DeviceID}}/config-preview">{{icon "preview"}}<span>打开预览器</span></a>
<a class="btn secondary" href="/ui/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="/devices/{{.Device.DeviceID}}/config-candidate/apply">
<form method="post" action="/ui/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="/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/ui/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="/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/ui/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="/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/ui/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="/devices/{{.Device.DeviceID}}/action">
<form method="post" action="/ui/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>

View File

@ -32,8 +32,9 @@
</div>
</div>
<form method="post" action="/devices/batch-action">
<div class="batch-toolbar" id="batch-config"{{if not .SelectedDeviceIDs}} style="display:none"{{end}}>
<form method="post" action="/ui/devices/batch-action">
{{if .SelectedDeviceIDs}}
<div class="batch-toolbar" id="batch-config">
<div>
<div class="batch-toolbar-count">已选 {{len .SelectedDeviceIDs}} 台</div>
</div>
@ -43,14 +44,12 @@
<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}}
<a class="btn secondary" href="{{.BatchConfigURL}}">下发设备分配</a>
<a class="btn secondary" href="/devices">清空选择</a>
<a class="btn secondary" href="/ui/devices">清空选择</a>
</div>
</div>
{{end}}
<div class="table-wrap">
<table id="device-list">
<thead>
@ -63,9 +62,8 @@
</tr>
</thead>
<tbody>
{{range .DeviceRows}}
{{$rowWarn := and .Device.Online (or (and .ConfigStatus (not .ConfigStatus.MediaServer.Running)) (not .ConfigStatus))}}
<tr {{if $rowWarn}}class="device-row-warn"{{end}}>
{{range .DeviceRows}}
<tr>
<td class="select-cell">
<input type="checkbox" name="device_id" value="{{.Device.DeviceID}}" {{if hasString $.SelectedDeviceIDs .Device.DeviceID}}checked{{end}} />
</td>
@ -77,6 +75,8 @@
<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,7 +92,6 @@
{{else}}
<span class="pill bad">未知</span>
{{end}}
{{if .IntegrationStale}}<span class="pill warn">需重新下发</span>{{end}}
</div>
</div>
</td>
@ -109,7 +108,7 @@
</td>
<td>
<div class="actions">
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}">{{icon "detail"}}<span>详情</span></a>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}">{{icon "detail"}}<span>详情</span></a>
</div>
</td>
</tr>
@ -151,28 +150,7 @@
}
}
const next = `${url.pathname}${url.searchParams.toString() ? `?${url.searchParams.toString()}` : ''}`;
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';
}
window.location.assign(next);
};
for (const box of selectedBoxes) {
box.addEventListener('change', syncSelected);

View File

@ -79,15 +79,15 @@
{{range .Devices}}
<tr>
<td>
<a class="mono" href="/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a>
<a class="mono" href="/ui/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="/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/devices/{{.DeviceID}}/graphs">运行指标</a>
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/graphs">运行指标</a>
</div>
</td>
</tr>

View File

@ -13,24 +13,17 @@
<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 class="person-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px;margin-top:12px">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px;margin-top:12px">
{{range .FaceGalleryPersons}}
<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">
<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">
{{if .Photos}}
<img src="/face-photo/{{index .Photos 0}}" style="width:100%;height:100%;object-fit:cover" id="img-{{.ID}}" />
<img src="/ui/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)">&#9664;</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)">&#9654;</span>
@ -40,10 +33,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>{{$quality := index $.FaceGalleryQuality .Name}}{{if $quality}} <span class="pill ok" style="font-size:10px">{{$quality}}通过</span>{{end}}</div>
<div><span style="font-weight:500;font-size:13px">{{.Name}}</span> <span class="muted small">{{.PhotoCount}}张</span></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":"/face-photo/{{$p}}","id":{{index $person.PhotoIDs $i}}}{{end}}]'>&#9998;</span>
<form method="post" action="/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":"/ui/face-photo/{{$p}}","id":{{index $person.PhotoIDs $i}}}{{end}}]'>&#9998;</span>
<form method="post" action="/ui/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()">&#10005;</span>
</form>
@ -57,58 +50,6 @@
{{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){
@ -124,16 +65,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="/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="/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').style.display='flex';
}
function showAdd(){
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-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').style.display='flex';
}
function showEdit(id,name,photos){
var h='<h3>编辑人员</h3>';
h+='<form method="post" action="/face-gallery/rename" style="margin-bottom:12px;display:flex;gap:8px;align-items:flex-end">';
h+='<form method="post" action="/ui/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">';
@ -143,7 +84,7 @@ function showEdit(id,name,photos){
h+='</div>';
});
h+='</div>';
h+='<form method="post" action="/face-gallery/add-photo" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:flex-end">';
h+='<form method="post" action="/ui/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;
@ -151,23 +92,11 @@ function showEdit(id,name,photos){
}
function delPhoto(id){
if(!confirm('确认删除这张照片?'))return;
var f=document.createElement('form');f.method='post';f.action='/face-gallery/delete-photo';
var f=document.createElement('form');f.method='post';f.action='/ui/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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
</script>

View File

@ -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="/assets/vendor/tabler.min.css" />
<link rel="stylesheet" href="/assets/style.css?v=ia12" />
<link rel="stylesheet" href="/assets/graph_editor.css?v=20260720-ia01" />
<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" />
</head>
<body data-theme="blue-dark">
<div class="app-shell">
@ -19,45 +19,33 @@
</div>
</div>
<nav class="side-nav" aria-label="主导航">
<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>
<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>
<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="/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>
<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>
</div>
</details>
</nav>
<div class="sidebar-footer">
<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>北京泰奥理科技有限公司</div>
<div>&copy; {{.Year}} V{{.Version}}</div>
</div>
</aside>
@ -77,12 +65,11 @@
<button type="button" data-theme-option="graphite-gold">石墨金色</button>
</div>
</div>
<a class="topbar-icon-btn" href="/alarms" aria-label="告警中心" title="告警中心" id="topbar-alarm-btn">
<a class="topbar-icon-btn" href="/ui/diagnostics" aria-label="日志审计" title="日志审计">
{{icon "bell"}}
<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>
<span class="topbar-dot" aria-hidden="true"></span>
</a>
<a class="topbar-icon-btn" href="/system" aria-label="系统" title="系统">
<a class="topbar-icon-btn" href="/ui/system" aria-label="系统" title="系统">
{{icon "system"}}
</a>
</div>
@ -94,8 +81,7 @@
</main>
</div>
</div>
<div id="toast-container" class="toast-container"></div>
<script src="/assets/vendor/tabler.min.js"></script>
<script src="/ui/assets/vendor/tabler.min.js"></script>
<script>
(function () {
const themeKey = "3588-admin-theme";
@ -340,103 +326,21 @@
if (systemNavGroup) {
const path = window.location.pathname || "";
if (
path === "/system" ||
path === "/diagnostics" ||
path === "/tasks" ||
path === "/scene-templates" ||
path === "/recognition-units" ||
path === "/device-assignments" ||
path === "/assets" ||
path === "/models" ||
path === "/audit" ||
path === "/api"
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"
) {
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>

View File

@ -5,7 +5,7 @@
<h2>日志分析</h2>
<div class="muted small">按设备查看诊断日志、运行指标,并进入高级调试。</div>
</div>
<a class="btn ghost" href="/api">高级调试</a>
<a class="btn ghost" href="/ui/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="/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a><div class="muted small mono">{{.DeviceID}}</div></td>
<td><a class="mono" href="/ui/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="/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/devices/{{.DeviceID}}/graphs">运行指标</a>
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/graphs">运行指标</a>
</div>
</td>
</tr>

View File

@ -7,9 +7,9 @@
<div class="form-hint">统一维护标准模型目录,设备页只看当前生效状态,更新动作统一走任务。</div>
</div>
<div class="actions compact">
<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}}
<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}}
<input type="hidden" name="action" value="model_sync_all">
<button class="btn" type="submit">更新全部模型</button>
</form>
@ -87,13 +87,12 @@
<tr>
<td>
<div class="table-key">{{.DeviceName}}</div>
<div class="muted small mono">{{.DeviceIP}}</div>
<div class="muted small mono">{{.DeviceID}}</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}}
@ -120,45 +119,4 @@
</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()">&times;</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}}

View File

@ -15,7 +15,7 @@
<div class="card muted" style="text-align:center;grid-column:1/-1">加载中…</div>
</div>
<script src="/assets/vendor/hls.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
function loadAll() {
var wall = document.getElementById("video-wall");
@ -32,7 +32,7 @@ function loadAll() {
}
var promises = devices.map(function(dev) {
return fetch('/api/monitor/channels?device_id=' + encodeURIComponent(dev.id))
return fetch('/ui/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 = '/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
var proxyUrl = '/ui/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 = '/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
var proxyUrl = '/ui/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
var video = document.getElementById('v' + i);
if (!video) return;
if (Hls.isSupported()) {

View File

@ -5,7 +5,7 @@
<h2>识别配置</h2>
<div class="muted small">管理识别方案、视频通道、模板参数和推理配置。</div>
</div>
<a class="btn ghost" href="/templates">模板库</a>
<a class="btn ghost" href="/ui/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="/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a><div class="muted small mono">{{.DeviceID}}</div></td>
<td><a class="mono" href="/ui/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="/devices/{{.DeviceID}}/config-friendly">编辑视频通道</a>
<a class="btn ghost" href="/devices/{{.DeviceID}}/config-ui">高级 JSON</a>
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/config-friendly">编辑视频通道</a>
<a class="btn ghost" href="/ui/devices/{{.DeviceID}}/config-ui">高级 JSON</a>
</div>
</td>
</tr>

View File

@ -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="/recognition-units?new=1">{{icon "apply"}}<span>新增检测通道</span></a>
<a class="btn secondary" href="/ui/recognition-units?new=1">{{icon "apply"}}<span>新增视频通道</span></a>
{{if .SelectedRecognitionUnit}}
<a class="btn secondary" href="/recognition-units?ref={{.SelectedRecognitionUnit}}&edit=1">编辑</a>
<form method="post" action="/recognition-units/delete" onsubmit="return confirm('确认删除这个检测通道吗?');">
<a class="btn secondary" href="/ui/recognition-units?ref={{.SelectedRecognitionUnit}}&edit=1">编辑</a>
<form method="post" action="/ui/recognition-units/delete" onsubmit="return confirm('确认删除这个视频通道吗?');">
<input type="hidden" name="ref" value="{{.SelectedRecognitionUnit}}" />
<button class="btn secondary" type="submit">删除</button>
</form>
@ -19,7 +19,7 @@
<table>
<thead>
<tr>
<th>检测通道</th>
<th>视频通道</th>
<th>场景</th>
<th>视频源</th>
<th>输出频道号</th>
@ -27,14 +27,14 @@
</thead>
<tbody>
{{range .RecognitionUnits}}
<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>
<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>
<td class="mono">{{.SceneTemplateName}}</td>
<td class="mono">{{if .VideoSourceRef}}{{.VideoSourceRef}}{{else}}-{{end}}</td>
<td class="mono">{{if .OutputChannel}}{{.OutputChannel}}{{else}}-{{end}}</td>
</tr>
{{else}}
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有检测通道</div></div></td></tr>
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有视频通道</div></div></td></tr>
{{end}}
</tbody>
</table>
@ -42,16 +42,16 @@
</div>
{{if .RecognitionUnit}}
<form method="post" action="/recognition-units">
<form method="post" action="/ui/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 +60,14 @@
</div>
<div class="actions compact">
{{if .RecognitionUnitEditing}}
<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>
<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>
{{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,7 +92,7 @@
</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>

View File

@ -7,7 +7,7 @@
<div class="form-hint">统一维护标准资源,设备侧通过任务同步。当前支持人脸库等资源类型。</div>
</div>
<div class="actions compact">
<form method="post" action="/resources/sync">
<form method="post" action="/ui/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">{{.DeviceIP}}</div>
<div class="muted small mono">{{.DeviceID}}</div>
</td>
{{range .Cells}}
<td>

View File

@ -5,10 +5,10 @@
<h2 class="title-with-icon">{{icon "profile"}}<span>场景列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/scene-templates?new=1">{{icon "apply"}}<span>新建场景</span></a>
<a class="btn secondary" href="/ui/scene-templates?new=1">{{icon "apply"}}<span>新建场景</span></a>
{{if .SelectedProfile}}
<a class="btn secondary" href="/scene-templates?name={{.SelectedProfile}}&edit=1">编辑</a>
<form method="post" action="/scene-templates/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景吗?');">
<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('确认删除这个场景吗?');">
<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="/scene-templates?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
<td><a class="mono" href="/scene-templates?name={{.Name}}">{{.Name}}</a></td>
<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>
<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="/scene-templates{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
<a class="btn secondary" href="/ui/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="/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="/ui/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}}

View File

@ -7,12 +7,12 @@
<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>
<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="/system/db-restore" enctype="multipart/form-data">
<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>
@ -34,18 +34,6 @@
<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>
</div>
<div class="actions" style="margin-top:12px">
<button type="submit" class="secondary">保存配置</button>
</div>
</form>
</div>
</div>
<script>

View File

@ -1,7 +1,7 @@
{{define "task"}}
<div class="card">
<div class="actions">
<a class="btn ghost" href="/tasks">{{icon "devices"}}<span>返回任务列表</span></a>
<a class="btn ghost" href="/ui/tasks">{{icon "devices"}}<span>返回任务列表</span></a>
</div>
</div>

View File

@ -10,7 +10,7 @@
{{range .Tasks}}
<tr>
<td>
<div><a class="mono" href="/tasks/{{.ID}}">{{shortID .ID}}</a></div>
<div><a class="mono" href="/ui/tasks/{{.ID}}">{{shortID .ID}}</a></div>
<div class="muted small">{{taskActionLabel .Type}}</div>
</td>
<td>

View File

@ -1,6 +1,6 @@
{{define "template"}}
<div class="card">
<div><a href="/templates">返回识别配置模板</a></div>
<div><a href="/ui/templates">返回识别配置模板</a></div>
</div>
<div class="card">

View File

@ -9,7 +9,7 @@
<tbody>
{{range .Templates}}
<tr>
<td><a class="mono" href="/templates/{{.Name}}">{{.Name}}</a></td>
<td><a class="mono" href="/ui/templates/{{.Name}}">{{.Name}}</a></td>
<td><pre style="margin:0">{{json .Schema}}</pre></td>
</tr>
{{end}}

View File

@ -1,382 +0,0 @@
{{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}}

View File

@ -1,25 +1,25 @@
package web
import (
"safesight-control/internal/config"
"safesight-control/internal/models"
"safesight-control/internal/service"
"safesight-control/internal/storage"
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/service"
"3588AdminBackend/internal/storage"
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
@ -2162,7 +2162,7 @@ func TestUI_DeviceControlPageShowsLiveActions(t *testing.T) {
func TestUI_ActionDeviceActionCanRenderControlPage(t *testing.T) {
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/edge-server/reload":
case r.Method == http.MethodPost && r.URL.Path == "/v1/media-server/reload":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/config/status":
@ -2211,7 +2211,7 @@ func TestUI_ActionDeviceActionCanRenderControlPage(t *testing.T) {
ui.actionDeviceAction(rr, req)
body := rr.Body.String()
for _, want := range []string{"设备工作台", "运行与服务", "POST /v1/edge-server/reload", "执行结果摘要"} {
for _, want := range []string{"设备工作台", "运行与服务", "POST /v1/media-server/reload", "执行结果摘要"} {
if !strings.Contains(body, want) {
t.Fatalf("expected control page result HTML to contain %q, got:\n%s", want, body)
}

View File

@ -1,5 +1,5 @@
{
"name": "safesight-control",
"name": "3588adminbackend",
"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/safesight-control.git"
"url": "http://10.0.0.99:4000/Doni/3588AdminBackend.git"
},
"keywords": [],
"author": "",

Binary file not shown.

View File

@ -1,13 +0,0 @@
{
"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,
"company_name": "北京泰奥理科技有限公司"
}

View File

@ -1,8 +0,0 @@
#!/bin/bash
# 快速编译 Linux ARM64 二进制(仅编译,不打包)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_DIR"
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o safesightd-linux-arm64 ./cmd/safesightd/
echo "$(ls -lh safesightd-linux-arm64 | awk '{print $5,$NF}')"

View File

@ -1,5 +1,5 @@
#!/bin/bash
# SafeSight Control 统一管理脚本
# 3588AdminBackend 统一管理脚本
# 提供安装、状态、升级、卸载等功能
set -e
@ -12,21 +12,21 @@ BLUE='\033[0;34m'
NC='\033[0m'
# 配置
APP_NAME="safesightd"
APP_NAME="managerd"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DEFAULT_INSTALL_DIR="/opt/safesightd"
DEFAULT_INSTALL_DIR="/opt/3588admin"
INSTALL_DIR="${DEFAULT_INSTALL_DIR}"
PID_FILE="$INSTALL_DIR/$APP_NAME.pid"
# 显示帮助
cmd_help() {
cat << EOF
SafeSight Control 管理脚本
3588AdminBackend 管理脚本
用法: $0 <命令> [选项]
命令:
install [目录] 安装服务 (默认: /opt/safesightd)
install [目录] 安装服务 (默认: /opt/3588admin)
status 查看服务状态
start 启动服务
stop 停止服务
@ -69,7 +69,7 @@ get_install_dir() {
# 环境检查
cmd_check() {
echo "========== SafeSight Control 环境检查 =========="
echo "========== 3588AdminBackend 环境检查 =========="
echo ""
PASS=0
@ -233,7 +233,7 @@ cmd_install() {
local CURRENT_DIR="$SCRIPT_DIR"
local BASE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "========== SafeSight Control 离线安装 =========="
echo "========== 3588AdminBackend 离线安装 =========="
echo "安装目录: $INSTALL_DIR"
echo ""
@ -247,10 +247,10 @@ cmd_install() {
exit 1
fi
# 备份现有配置
if [ -f "$INSTALL_DIR/config/safesightd.json" ]; then
local BACKUP_FILE="$INSTALL_DIR/config/safesightd.json.bak.$(date +%Y%m%d%H%M%S)"
if [ -f "$INSTALL_DIR/config/managerd.json" ]; then
local BACKUP_FILE="$INSTALL_DIR/config/managerd.json.bak.$(date +%Y%m%d%H%M%S)"
echo "备份现有配置到: $BACKUP_FILE"
cp "$INSTALL_DIR/config/safesightd.json" "$BACKUP_FILE"
cp "$INSTALL_DIR/config/managerd.json" "$BACKUP_FILE"
fi
fi
@ -273,18 +273,18 @@ cmd_install() {
echo " 复制二进制文件..."
mkdir -p "$INSTALL_DIR/bin"
cp "$BASE_DIR/bin/safesightd" "$INSTALL_DIR/bin/" 2>/dev/null || {
echo -e "${RED}错误: 未找到 safesightd 二进制文件${NC}"
cp "$BASE_DIR/bin/managerd" "$INSTALL_DIR/bin/" 2>/dev/null || {
echo -e "${RED}错误: 未找到 managerd 二进制文件${NC}"
echo "请确保在打包前已编译"
exit 1
}
echo " 复制配置文件..."
mkdir -p "$INSTALL_DIR/config"
if [ -f "$BASE_DIR/config/safesightd.json" ]; then
cp "$BASE_DIR/config/safesightd.json" "$INSTALL_DIR/config/"
elif [ -f "$BASE_DIR/config/safesightd.json.example" ]; then
cp "$BASE_DIR/config/safesightd.json.example" "$INSTALL_DIR/config/"
if [ -f "$BASE_DIR/config/managerd.json" ]; then
cp "$BASE_DIR/config/managerd.json" "$INSTALL_DIR/config/"
elif [ -f "$BASE_DIR/config/managerd.json.example" ]; then
cp "$BASE_DIR/config/managerd.json.example" "$INSTALL_DIR/config/"
fi
# 复制模板文件
@ -295,7 +295,7 @@ cmd_install() {
# 3. 设置权限
echo "[3/5] 设置权限..."
chmod +x "$INSTALL_DIR/bin/safesightd"
chmod +x "$INSTALL_DIR/bin/managerd"
mkdir -p "$INSTALL_DIR/logs" || {
echo -e "${RED}错误: 无法创建日志目录 $INSTALL_DIR/logs${NC}"
exit 1
@ -307,8 +307,8 @@ cmd_install() {
# 4. 初始化配置文件
echo "[4/5] 初始化配置文件..."
if [ ! -f "$INSTALL_DIR/config/safesightd.json" ]; then
cat > "$INSTALL_DIR/config/safesightd.json" << 'EOF'
if [ ! -f "$INSTALL_DIR/config/managerd.json" ]; then
cat > "$INSTALL_DIR/config/managerd.json" << 'EOF'
{
"listen": "0.0.0.0:18080",
"discovery_port": 35688,
@ -319,7 +319,7 @@ cmd_install() {
}
EOF
echo -e " ${GREEN}已创建默认配置文件${NC}"
echo -e " ${YELLOW}⚠️ 请编辑 $INSTALL_DIR/config/safesightd.json 修改配置(特别是 agent_token${NC}"
echo -e " ${YELLOW}⚠️ 请编辑 $INSTALL_DIR/config/managerd.json 修改配置(特别是 agent_token${NC}"
else
echo " 配置文件已存在,保留现有配置"
fi
@ -340,7 +340,7 @@ EOF
if [ -f "$CURRENT_DIR/3588admin.service" ]; then
echo "[可选] 安装 Systemd 服务..."
cp "$CURRENT_DIR/3588admin.service" /etc/systemd/system/
sed -i "s|/opt/safesightd|$INSTALL_DIR|g" /etc/systemd/system/3588admin.service
sed -i "s|/opt/3588admin|$INSTALL_DIR|g" /etc/systemd/system/3588admin.service
systemctl daemon-reload
echo " 已安装 Systemd 服务"
fi
@ -358,7 +358,7 @@ EOF
echo " 3588admin uninstall - 卸载服务"
echo ""
echo -e "${YELLOW}重要提示:${NC}"
echo " 1. 请先编辑配置文件: $INSTALL_DIR/config/safesightd.json"
echo " 1. 请先编辑配置文件: $INSTALL_DIR/config/managerd.json"
echo " 2. 务必修改 agent_token 为安全的随机字符串"
echo ""
echo -e "${YELLOW}防火墙配置:${NC}"
@ -373,10 +373,10 @@ cmd_start() {
get_install_dir "$1"
local BIN_PATH="$INSTALL_DIR/bin/$APP_NAME"
local CONFIG_PATH="$INSTALL_DIR/config/safesightd.json"
local CONFIG_PATH="$INSTALL_DIR/config/managerd.json"
local LOG_DIR="$INSTALL_DIR/logs"
echo "========== 启动 SafeSight Control =========="
echo "========== 启动 3588AdminBackend =========="
echo "应用目录: $INSTALL_DIR"
echo "配置文件: $CONFIG_PATH"
echo ""
@ -468,7 +468,7 @@ cmd_start() {
cmd_stop() {
get_install_dir "$1"
echo "========== 停止 SafeSight Control =========="
echo "========== 停止 3588AdminBackend =========="
if [ -f "$PID_FILE" ]; then
local PID=$(cat "$PID_FILE")
@ -528,10 +528,10 @@ cmd_status() {
get_install_dir "$1"
local BIN_PATH="$INSTALL_DIR/bin/$APP_NAME"
local CONFIG_PATH="$INSTALL_DIR/config/safesightd.json"
local CONFIG_PATH="$INSTALL_DIR/config/managerd.json"
local LOG_DIR="$INSTALL_DIR/logs"
echo "========== SafeSight Control 状态 =========="
echo "========== 3588AdminBackend 状态 =========="
echo ""
echo "基本信息:"
echo " 应用目录: $INSTALL_DIR"
@ -640,7 +640,7 @@ cmd_upgrade() {
local SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
local BACKUP_DIR="/root/3588admin-backup-$(date +%Y%m%d%H%M%S)"
echo "========== SafeSight Control 升级 =========="
echo "========== 3588AdminBackend 升级 =========="
echo "安装目录: $INSTALL_DIR"
echo "备份目录: $BACKUP_DIR"
echo ""
@ -653,7 +653,7 @@ cmd_upgrade() {
fi
# 确认
read -p "确定要升级 SafeSight Control? (y/N): " -n 1 -r
read -p "确定要升级 3588AdminBackend? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "升级已取消"
@ -669,8 +669,8 @@ cmd_upgrade() {
echo "[2/5] 备份现有数据..."
mkdir -p "$BACKUP_DIR"
if [ -f "$INSTALL_DIR/config/safesightd.json" ]; then
cp "$INSTALL_DIR/config/safesightd.json" "$BACKUP_DIR/"
if [ -f "$INSTALL_DIR/config/managerd.json" ]; then
cp "$INSTALL_DIR/config/managerd.json" "$BACKUP_DIR/"
echo " 已备份配置文件"
fi
@ -679,17 +679,17 @@ cmd_upgrade() {
echo " 已备份日志文件"
fi
if [ -f "$INSTALL_DIR/bin/safesightd" ]; then
cp "$INSTALL_DIR/bin/safesightd" "$BACKUP_DIR/safesightd.old"
if [ -f "$INSTALL_DIR/bin/managerd" ]; then
cp "$INSTALL_DIR/bin/managerd" "$BACKUP_DIR/managerd.old"
echo " 已备份旧版本二进制"
fi
# 3. 更新二进制文件
echo "[3/5] 更新二进制文件..."
local BASE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
if [ -f "$BASE_DIR/bin/safesightd" ]; then
cp "$BASE_DIR/bin/safesightd" "$INSTALL_DIR/bin/safesightd"
chmod +x "$INSTALL_DIR/bin/safesightd"
if [ -f "$BASE_DIR/bin/managerd" ]; then
cp "$BASE_DIR/bin/managerd" "$INSTALL_DIR/bin/managerd"
chmod +x "$INSTALL_DIR/bin/managerd"
echo -e " ${GREEN}已更新二进制文件${NC}"
else
echo -e "${YELLOW}警告: 未找到新的二进制文件${NC}"
@ -715,12 +715,12 @@ cmd_uninstall() {
check_root
get_install_dir "$1"
echo "========== SafeSight Control 卸载 =========="
echo "========== 3588AdminBackend 卸载 =========="
echo "安装目录: $INSTALL_DIR"
echo ""
# 确认
read -p "确定要完全卸载 SafeSight Control? 此操作不可恢复! (y/N): " -n 1 -r
read -p "确定要完全卸载 3588AdminBackend? 此操作不可恢复! (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "卸载已取消"
@ -759,10 +759,10 @@ cmd_uninstall() {
echo "[4/4] 删除安装文件..."
local BACKUP_DIR=""
if [ -d "$INSTALL_DIR" ]; then
if [ -f "$INSTALL_DIR/config/safesightd.json" ]; then
if [ -f "$INSTALL_DIR/config/managerd.json" ]; then
BACKUP_DIR="/root/3588admin-backup-$(date +%Y%m%d%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp "$INSTALL_DIR/config/safesightd.json" "$BACKUP_DIR/"
cp "$INSTALL_DIR/config/managerd.json" "$BACKUP_DIR/"
echo " 配置已备份到: $BACKUP_DIR/"
fi
@ -773,7 +773,7 @@ cmd_uninstall() {
echo ""
echo -e "${GREEN}========== 卸载完成 ==========${NC}"
if [ -n "$BACKUP_DIR" ]; then
echo "配置文件备份: $BACKUP_DIR/safesightd.json"
echo "配置文件备份: $BACKUP_DIR/managerd.json"
fi
}

View File

@ -0,0 +1,31 @@
[Unit]
Description=3588AdminBackend Service
Documentation=https://github.com/your-org/3588AdminBackend
After=network.target network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/3588admin
ExecStart=/opt/3588admin/bin/managerd /opt/3588admin/config/managerd.json
ExecReload=/bin/kill -HUP $MAINPID
KillMode=process
KillSignal=SIGTERM
Restart=on-failure
RestartSec=5
StartLimitInterval=60s
StartLimitBurst=3
# 资源限制(根据需要调整)
# LimitNOFILE=65535
# LimitNPROC=4096
# 安全设置
NoNewPrivileges=false
ProtectSystem=false
ProtectHome=false
[Install]
WantedBy=multi-user.target

View File

@ -50,24 +50,24 @@ echo "[3/7] 编译项目..."
cd "$PROJECT_DIR"
echo " 编译 Linux AMD64 版本..."
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$PACKAGE_DIR/bin/safesightd" ./cmd/safesightd
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$PACKAGE_DIR/bin/managerd" ./cmd/managerd
echo " 验证编译结果..."
if [ ! -f "$PACKAGE_DIR/bin/safesightd" ]; then
if [ ! -f "$PACKAGE_DIR/bin/managerd" ]; then
echo -e "${RED}错误: 编译失败${NC}"
exit 1
fi
FILE_INFO=$(file "$PACKAGE_DIR/bin/safesightd")
FILE_INFO=$(file "$PACKAGE_DIR/bin/managerd")
echo " $FILE_INFO"
# 复制配置文件
echo "[4/7] 复制配置文件..."
if [ -f "$PROJECT_DIR/safesightd.json" ]; then
cp "$PROJECT_DIR/safesightd.json" "$PACKAGE_DIR/config/safesightd.json.example"
if [ -f "$PROJECT_DIR/managerd.json" ]; then
cp "$PROJECT_DIR/managerd.json" "$PACKAGE_DIR/config/managerd.json.example"
else
# 创建默认配置示例
cat > "$PACKAGE_DIR/config/safesightd.json.example" << 'EOF'
cat > "$PACKAGE_DIR/config/managerd.json.example" << 'EOF'
{
"listen": "0.0.0.0:18080",
"discovery_port": 35688,
@ -141,7 +141,7 @@ cat > "$PACKAGE_DIR/README-离线安装.txt" << 'EOF'
sudo ./scripts/3588admin install /usr/local/3588admin
3. 修改配置
sudo nano /opt/3588admin/config/safesightd.json
sudo nano /opt/3588admin/config/managerd.json
重点修改:
- agent_token: 修改为安全的随机字符串
@ -174,13 +174,13 @@ cat > "$PACKAGE_DIR/README-离线安装.txt" << 'EOF'
【文件位置】
安装目录: /opt/3588admin
配置文件: /opt/3588admin/config/safesightd.json
日志文件: /opt/3588admin/logs/safesightd.log
配置文件: /opt/3588admin/config/managerd.json
日志文件: /opt/3588admin/logs/managerd.log
【问题排查】
1. 查看日志: tail -f /opt/3588admin/logs/safesightd.log
2. 检查配置: python3 -m json.tool /opt/3588admin/config/safesightd.json
3. 检查端口: sudo netstat -tlnp | grep safesightd
1. 查看日志: tail -f /opt/3588admin/logs/managerd.log
2. 检查配置: python3 -m json.tool /opt/3588admin/config/managerd.json
3. 检查端口: sudo netstat -tlnp | grep managerd
【防火墙】
sudo ufw allow 18080/tcp # HTTP API

View File

@ -1,14 +1,14 @@
# SafeSight Control Windows 构建脚本
# 3588AdminBackend Windows 构建脚本
# 在 Windows 上交叉编译 Linux 版本并打包
# 配置
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectDir = Split-Path -Parent (Split-Path -Parent $ScriptDir)
$BuildDir = "$ProjectDir\build"
$PackageName = "SafeSight Control-离线部署包-$(Get-Date -Format 'yyyyMMdd')"
$PackageName = "3588AdminBackend-离线部署包-$(Get-Date -Format 'yyyyMMdd')"
$PackageDir = "$BuildDir\$PackageName"
Write-Host "========== SafeSight Control Windows 构建 ==========" -ForegroundColor Green
Write-Host "========== 3588AdminBackend Windows 构建 ==========" -ForegroundColor Green
Write-Host "项目目录: $ProjectDir"
Write-Host "构建目录: $BuildDir"
Write-Host ""
@ -45,9 +45,9 @@ $env:GOOS = "linux"
$env:GOARCH = "amd64"
$env:GOTOOLCHAIN = "local"
go build -ldflags="-s -w" -o "$PackageDir\bin\safesightd" ./cmd/safesightd
go build -ldflags="-s -w" -o "$PackageDir\bin\managerd" ./cmd/managerd
if ($LASTEXITCODE -ne 0 -or -not (Test-Path "$PackageDir\bin\safesightd")) {
if ($LASTEXITCODE -ne 0 -or -not (Test-Path "$PackageDir\bin\managerd")) {
Write-Host "错误: 编译失败" -ForegroundColor Red
exit 1
}
@ -56,8 +56,8 @@ Write-Host " 编译成功" -ForegroundColor Green
# 复制配置文件
Write-Host "[4/6] 复制配置文件..."
if (Test-Path "$ProjectDir\safesightd.json") {
Copy-Item "$ProjectDir\safesightd.json" "$PackageDir\config\safesightd.json.example"
if (Test-Path "$ProjectDir\managerd.json") {
Copy-Item "$ProjectDir\managerd.json" "$PackageDir\config\managerd.json.example"
} else {
@"
{
@ -68,7 +68,7 @@ if (Test-Path "$ProjectDir\safesightd.json") {
"agent_token": "CHANGE_ME",
"concurrency": 5
}
"@ | Out-File -FilePath "$PackageDir\config\safesightd.json.example" -Encoding UTF8
"@ | Out-File -FilePath "$PackageDir\config\managerd.json.example" -Encoding UTF8
}
# 复制模板文件(如果有)
@ -79,17 +79,17 @@ if (Test-Path "$ProjectDir\templates") {
# 复制脚本
Write-Host "[5/6] 复制部署脚本..."
Copy-Item "$ScriptDir\safesightd" "$PackageDir\scripts\"
Copy-Item "$ScriptDir\safesightd.service" "$PackageDir\systemd\"
Copy-Item "$ScriptDir\3588admin" "$PackageDir\scripts\"
Copy-Item "$ScriptDir\3588admin.service" "$PackageDir\systemd\"
# 修复换行符为 LFUnix 格式)
$scriptContent = Get-Content "$PackageDir\scripts\safesightd" -Raw
$scriptContent = Get-Content "$PackageDir\scripts\3588admin" -Raw
$scriptContent = $scriptContent -replace "`r`n", "`n"
Set-Content -Path "$PackageDir\scripts\safesightd" -Value $scriptContent -NoNewLine
Set-Content -Path "$PackageDir\scripts\3588admin" -Value $scriptContent -NoNewLine
$serviceContent = Get-Content "$PackageDir\systemd\safesightd.service" -Raw
$serviceContent = Get-Content "$PackageDir\systemd\3588admin.service" -Raw
$serviceContent = $serviceContent -replace "`r`n", "`n"
Set-Content -Path "$PackageDir\systemd\safesightd.service" -Value $serviceContent -NoNewLine
Set-Content -Path "$PackageDir\systemd\3588admin.service" -Value $serviceContent -NoNewLine
# 复制文档
Write-Host "[6/6] 复制文档..."
@ -107,7 +107,7 @@ if (Test-Path "$ProjectDir\API_Device_RemoteMgmt_InterfaceTable.md") {
# 创建版本信息
@"
SafeSight Control 离线部署包
3588AdminBackend 离线部署包
版本: 1.0.0
构建时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
Go版本: $GoVersion
@ -118,7 +118,7 @@ Go版本: $GoVersion
# 创建快速安装说明
@"
SafeSight Control 离线部署包 - 快速安装指南
3588AdminBackend 离线部署包 - 快速安装指南
系统要求
@ -141,44 +141,44 @@ Go版本: $GoVersion
cd $PackageName/
3. 执行安装
sudo ./scripts/safesightd install
sudo ./scripts/3588admin install
4. 修改配置
sudo nano /opt/safesightd/config/safesightd.json
sudo nano /opt/3588admin/config/managerd.json
重点修改:
- agent_token: 修改为安全的随机字符串
- listen: 如需外部访问改为 "0.0.0.0:18080"
5. 启动服务
sudo safesightd start
sudo 3588admin start
6. 验证安装
sudo safesightd status
sudo 3588admin status
常用命令
safesightd start - 启动服务
safesightd stop - 停止服务
safesightd status - 查看状态
safesightd restart - 重启服务
safesightd upgrade - 升级服务
safesightd uninstall - 卸载服务
safesightd check - 检查环境
3588admin start - 启动服务
3588admin stop - 停止服务
3588admin status - 查看状态
3588admin restart - 重启服务
3588admin upgrade - 升级服务
3588admin uninstall - 卸载服务
3588admin check - 检查环境
快捷命令
safesightd-start - 启动服务
safesightd-stop - 停止服务
safesightd-status - 查看状态
3588admin-start - 启动服务
3588admin-stop - 停止服务
3588admin-status - 查看状态
文件位置
安装目录: /opt/safesightd
配置文件: /opt/safesightd/config/safesightd.json
日志文件: /opt/safesightd/logs/safesightd.log
安装目录: /opt/3588admin
配置文件: /opt/3588admin/config/managerd.json
日志文件: /opt/3588admin/logs/managerd.log
问题排查
1. 查看日志: tail -f /opt/safesightd/logs/safesightd.log
2. 检查配置: python3 -m json.tool /opt/safesightd/config/safesightd.json
3. 检查端口: sudo netstat -tlnp | grep safesightd
1. 查看日志: tail -f /opt/3588admin/logs/managerd.log
2. 检查配置: python3 -m json.tool /opt/3588admin/config/managerd.json
3. 检查端口: sudo netstat -tlnp | grep managerd
防火墙
sudo ufw allow 18080/tcp # HTTP API
@ -209,7 +209,7 @@ if ($LASTEXITCODE -eq 0 -and (Test-Path "$PackageName.tar.gz")) {
Write-Host ""
Write-Host "使用说明:"
Write-Host "1. 将 $PackageName.tar.gz 上传到 Ubuntu 服务器"
Write-Host "2. 解压并运行: sudo ./scripts/safesightd install"
Write-Host "2. 解压并运行: sudo ./scripts/3588admin install"
Write-Host ""
} else {
Write-Host "错误: 打包失败" -ForegroundColor Red

View File

@ -1,75 +0,0 @@
#!/usr/bin/env python3
"""SafeSight 初始资产导入脚本
edge 项目 configs 目录下的模板/场景/覆盖层导入管理端数据库
用法: sudo python3 import-assets.py /tmp/configs
"""
import sys, os, json, sqlite3
from datetime import datetime, timezone
def main():
configs_dir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/configs"
db_path = "/opt/safesightd/data/app.db"
if not os.path.isfile(db_path):
print(f"错误: 数据库文件不存在: {db_path}")
sys.exit(1)
conn = sqlite3.connect(db_path)
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
kinds = [
("templates", "templates", ["name", "description", "body_json", "created_at", "updated_at"]),
("profiles", "profiles", ["name", "primary_template_name", "business_name", "description", "body_json", "created_at", "updated_at"]),
("overlays", "overlays", ["name", "description", "body_json", "created_at", "updated_at"]),
]
print("========== SafeSight 资产导入 ==========")
print(f"数据库: {db_path}")
print(f"配置源: {configs_dir}\n")
for dir_name, table, cols in kinds:
src_dir = os.path.join(configs_dir, dir_name)
if not os.path.isdir(src_dir):
print(f" ⚠ 目录不存在: {src_dir}")
continue
count = 0
for fname in sorted(os.listdir(src_dir)):
if not fname.endswith(".json"):
continue
fpath = os.path.join(src_dir, fname)
name = fname[:-5] # remove .json
with open(fpath, "r", encoding="utf-8") as f:
body = f.read()
placeholders = ", ".join(["?" for _ in cols])
col_names = ", ".join(cols)
if table == "templates":
values = [name, "", body, now, now]
elif table == "overlays":
values = [name, "", body, now, now]
else: # profiles
values = [name, "", "", "", body, now, now]
try:
conn.execute(
f"INSERT OR IGNORE INTO {table} ({col_names}) VALUES ({placeholders})",
values,
)
count += 1
print(f"{dir_name.rstrip('s')}: {name}")
except Exception as e:
print(f"{name}: {e}")
conn.commit()
if count > 0:
print(f" → 导入 {count}\n")
conn.close()
print("========== 导入完成 ==========")
print("\n请执行: sudo systemctl restart safesightd")
if __name__ == "__main__":
main()

View File

@ -1,69 +0,0 @@
#!/bin/bash
# SafeSight 初始资产导入脚本
# 将 edge 项目 configs 目录下的模板/场景/覆盖层导入管理端数据库
# 用法: sudo bash import-assets.sh
set -e
DB="/opt/safesightd/data/app.db"
CONFIGS_DIR="${1:-/tmp/configs}"
if [ ! -f "$DB" ]; then
echo "错误: 数据库文件不存在: $DB"
exit 1
fi
if [ ! -d "$CONFIGS_DIR/templates" ]; then
echo "错误: configs 目录不存在: $CONFIGS_DIR"
echo "请先把 edge 项目的 configs/templates configs/profiles configs/overlays 复制到 $CONFIGS_DIR"
exit 1
fi
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
T=0; P=0; O=0
import_json() {
local table="$1" name="$2" file="$3"
# 使用 base64 编码绕过 JSON 中的特殊字符问题
local b64=$(cat "$file" | base64 -w0)
sqlite3 "$DB" "INSERT OR IGNORE INTO ${table} (name, description, body_json, created_at, updated_at)
VALUES ('${name}', '', CAST(X'$(echo -n "$b64" | base64 -d | xxd -p -c0 | tr -d '\n')' AS TEXT), '${NOW}', '${NOW}');"
}
echo "========== SafeSight 资产导入 =========="
echo "数据库: $DB"
echo "配置源: $CONFIGS_DIR"
echo ""
# 导入模板
for f in "$CONFIGS_DIR/templates"/*.json; do
[ -f "$f" ] || continue
name=$(basename "$f" .json)
import_json "templates" "$name" "$f"
echo " ✓ 模板: $name"
((T++))
done
# 导入场景
for f in "$CONFIGS_DIR/profiles"/*.json; do
[ -f "$f" ] || continue
name=$(basename "$f" .json)
import_json "profiles" "$name" "$f"
echo " ✓ 场景: $name"
((P++))
done
# 导入覆盖层
for f in "$CONFIGS_DIR/overlays"/*.json; do
[ -f "$f" ] || continue
name=$(basename "$f" .json)
import_json "overlays" "$name" "$f"
echo " ✓ 覆盖层: $name"
((O++))
done
echo ""
echo "========== 导入完成 =========="
echo "模板: $T 场景: $P 覆盖层: $O"
echo ""
echo "请执行: sudo systemctl restart safesightd"

View File

@ -1,93 +0,0 @@
#!/bin/bash
# SafeSight Control 一键安装/升级脚本
# 用法: sudo bash install.sh
set -e
if [ "$EUID" -ne 0 ]; then
echo "请用 sudo 运行"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_DIR="${1:-/opt/safesightd}"
ARCH=$(uname -m)
UPGRADE=false
if [ -f "$INSTALL_DIR/bin/safesightd" ]; then
UPGRADE=true
echo "========== SafeSight Control 升级 =========="
echo "检测到已有安装,将保留数据库和配置文件"
systemctl stop safesightd 2>/dev/null || true
else
echo "========== SafeSight Control 安装 =========="
fi
echo "安装目录: $INSTALL_DIR"
# 选二进制
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
BIN="safesightd-linux-arm64"
elif [ "$ARCH" = "x86_64" ]; then
BIN="safesightd-linux-amd64"
else
echo "不支持的架构: $ARCH"
exit 1
fi
# 创建目录
mkdir -p "$INSTALL_DIR"/{bin,config,data,configs,tools,scripts,models/standard_models,resources/standard_resources/face_gallery}
# 复制二进制(始终更新)
cp "$SCRIPT_DIR/$BIN" "$INSTALL_DIR/bin/safesightd"
chmod +x "$INSTALL_DIR/bin/safesightd"
# 配置文件:仅在全新安装时创建,升级保留
if [ "$UPGRADE" = false ] && [ -f "$SCRIPT_DIR/safesightd.json.template" ]; then
cp "$SCRIPT_DIR/safesightd.json.template" "$INSTALL_DIR/config/safesightd.json"
echo " ✓ 配置已创建(请修改 agent_token"
else
echo " ✓ 配置文件保留"
fi
# 种子数据、工具、脚本(始终更新)
[ -d "$SCRIPT_DIR/configs" ] && cp -r "$SCRIPT_DIR/configs"/* "$INSTALL_DIR/configs/" 2>/dev/null && echo " ✓ 种子数据已更新"
[ -d "$SCRIPT_DIR/tools" ] && cp -r "$SCRIPT_DIR/tools"/* "$INSTALL_DIR/tools/" 2>/dev/null && echo " ✓ 构建工具已更新"
[ -d "$SCRIPT_DIR/scripts" ] && cp -r "$SCRIPT_DIR/scripts"/* "$INSTALL_DIR/scripts/" 2>/dev/null && echo " ✓ 脚本已更新"
[ -d "$SCRIPT_DIR/models" ] && cp -r "$SCRIPT_DIR/models"/* "$INSTALL_DIR/models/" 2>/dev/null && echo " ✓ 标准模型已更新"
[ -d "$SCRIPT_DIR/deps" ] && rm -rf "$INSTALL_DIR/deps/" && cp -r "$SCRIPT_DIR/deps" "$INSTALL_DIR/" 2>/dev/null && echo " ✓ 依赖包已更新"
# 安装 Python 依赖全局root 可访问)
if [ -d "$INSTALL_DIR/deps" ] && ls "$INSTALL_DIR/deps"/*.whl >/dev/null 2>&1; then
# 先确保 pip3 可用(离线安装)
if ! command -v pip3 &>/dev/null; then
if [ -f "$INSTALL_DIR/deps/get-pip.py" ]; then
PIP_ROOT_USER_ACTION=ignore python3 "$INSTALL_DIR/deps/get-pip.py" --no-index --find-links="$INSTALL_DIR/deps/" --no-setuptools --no-wheel 2>&1
fi
fi
if pip3 install --force-reinstall --root-user-action=ignore "$INSTALL_DIR"/deps/*.whl 2>&1; then
echo " ✓ Python 依赖已安装"
else
echo " ⚠ Python 依赖安装失败,尝试桥接用户包..."
# Fallback: let root's Python see the current user's site-packages
USER_SITE=$(python3 -c "import site; print(site.USER_SITE)" 2>/dev/null)
if [ -n "$USER_SITE" ] && [ -d "$USER_SITE" ]; then
SYS_SITE=$(python3 -c "import site; print(site.getsitepackages()[0])" 2>/dev/null)
echo "$USER_SITE" > "$SYS_SITE/orangepi-local.pth" 2>/dev/null && echo " ✓ 用户包桥接已创建" || echo " ⚠ 桥接失败"
fi
fi
fi
# 安装/更新服务
cp "$SCRIPT_DIR/safesightd.service" /etc/systemd/system/
systemctl daemon-reload
if [ "$UPGRADE" = true ]; then
systemctl start safesightd
else
systemctl enable --now safesightd
fi
echo ""
echo "========== 完成 =========="
systemctl status safesightd --no-pager -l | head -10
echo ""
echo "打开 http://$(hostname -I | awk '{print $1}'):18080"

View File

@ -1,17 +0,0 @@
#!/usr/bin/env python3
import sqlite3, hashlib, os
db = '/opt/safesightd/data/app.db'
fg = '/opt/safesightd/resources/standard_resources/face_gallery/face_gallery.db'
sha = hashlib.sha256(open(fg, 'rb').read()).hexdigest()
size = os.path.getsize(fg)
conn = sqlite3.connect(db)
conn.execute(
"INSERT OR REPLACE INTO standard_resources(name,resource_type,version,sha256,size_bytes,description,file_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,datetime('now'),datetime('now'))",
('face_gallery', 'face_gallery', 'v1', sha, size, '人脸库', 'resources/standard_resources/face_gallery/face_gallery.db'),
)
conn.commit()
conn.close()
print('OK sha256=' + sha + ' size=' + str(size))

Some files were not shown because too many files have changed in this diff Show More