Merge branch 'codex/third-party-services'

This commit is contained in:
tian 2026-05-03 09:55:20 +08:00
commit 6eb712fcfe
55 changed files with 11196 additions and 1424 deletions

4
.gitignore vendored
View File

@ -30,3 +30,7 @@ Thumbs.db
.idea/
.vscode/
.worktrees/
.claude/
CLAUDE.md
managerd.exe~
nul

View File

@ -5,6 +5,7 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"3588AdminBackend/internal/api"
"3588AdminBackend/internal/config"
@ -39,6 +40,11 @@ func main() {
defer store.Close()
taskRepo := storage.NewTasksRepo(store.DB())
assetsRepo := storage.NewAssetsRepo(store.DB())
if imported, err := service.ImportStandardTemplatesFromDir(assetsRepo, filepath.Join("templates", "standard_templates")); err != nil {
log.Fatalf("import standard templates: %v", err)
} else if imported > 0 {
log.Printf("imported %d standard templates", imported)
}
stateRepo := storage.NewDeviceConfigStateRepo(store.DB())
auditRepo := storage.NewAuditLogsRepo(store.DB())
taskSvc := service.NewTaskService(cfg, agentClient, regSvc, taskRepo)

View File

@ -0,0 +1,462 @@
# Template / Scene Binding Refactor Design
## Goal
Refactor the configuration model across the admin backend and the OrangePi media service so that:
- `识别模板` only defines a single-stream processing flow and its default technical parameters.
- `基础配置` owns reusable concrete instances such as `视频源` and `第三方服务`.
- `场景配置` becomes the single assembly layer that binds template slots to concrete instances and scene-level output parameters.
- The agent runtime config format stays stable in the first phase; the main change is in the modeling and render pipeline.
This is a cross-repo refactor involving:
- `C:/Users/Tellme/apps/3588AdminBackend`
- `C:/Users/Tellme/apps/OrangePi3588Media`
## Problem Statement
The current template JSON mixes three different concerns:
1. Flow definition
2. Scene instance parameters
3. Concrete third-party service endpoints and credentials
That creates several problems:
- Templates are not reusable enough because they carry site-specific and service-specific values.
- Service endpoints and credentials are duplicated in templates instead of being centrally managed.
- Scene configuration is not the true assembly layer even though it is the closest object to actual deployment.
- The same conceptual binding is split between template params, profile params, and runtime substitution.
The target model should separate these concerns cleanly.
## Target Information Architecture
### 1. 识别模板
Template is a reusable processing definition for **one video stream**.
Template owns:
- Node graph
- Edge graph
- Default algorithm parameters
- Default preprocess and publish technical parameters
- Declarations of required input / output / service slots
Template does **not** own:
- Concrete video source URLs
- Concrete third-party service instances
- Scene-specific publish paths or ports
- Site-specific display labels
### 2. 基础配置
Base config owns reusable concrete instances.
Initial scope:
- `视频源`
- `第三方服务`
These objects are real instances that can be shared by many scenes.
### 3. 场景配置
Scene config is the only assembly layer.
It owns:
- Template selection
- Binding of template input slots to video-source instances
- Binding of template service slots to third-party service instances
- Binding of template output slots to scene-specific publish settings
- Scene-level labels and business parameters
## Data Model Boundaries
### Template-owned fields
These remain inside template definitions:
- Node types
- Edges
- Model paths
- Thresholds
- Inference rates
- Preprocess defaults
- Publish codec defaults
- Alarm rule structure
- Default sink behavior
Examples from existing templates:
- `infer_fps`
- `conf_thresh`
- `nms_thresh`
- `dst_w`
- `dst_h`
- `codec`
- `bitrate_kbps`
- `rules`
- `face_rules`
### Scene-owned fields
These move to scene config:
- `publish_hls_path`
- `publish_rtsp_port`
- `publish_rtsp_path`
- `channel_no`
- `display_name`
- `site_name`
- any scene-specific output behavior or business label
`device_code` should be treated as legacy and kept only for compatibility during migration. It should not be expanded further as a primary scene-model concept.
### Base-config-owned fields
These move into reusable instance objects:
#### 视频源
- input URL
- source type
- resolution metadata
- frame size
- fps
- format
- installation metadata
#### 第三方服务
- object storage endpoint / bucket / keys
- token service URL
- alarm service URL
- tenant or service-side connection metadata
## Slot-Based Template Model
The core change is to stop treating template placeholders as generic flat vars and instead treat them as **declared slots**.
### Template slot categories
Templates may declare:
- `input` slots
- `service` slots
- `output` slots
Examples:
- `video_input_main`
- `object_storage_main`
- `token_service_main`
- `alarm_service_main`
- `stream_output_main`
### Meaning of slots
A slot expresses a requirement, not an instance.
Examples:
- `video_input_main` means this template requires one concrete video input.
- `object_storage_main` means this template requires one object storage service instance.
- `stream_output_main` means this template produces a stream and needs scene-level output binding.
## Scene Binding Model
Scene config binds template slots to concrete values.
### Input binding
Example:
```json
{
"input_bindings": {
"video_input_main": {
"video_source_ref": "gate_cam_01"
}
}
}
```
### Service binding
Example:
```json
{
"service_bindings": {
"object_storage_main": {
"service_ref": "minio_prod_main"
},
"token_service_main": {
"service_ref": "token_service_prod"
},
"alarm_service_main": {
"service_ref": "alarm_center_prod"
}
}
}
```
### Output binding
Example:
```json
{
"output_bindings": {
"stream_output_main": {
"publish_hls_path": "./web/hls/cam1/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam1"
}
}
}
```
### Scene metadata
Scene keeps business-facing labels:
- `display_name`
- `site_name`
- `channel_no`
These are scene-level values and should not live in templates.
## Backward Compatibility Strategy
Phase 1 must preserve the existing agent runtime config format.
That means:
- `render_config.py` may continue producing the current final flat config shape.
- Agent runtime does not need to understand slots yet.
- Binding logic is introduced in the config-render pipeline, not in the agent runtime.
Compatibility rules:
1. If new slot bindings exist, they take priority.
2. If old flat fields still exist, they remain supported during migration.
3. Existing templates and scene configs must continue rendering during the transition.
## Existing Field Migration Matrix
### Input-related
- `rtsp_url`
- Current location: scene/profile params
- Target location: video source instance referenced from scene input binding
- Compatibility: continue supporting direct inline `rtsp_url`
### Output-related
- `publish_hls_path`
- Current location: scene/profile params
- Target location: scene output binding
- `publish_rtsp_port`
- Current location: scene/profile params
- Target location: scene output binding
- `publish_rtsp_path`
- Current location: scene/profile params
- Target location: scene output binding
### Scene label fields
- `channel_no`
- Current location: scene/profile params and used by alarm external API
- Target location: scene metadata or output binding context
- `display_name`
- Current location: scene/profile params
- Target location: scene metadata
- `site_name`
- Current location: scene/profile params
- Target location: scene metadata
- `device_code`
- Current location: scene/profile params
- Target location: legacy compatibility only in phase 1
### Third-party service fields
- `minio_endpoint`
- Current location: template params
- Target location: object storage service instance
- `minio_bucket`
- Current location: template params
- Target location: object storage service instance
- `minio_access_key`
- Current location: template params
- Target location: object storage service instance
- `minio_secret_key`
- Current location: template params
- Target location: object storage service instance
- `external_get_token_url`
- Current location: template params
- Target location: token service instance
- `external_put_message_url`
- Current location: template params
- Target location: alarm service instance
- `tenant_code`
- Current location: template params
- Target location: token/alarm service instance, with optional scene-level override if needed later
## Cross-Repo Responsibilities
### 3588AdminBackend
Admin backend changes:
- Extend template asset model to expose slot declarations.
- Extend scene config model to store slot bindings.
- Maintain `视频源` and `第三方服务` as reusable base config objects.
- Update scene-config UI so users bind template slots to concrete instances.
- Update preview pipeline inputs to pass structured bindings to the renderer.
### OrangePi3588Media
Media service changes:
- Upgrade template JSON model to support explicit slot declarations.
- Update `render_config.py` to resolve slot bindings into the current runtime config format.
- Keep final generated runtime config compatible with the current agent in phase 1.
- Support fallback rendering for older templates and profiles during migration.
## Rendering Strategy
Rendering should become a two-stage process.
### Stage 1: Resolve scene bindings
Resolve:
- video source refs
- third-party service refs
- scene output parameters
into a structured bound scene model.
### Stage 2: Expand into final runtime config
Expand the bound scene model into the current flat runtime config fields expected by the agent.
Examples:
- resolved video source URL becomes runtime `rtsp_url`
- resolved object storage instance becomes runtime `minio_*`
- resolved alarm/token services become runtime `external_*` and `tenant_code`
This lets us modernize the model without forcing an immediate runtime-schema rewrite.
## UI Implications
### Template page
Template page should gradually shift from editing concrete values to editing:
- graph structure
- default technical parameters
- declared slots
It should no longer encourage editing concrete service endpoints or stream addresses inside templates.
### Scene page
Scene page becomes the main assembly workspace.
It should expose:
- input bindings
- service bindings
- output bindings
- scene labels and business fields
### Base-config pages
These remain the concrete instance maintenance surfaces:
- video source list/detail
- third-party service list/detail
## Phased Rollout
### Phase 1
- Keep current runtime config shape.
- Add slot-aware scene bindings.
- Add renderer logic that expands new bindings into old flat values.
- Preserve legacy flat template/profile fields.
### Phase 2
- Move standard templates to explicit slot declarations.
- Stop storing real service endpoints in new template definitions.
- Prefer video-source refs over inline stream URLs.
### Phase 3
- Remove or deprecate legacy flat substitutions from template authoring.
- Reduce compatibility code once all standard and active custom configs are migrated.
## Risks
### 1. Dual-model complexity during migration
Supporting both legacy flat fields and new bindings will increase renderer complexity temporarily.
Mitigation:
- Keep priority order explicit.
- Add fixture-based render tests for old and new models.
### 2. Template authoring confusion
Users may still expect templates to own instance values.
Mitigation:
- Shift template UI away from concrete service editing.
- Make scene config the clear assembly surface.
### 3. Cross-repo drift
If backend and media-repo schema changes are not kept aligned, preview and apply flows will break.
Mitigation:
- Treat slot/binding schema as a shared contract.
- Land cross-repo fixture tests around the render boundary.
## Testing Strategy
### Admin backend
- Unit tests for slot-aware scene models
- Unit tests for binding persistence
- UI tests for scene binding forms
- Preview tests for structured binding expansion
### Media repo
- Fixture tests for template slot declarations
- Fixture tests for legacy-template rendering
- Fixture tests for new scene-binding rendering
- Golden tests for final runtime config output
## Recommendation
Use the ideal layered model now, but keep runtime compatibility in phase 1:
- Template defines requirements and defaults.
- Base config provides reusable concrete instances.
- Scene config performs all actual binding.
- Render pipeline expands the new structure into the current agent runtime config.
This delivers the clean architecture we want without forcing a dangerous all-at-once runtime rewrite.

View File

@ -0,0 +1,329 @@
# 视频源结构化设计
## 背景
当前后台已经将配置体系逐步拆分为两层:
- `场景配置`:定义一个最终要运行的业务场景
- `基础配置`:承载可被场景配置复用的公共配置
第三方服务已经明确要作为一类可复用的基础配置存在。视频输入端也具有相同特征:
- 由客户现场提供
- 生命周期通常长于单个场景配置
- 多个场景可能复用同一路输入流
- 修改地址或元信息时,不应该逐个修改场景配置
因此,视频输入不应继续散落在场景配置实例的 `rtsp_url` 等字段中,而应独立为一类基础配置。
## 核心定义
本设计中的**一个视频源 = 一路输入流**。
它不是一台物理摄像机设备,也不是一台 NVR而是一条能够被场景配置直接引用的输入流。
例如:
- 一台摄像机只有一路 RTSP则对应一个视频源
- 一个 NVR 下有 8 路通道,则应建 8 个视频源
这个定义与当前识别链路最匹配,也最利于场景配置引用和后续维护。
## 目标
本次设计要解决以下问题:
1. 为视频源建立统一、结构化的数据模型
2. 在 `基础配置` 中增加独立的 `视频源` 管理页面
3. 让 `场景配置` 通过引用视频源,而不是直接填写 `rtsp_url`
4. 在不破坏现有配置链路的前提下,为后续逐步替换场景内联输入字段打基础
## 非目标
本次不包含以下内容:
- 不做完整的摄像机资产台账
- 不引入厂商、型号、采购信息、维护记录等固定资产字段
- 不做视频源在线探测或取流连通性检测
- 不做自动读取码流元信息
- 不处理多通道设备实体模型
## 设计原则
1. 一个视频源只表示一路可引用输入流
2. 识别相关字段优先,现场安装字段作为可选补充
3. 表单默认保持简洁,非必要字段不阻塞创建
4. 页面交互和第三方服务保持一致,降低认知成本
## 字段设计
推荐将视频源字段分为三组。
### 1. 基本信息
- `name`:视频源名称,唯一标识
- `source_type`:视频源类型
- `area`:区域
- `description`:描述
说明:
- `name` 用于被场景配置引用
- `area` 用于表达现场语义例如“东门入口”“1号产线”“仓库西侧”
- `description` 用于补充说明
### 2. 输入参数
- `url`:输入地址
- `resolution`:标准分辨率等级,例如 `720p`、`1080p`、`1440p`、`4k`
- `frame_size`:像素尺寸,例如 `1280x720`、`1920x1080`
- `fps`:帧率
- `video_format`:视频格式,例如 `h264`、`h265`、`mjpeg`
说明:
- `url` 必填
- `resolution`、`frame_size`、`fps`、`video_format` 可选
### 3. 安装信息
- `focal_length`:焦距
- `mount_height`:安装高度
- `mount_angle`:安装角度
说明:
- 三个字段均为可选
- 它们主要用于表达现场安装条件,不应阻塞视频源创建
## 类型设计
第一版推荐支持以下视频源类型:
- `rtsp`
- `rtmp`
- `file`
- `usb_camera`
当前主路径仍然是 `rtsp`,但枚举应预先保留扩展空间。
页面文案可使用:
- RTSP
- RTMP
- 文件流
- USB 摄像头
## 数据结构
视频源建议使用与第三方服务类似的“顶层基础字段 + config 承载详细参数”的方式。
推荐结构:
```json
{
"name": "gate_cam_01",
"source_type": "rtsp",
"area": "东门入口",
"description": "东门主入口摄像头",
"config": {
"url": "rtsp://10.0.0.1/live",
"resolution": "1080p",
"frame_size": "1920x1080",
"fps": 25,
"video_format": "h264",
"focal_length": "4mm",
"mount_height": "3.2m",
"mount_angle": "15deg"
}
}
```
说明:
- 顶层保持对象识别字段
- `config` 用于承载输入参数和安装信息
- 第一版不再继续向下拆分成多层嵌套,避免结构过重
## 与现有配置字段的关系
当前场景配置实例中已存在:
- `display_name`
- `site_name`
- `rtsp_url`
- `channel_no`
在新结构下:
- `rtsp_url` 应逐步由视频源 `config.url` 替代
- `display_name` 仍属于场景实例显示语义,不属于视频源本身
- `site_name` 仍偏场景/站点上下文,不建议塞进视频源
- `channel_no` 如果表达的是输入流标识,可保留在场景实例或后续再决定是否并入视频源
## 场景配置中的引用方式
场景配置不应长期继续直接持有 `rtsp_url`,而应改为引用视频源。
由于当前场景配置是多实例结构,推荐按实例增加引用字段:
- `video_source_ref`
示例:
```json
{
"instances": [
{
"name": "cam1",
"template": "std_workshop_face_recognition_shoe_alarm",
"video_source_ref": "gate_cam_01",
"params": {
"display_name": "东门入口"
}
}
]
}
```
这比把视频源引用放在整个场景顶层更合理,因为一个场景通常会包含多路输入。
## 兼容策略
第一阶段建议采用兼容式落地:
1. 新建视频源资产
2. 场景配置实例开始支持 `video_source_ref`
3. 若实例中存在 `video_source_ref`,则预览和下发时优先使用视频源中的 `url`
4. 若不存在 `video_source_ref`,仍兼容现有 `rtsp_url`
这样可以逐步迁移,而不需要一次性改完所有历史配置。
## UI 设计
入口:
- `基础配置 -> 视频源`
页面交互与第三方服务保持一致:
- 顶部按钮:`新增视频源`、`编辑`、`删除`
- 列表区:视频源列表
- 详情区:默认只读
- 点击 `编辑` 后进入编辑态
- 点击 `新增视频源` 后清空详情并聚焦名称输入框
## 列表页字段
推荐列表列:
- 视频源名称
- 类型
- 区域
- URL 摘要
- 分辨率
- 帧率
说明:
- 列表中的“分辨率”优先显示 `resolution` 这类标准等级
- `frame_size` 作为详情中的技术补充字段,不默认放在列表里
- 焦距、安装高度、安装角度不放在列表里
## 详情页字段分组
### 基本信息
- 视频源名称
- 类型
- 区域
- 描述
### 输入参数
- URL
- 分辨率
- 像素尺寸
- 帧率
- 视频格式
### 安装信息
- 焦距
- 安装高度
- 安装角度
## 校验规则
### 必填
- `name`
- `source_type`
- `config.url`
### 可选
- `area`
- `description`
- `config.resolution`
- `config.frame_size`
- `config.fps`
- `config.video_format`
- `config.focal_length`
- `config.mount_height`
- `config.mount_angle`
### 基本格式建议
- `name` 必须唯一
- `fps` 如填写,应可解析为数值
- `resolution` 使用标准分辨率等级表达,例如 `720p`、`1080p`
- `frame_size` 第一版可先作为字符串保存,不强制拆成宽高整数
## 删除约束
如果某个视频源已被场景配置实例引用,则不允许直接删除。
删除前应检查:
- 是否存在场景实例的 `video_source_ref` 指向该视频源
如果存在引用,应提示:
- 当前视频源已被某些场景配置使用,不能删除
## 存储建议
建议沿用现有 SQLite 基础配置仓储模式,新增一类 `video_sources` 存储对象。
每条记录至少包含:
- `name`
- `source_type`
- `area`
- `description`
- `body_json`
- `created_at`
- `updated_at`
## 第一阶段实施范围
第一阶段只实现以下内容:
1. 视频源的数据结构与存储
2. `基础配置 -> 视频源` 列表和详情维护页
3. 场景配置实例支持 `video_source_ref`
4. 预览/下发时优先用视频源 URL 展开
5. 删除前引用检查
## 结论
视频源应被定义为**一路可被场景配置引用的输入流**。
采用“基础识别字段 + 输入参数 + 可选安装信息”的结构最适合当前系统阶段:
- 比单纯只存 URL 更完整
- 比完整摄像机资产台账更轻
- 既能服务识别配置,又能保留现场语义
第一版推荐按 `video_source_ref` 的方式逐步替换场景配置中的内联 `rtsp_url`,从而让视频源真正成为可复用、可维护的基础配置对象。

View File

@ -0,0 +1,293 @@
# 设备分配看板设计
日期2026-04-30
## 背景
当前“设备分配”页面延续了传统的“列表 + 详情表单”结构,但这类结构不适合当前业务目标。
对设备分配来说,用户最关心的不是单条分配记录的编辑细节,而是整体分配态势:
- 一共有多少识别单元
- 一共有多少设备
- 已分配多少识别单元
- 还有多少识别单元未分配
- 每台设备分配了多少识别单元
- 哪些设备接近上限、已经满载、或者处于空闲
- 在给定设备承载上限的前提下,如何快速自动平均分配
因此,这个页面应从“表单页”转为“分配看板”。
## 目标
设备分配页需要回答四个问题:
1. 当前整体分配情况如何
2. 哪些设备负载偏高,哪些设备空闲
3. 哪些识别单元还未分配
4. 如何基于设备上限快速自动平均分配,并允许少量手工调整
## 界面原则
本页优先强调“看清分配情况”和“快速完成分配”,界面应尽量简洁。
具体原则:
- 优先展示数字、状态和卡片,不展示大段说明文字
- 不放与分配主题关系弱的输入框
- 非关键说明尽量缩成短标签、短提示或折叠信息
- 让用户进入页面后先看到总量、负载、未分配,再看到操作
- 操作按钮集中放在一行,避免表单式堆叠
## 非目标
本次不做以下内容:
- 不做复杂拖拽交互
- 不做实时设备性能采样驱动的智能调度
- 不做按设备型号自动推算上限
- 不修改设备最终运行配置 JSON 合同
- 不引入新的底层部署引擎
## 页面定位
“设备分配”页面是一个调度和部署看板,不是传统资产详情页。
它的核心职责是:
- 展示识别单元与设备之间的承载关系
- 辅助用户快速完成平均分配
- 让未分配、超载、空闲这些问题一眼可见
## 信息架构
页面从上到下分为四个区域:
1. 分配概览
2. 操作区
3. 设备分配看板
4. 未分配识别单元
## 一、分配概览
页面顶部显示 6 个关键统计值:
- 识别单元总数
- 设备总数
- 已分配识别单元
- 未分配识别单元
- 平均每台设备负载
- 超载设备数
说明:
- “已分配识别单元”按是否被任一设备分配计算
- “未分配识别单元”按未出现在任何设备分配中的识别单元计算
- “平均每台设备负载”按 `已分配识别单元 / 设备总数` 计算,保留 1 位小数
- “超载设备数”按当前页面设定的“每台设备最大单元数”判断
这些统计值用于快速判断整体均衡情况。
## 二、操作区
操作区位于概览下方,采用一行布局,包含:
- 每台设备最大单元数滑块
- 自动平均分配
- 清空分配
- 保存设备分配
### 每台设备最大单元数
- 类型:滑块
- 默认值4
- 范围1 到 8
- 右侧显示当前值,例如:`4 路/台`
这是页面级控制参数,不直接写入设备运行配置,只影响当前页面的自动分配和负载判断。
### 自动平均分配
点击后执行自动分配算法:
1. 收集所有识别单元
2. 收集所有设备
3. 清空当前页面中的暂存分配结果
4. 按设备当前已分配数量从少到多依次分配
5. 单台设备不超过当前滑块上限
6. 剩余放不下的识别单元保留在“未分配识别单元”区域
目标是:
- 尽量平均
- 不超过上限
- 剩余明确可见
自动分配完成后,页面显示轻量提示,例如:
`已按每台最多 4 路完成自动分配,剩余 3 路未分配。`
### 清空分配
清空当前页面暂存的所有设备分配,不立即落库。
### 保存设备分配
将当前页面中的设备分配关系统一保存到数据库。
## 三、设备分配看板
页面主体改为卡片式设备板,而不是传统表格。
每台设备显示为一张卡片,卡片内容包括:
- 设备名称
- 设备 ID
- 当前分配数量,例如 `3 / 4`
- 当前状态
- 已分配的识别单元标签列表
### 设备状态
基于当前“每台设备最大单元数”计算:
- `0`:空闲
- `1``上限 - 1`:正常
- `= 上限`:满载
- `> 上限`:超载
### 排序规则
卡片按以下优先级排序:
1. 超载
2. 满载
3. 正常
4. 空闲
同类内再按设备名称或设备 ID 排序。
这样最需要关注的设备会优先显示。
### 视觉形式
每张设备卡片建议包含:
- 状态标签
- 负载数字
- 简短负载条
- 识别单元标签块
这已经构成足够直观的图形化表达,不需要第一版引入复杂图表。
## 四、未分配识别单元
单独放在页面下方或右侧,作为一个明确区域。
显示:
- 未分配识别单元数量
- 未分配识别单元列表
若没有未分配单元,则显示:
`已全部分配`
这个区域用于快速暴露“还有哪些识别单元没有落到任何设备上”。
## 手工调整
第一版不做拖拽。
手工调整采用轻量操作方式:
- 在设备卡片中的识别单元标签上,支持“移出”
- 在未分配识别单元列表中,支持“加入某设备”
- 在设备卡片上,支持“从未分配中加入”
也可以支持简单的“移动到其他设备”菜单,但不要求第一版一步到位。
原则是:
- 先保证清楚和稳定
- 再考虑拖拽式优化
## 数据模型要求
本页使用现有底层对象,不新增新的核心存储结构:
- 识别单元:`recognition_units`
- 设备分配:`device_assignments`
页面上的“每台设备最大单元数”是 UI 级暂存参数,不要求本次入库。
保存时,仅保存:
- 每台设备对应的识别单元列表
- 描述信息
不修改设备最终运行配置的生成合同。
## 页面与详情页关系
设备分配页负责整体看板和编辑。
设备详情页只做状态展示:
- 当前设备分配了多少识别单元
- 当前设备对应哪个场景模板
设备详情页不再承担分配编辑职责。
## 低优先级信息处理
以下内容不应在主界面占据明显位置:
- 长段文字说明
- 单独的“描述”输入框
- 与分配动作无关的扩展属性
如确有必要保留,应放入折叠区域或次级编辑入口,而不是主操作区。
## 错误处理
需要处理这些情况:
- 没有设备:页面显示空状态,并禁用自动分配/保存
- 没有识别单元:页面显示空状态,并禁用自动分配
- 某识别单元引用缺失:在卡片或未分配区显示异常标记
- 保存失败:保留当前页面暂存状态,并提示错误
## 测试
至少覆盖以下测试:
1. 统计信息正确
2. 自动平均分配在不同设备/单元数量组合下结果正确
3. 超载、满载、空闲状态判定正确
4. 未分配识别单元列表正确
5. 保存设备分配后,数据库中的 `device_assignments` 正确更新
6. 设备详情页和预览页仍能读取保存后的分配结果
## 实施建议
建议按以下顺序实施:
1. 后端补充分配统计与自动分配计算
2. 改造设备分配页面为“概览 + 操作区 + 看板 + 未分配区”
3. 接入保存逻辑
4. 最后补样式和状态表现
## 结论
设备分配页应从“单条配置表单”转为“分配看板”。
核心不是编辑细节,而是:
- 看清楚总量
- 看清楚负载
- 看清楚未分配
- 一键自动平均分配
- 再做少量手工修正
这会比当前的列表/勾选模式更符合实际使用场景,也更适合 32 路视频、多台边缘设备的部署方式。

View File

@ -17,7 +17,7 @@ type Config struct {
DataDir string `json:"data_dir,omitempty"`
DBPath string `json:"db_path,omitempty"`
LogDir string `json:"log_dir,omitempty"`
MediaRepoPath string `json:"media_repo_path,omitempty"`
MediaRepoPath string `json:"media_repo_path,omitempty"` // explicit import-only source; not used for runtime rendering
DeviceAliases map[string]string `json:"device_aliases,omitempty"`
path string
}

File diff suppressed because it is too large Load Diff

View File

@ -2,18 +2,44 @@ package service
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"3588AdminBackend/internal/config"
"3588AdminBackend/internal/models"
"3588AdminBackend/internal/storage"
)
func mustSaveTemplateRecord(t *testing.T, repo *storage.AssetsRepo, name string, description string, body string) {
t.Helper()
if err := repo.SaveTemplate(name, description, body); err != nil {
t.Fatalf("SaveTemplate(%s): %v", name, err)
}
}
func mustReadFileBytes(t *testing.T, path string) []byte {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s): %v", path, err)
}
return body
}
func mustImportPreviewAssets(t *testing.T, svc *ConfigPreviewService) {
t.Helper()
if _, err := svc.ImportAssetsFromMediaRepo(); err != nil {
t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
}
}
func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
templateBody := `{
"name": "std_workshop_face_recognition_shoe_alarm",
"source": "standard",
"params": {
"minio_endpoint": "http://10.0.0.49:9000",
"minio_bucket": "myminio",
@ -23,31 +49,38 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
"snapshot_region": "us-east-1"
},
"template": {"nodes": [], "edges": []}
}`)
}`
mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{
"name": "local_3588_test",
"business_name": "A厂区视觉识别",
"description": "test profile",
"queue": {"size": 8, "strategy": "drop_oldest"},
"instances": [{
"instances": [{
"name": "cam1",
"template": "std_workshop_face_recognition_shoe_alarm",
"params": {
"display_name": "东门入口",
"device_code": "rk3588-a-001",
"site_name": "A厂区",
"rtsp_url": "rtsp://10.0.0.1/live",
"publish_hls_path": "./web/hls/cam1/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam1",
"channel_no": "cam1",
"queue_debug": true
}
"params": {"queue_debug": true},
"scene_meta": {"display_name": "东门入口", "device_code": "rk3588-a-001", "site_name": "A厂区"},
"input_bindings": {"video_input_main": {"video_source_ref": "gate_cam_01"}},
"output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}}
}]
}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveTemplateRecord(t, repo, "std_workshop_face_recognition_shoe_alarm", "标准模板", templateBody)
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveOverlay("face_debug", "", `{}`); err != nil {
t.Fatalf("SaveOverlay: %v", err)
}
item, err := svc.GetProfileAsset("local_3588_test")
if err != nil {
t.Fatalf("GetProfileAsset: %v", err)
@ -69,9 +102,6 @@ func TestConfigPreviewServiceGetsProfileAssetSummary(t *testing.T) {
if item, err := svc.GetTemplateAsset("std_workshop_face_recognition_shoe_alarm"); err != nil {
t.Fatalf("GetTemplateAsset: %v", err)
} else {
if item.MinIOEndpoint != "http://10.0.0.49:9000" || item.TenantCode != "32" {
t.Fatalf("expected shared service params on template asset, got %#v", item)
}
if _, ok := item.AdvancedParams["snapshot_region"]; !ok {
t.Fatalf("expected template advanced params to preserve extra keys, got %#v", item.AdvancedParams)
}
@ -83,14 +113,21 @@ func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) {
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`)
mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{
"description": "debug overlay",
"description": "启用人脸识别和陌生候选调试日志,用于联调和测试。",
"instance_overrides": {
"*": {"override": {}},
"cam1": {"override": {}}
}
}`)
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportPreviewAssets(t, svc)
item, err := svc.GetOverlayAsset("face_debug")
if err != nil {
t.Fatalf("GetOverlayAsset: %v", err)
@ -102,6 +139,9 @@ func TestConfigPreviewServiceGetsOverlayAssetTargets(t *testing.T) {
if item.OverrideTargets[0] != "*" || item.OverrideTargets[1] != "cam1" {
t.Fatalf("unexpected targets: %#v", item.OverrideTargets)
}
if item.Description != "启用人脸识别和陌生候选调试日志,用于联调和测试。" {
t.Fatalf("expected localized overlay description, got %#v", item.Description)
}
}
func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
@ -115,34 +155,34 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
{
"name": "cam1",
"template": "std_workshop_face_recognition_shoe_alarm",
"params": {
"display_name": "东门入口",
"device_code": "rk3588-a-001",
"site_name": "A厂区",
"rtsp_url": "rtsp://10.0.0.1/live",
"publish_hls_path": "./web/hls/cam1/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam1",
"channel_no": "cam1",
"queue_debug": true
}
"params": {"queue_debug": true},
"scene_meta": {"display_name": "东门入口", "device_code": "rk3588-a-001", "site_name": "A厂区"},
"input_bindings": {"video_input_main": {"video_source_ref": "gate_cam_01"}},
"output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam1/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam1", "channel_no": "cam1"}}
},
{
"name": "cam2",
"template": "std_workshop_face_recognition_shoe_alarm",
"params": {
"display_name": "西门入口",
"rtsp_url": "rtsp://10.0.0.2/live",
"publish_hls_path": "./web/hls/cam2/index.m3u8",
"publish_rtsp_port": 8555,
"publish_rtsp_path": "/live/cam2",
"channel_no": "cam2"
}
"scene_meta": {"display_name": "西门入口", "site_name": "A厂区"},
"input_bindings": {"video_input_main": {"video_source_ref": "line_cam_02"}},
"output_bindings": {"stream_output_main": {"publish_hls_path": "./web/hls/cam2/index.m3u8", "publish_rtsp_port": 8555, "publish_rtsp_path": "/live/cam2", "channel_no": "cam2"}}
}
]
}`)
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "assets.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("std_workshop_face_recognition_shoe_alarm", "标准模板", `{"name":"std_workshop_face_recognition_shoe_alarm","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
if err := repo.SaveProfile("local_3588_test", "std_workshop_face_recognition_shoe_alarm", "A厂区视觉识别", "test profile", string(mustReadFileBytes(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json")))); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
editor, err := svc.GetProfileEditor("local_3588_test")
if err != nil {
t.Fatalf("GetProfileEditor: %v", err)
@ -166,7 +206,7 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
if editor.Instances[0].Name != "cam1" || editor.Instances[0].DisplayName != "东门入口" {
t.Fatalf("unexpected first instance summary: %#v", editor.Instances[0])
}
if editor.Instances[1].Name != "cam2" || editor.Instances[1].RTSPURL != "rtsp://10.0.0.2/live" {
if editor.Instances[1].Name != "cam2" || editor.Instances[1].VideoSourceRef != "line_cam_02" {
t.Fatalf("unexpected second instance summary: %#v", editor.Instances[1])
}
if editor.Instances[0].PublishRTSPPort != "8555" {
@ -175,6 +215,9 @@ func TestConfigPreviewServiceBuildsProfileEditor(t *testing.T) {
if editor.Queue.Size != "8" || editor.Queue.Strategy != "drop_oldest" {
t.Fatalf("unexpected queue model: %#v", editor.Queue)
}
if editor.Instances[0].VideoSourceRef != "gate_cam_01" {
t.Fatalf("expected slot-driven video source ref, got %#v", editor.Instances[0])
}
if _, ok := editor.Instances[0].AdvancedParams["queue_debug"]; !ok {
t.Fatalf("expected advanced param to remain in editor, got %#v", editor.Instances[0].AdvancedParams)
}
@ -186,18 +229,15 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
Name: "local_3588_test",
BusinessName: "A厂区视觉识别",
Description: "test profile",
OverlayName: "face_debug",
DeviceCode: "rk3588-a-001",
SiteName: "A厂区",
Queue: ConfigProfileQueueEditor{
Size: "8",
Strategy: "drop_oldest",
},
Instances: []ConfigProfileInstanceEditor{
{
Name: "cam1",
Template: "std_workshop_face_recognition_shoe_alarm",
VideoSourceRef: "gate_cam_01",
DisplayName: "东门入口",
RTSPURL: "rtsp://10.0.0.1/live",
PublishHLSPath: "./web/hls/cam1/index.m3u8",
PublishRTSPPort: "8555",
PublishRTSPPath: "/live/cam1",
@ -209,8 +249,8 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
{
Name: "cam2",
Template: "std_workshop_face_recognition_shoe_alarm",
VideoSourceRef: "line_cam_02",
DisplayName: "视觉识别终端-B厂区",
RTSPURL: "rtsp://10.0.0.2/live",
PublishHLSPath: "./web/hls/cam2/index.m3u8",
PublishRTSPPort: "8556",
PublishRTSPPath: "/live/cam2",
@ -230,6 +270,9 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
if doc["business_name"] != "A厂区视觉识别" {
t.Fatalf("unexpected business name: %#v", doc)
}
if overlays, _ := doc["overlays"].([]any); len(overlays) != 1 || overlays[0] != "face_debug" {
t.Fatalf("unexpected overlays doc: %#v", doc["overlays"])
}
queue, _ := doc["queue"].(map[string]any)
if queue["size"] != 8 || queue["strategy"] != "drop_oldest" {
t.Fatalf("unexpected queue doc: %#v", queue)
@ -239,24 +282,208 @@ func TestConfigPreviewServiceBuildsProfileDocumentFromEditor(t *testing.T) {
t.Fatalf("expected two instances, got %#v", doc["instances"])
}
params, _ := instances[0]["params"].(map[string]any)
if params["publish_rtsp_port"] != 8555 {
t.Fatalf("expected numeric rtsp port, got %#v", params["publish_rtsp_port"])
}
if params["queue_debug"] != true {
t.Fatalf("expected advanced param to survive rebuild, got %#v", params)
}
if params["device_code"] != "rk3588-a-001" {
t.Fatalf("expected legacy device code to be preserved in params, got %#v", params)
}
if params["site_name"] != "A厂区" {
t.Fatalf("expected profile site name to be written to instance params, got %#v", params)
if _, exists := params["video_source_ref"]; exists {
t.Fatalf("expected new profile document to avoid legacy video_source_ref in params, got %#v", params)
}
params2, _ := instances[1]["params"].(map[string]any)
if params2["publish_rtsp_path"] != "/live/cam2" {
t.Fatalf("expected second instance to survive rebuild, got %#v", params2)
if len(params2) != 0 {
t.Fatalf("expected second instance params to stay empty under new model, got %#v", params2)
}
if params2["site_name"] != "A厂区" {
t.Fatalf("expected profile site name on second instance, got %#v", params2)
sceneMeta, _ := instances[0]["scene_meta"].(map[string]any)
if sceneMeta["display_name"] != "东门入口" || sceneMeta["site_name"] != "A厂区" || sceneMeta["device_code"] != "rk3588-a-001" {
t.Fatalf("expected scene meta to carry scene fields, got %#v", sceneMeta)
}
}
func TestBuildProfileDocumentUsesSlotBindings(t *testing.T) {
svc := NewConfigPreviewService(&config.Config{})
doc, err := svc.BuildProfileDocument(ConfigProfileEditor{
Name: "line_a",
Instances: []ConfigProfileInstanceEditor{{
Name: "cam1",
Template: "std_workshop_face_recognition_shoe_alarm",
DisplayName: "B厂区通道1",
SiteName: "B厂区",
InputBindings: map[string]InputBindingEditor{
"video_input_main": {VideoSourceRef: "gate_cam_01"},
},
ServiceBindings: map[string]ServiceBindingEditor{
"object_storage_main": {ServiceRef: "minio_main"},
"token_service_main": {ServiceRef: "token_main"},
"alarm_service_main": {ServiceRef: "alarm_main"},
},
OutputBindings: map[string]OutputBindingEditor{
"stream_output_main": {
PublishHLSPath: "./web/hls/cam1/index.m3u8",
PublishRTSPPort: "8555",
PublishRTSPPath: "/live/cam1",
ChannelNo: "cam1",
},
},
}},
})
if err != nil {
t.Fatalf("BuildProfileDocument: %v", err)
}
instances, _ := doc["instances"].([]map[string]any)
if len(instances) != 1 {
t.Fatalf("expected one instance, got %#v", doc["instances"])
}
inst := instances[0]
inputBindings, _ := inst["input_bindings"].(map[string]any)
if inputBindings == nil {
t.Fatalf("expected input_bindings, got %#v", inst)
}
videoInput, _ := inputBindings["video_input_main"].(map[string]any)
if videoInput["video_source_ref"] != "gate_cam_01" {
t.Fatalf("unexpected input binding: %#v", videoInput)
}
serviceBindings, _ := inst["service_bindings"].(map[string]any)
objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any)
if objectStorage["service_ref"] != "minio_main" {
t.Fatalf("unexpected service binding: %#v", serviceBindings)
}
outputBindings, _ := inst["output_bindings"].(map[string]any)
streamOutput, _ := outputBindings["stream_output_main"].(map[string]any)
if streamOutput["publish_rtsp_port"] != 8555 {
t.Fatalf("unexpected output binding: %#v", streamOutput)
}
sceneMeta, _ := inst["scene_meta"].(map[string]any)
if sceneMeta["display_name"] != "B厂区通道1" || sceneMeta["site_name"] != "B厂区" {
t.Fatalf("unexpected scene meta: %#v", sceneMeta)
}
}
func TestBuildProfileDocumentDefaultsStreamOutputFromInstanceName(t *testing.T) {
svc := NewConfigPreviewService(&config.Config{})
doc, err := svc.BuildProfileDocument(ConfigProfileEditor{
Name: "line_a",
Instances: []ConfigProfileInstanceEditor{{
Name: "cam7",
Template: "std_workshop_face_recognition_shoe_alarm",
DisplayName: "B厂区通道7",
InputBindings: map[string]InputBindingEditor{
"video_input_main": {VideoSourceRef: "gate_cam_07"},
},
OutputBindings: map[string]OutputBindingEditor{
"stream_output_main": {
PublishRTSPPort: "8558",
},
},
}},
})
if err != nil {
t.Fatalf("BuildProfileDocument: %v", err)
}
instances, _ := doc["instances"].([]map[string]any)
inst := instances[0]
outputBindings, _ := inst["output_bindings"].(map[string]any)
streamOutput, _ := outputBindings["stream_output_main"].(map[string]any)
if streamOutput["publish_hls_path"] != "./web/hls/cam7/index.m3u8" {
t.Fatalf("expected default hls path, got %#v", streamOutput)
}
if streamOutput["publish_rtsp_path"] != "/live/cam7" {
t.Fatalf("expected default rtsp path, got %#v", streamOutput)
}
if streamOutput["channel_no"] != "cam7" {
t.Fatalf("expected default channel no, got %#v", streamOutput)
}
if streamOutput["publish_rtsp_port"] != 8558 {
t.Fatalf("expected explicit rtsp port to be preserved, got %#v", streamOutput)
}
}
func TestListVideoSources(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveVideoSource(
"gate_cam_01",
"rtsp",
"东门入口",
"东门主入口摄像头",
`{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264","focal_length":"4mm","mount_height":"3.2m","mount_angle":"15deg"}}`,
); err != nil {
t.Fatalf("SaveVideoSource: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
items, err := svc.ListVideoSources()
if err != nil {
t.Fatalf("ListVideoSources: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 video source, got %#v", items)
}
if items[0].SourceType != "rtsp" || items[0].SourceTypeLabel != "RTSP" {
t.Fatalf("unexpected video source summary: %#v", items[0])
}
if items[0].Config.Resolution != "1080p" || items[0].Config.FrameSize != "1920x1080" {
t.Fatalf("unexpected video source config: %#v", items[0])
}
}
func TestDeleteVideoSourceBlocksWhenReferenced(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveVideoSource(
"gate_cam_01",
"rtsp",
"东门入口",
"东门主入口摄像头",
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
); err != nil {
t.Fatalf("SaveVideoSource: %v", err)
}
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
err = svc.DeleteVideoSource("gate_cam_01")
if err == nil || !strings.Contains(err.Error(), "已被场景模板引用") {
t.Fatalf("expected referenced delete to be blocked, got %v", err)
}
}
func TestSaveVideoSourceAssetAllowsChineseName(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
err = svc.SaveVideoSourceAsset(ConfigVideoSourceAsset{
Name: "东门主入口",
SourceType: "rtsp",
Area: "东门",
Description: "入口相机",
Config: VideoSourceConfig{
URL: "rtsp://10.0.0.1/live",
},
})
if err != nil {
t.Fatalf("expected chinese video source name to be accepted, got %v", err)
}
item, err := svc.GetVideoSource("东门主入口")
if err != nil {
t.Fatalf("GetVideoSource: %v", err)
}
if item == nil || item.Name != "东门主入口" {
t.Fatalf("unexpected saved item: %#v", item)
}
}
@ -267,8 +494,8 @@ func TestConfigPreviewServiceBuildProfileDocumentRejectsBadPort(t *testing.T) {
Instances: []ConfigProfileInstanceEditor{
{
Name: "cam1",
VideoSourceRef: "gate_cam_01",
DisplayName: "视觉识别终端-A厂区",
RTSPURL: "rtsp://10.0.0.1/live",
PublishRTSPPort: "bad-port",
},
},
@ -284,9 +511,9 @@ func TestConfigPreviewServiceBuildProfileDocumentJSONShape(t *testing.T) {
Name: "local_3588_test",
Instances: []ConfigProfileInstanceEditor{
{
Name: "cam1",
DisplayName: "视觉识别终端-A厂区",
RTSPURL: "rtsp://10.0.0.1/live",
Name: "cam1",
VideoSourceRef: "gate_cam_01",
DisplayName: "视觉识别终端-A厂区",
},
},
})
@ -313,7 +540,7 @@ func TestConfigPreviewServiceListsSourcesFromAssetsRepo(t *testing.T) {
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`); err != nil {
@ -336,6 +563,177 @@ func TestConfigPreviewServiceListsSourcesFromAssetsRepo(t *testing.T) {
}
}
func TestListIntegrationServices(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveIntegrationService(
"minio_primary",
"object_storage",
"primary object store",
false,
`{"name":"minio_primary","type":"object_storage","provider":"minio","enabled":false,"config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`,
); err != nil {
t.Fatalf("SaveIntegrationService: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
items, err := svc.ListIntegrationServices()
if err != nil {
t.Fatalf("ListIntegrationServices: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 integration service, got %#v", items)
}
if items[0].Name != "minio_primary" || items[0].Type != "object_storage" || items[0].Enabled {
t.Fatalf("unexpected integration service summary: %#v", items[0])
}
item, err := svc.GetIntegrationService("minio_primary")
if err != nil {
t.Fatalf("GetIntegrationService: %v", err)
}
if item == nil {
t.Fatal("expected integration service")
}
if item.Description != "primary object store" {
t.Fatalf("unexpected integration service description: %#v", item)
}
if item.Type != "object_storage" || item.Enabled {
t.Fatalf("unexpected integration service status: %#v", item)
}
if got := stringValue(item.Raw["type"]); got != "object_storage" {
t.Fatalf("expected raw type to come from body_json, got %#v", item.Raw)
}
if got := stringValue(item.Raw["provider"]); got != "minio" {
t.Fatalf("unexpected integration service provider: %#v", item.Raw)
}
if item.TypeLabel != "对象存储" || item.AddressSummary != "http://10.0.0.49:9000 / myminio" {
t.Fatalf("unexpected integration display fields: %#v", item)
}
if item.ObjectStorage == nil || item.ObjectStorage.Bucket != "myminio" {
t.Fatalf("expected object storage details, got %#v", item)
}
configMap, _ := item.Raw["config"].(map[string]any)
if got := stringValue(configMap["endpoint"]); got != "http://10.0.0.49:9000" {
t.Fatalf("unexpected integration service endpoint: %#v", item.Raw)
}
if enabled, ok := item.Raw["enabled"].(bool); !ok || enabled {
t.Fatalf("expected raw enabled=false, got %#v", item.Raw)
}
}
func TestListIntegrationServicesCountsProfileReferences(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveIntegrationService(
"minio_primary",
"object_storage",
"primary object store",
true,
`{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`,
); err != nil {
t.Fatalf("SaveIntegrationService: %v", err)
}
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
items, err := svc.ListIntegrationServices()
if err != nil {
t.Fatalf("ListIntegrationServices: %v", err)
}
if len(items) != 1 || items[0].RefCount != 1 {
t.Fatalf("expected one referenced service, got %#v", items)
}
}
func TestGetIntegrationServiceNotFound(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
svc := NewConfigPreviewService(&config.Config{}, storage.NewAssetsRepo(store.DB()))
item, err := svc.GetIntegrationService("missing_service")
if !strings.Contains(err.Error(), "file does not exist") && !os.IsNotExist(err) {
t.Fatalf("expected not found error, got item=%#v err=%v", item, err)
}
if item != nil {
t.Fatalf("expected nil item for missing integration service, got %#v", item)
}
}
func TestGetIntegrationServicePrefersRecordTypeOverRawType(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveIntegrationService(
"minio_primary",
"object_storage",
"primary object store",
true,
`{"name":"minio_primary","type":"minio","provider":"minio","enabled":true}`,
); err != nil {
t.Fatalf("SaveIntegrationService: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
item, err := svc.GetIntegrationService("minio_primary")
if err != nil {
t.Fatalf("GetIntegrationService: %v", err)
}
if item.Type != "object_storage" {
t.Fatalf("expected canonical type from record, got %#v", item)
}
if got := stringValue(item.Raw["type"]); got != "minio" {
t.Fatalf("expected raw type to preserve original body_json, got %#v", item.Raw)
}
}
func TestDeleteIntegrationServiceBlocksWhenReferenced(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveIntegrationService(
"minio_primary",
"object_storage",
"primary object store",
true,
`{"name":"minio_primary","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`,
); err != nil {
t.Fatalf("SaveIntegrationService: %v", err)
}
if err := repo.SaveProfile("line_a", "std_workshop_face_recognition_shoe_alarm", "line a", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"std_workshop_face_recognition_shoe_alarm","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},"service_bindings":{"object_storage_main":{"service_ref":"minio_primary"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
err = svc.DeleteIntegrationService("minio_primary")
if err == nil || !strings.Contains(err.Error(), "used by scene configs") {
t.Fatalf("expected referenced delete to be blocked, got %v", err)
}
}
func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
@ -352,10 +750,10 @@ func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) {
SiteName: "A厂区",
Instances: []ConfigProfileInstanceEditor{
{
Name: "cam1",
Template: "helmet",
DisplayName: "东门入口",
RTSPURL: "rtsp://10.0.0.1/live",
Name: "cam1",
Template: "helmet",
DisplayName: "东门入口",
VideoSourceRef: "gate_cam_01",
},
},
}
@ -382,7 +780,7 @@ func TestConfigPreviewServiceSavesProfileEditorToAssetsRepo(t *testing.T) {
}
}
func TestConfigPreviewServicePrefersBuiltinTemplateOverRepoShadow(t *testing.T) {
func TestConfigPreviewServicePrefersRepoTemplateOverBuiltinFallback(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{
"name": "helmet",
@ -404,24 +802,23 @@ func TestConfigPreviewServicePrefersBuiltinTemplateOverRepoShadow(t *testing.T)
if err != nil {
t.Fatalf("GetTemplateAsset: %v", err)
}
if !item.ReadOnly || item.Origin != "builtin" {
t.Fatalf("expected builtin readonly template, got %#v", item)
if item.ReadOnly || item.Origin != "user" {
t.Fatalf("expected sqlite template to be preferred, got %#v", item)
}
if item.Description != "builtin template" {
t.Fatalf("expected builtin template payload, got %#v", item)
if item.Description != "shadow template" {
t.Fatalf("expected sqlite template payload, got %#v", item)
}
items, err := svc.ListTemplateAssets()
if err != nil {
t.Fatalf("ListTemplateAssets: %v", err)
}
if len(items) != 1 || items[0].Name != "helmet" || !items[0].ReadOnly {
t.Fatalf("expected only builtin template in merged list, got %#v", items)
if len(items) != 1 || items[0].Name != "helmet" || items[0].ReadOnly {
t.Fatalf("expected only sqlite template in merged list, got %#v", items)
}
}
func TestConfigPreviewServiceRejectsSavingBuiltinTemplateName(t *testing.T) {
func TestConfigPreviewServiceRejectsSavingStandardTemplateName(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","template":{"nodes":[],"edges":[]}}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
@ -430,7 +827,7 @@ func TestConfigPreviewServiceRejectsSavingBuiltinTemplateName(t *testing.T) {
repo := storage.NewAssetsRepo(store.DB())
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
err = svc.SaveTemplateAsset("helmet", "new body", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`)
err = svc.SaveTemplateAsset("std_face_recognition_stream", "new body", `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`)
if err == nil || !strings.Contains(err.Error(), "read-only") {
t.Fatalf("expected readonly rejection, got %v", err)
}
@ -446,7 +843,7 @@ func TestConfigPreviewServiceRenamesTemplateAndUpdatesProfileRefs(t *testing.T)
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
@ -467,7 +864,7 @@ func TestConfigPreviewServiceRenamesTemplateAndUpdatesProfileRefs(t *testing.T)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if profile == nil || profile.TemplateName != "helmet_v2" || (!strings.Contains(profile.BodyJSON, `"template": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"template":"helmet_v2"`)) {
if profile == nil || profile.TemplateName != "helmet_v2" || (!strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`)) {
t.Fatalf("expected updated profile refs, got %#v", profile)
}
}
@ -482,7 +879,7 @@ func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(t *testing.T) {
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`); err != nil {
if err := repo.SaveProfile("gate_a", "helmet", "gate", "gate profile", `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
@ -493,10 +890,10 @@ func TestConfigPreviewServiceRejectsDeletingReferencedTemplate(t *testing.T) {
}
}
func TestConfigPreviewServiceImportAssetsSkipsBuiltinTemplates(t *testing.T) {
func TestConfigPreviewServiceImportAssetsIncludesTemplates(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","template":{"nodes":[],"edges":[]}}`)
mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`)
mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","template":{"nodes":[],"edges":[]}}`)
mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"gate","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","instance_overrides":{"cam1":{"override":{}}}}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
@ -510,14 +907,205 @@ func TestConfigPreviewServiceImportAssetsSkipsBuiltinTemplates(t *testing.T) {
if err != nil {
t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
}
if result.Templates != 0 || result.Profiles != 1 || result.Overlays != 1 {
if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 {
t.Fatalf("unexpected import result: %#v", result)
}
record, err := repo.GetTemplate("helmet")
record, err := repo.GetTemplate("std_face_recognition_stream")
if err != nil {
t.Fatalf("GetTemplate: %v", err)
}
if record != nil {
t.Fatalf("expected builtin template to stay out of sqlite, got %#v", record)
if record == nil || !strings.Contains(record.BodyJSON, `"source": "standard"`) && !strings.Contains(record.BodyJSON, `"source":"standard"`) {
t.Fatalf("expected standard template to be imported into sqlite, got %#v", record)
}
}
func TestImportStandardTemplatesFromDirSyncsExisting(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"standard face","template":{"nodes":[],"edges":[]}}`)
mustWrite(t, filepath.Join(root, "std_service_test_stream.json"), `{"name":"std_service_test_stream","description":"standard service","template":{"nodes":[],"edges":[]}}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("std_service_test_stream", "existing service", `{"name":"std_service_test_stream","source":"standard","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
imported, err := ImportStandardTemplatesFromDir(repo, root)
if err != nil {
t.Fatalf("ImportStandardTemplatesFromDir: %v", err)
}
if imported != 2 {
t.Fatalf("expected two synced standard templates, got %d", imported)
}
face, err := repo.GetTemplate("std_face_recognition_stream")
if err != nil || face == nil {
t.Fatalf("expected imported face template, got %#v err=%v", face, err)
}
if !strings.Contains(face.BodyJSON, `"source": "standard"`) && !strings.Contains(face.BodyJSON, `"source":"standard"`) {
t.Fatalf("expected imported template source marker, got %#v", face)
}
serviceRecord, err := repo.GetTemplate("std_service_test_stream")
if err != nil || serviceRecord == nil {
t.Fatalf("expected existing service template, got %#v err=%v", serviceRecord, err)
}
if serviceRecord.Description != "standard service" {
t.Fatalf("expected existing template to sync from directory, got %#v", serviceRecord)
}
if !strings.Contains(serviceRecord.BodyJSON, `"description": "standard service"`) && !strings.Contains(serviceRecord.BodyJSON, `"description":"standard service"`) {
t.Fatalf("expected synced template body, got %#v", serviceRecord)
}
}
func TestBuildDeviceAssignmentBoardDataSummarizesLoad(t *testing.T) {
devices := []*models.Device{
{DeviceID: "edge-01", DeviceName: "设备一"},
{DeviceID: "edge-02", DeviceName: "设备二"},
{DeviceID: "edge-03", DeviceName: "设备三"},
{DeviceID: "edge-04", DeviceName: "设备四"},
}
units := []RecognitionUnitAsset{
{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1", VideoSourceRef: "vs1"},
{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2", VideoSourceRef: "vs2"},
{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3", VideoSourceRef: "vs3"},
{Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4", VideoSourceRef: "vs4"},
{Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5", VideoSourceRef: "vs5"},
{Ref: "scene_a::cam6", SceneTemplateName: "scene_a", Name: "cam6", VideoSourceRef: "vs6"},
}
assignments := []DeviceAssignmentAsset{
{DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3},
{DeviceID: "edge-02", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam4"}, RecognitionCount: 1},
{DeviceID: "edge-03", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam5", "scene_a::cam6"}, RecognitionCount: 2},
}
board := BuildDeviceAssignmentBoardData(devices, assignments, units, 4)
if board.Stats.TotalUnits != 6 || board.Stats.TotalDevices != 4 {
t.Fatalf("unexpected totals: %#v", board.Stats)
}
if board.Stats.AssignedUnits != 6 || board.Stats.UnassignedUnits != 0 {
t.Fatalf("unexpected assignment counts: %#v", board.Stats)
}
if board.Stats.OverloadedDevices != 0 {
t.Fatalf("expected no full devices in this dataset, got %#v", board.Stats)
}
if len(board.Cards) != 4 {
t.Fatalf("expected four device cards, got %#v", board.Cards)
}
if board.Cards[0].DeviceID != "edge-01" || board.Cards[0].Status != "busy" {
t.Fatalf("expected >50%% loaded device to sort first, got %#v", board.Cards)
}
if board.Cards[1].DeviceID != "edge-03" || board.Cards[1].Status != "busy" {
t.Fatalf("expected 50%% loaded device to count as busy, got %#v", board.Cards)
}
if board.Cards[3].Status != "idle" {
t.Fatalf("expected idle device card, got %#v", board.Cards[3])
}
}
func TestBuildDeviceAssignmentBoardDataTreatsOverMaxAsFull(t *testing.T) {
devices := []*models.Device{{DeviceID: "edge-01", DeviceName: "设备一"}}
units := []RecognitionUnitAsset{
{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"},
{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"},
{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"},
}
assignments := []DeviceAssignmentAsset{
{DeviceID: "edge-01", ProfileName: "scene_a", RecognitionUnits: []string{"scene_a::cam1", "scene_a::cam2", "scene_a::cam3"}, RecognitionCount: 3},
}
board := BuildDeviceAssignmentBoardData(devices, assignments, units, 2)
if board.Cards[0].Status != "full" {
t.Fatalf("expected over-max card to collapse into full, got %#v", board.Cards[0])
}
if board.Stats.OverloadedDevices != 1 {
t.Fatalf("expected full-device counter to include over-max card, got %#v", board.Stats)
}
}
func TestBuildAutoDeviceAssignmentsBalancesUnits(t *testing.T) {
devices := []*models.Device{
{DeviceID: "edge-01"},
{DeviceID: "edge-02"},
{DeviceID: "edge-03"},
}
units := []RecognitionUnitAsset{
{Ref: "scene_a::cam1", SceneTemplateName: "scene_a", Name: "cam1"},
{Ref: "scene_a::cam2", SceneTemplateName: "scene_a", Name: "cam2"},
{Ref: "scene_a::cam3", SceneTemplateName: "scene_a", Name: "cam3"},
{Ref: "scene_a::cam4", SceneTemplateName: "scene_a", Name: "cam4"},
{Ref: "scene_a::cam5", SceneTemplateName: "scene_a", Name: "cam5"},
}
assignments := BuildAutoDeviceAssignments(devices, units, 2)
if len(assignments) != 3 {
t.Fatalf("expected three populated device assignments, got %#v", assignments)
}
counts := map[string]int{}
total := 0
for _, item := range assignments {
counts[item.DeviceID] = len(item.RecognitionUnits)
total += len(item.RecognitionUnits)
if item.ProfileName != "scene_a" {
t.Fatalf("expected scene template preserved, got %#v", item)
}
if len(item.RecognitionUnits) > 2 {
t.Fatalf("expected max two units per device, got %#v", item)
}
}
if total != 5 {
t.Fatalf("expected all units assigned, got %#v", assignments)
}
if counts["edge-01"] != 2 || counts["edge-02"] != 2 || counts["edge-03"] != 1 {
t.Fatalf("expected balanced distribution, got %#v", counts)
}
}
func TestSaveDeviceAssignmentBoardPersistsAssignments(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveProfile("scene_a", "helmet", "Scene A", "desc", `{"name":"scene_a","primary_template_name":"helmet"}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
SceneTemplateName: "scene_a",
Name: "cam1",
VideoSourceRef: "vs1",
OutputChannel: "cam1",
RTSPPort: "8555",
BodyJSON: `{"name":"cam1","input_bindings":{"video_input_main":{"video_source_ref":"vs1"}}}`,
}); err != nil {
t.Fatalf("SaveRecognitionUnit cam1: %v", err)
}
if err := repo.SaveRecognitionUnit(storage.RecognitionUnitRecord{
SceneTemplateName: "scene_a",
Name: "cam2",
VideoSourceRef: "vs2",
OutputChannel: "cam2",
RTSPPort: "8555",
BodyJSON: `{"name":"cam2","input_bindings":{"video_input_main":{"video_source_ref":"vs2"}}}`,
}); err != nil {
t.Fatalf("SaveRecognitionUnit cam2: %v", err)
}
svc := NewConfigPreviewService(&config.Config{}, repo)
if err := svc.SaveDeviceAssignmentBoard(map[string][]string{
"edge-01": []string{"scene_a::cam1", "scene_a::cam2"},
"edge-02": []string{},
}); err != nil {
t.Fatalf("SaveDeviceAssignmentBoard: %v", err)
}
item, err := svc.GetDeviceAssignment("edge-01")
if err != nil {
t.Fatalf("GetDeviceAssignment: %v", err)
}
if item == nil || item.ProfileName != "scene_a" || len(item.RecognitionUnits) != 2 {
t.Fatalf("unexpected saved assignment: %#v", item)
}
}

View File

@ -1,13 +1,11 @@
package service
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
@ -43,6 +41,7 @@ type ConfigPreviewRequest struct {
Overlays []string
ConfigID string
ConfigVersion string
DeviceID string
}
type ConfigPreviewResult struct {
@ -70,159 +69,188 @@ func NewConfigPreviewService(cfg *config.Config, repo ...*storage.AssetsRepo) *C
}
func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) {
root := s.mediaRepoRoot()
out := ConfigPreviewSources{Root: root}
seenTemplates := map[string]bool{}
if root != "" {
templates, err := listConfigSources(filepath.Join(root, "configs", "templates"))
if err != nil {
if s.hasExplicitRoot() {
return out, err
}
} else {
out.Templates = append(out.Templates, templates...)
for _, item := range templates {
seenTemplates[item.Name] = true
}
}
profiles, err := listConfigSources(filepath.Join(root, "configs", "profiles"))
if err != nil {
if s.hasExplicitRoot() {
return out, err
}
} else {
out.Profiles = profiles
}
overlays, err := listConfigSources(filepath.Join(root, "configs", "overlays"))
if err != nil {
if s.hasExplicitRoot() {
return out, err
}
} else {
out.Overlays = overlays
}
}
if s != nil && s.assets != nil {
templates, err := s.assets.ListTemplates()
if err != nil {
return out, err
}
for _, item := range templates {
if seenTemplates[item.Name] {
continue
}
out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
}
profiles, err := s.assets.ListProfiles()
if err != nil {
return out, err
}
if len(profiles) > 0 {
out.Profiles = out.Profiles[:0]
for _, item := range profiles {
out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)})
}
}
overlays, err := s.assets.ListOverlays()
if err != nil {
return out, err
}
if len(overlays) > 0 {
out.Overlays = out.Overlays[:0]
for _, item := range overlays {
out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)})
}
}
}
sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name })
sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name })
sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name })
if out.Root == "" && s != nil && s.assets != nil && (len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0) {
out.Root = "SQLite"
}
if out.Root == "" && len(out.Templates) == 0 && len(out.Profiles) == 0 && len(out.Overlays) == 0 {
return defaultConfigPreviewSources(""), nil
}
return out, nil
}
func (s *ConfigPreviewService) listRepoSources() (ConfigPreviewSources, bool, error) {
out := ConfigPreviewSources{}
if s == nil || s.assets == nil {
return ConfigPreviewSources{}, false, nil
return out, nil
}
templates, err := s.assets.ListTemplates()
if err != nil {
return ConfigPreviewSources{}, true, err
return out, err
}
for _, item := range templates {
out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
}
profiles, err := s.assets.ListProfiles()
if err != nil {
return ConfigPreviewSources{}, true, err
}
overlays, err := s.assets.ListOverlays()
if err != nil {
return ConfigPreviewSources{}, true, err
}
if len(templates) == 0 && len(profiles) == 0 && len(overlays) == 0 {
return ConfigPreviewSources{}, false, nil
}
out := ConfigPreviewSources{Root: "SQLite"}
for _, item := range templates {
out.Templates = append(out.Templates, ConfigSource{Name: item.Name, Path: repoAssetPath("templates", item.Name)})
return out, err
}
for _, item := range profiles {
out.Profiles = append(out.Profiles, ConfigSource{Name: item.Name, Path: repoAssetPath("profiles", item.Name)})
}
overlays, err := s.assets.ListOverlays()
if err != nil {
return out, err
}
for _, item := range overlays {
out.Overlays = append(out.Overlays, ConfigSource{Name: item.Name, Path: repoAssetPath("overlays", item.Name)})
}
return out, true, nil
sort.Slice(out.Templates, func(i, j int) bool { return out.Templates[i].Name < out.Templates[j].Name })
sort.Slice(out.Profiles, func(i, j int) bool { return out.Profiles[i].Name < out.Profiles[j].Name })
sort.Slice(out.Overlays, func(i, j int) bool { return out.Overlays[i].Name < out.Overlays[j].Name })
if len(out.Templates) > 0 || len(out.Profiles) > 0 || len(out.Overlays) > 0 {
out.Root = "SQLite"
}
return out, nil
}
func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
root := s.mediaRepoRoot()
if root == "" {
return nil, fmt.Errorf("media repo path is not configured")
if err := validateConfigName(req.Template); err != nil {
return nil, fmt.Errorf("invalid template: %w", err)
}
templatePath := filepath.Join(root, "configs", "templates", req.Template+".json")
profilePath := filepath.Join(root, "configs", "profiles", req.Profile+".json")
return s.renderFromPaths(root, req, templatePath, profilePath)
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)
}
}
templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template)
if err != nil {
return nil, err
}
profilePath := repoAssetPath("profiles", req.Profile)
profileRaw := map[string]any{}
if strings.TrimSpace(req.Profile) != "" {
editor, err := s.GetProfileEditor(req.Profile)
if err != nil {
return nil, err
}
profileRaw, err = s.BuildProfileDocument(*editor)
if err != nil {
return nil, err
}
}
selectedOverlays := req.Overlays
if len(selectedOverlays) == 0 {
selectedOverlays = profileOverlayNames(profileRaw)
}
overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays))
for _, overlay := range selectedOverlays {
raw, path, err := s.readAssetJSON("overlays", overlay)
if err != nil {
return nil, err
}
overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
}
req.Overlays = append([]string(nil), selectedOverlays...)
return s.renderFromAssets(req, templateRaw, templatePath, profileRaw, profilePath, overlays)
}
func (s *ConfigPreviewService) RenderProfileEditor(editor ConfigProfileEditor, req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
root := s.mediaRepoRoot()
if root == "" {
return nil, fmt.Errorf("media repo path is not configured")
if err := validateConfigName(req.Template); err != nil {
return nil, fmt.Errorf("invalid template: %w", err)
}
for _, overlay := range req.Overlays {
if err := validateConfigName(overlay); err != nil {
return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
}
}
doc, err := s.BuildProfileDocument(editor)
if err != nil {
return nil, err
}
body, err := marshalConfigJSON(doc)
if err != nil {
return nil, err
}
tempProfile, err := os.CreateTemp("", "rk3588-profile-editor-*.json")
if err != nil {
return nil, err
}
tempProfilePath := tempProfile.Name()
if _, err := tempProfile.Write(body); err != nil {
_ = tempProfile.Close()
_ = os.Remove(tempProfilePath)
return nil, err
}
_ = tempProfile.Close()
defer os.Remove(tempProfilePath)
if strings.TrimSpace(req.Profile) == "" {
req.Profile = strings.TrimSpace(editor.Name)
}
templatePath := filepath.Join(root, "configs", "templates", req.Template+".json")
return s.renderFromPaths(root, req, templatePath, tempProfilePath)
templateRaw, templatePath, err := s.readAssetJSON("templates", req.Template)
if err != nil {
return nil, err
}
selectedOverlays := req.Overlays
if len(selectedOverlays) == 0 {
selectedOverlays = profileOverlayNames(doc)
}
overlays := make([]runtimeOverlayInput, 0, len(selectedOverlays))
for _, overlay := range selectedOverlays {
raw, path, err := s.readAssetJSON("overlays", overlay)
if err != nil {
return nil, err
}
overlays = append(overlays, runtimeOverlayInput{Name: overlay, Path: path, Raw: raw})
}
req.Overlays = append([]string(nil), selectedOverlays...)
return s.renderFromAssets(req, templateRaw, templatePath, doc, repoAssetPath("profiles", req.Profile), overlays)
}
func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewRequest, templatePath string, profilePath string) (*ConfigPreviewResult, error) {
func (s *ConfigPreviewService) BuildProfileEditorForDeviceAssignment(deviceID string) (*ConfigProfileEditor, *DeviceAssignmentAsset, error) {
assignment, err := s.GetDeviceAssignment(deviceID)
if err != nil {
return nil, nil, err
}
editor, err := s.GetProfileEditor(assignment.ProfileName)
if err != nil {
return nil, nil, err
}
if len(assignment.RecognitionUnits) == 0 {
editor.Instances = nil
return editor, assignment, nil
}
selected := make(map[string]struct{}, len(assignment.RecognitionUnits))
for _, ref := range assignment.RecognitionUnits {
selected[ref] = struct{}{}
}
filtered := make([]ConfigProfileInstanceEditor, 0, len(assignment.RecognitionUnits))
for _, inst := range editor.Instances {
if _, ok := selected[recognitionUnitRef(editor.Name, inst.Name)]; ok {
filtered = append(filtered, inst)
}
}
editor.Instances = filtered
return editor, assignment, nil
}
func (s *ConfigPreviewService) RenderDeviceAssignment(deviceID string) (*ConfigPreviewResult, error) {
editor, assignment, err := s.BuildProfileEditorForDeviceAssignment(deviceID)
if err != nil {
return nil, err
}
if len(editor.Instances) == 0 {
return nil, fmt.Errorf("设备 %s 还没有分配识别单元", deviceID)
}
templateName := firstProfileTemplate(editor.Instances)
if strings.TrimSpace(templateName) == "" {
templateName = editor.RawTemplateName()
}
return s.RenderProfileEditor(*editor, ConfigPreviewRequest{
Template: templateName,
Profile: assignment.ProfileName,
ConfigID: "assignment_" + deviceID,
ConfigVersion: time.Now().Format("20060102.150405"),
DeviceID: deviceID,
})
}
func profileOverlayNames(raw map[string]any) []string {
items, _ := raw["overlays"].([]any)
if len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
if v := stringAny(item); strings.TrimSpace(v) != "" {
out = append(out, strings.TrimSpace(v))
}
}
if len(out) == 0 {
return nil
}
return out
}
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)
}
@ -242,97 +270,217 @@ func (s *ConfigPreviewService) renderFromPaths(root string, req ConfigPreviewReq
if req.ConfigVersion == "" {
req.ConfigVersion = time.Now().Format("20060102.150405")
}
if _, err := os.Stat(templatePath); err != nil {
return nil, err
}
if _, err := os.Stat(profilePath); err != nil {
return nil, fmt.Errorf("invalid profile: %w", err)
}
out, err := os.CreateTemp("", "rk3588-config-preview-*.json")
resolvedProfileRaw, err := s.resolveSceneBindings(profileRaw)
if err != nil {
return nil, err
}
outPath := out.Name()
_ = out.Close()
defer os.Remove(outPath)
args := []string{
filepath.Join(root, "tools", "render_config.py"),
"--template", templatePath,
"--profile", profilePath,
"--out", outPath,
"--config-id", req.ConfigID,
"--config-version", req.ConfigVersion,
"--rendered-at", time.Now().Format(time.RFC3339),
metadata := map[string]any{
"config_id": req.ConfigID,
"config_version": req.ConfigVersion,
"rendered_at": time.Now().Format(time.RFC3339),
"rendered_by": "managerd",
}
for _, overlay := range req.Overlays {
args = append(args, "--overlay", filepath.Join(root, "configs", "overlays", overlay+".json"))
if strings.TrimSpace(req.DeviceID) != "" {
metadata["device_id"] = strings.TrimSpace(req.DeviceID)
}
cmd := exec.Command("python", args...)
cmd.Dir = root
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return nil, fmt.Errorf("render config preview: %s", msg)
}
body, err := os.ReadFile(outPath)
doc, err := renderRuntimeConfig(templateRaw, templatePath, resolvedProfileRaw, profilePath, overlays, metadata)
if err != nil {
return nil, err
}
var doc map[string]any
if err := json.Unmarshal(body, &doc); err != nil {
body, err := marshalConfigJSON(doc)
if err != nil {
return nil, err
}
metadata, _ := doc["metadata"].(map[string]any)
renderedMetadata, _ := doc["metadata"].(map[string]any)
sum := sha256.Sum256(body)
return &ConfigPreviewResult{
Request: req,
Root: root,
Root: previewRenderRoot(templatePath, profilePath),
Sha256: hex.EncodeToString(sum[:]),
Size: len(body),
Metadata: metadata,
Metadata: renderedMetadata,
JSON: string(body),
}, nil
}
func (s *ConfigPreviewService) mediaRepoRoot() string {
if s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != "" {
return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath))
func (s *ConfigPreviewService) resolveSceneBindings(raw map[string]any) (map[string]any, error) {
if raw == nil {
return map[string]any{}, nil
}
if env := strings.TrimSpace(os.Getenv("ORANGEPI_MEDIA_REPO")); env != "" {
return filepath.Clean(env)
}
wd, err := os.Getwd()
body, err := json.Marshal(raw)
if err != nil {
return ""
return nil, err
}
candidates := []string{
filepath.Join(wd, "..", "OrangePi3588Media"),
filepath.Join(wd, "..", "..", "OrangePi3588Media"),
filepath.Join(filepath.Dir(wd), "OrangePi3588Media"),
var clone map[string]any
if err := json.Unmarshal(body, &clone); err != nil {
return nil, err
}
for _, candidate := range candidates {
if _, err := os.Stat(filepath.Join(candidate, "tools", "render_config.py")); err == nil {
return filepath.Clean(candidate)
instances, _ := clone["instances"].([]any)
for _, item := range instances {
instanceMap, _ := item.(map[string]any)
paramsMap, _ := instanceMap["params"].(map[string]any)
if paramsMap == nil {
paramsMap = map[string]any{}
instanceMap["params"] = paramsMap
}
inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
if inputBindings == nil {
inputBindings = map[string]any{}
instanceMap["input_bindings"] = inputBindings
}
videoSourceRef := bindingField(inputBindings, "video_input_main", "video_source_ref")
if videoSourceRef != "" {
asset, err := s.GetVideoSource(videoSourceRef)
if err != nil {
return nil, fmt.Errorf("load video_source_ref %q: %w", videoSourceRef, err)
}
entry, _ := inputBindings["video_input_main"].(map[string]any)
if entry == nil {
entry = map[string]any{}
}
entry["video_source_ref"] = videoSourceRef
entry["resolved"] = map[string]any{
"url": asset.Config.URL,
"resolution": asset.Config.Resolution,
"frame_size": asset.Config.FrameSize,
"fps": asset.Config.FPS,
"video_format": asset.Config.VideoFormat,
}
inputBindings["video_input_main"] = entry
}
serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
if serviceBindings == nil {
serviceBindings = map[string]any{}
instanceMap["service_bindings"] = serviceBindings
}
for _, binding := range []struct {
slot string
expected string
}{
{slot: "object_storage_main", expected: "object_storage"},
{slot: "token_service_main", expected: "token_service"},
{slot: "alarm_service_main", expected: "alarm_service"},
} {
serviceRef := bindingField(serviceBindings, binding.slot, "service_ref")
if serviceRef == "" {
continue
}
asset, err := s.GetIntegrationService(serviceRef)
if err != nil {
return nil, fmt.Errorf("load service_ref %q: %w", serviceRef, err)
}
if strings.TrimSpace(asset.Type) != binding.expected {
return nil, fmt.Errorf("service_ref %q has type %q, expected %q", serviceRef, asset.Type, binding.expected)
}
entry, _ := serviceBindings[binding.slot].(map[string]any)
if entry == nil {
entry = map[string]any{}
}
entry["service_ref"] = serviceRef
entry["resolved"] = resolvedServiceBinding(asset)
serviceBindings[binding.slot] = entry
}
}
return ""
return clone, nil
}
func (s *ConfigPreviewService) hasExplicitRoot() bool {
return s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != ""
func bindingField(bindings map[string]any, slot string, field string) string {
entry, _ := bindings[slot].(map[string]any)
return stringValue(entry[field])
}
func resolvedServiceBinding(asset *ConfigIntegrationServiceAsset) map[string]any {
if asset == nil {
return nil
}
switch asset.Type {
case "object_storage":
if asset.ObjectStorage == nil {
return nil
}
return map[string]any{
"endpoint": asset.ObjectStorage.Endpoint,
"bucket": asset.ObjectStorage.Bucket,
"access_key": asset.ObjectStorage.AccessKey,
"secret_key": asset.ObjectStorage.SecretKey,
}
case "token_service":
if asset.TokenService == nil {
return nil
}
return map[string]any{
"get_token_url": asset.TokenService.GetTokenURL,
"username": asset.TokenService.Username,
"password": asset.TokenService.Password,
"tenant_code": asset.TokenService.TenantCode,
}
case "alarm_service":
if asset.AlarmService == nil {
return nil
}
return map[string]any{
"put_message_url": asset.AlarmService.PutMessageURL,
"username": asset.AlarmService.Username,
"password": asset.AlarmService.Password,
"tenant_code": asset.AlarmService.TenantCode,
}
default:
return nil
}
}
func previewRenderRoot(templatePath string, profilePath string) string {
if strings.HasPrefix(templatePath, "sqlite:") || strings.HasPrefix(profilePath, "sqlite:") {
return "SQLite"
}
if dir := filepath.Dir(templatePath); strings.TrimSpace(dir) != "" && dir != "." {
return dir
}
return "managerd"
}
func writeResolvedConfigFile(pattern string, raw map[string]any) (string, error) {
body, err := marshalConfigJSON(raw)
if err != nil {
return "", err
}
tempFile, err := os.CreateTemp("", pattern)
if err != nil {
return "", err
}
path := tempFile.Name()
if _, err := tempFile.Write(body); err != nil {
_ = tempFile.Close()
_ = os.Remove(path)
return "", err
}
_ = tempFile.Close()
return path, nil
}
func setAnyString(m map[string]any, key string, value string) {
if strings.TrimSpace(value) != "" {
m[key] = strings.TrimSpace(value)
}
}
func (s *ConfigPreviewService) mediaRepoRoot() string {
if s.cfg == nil {
return ""
}
if strings.TrimSpace(s.cfg.MediaRepoPath) == "" {
return ""
}
return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath))
}
func listConfigSources(dir string) ([]ConfigSource, error) {
files, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return []ConfigSource{}, nil
}
return nil, err
}
out := make([]ConfigSource, 0)
@ -357,45 +505,24 @@ func validateConfigName(name string) error {
return nil
}
func defaultConfigPreviewSources(root string) ConfigPreviewSources {
return ConfigPreviewSources{
Root: root,
Templates: []ConfigSource{
{Name: "std_face_recognition_stream"},
{Name: "std_service_test_stream"},
{Name: "std_workshoe_detection_stream"},
{Name: "std_workshop_face_recognition_shoe_alarm"},
},
Profiles: []ConfigSource{
{Name: "local_3588_test"},
},
Overlays: []ConfigSource{
{Name: "face_debug"},
{Name: "face_test_sensitive"},
{Name: "production_quiet"},
{Name: "shoe_debug"},
{Name: "shoe_test_sensitive"},
},
}
}
func repoAssetPath(kind string, name string) string {
return "sqlite:" + kind + "/" + strings.TrimSpace(name)
}
func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportResult, error) {
if s == nil || s.assets == nil {
return nil, fmt.Errorf("assets repository is not configured")
return nil, fmt.Errorf("asset repository is not configured")
}
root := s.mediaRepoRoot()
if root == "" {
return nil, fmt.Errorf("media repo path is not configured")
return nil, fmt.Errorf("legacy import source path is not configured")
}
result := &ConfigAssetImportResult{Root: root}
for _, item := range []struct {
kind string
inc *int
}{
{kind: "templates", inc: &result.Templates},
{kind: "profiles", inc: &result.Profiles},
{kind: "overlays", inc: &result.Overlays},
} {
@ -416,6 +543,16 @@ func (s *ConfigPreviewService) ImportAssetsFromMediaRepo() (*ConfigAssetImportRe
description := stringValue(raw["description"])
switch item.kind {
case "templates":
if raw == nil {
raw = map[string]any{}
}
if isStandardTemplateName(name) && strings.TrimSpace(stringValue(raw["source"])) == "" {
raw["source"] = "standard"
body, err = marshalConfigJSON(raw)
if err != nil {
return nil, err
}
}
if err := s.assets.SaveTemplate(name, description, string(body)); err != nil {
return nil, err
}
@ -438,21 +575,13 @@ func (s *ConfigPreviewService) ExportAssetJSON(kind string, name string) ([]byte
if err := validateConfigName(name); err != nil {
return nil, "", err
}
if s != nil && s.assets != nil {
if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil {
return body, name + ".json", err
}
if s == nil || s.assets == nil {
return nil, "", fmt.Errorf("asset repository is not configured")
}
root := s.mediaRepoRoot()
if root == "" {
return nil, "", fmt.Errorf("media repo path is not configured")
if body, ok, err := s.exportRepoAssetJSON(kind, name); ok || err != nil {
return body, name + ".json", err
}
path := filepath.Join(root, "configs", kind, name+".json")
body, err := os.ReadFile(path)
if err != nil {
return nil, "", err
}
return body, name + ".json", nil
return nil, "", os.ErrNotExist
}
func (s *ConfigPreviewService) exportRepoAssetJSON(kind string, name string) ([]byte, bool, error) {
@ -487,5 +616,5 @@ func profileRawTemplateName(raw map[string]any) string {
return v
}
}
return stringValue(raw["template_name"])
return stringValue(raw["primary_template_name"])
}

View File

@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"os"
"path/filepath"
"strings"
@ -10,20 +11,34 @@ import (
"3588AdminBackend/internal/storage"
)
func mustImportAssetsFromMediaRepo(t *testing.T, svc *ConfigPreviewService) {
t.Helper()
if _, err := svc.ImportAssetsFromMediaRepo(); err != nil {
t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
}
}
func TestConfigPreviewServiceListsSources(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{}`)
mustWrite(t, filepath.Join(root, "configs", "profiles", "local_3588_test.json"), `{}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "face_debug.json"), `{}`)
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsFromMediaRepo(t, svc)
sources, err := svc.ListSources()
if err != nil {
t.Fatalf("ListSources: %v", err)
}
if sources.Root != root {
t.Fatalf("expected root %q, got %q", root, sources.Root)
if sources.Root != "SQLite" {
t.Fatalf("expected root %q, got %q", "SQLite", sources.Root)
}
if got := sourceNames(sources.Templates); strings.Join(got, ",") != "std_workshop_face_recognition_shoe_alarm" {
t.Fatalf("unexpected templates: %v", got)
@ -36,6 +51,35 @@ func TestConfigPreviewServiceListsSources(t *testing.T) {
}
}
func TestConfigPreviewServiceListSourcesAllowsEmptyConfigsDir(t *testing.T) {
root := t.TempDir()
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveTemplate("helmet", "helmet template", `{"name":"helmet","template":{"nodes":[],"edges":[]}}`); err != nil {
t.Fatalf("SaveTemplate: %v", err)
}
if err := repo.SaveProfile("line_a", "helmet", "Line A", "profile", `{"name":"line_a","instances":[{"name":"cam1","template":"helmet","input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
sources, err := svc.ListSources()
if err != nil {
t.Fatalf("ListSources: %v", err)
}
if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" {
t.Fatalf("unexpected templates: %#v", got)
}
if got := sourceNames(sources.Profiles); len(got) != 1 || got[0] != "line_a" {
t.Fatalf("unexpected profiles: %#v", got)
}
}
func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) {
root := t.TempDir()
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root})
@ -53,8 +97,8 @@ func TestConfigPreviewServiceRejectsUnsafeNames(t *testing.T) {
func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "helmet.json"), `{"name":"helmet","description":"helmet template","template":{"nodes":[],"edges":[]}}`)
mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","params":{"display_name":"Gate A","rtsp_url":"rtsp://10.0.0.1/live"}}]}`)
mustWrite(t, filepath.Join(root, "configs", "templates", "std_face_recognition_stream.json"), `{"name":"std_face_recognition_stream","description":"helmet template","template":{"nodes":[],"edges":[]}}`)
mustWrite(t, filepath.Join(root, "configs", "profiles", "gate_a.json"), `{"name":"gate_a","business_name":"Gate A","description":"gate profile","instances":[{"name":"cam1","template":"helmet","scene_meta":{"display_name":"Gate A"},"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}}]}`)
mustWrite(t, filepath.Join(root, "configs", "overlays", "night_relaxed.json"), `{"name":"night_relaxed","description":"overlay","instance_overrides":{"cam1":{"override":{}}}}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
@ -69,7 +113,7 @@ func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) {
if err != nil {
t.Fatalf("ImportAssetsFromMediaRepo: %v", err)
}
if result.Templates != 0 || result.Profiles != 1 || result.Overlays != 1 {
if result.Templates != 1 || result.Profiles != 1 || result.Overlays != 1 {
t.Fatalf("unexpected import result: %#v", result)
}
@ -77,13 +121,13 @@ func TestConfigPreviewServiceImportsAssetsIntoSQLite(t *testing.T) {
if err != nil {
t.Fatalf("ListSources: %v", err)
}
if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "helmet" {
if got := sourceNames(sources.Templates); len(got) != 1 || got[0] != "std_face_recognition_stream" {
t.Fatalf("unexpected templates after import: %#v", got)
}
if record, err := repo.GetTemplate("helmet"); err != nil {
if record, err := repo.GetTemplate("std_face_recognition_stream"); err != nil {
t.Fatalf("GetTemplate: %v", err)
} else if record != nil {
t.Fatalf("expected builtin template to remain outside sqlite, got %#v", record)
} else if record == nil {
t.Fatal("expected imported standard template")
}
}
@ -113,6 +157,329 @@ func TestConfigPreviewServiceExportsAssetJSONFromSQLite(t *testing.T) {
}
}
func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "std_workshop_face_recognition_shoe_alarm.json"), `{
"name":"std_workshop_face_recognition_shoe_alarm",
"template":{
"nodes":[
{"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"},
{"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]}
],
"edges":[]
}
}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveVideoSource(
"gate_cam_01",
"rtsp",
"东门入口",
"东门主入口摄像头",
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
); err != nil {
t.Fatalf("SaveVideoSource: %v", err)
}
saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
saveIntegrationServiceForPreviewTest(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsFromMediaRepo(t, svc)
editor := ConfigProfileEditor{
Name: "line_a",
Instances: []ConfigProfileInstanceEditor{{
Name: "cam1",
Template: "std_workshop_face_recognition_shoe_alarm",
VideoSourceRef: "gate_cam_01",
PublishHLSPath: "./web/hls/cam1/index.m3u8",
PublishRTSPPort: "8555",
PublishRTSPPath: "/live/cam1",
ChannelNo: "cam1",
ServiceBindings: map[string]ServiceBindingEditor{
"object_storage_main": {ServiceRef: "minio_main"},
"token_service_main": {ServiceRef: "token_main"},
"alarm_service_main": {ServiceRef: "alarm_main"},
},
}},
}
result, err := svc.RenderProfileEditor(editor, ConfigPreviewRequest{
Template: "std_workshop_face_recognition_shoe_alarm",
ConfigID: "preview",
ConfigVersion: "v1",
})
if err != nil {
t.Fatalf("RenderProfileEditor: %v", err)
}
var doc map[string]any
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
t.Fatalf("unmarshal render result: %v", err)
}
templates, _ := doc["templates"].(map[string]any)
renderedTemplate, _ := templates["std_workshop_face_recognition_shoe_alarm__cam1"].(map[string]any)
nodes, _ := renderedTemplate["nodes"].([]any)
inputNode, _ := nodes[0].(map[string]any)
if got := stringValue(inputNode["url"]); got != "rtsp://10.0.0.1/live" {
t.Fatalf("expected expanded input url, got %#v", inputNode)
}
publishNode, _ := nodes[1].(map[string]any)
outputs, _ := publishNode["outputs"].([]any)
output, _ := outputs[0].(map[string]any)
if got := stringValue(output["path"]); got != "./web/hls/cam1/index.m3u8" {
t.Fatalf("expected expanded output path, got %#v", output)
}
metadata, _ := doc["metadata"].(map[string]any)
if got := stringValue(metadata["rendered_by"]); got != "managerd" {
t.Fatalf("expected managerd renderer metadata, got %#v", metadata)
}
}
func TestConfigPreviewServiceRenderUsesSQLiteProfileAndOverlay(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{
"name":"std_service_test_stream",
"template":{
"nodes":[
{"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"},
{"id":"publish_stream","type":"publish","outputs":[{"proto":"hls","path":"${slot:stream_output_main.publish_hls_path}"}]}
],
"edges":[]
}
}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{
"name":"line_a",
"business_name":"Line A",
"instances":[
{
"name":"cam1",
"template":"std_service_test_stream",
"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}},
"output_bindings":{"stream_output_main":{"publish_hls_path":"./web/hls/cam1/index.m3u8","publish_rtsp_port":8555,"publish_rtsp_path":"/live/cam1","channel_no":"cam1"}}
}
]
}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil {
t.Fatalf("SaveOverlay: %v", err)
}
if err := repo.SaveVideoSource(
"gate_cam_01",
"rtsp",
"东门入口",
"东门主入口摄像头",
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
); err != nil {
t.Fatalf("SaveVideoSource: %v", err)
}
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsFromMediaRepo(t, svc)
result, err := svc.Render(ConfigPreviewRequest{
Template: "std_service_test_stream",
Profile: "line_a",
Overlays: []string{"night_relaxed"},
ConfigID: "preview",
ConfigVersion: "v1",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
var doc map[string]any
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
t.Fatalf("unmarshal render result: %v", err)
}
metadata, _ := doc["metadata"].(map[string]any)
if got := stringValue(metadata["profile"]); got != "line_a" {
t.Fatalf("expected sqlite profile metadata, got %#v", metadata)
}
names, _ := metadata["overlays"].([]any)
if len(names) != 1 || stringValue(names[0]) != "night_relaxed" {
t.Fatalf("expected sqlite overlay metadata, got %#v", metadata["overlays"])
}
templates, _ := doc["templates"].(map[string]any)
renderedTemplate, _ := templates["std_service_test_stream__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)
}
instances, _ := doc["instances"].([]any)
instance, _ := instances[0].(map[string]any)
params, _ := instance["params"].(map[string]any)
if got := boolValue(params["debug"], false); !got {
t.Fatalf("expected overlay params to merge into runtime instance, got %#v", instance)
}
}
func TestConfigPreviewServiceRenderUsesProfileOverlayWhenRequestOmitsIt(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "configs", "templates", "std_service_test_stream.json"), `{
"name":"std_service_test_stream",
"template":{
"nodes":[
{"id":"input_rtsp_main","type":"input_rtsp","url":"${slot:video_input_main.url}"}
],
"edges":[]
}
}`)
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveProfile("line_a", "std_service_test_stream", "Line A", "scene profile", `{
"name":"line_a",
"business_name":"Line A",
"overlays":["night_relaxed"],
"instances":[
{
"name":"cam1",
"template":"std_service_test_stream",
"input_bindings":{"video_input_main":{"video_source_ref":"gate_cam_01"}}
}
]
}`); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
if err := repo.SaveOverlay("night_relaxed", "night overlay", `{"name":"night_relaxed","instance_overrides":{"cam1":{"params":{"debug":true}}}}`); err != nil {
t.Fatalf("SaveOverlay: %v", err)
}
if err := repo.SaveVideoSource(
"gate_cam_01",
"rtsp",
"东门入口",
"东门主入口摄像头",
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
); err != nil {
t.Fatalf("SaveVideoSource: %v", err)
}
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsFromMediaRepo(t, svc)
result, err := svc.Render(ConfigPreviewRequest{
Template: "std_service_test_stream",
Profile: "line_a",
ConfigID: "preview",
ConfigVersion: "v1",
})
if err != nil {
t.Fatalf("Render: %v", err)
}
var doc map[string]any
if err := json.Unmarshal([]byte(result.JSON), &doc); err != nil {
t.Fatalf("unmarshal render result: %v", err)
}
metadata, _ := doc["metadata"].(map[string]any)
names, _ := metadata["overlays"].([]any)
if len(names) != 1 || stringValue(names[0]) != "night_relaxed" {
t.Fatalf("expected profile overlay metadata, got %#v", metadata["overlays"])
}
instances, _ := doc["instances"].([]any)
instance, _ := instances[0].(map[string]any)
params, _ := instance["params"].(map[string]any)
if got := boolValue(params["debug"], false); !got {
t.Fatalf("expected profile overlay params to merge into runtime instance, got %#v", instance)
}
}
func TestSaveProfileEditorRequiresAssetRepository(t *testing.T) {
svc := NewConfigPreviewService(&config.Config{})
err := svc.SaveProfileEditor(ConfigProfileEditor{
Name: "line_a",
Instances: []ConfigProfileInstanceEditor{{
Name: "cam1",
Template: "helmet",
VideoSourceRef: "gate_cam_01",
}},
})
if err == nil || !strings.Contains(err.Error(), "asset repository is not configured") {
t.Fatalf("expected asset repository error, got %v", err)
}
}
func TestConfigPreviewServiceResolveSceneBindings(t *testing.T) {
store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
if err := repo.SaveVideoSource(
"gate_cam_01",
"rtsp",
"东门入口",
"东门主入口摄像头",
`{"name":"gate_cam_01","source_type":"rtsp","config":{"url":"rtsp://10.0.0.1/live"}}`,
); err != nil {
t.Fatalf("SaveVideoSource: %v", err)
}
saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
svc := NewConfigPreviewService(&config.Config{}, repo)
resolved, err := svc.resolveSceneBindings(map[string]any{
"name": "line_a",
"instances": []any{
map[string]any{
"name": "cam1",
"template": "std_workshop_face_recognition_shoe_alarm",
"input_bindings": map[string]any{
"video_input_main": map[string]any{
"video_source_ref": "gate_cam_01",
},
},
"service_bindings": map[string]any{
"object_storage_main": map[string]any{
"service_ref": "minio_main",
},
},
},
},
})
if err != nil {
t.Fatalf("resolveSceneBindings: %v", err)
}
instances, _ := resolved["instances"].([]any)
instanceMap, _ := instances[0].(map[string]any)
inputBindings, _ := instanceMap["input_bindings"].(map[string]any)
videoInput, _ := inputBindings["video_input_main"].(map[string]any)
resolvedInput, _ := videoInput["resolved"].(map[string]any)
if got := stringValue(resolvedInput["url"]); got != "rtsp://10.0.0.1/live" {
t.Fatalf("expected resolved input url, got %#v", resolvedInput)
}
serviceBindings, _ := instanceMap["service_bindings"].(map[string]any)
objectStorage, _ := serviceBindings["object_storage_main"].(map[string]any)
resolvedService, _ := objectStorage["resolved"].(map[string]any)
if got := stringValue(resolvedService["bucket"]); got != "myminio" {
t.Fatalf("expected resolved service binding, got %#v", resolvedService)
}
}
func saveIntegrationServiceForPreviewTest(t *testing.T, repo *storage.AssetsRepo, name string, serviceType string, body string) {
t.Helper()
if err := repo.SaveIntegrationService(name, serviceType, name, true, body); err != nil {
t.Fatalf("SaveIntegrationService(%s): %v", name, err)
}
}
func sourceNames(items []ConfigSource) []string {
out := make([]string, 0, len(items))
for _, item := range items {

View File

@ -0,0 +1,415 @@
package service
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strings"
)
var slotTokenRE = regexp.MustCompile(`^\$\{slot:([A-Za-z0-9_.-]+)\.([A-Za-z0-9_.-]+)\}$`)
func renderRuntimeConfig(templateRaw map[string]any, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, metadata map[string]any) (map[string]any, error) {
tplName, err := runtimeTemplateName(templateRaw, templatePath)
if err != nil {
return nil, err
}
instances, err := runtimeProfileInstances(profileRaw, tplName)
if err != nil {
return nil, err
}
instances, err = mergeRuntimeTemplateParams(instances, runtimeTemplateParams(templateRaw))
if err != nil {
return nil, err
}
renderedTemplates := map[string]any{}
renderedInstances := make([]any, 0, len(instances))
for _, instance := range instances {
boundName, boundTemplate, renderedInstance, err := renderRuntimeSceneInstance(templateRaw, templatePath, instance)
if err != nil {
return nil, err
}
renderedTemplates[boundName] = boundTemplate
renderedInstances = append(renderedInstances, renderedInstance)
}
root := map[string]any{
"templates": renderedTemplates,
"instances": renderedInstances,
}
for _, key := range []string{"global", "queue"} {
if value, ok := profileRaw[key]; ok {
root[key] = deepCopyAny(value)
}
}
for _, overlay := range overlays {
var err error
root, err = applyRuntimeOverlay(root, overlay.Raw)
if err != nil {
return nil, err
}
}
if metadata != nil {
root["metadata"] = buildRuntimeMetadata(tplName, templatePath, profileRaw, profilePath, overlays, root, metadata)
}
return root, nil
}
type runtimeOverlayInput struct {
Name string
Path string
Raw map[string]any
}
func runtimeTemplateName(templateRaw map[string]any, templatePath string) (string, error) {
name := strings.TrimSpace(firstString(templateRaw["name"], strings.TrimSuffix(filepath.Base(templatePath), filepath.Ext(templatePath))))
if name == "" {
return "", fmt.Errorf("%s: template name is empty", templatePath)
}
return name, nil
}
func runtimeTemplateBody(templateRaw map[string]any) (map[string]any, error) {
body := templateRaw
if nested, ok := templateRaw["template"].(map[string]any); ok {
body = nested
}
nodes, hasNodes := body["nodes"].([]any)
edges, hasEdges := body["edges"].([]any)
if !hasNodes || !hasEdges {
return nil, fmt.Errorf("template body must contain nodes[] and edges[]")
}
out := map[string]any{
"nodes": deepCopyAny(nodes),
"edges": deepCopyAny(edges),
}
if executor, ok := body["executor"]; ok {
out["executor"] = deepCopyAny(executor)
}
return out, nil
}
func runtimeTemplateParams(templateRaw map[string]any) map[string]any {
params, _ := templateRaw["params"].(map[string]any)
if params == nil {
return map[string]any{}
}
return cloneMap(params)
}
func runtimeProfileInstances(profileRaw map[string]any, tplName string) ([]map[string]any, error) {
if items, ok := profileRaw["instances"].([]any); ok {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
instance, _ := item.(map[string]any)
if instance == nil {
return nil, fmt.Errorf("profile.instances entries must be objects")
}
cloned := deepCopyMap(instance)
if strings.TrimSpace(stringValue(cloned["template"])) == "" {
cloned["template"] = tplName
}
out = append(out, cloned)
}
return out, nil
}
name := strings.TrimSpace(stringValue(profileRaw["name"]))
if name == "" {
return nil, fmt.Errorf("profile must contain name or instances[]")
}
instance := map[string]any{
"name": name,
"template": tplName,
}
if params, ok := profileRaw["params"].(map[string]any); ok && len(params) > 0 {
instance["params"] = deepCopyMap(params)
}
if override, ok := profileRaw["override"].(map[string]any); ok && len(override) > 0 {
instance["override"] = deepCopyMap(override)
}
return []map[string]any{instance}, nil
}
func mergeRuntimeTemplateParams(instances []map[string]any, sharedParams map[string]any) ([]map[string]any, error) {
if len(sharedParams) == 0 {
return instances, nil
}
out := make([]map[string]any, 0, len(instances))
for _, item := range instances {
inst := deepCopyMap(item)
params, _ := inst["params"].(map[string]any)
if params == nil {
params = map[string]any{}
}
inst["params"] = deepMergeMap(sharedParams, params)
out = append(out, inst)
}
return out, nil
}
func renderRuntimeSceneInstance(templateRaw map[string]any, templatePath string, instance map[string]any) (string, map[string]any, map[string]any, error) {
instanceName := strings.TrimSpace(stringValue(instance["name"]))
if instanceName == "" {
return "", nil, nil, fmt.Errorf("scene instance name is required")
}
tplName, err := runtimeTemplateName(templateRaw, templatePath)
if err != nil {
return "", nil, nil, err
}
boundName := tplName + "__" + instanceName
context, err := buildRuntimeBindingContext(instance)
if err != nil {
return "", nil, nil, err
}
templateBody, err := runtimeTemplateBody(templateRaw)
if err != nil {
return "", nil, nil, err
}
renderedTemplateAny, err := expandRuntimeSlotTokens(templateBody, context)
if err != nil {
return "", nil, nil, err
}
renderedTemplate, _ := renderedTemplateAny.(map[string]any)
renderedInstance := map[string]any{
"name": instanceName,
"template": boundName,
}
if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 {
renderedInstance["scene_meta"] = deepCopyMap(sceneMeta)
}
if params, ok := instance["params"].(map[string]any); ok && len(params) > 0 {
renderedInstance["params"] = deepCopyMap(params)
}
return boundName, renderedTemplate, renderedInstance, nil
}
func buildRuntimeBindingContext(instance map[string]any) (map[string]any, error) {
context := map[string]any{}
if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok && len(sceneMeta) > 0 {
context["scene"] = deepCopyMap(sceneMeta)
}
for _, groupName := range []string{"input_bindings", "service_bindings", "output_bindings"} {
group, _ := instance[groupName].(map[string]any)
for slotName, raw := range group {
entry, _ := raw.(map[string]any)
if entry == nil {
return nil, fmt.Errorf("binding entry must be an object")
}
context[slotName] = resolvedRuntimeBindingValue(entry)
}
}
return context, nil
}
func resolvedRuntimeBindingValue(entry map[string]any) map[string]any {
if resolved, ok := entry["resolved"].(map[string]any); ok && resolved != nil {
return deepCopyMap(resolved)
}
return deepCopyMap(entry)
}
func expandRuntimeSlotTokens(value any, context map[string]any) (any, error) {
switch typed := value.(type) {
case map[string]any:
out := make(map[string]any, len(typed))
for key, item := range typed {
expanded, err := expandRuntimeSlotTokens(item, context)
if err != nil {
return nil, err
}
out[key] = expanded
}
return out, nil
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
expanded, err := expandRuntimeSlotTokens(item, context)
if err != nil {
return nil, err
}
out = append(out, expanded)
}
return out, nil
case string:
match := slotTokenRE.FindStringSubmatch(strings.TrimSpace(typed))
if len(match) != 3 {
return typed, nil
}
slotValues, _ := context[match[1]].(map[string]any)
if slotValues == nil {
return nil, fmt.Errorf("required slot '%s' is not bound", match[1])
}
fieldValue, ok := slotValues[match[2]]
if !ok {
return nil, fmt.Errorf("required slot field '%s.%s' is not bound", match[1], match[2])
}
return deepCopyAny(fieldValue), nil
default:
return deepCopyAny(value), nil
}
}
func applyRuntimeOverlay(root map[string]any, overlay map[string]any) (map[string]any, error) {
out := deepCopyMap(root)
for _, key := range []string{"global", "queue", "templates"} {
if value, ok := overlay[key]; ok {
out[key] = deepMergeAny(out[key], value)
}
}
if rawPatches, ok := overlay["instance_overrides"]; ok {
patches, _ := rawPatches.(map[string]any)
if patches == nil {
return nil, fmt.Errorf("overlay.instance_overrides must be an object")
}
instances, _ := out["instances"].([]any)
mergedInstances := make([]any, 0, len(instances))
for _, item := range instances {
instance, _ := item.(map[string]any)
merged := deepCopyMap(instance)
if patch, ok := patches["*"].(map[string]any); ok {
merged = mergeRuntimeInstancePatch(merged, patch)
}
name := stringValue(merged["name"])
if patch, ok := patches[name].(map[string]any); ok {
merged = mergeRuntimeInstancePatch(merged, patch)
}
mergedInstances = append(mergedInstances, merged)
}
out["instances"] = mergedInstances
}
if rawInstances, ok := overlay["instances"]; ok {
patchList, _ := rawInstances.([]any)
if patchList == nil {
return nil, fmt.Errorf("overlay.instances must be an array")
}
instances, _ := out["instances"].([]any)
byName := map[string]int{}
for i, item := range instances {
instance, _ := item.(map[string]any)
byName[stringValue(instance["name"])] = i
}
for _, item := range patchList {
patch, _ := item.(map[string]any)
name := stringValue(patch["name"])
if patch == nil || name == "" {
return nil, fmt.Errorf("overlay.instances entries must be objects with name")
}
idx, ok := byName[name]
if !ok {
return nil, fmt.Errorf("overlay instance not found in profile: %s", name)
}
instance, _ := instances[idx].(map[string]any)
instances[idx] = mergeRuntimeInstancePatch(instance, patch)
}
out["instances"] = instances
}
return out, nil
}
func mergeRuntimeInstancePatch(instance map[string]any, patch map[string]any) map[string]any {
merged := deepCopyMap(instance)
if params, ok := patch["params"].(map[string]any); ok {
existing, _ := merged["params"].(map[string]any)
merged["params"] = deepMergeMap(existing, params)
}
if override, ok := patch["override"].(map[string]any); ok {
existing, _ := merged["override"].(map[string]any)
merged["override"] = deepMergeMap(existing, override)
}
for key, value := range patch {
if key == "name" || key == "template" || key == "params" || key == "override" {
continue
}
merged[key] = deepMergeAny(merged[key], value)
}
return merged
}
func buildRuntimeMetadata(templateName string, templatePath string, profileRaw map[string]any, profilePath string, overlays []runtimeOverlayInput, root map[string]any, metadata map[string]any) map[string]any {
instanceNames := make([]string, 0)
instanceDisplayNames := make([]string, 0)
instances, _ := root["instances"].([]any)
for _, item := range instances {
instance, _ := item.(map[string]any)
if name := strings.TrimSpace(stringValue(instance["name"])); name != "" {
instanceNames = append(instanceNames, name)
}
if sceneMeta, ok := instance["scene_meta"].(map[string]any); ok {
if displayName := strings.TrimSpace(stringValue(sceneMeta["display_name"])); displayName != "" {
instanceDisplayNames = append(instanceDisplayNames, displayName)
}
}
}
overlayNames := make([]any, 0, len(overlays))
overlayPaths := make([]any, 0, len(overlays))
for _, overlay := range overlays {
overlayNames = append(overlayNames, overlay.Name)
overlayPaths = append(overlayPaths, overlay.Path)
}
out := map[string]any{
"template": templateName,
"template_path": templatePath,
"profile": strings.TrimSpace(firstString(profileRaw["name"], strings.TrimSuffix(filepath.Base(profilePath), filepath.Ext(profilePath)))),
"business_name": strings.TrimSpace(stringValue(profileRaw["business_name"])),
"profile_path": profilePath,
"instance_names": instanceNames,
"instance_display_names": instanceDisplayNames,
"overlays": overlayNames,
"overlay_paths": overlayPaths,
}
for key, value := range metadata {
out[key] = deepCopyAny(value)
}
return out
}
func deepMergeMap(base map[string]any, override map[string]any) map[string]any {
if base == nil {
base = map[string]any{}
}
return deepMergeAny(base, override).(map[string]any)
}
func deepMergeAny(base any, override any) any {
baseMap, baseIsMap := base.(map[string]any)
overrideMap, overrideIsMap := override.(map[string]any)
if baseIsMap && overrideIsMap {
merged := deepCopyMap(baseMap)
for key, value := range overrideMap {
if existing, ok := merged[key]; ok {
merged[key] = deepMergeAny(existing, value)
} else {
merged[key] = deepCopyAny(value)
}
}
return merged
}
return deepCopyAny(override)
}
func deepCopyMap(in map[string]any) map[string]any {
if in == nil {
return map[string]any{}
}
out, _ := deepCopyAny(in).(map[string]any)
return out
}
func deepCopyAny(value any) any {
if value == nil {
return nil
}
body, err := json.Marshal(value)
if err != nil {
return value
}
var out any
if err := json.Unmarshal(body, &out); err != nil {
return value
}
return out
}

View File

@ -3,8 +3,6 @@ package service
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
@ -12,8 +10,10 @@ import (
type ConfigProfileEditor struct {
Name string `json:"name"`
Path string `json:"path"`
PrimaryTemplateName string `json:"primary_template_name"`
BusinessName string `json:"business_name"`
Description string `json:"description"`
OverlayName string `json:"overlay_name"`
DeviceCode string `json:"device_code"`
SiteName string `json:"site_name"`
Queue ConfigProfileQueueEditor `json:"queue"`
@ -26,17 +26,43 @@ type ConfigProfileQueueEditor struct {
Strategy string `json:"strategy"`
}
func DefaultConfigProfileQueue() ConfigProfileQueueEditor {
return ConfigProfileQueueEditor{
Size: "8",
Strategy: "drop_oldest",
}
}
type InputBindingEditor struct {
VideoSourceRef string `json:"video_source_ref"`
}
type ServiceBindingEditor struct {
ServiceRef string `json:"service_ref"`
}
type OutputBindingEditor struct {
PublishHLSPath string `json:"publish_hls_path"`
PublishRTSPPort string `json:"publish_rtsp_port"`
PublishRTSPPath string `json:"publish_rtsp_path"`
ChannelNo string `json:"channel_no"`
}
type ConfigProfileInstanceEditor struct {
Name string `json:"name"`
Template string `json:"template"`
DisplayName string `json:"display_name"`
RTSPURL string `json:"rtsp_url"`
PublishHLSPath string `json:"publish_hls_path"`
PublishRTSPPort string `json:"publish_rtsp_port"`
PublishRTSPPath string `json:"publish_rtsp_path"`
ChannelNo string `json:"channel_no"`
AdvancedParams map[string]any `json:"advanced_params"`
Delete bool `json:"delete"`
Name string `json:"name"`
Template string `json:"template"`
VideoSourceRef string `json:"video_source_ref"`
DisplayName string `json:"display_name"`
SiteName string `json:"site_name"`
PublishHLSPath string `json:"publish_hls_path"`
PublishRTSPPort string `json:"publish_rtsp_port"`
PublishRTSPPath string `json:"publish_rtsp_path"`
ChannelNo string `json:"channel_no"`
InputBindings map[string]InputBindingEditor `json:"input_bindings,omitempty"`
ServiceBindings map[string]ServiceBindingEditor `json:"service_bindings,omitempty"`
OutputBindings map[string]OutputBindingEditor `json:"output_bindings,omitempty"`
AdvancedParams map[string]any `json:"advanced_params"`
Delete bool `json:"delete"`
}
func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEditor, error) {
@ -45,54 +71,20 @@ func (s *ConfigPreviewService) GetProfileEditor(name string) (*ConfigProfileEdit
return nil, err
}
queueMap, _ := raw["queue"].(map[string]any)
instancesRaw, _ := raw["instances"].([]any)
instances := make([]ConfigProfileInstanceEditor, 0, len(instancesRaw))
deviceCode := ""
siteName := ""
for _, item := range instancesRaw {
instanceMap, _ := item.(map[string]any)
paramsMap, _ := instanceMap["params"].(map[string]any)
if deviceCode == "" {
deviceCode = stringValue(paramsMap["device_code"])
}
if siteName == "" {
siteName = stringValue(paramsMap["site_name"])
}
advanced := cloneMap(paramsMap)
for _, key := range []string{
"display_name",
"device_code",
"site_name",
"rtsp_url",
"publish_hls_path",
"publish_rtsp_port",
"publish_rtsp_path",
"channel_no",
} {
delete(advanced, key)
}
if len(advanced) == 0 {
advanced = nil
}
instances = append(instances, ConfigProfileInstanceEditor{
Name: stringValue(instanceMap["name"]),
Template: stringValue(instanceMap["template"]),
DisplayName: stringValue(paramsMap["display_name"]),
RTSPURL: stringValue(paramsMap["rtsp_url"]),
PublishHLSPath: stringValue(paramsMap["publish_hls_path"]),
PublishRTSPPort: valueString(paramsMap["publish_rtsp_port"]),
PublishRTSPPath: stringValue(paramsMap["publish_rtsp_path"]),
ChannelNo: stringValue(paramsMap["channel_no"]),
AdvancedParams: advanced,
})
templateName := stringValue(raw["primary_template_name"])
instances, siteName, deviceCode, err := s.loadRecognitionUnitEditors(name, templateName)
if err != nil {
return nil, err
}
return &ConfigProfileEditor{
Name: firstString(raw["name"], name),
Path: path,
BusinessName: stringValue(raw["business_name"]),
Description: stringValue(raw["description"]),
DeviceCode: deviceCode,
SiteName: siteName,
Name: firstString(raw["name"], name),
Path: path,
PrimaryTemplateName: stringValue(raw["primary_template_name"]),
BusinessName: stringValue(raw["business_name"]),
Description: stringValue(raw["description"]),
OverlayName: firstOverlayName(raw),
DeviceCode: deviceCode,
SiteName: siteName,
Queue: ConfigProfileQueueEditor{
Size: valueString(queueMap["size"]),
Strategy: stringValue(queueMap["strategy"]),
@ -111,9 +103,6 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
return nil, fmt.Errorf("invalid profile name: %w", err)
}
if len(editor.Instances) == 0 {
return nil, fmt.Errorf("at least one instance is required")
}
seen := map[string]struct{}{}
instances := make([]map[string]any, 0, len(editor.Instances))
for _, inst := range editor.Instances {
@ -132,59 +121,72 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
}
seen[instanceName] = struct{}{}
rtspURL := strings.TrimSpace(inst.RTSPURL)
if rtspURL == "" {
return nil, fmt.Errorf("rtsp url is required for %s", instanceName)
videoSourceRef := strings.TrimSpace(inputBindingRef(inst.InputBindings, "video_input_main"))
if videoSourceRef == "" {
videoSourceRef = strings.TrimSpace(inst.VideoSourceRef)
}
if videoSourceRef == "" {
return nil, fmt.Errorf("video source is required for %s", instanceName)
}
params := map[string]any{}
setString(params, "display_name", inst.DisplayName)
setString(params, "device_code", editor.DeviceCode)
setString(params, "site_name", editor.SiteName)
setString(params, "rtsp_url", rtspURL)
setString(params, "publish_hls_path", inst.PublishHLSPath)
setString(params, "publish_rtsp_path", inst.PublishRTSPPath)
setString(params, "channel_no", inst.ChannelNo)
if port := strings.TrimSpace(inst.PublishRTSPPort); port != "" {
value, err := strconv.Atoi(port)
if err != nil {
return nil, fmt.Errorf("publish rtsp port must be a number for %s", instanceName)
}
params["publish_rtsp_port"] = value
}
for key, value := range cloneMap(inst.AdvancedParams) {
params[key] = value
}
instance := map[string]any{
"name": instanceName,
"params": params,
"name": instanceName,
}
if len(params) > 0 {
instance["params"] = params
}
setString(instance, "template", inst.Template)
sceneMeta := map[string]any{}
setString(sceneMeta, "display_name", inst.DisplayName)
setString(sceneMeta, "site_name", firstString(inst.SiteName, editor.SiteName))
setString(sceneMeta, "device_code", editor.DeviceCode)
if len(sceneMeta) > 0 {
instance["scene_meta"] = sceneMeta
}
inputBindings := buildInputBindingDocument(inst)
if len(inputBindings) > 0 {
instance["input_bindings"] = inputBindings
}
serviceBindings := buildServiceBindingDocument(inst)
if len(serviceBindings) > 0 {
instance["service_bindings"] = serviceBindings
}
outputBindings, err := buildOutputBindingDocument(inst)
if err != nil {
return nil, err
}
if len(outputBindings) > 0 {
instance["output_bindings"] = outputBindings
}
instances = append(instances, instance)
}
if len(instances) == 0 {
return nil, fmt.Errorf("at least one active instance is required")
}
doc := map[string]any{
"name": name,
"instances": instances,
}
setString(doc, "business_name", editor.BusinessName)
setString(doc, "description", editor.Description)
if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" {
doc["overlays"] = []any{overlayName}
}
normalizedQueue := editor.Queue
if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" {
normalizedQueue = DefaultConfigProfileQueue()
}
queue := map[string]any{}
if size := strings.TrimSpace(editor.Queue.Size); size != "" {
if size := strings.TrimSpace(normalizedQueue.Size); size != "" {
value, err := strconv.Atoi(size)
if err != nil {
return nil, fmt.Errorf("queue size must be a number")
}
queue["size"] = value
}
setString(queue, "strategy", editor.Queue.Strategy)
setString(queue, "strategy", normalizedQueue.Strategy)
if len(queue) > 0 {
doc["queue"] = queue
}
@ -193,7 +195,7 @@ func (s *ConfigPreviewService) BuildProfileDocument(editor ConfigProfileEditor)
}
func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) error {
doc, err := s.BuildProfileDocument(editor)
doc, err := s.buildSceneTemplateDocument(editor)
if err != nil {
return err
}
@ -202,20 +204,56 @@ func (s *ConfigPreviewService) SaveProfileEditor(editor ConfigProfileEditor) err
return err
}
if s != nil && s.assets != nil {
return s.assets.SaveProfile(
strings.TrimSpace(editor.Name),
firstProfileTemplate(editor.Instances),
templateName := strings.TrimSpace(editor.PrimaryTemplateName)
if templateName == "" {
templateName = firstProfileTemplate(editor.Instances)
}
return s.assets.SaveProfile(
strings.TrimSpace(editor.Name),
templateName,
strings.TrimSpace(editor.BusinessName),
strings.TrimSpace(editor.Description),
string(body),
)
}
root := s.mediaRepoRoot()
if root == "" {
return fmt.Errorf("media repo path is not configured")
return fmt.Errorf("asset repository is not configured")
}
func (s *ConfigPreviewService) buildSceneTemplateDocument(editor ConfigProfileEditor) (map[string]any, error) {
name := strings.TrimSpace(editor.Name)
if name == "" {
return nil, fmt.Errorf("scene template name is required")
}
path := filepath.Join(root, "configs", "profiles", strings.TrimSpace(editor.Name)+".json")
return os.WriteFile(path, body, 0o644)
if err := validateConfigName(name); err != nil {
return nil, fmt.Errorf("invalid scene template name: %w", err)
}
doc := map[string]any{
"name": name,
}
setString(doc, "primary_template_name", editor.PrimaryTemplateName)
setString(doc, "business_name", editor.BusinessName)
setString(doc, "description", editor.Description)
setString(doc, "site_name", editor.SiteName)
if overlayName := strings.TrimSpace(editor.OverlayName); overlayName != "" {
doc["overlays"] = []any{overlayName}
}
normalizedQueue := editor.Queue
if strings.TrimSpace(normalizedQueue.Size) == "" || strings.TrimSpace(normalizedQueue.Strategy) == "" {
normalizedQueue = DefaultConfigProfileQueue()
}
queue := map[string]any{}
if size := strings.TrimSpace(normalizedQueue.Size); size != "" {
value, err := strconv.Atoi(size)
if err != nil {
return nil, fmt.Errorf("queue size must be a number")
}
queue["size"] = value
}
setString(queue, "strategy", normalizedQueue.Strategy)
if len(queue) > 0 {
doc["queue"] = queue
}
return doc, nil
}
func setString(m map[string]any, key string, value string) {
@ -224,6 +262,16 @@ func setString(m map[string]any, key string, value string) {
}
}
func firstOverlayName(raw map[string]any) string {
items, _ := raw["overlays"].([]any)
for _, item := range items {
if v := stringValue(item); strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func marshalConfigJSON(doc map[string]any) ([]byte, error) {
body, err := json.MarshalIndent(doc, "", " ")
if err != nil {
@ -243,3 +291,181 @@ func firstProfileTemplate(instances []ConfigProfileInstanceEditor) string {
}
return ""
}
func parseInputBindingEditors(raw any) map[string]InputBindingEditor {
items, _ := raw.(map[string]any)
if len(items) == 0 {
return nil
}
out := map[string]InputBindingEditor{}
for key, value := range items {
entry, _ := value.(map[string]any)
out[key] = InputBindingEditor{
VideoSourceRef: stringValue(entry["video_source_ref"]),
}
}
return out
}
func parseServiceBindingEditors(raw any) map[string]ServiceBindingEditor {
items, _ := raw.(map[string]any)
if len(items) == 0 {
return nil
}
out := map[string]ServiceBindingEditor{}
for key, value := range items {
entry, _ := value.(map[string]any)
out[key] = ServiceBindingEditor{
ServiceRef: stringValue(entry["service_ref"]),
}
}
return out
}
func parseOutputBindingEditors(raw any) map[string]OutputBindingEditor {
items, _ := raw.(map[string]any)
if len(items) == 0 {
return nil
}
out := map[string]OutputBindingEditor{}
for key, value := range items {
entry, _ := value.(map[string]any)
out[key] = OutputBindingEditor{
PublishHLSPath: stringValue(entry["publish_hls_path"]),
PublishRTSPPort: valueString(entry["publish_rtsp_port"]),
PublishRTSPPath: stringValue(entry["publish_rtsp_path"]),
ChannelNo: stringValue(entry["channel_no"]),
}
}
return out
}
func inputBindingRef(bindings map[string]InputBindingEditor, slot string) string {
if len(bindings) == 0 {
return ""
}
return strings.TrimSpace(bindings[slot].VideoSourceRef)
}
func outputBindingValue(bindings map[string]OutputBindingEditor, slot string, field string) string {
if len(bindings) == 0 {
return ""
}
item, ok := bindings[slot]
if !ok {
return ""
}
switch field {
case "publish_hls_path":
return strings.TrimSpace(item.PublishHLSPath)
case "publish_rtsp_port":
return strings.TrimSpace(item.PublishRTSPPort)
case "publish_rtsp_path":
return strings.TrimSpace(item.PublishRTSPPath)
case "channel_no":
return strings.TrimSpace(item.ChannelNo)
default:
return ""
}
}
func buildInputBindingDocument(inst ConfigProfileInstanceEditor) map[string]any {
out := map[string]any{}
for key, value := range inst.InputBindings {
entry := map[string]any{}
setString(entry, "video_source_ref", value.VideoSourceRef)
if len(entry) > 0 {
out[key] = entry
}
}
if strings.TrimSpace(inst.VideoSourceRef) != "" {
if _, ok := out["video_input_main"]; !ok {
out["video_input_main"] = map[string]any{"video_source_ref": strings.TrimSpace(inst.VideoSourceRef)}
}
}
if len(out) == 0 {
return nil
}
return out
}
func buildServiceBindingDocument(inst ConfigProfileInstanceEditor) map[string]any {
out := map[string]any{}
for key, value := range inst.ServiceBindings {
entry := map[string]any{}
setString(entry, "service_ref", value.ServiceRef)
if len(entry) > 0 {
out[key] = entry
}
}
if len(out) == 0 {
return nil
}
return out
}
func buildOutputBindingDocument(inst ConfigProfileInstanceEditor) (map[string]any, error) {
out := map[string]any{}
for key, value := range inst.OutputBindings {
if key == "stream_output_main" {
value = applyDefaultStreamOutputBinding(inst.Name, value)
}
entry, err := outputBindingEntry(value)
if err != nil {
return nil, err
}
if len(entry) > 0 {
out[key] = entry
}
}
if _, ok := out["stream_output_main"]; !ok {
entry, err := outputBindingEntry(applyDefaultStreamOutputBinding(inst.Name, OutputBindingEditor{
PublishHLSPath: inst.PublishHLSPath,
PublishRTSPPort: inst.PublishRTSPPort,
PublishRTSPPath: inst.PublishRTSPPath,
ChannelNo: inst.ChannelNo,
}))
if err != nil {
return nil, err
}
if len(entry) > 0 {
out["stream_output_main"] = entry
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func applyDefaultStreamOutputBinding(instanceName string, value OutputBindingEditor) OutputBindingEditor {
name := strings.TrimSpace(instanceName)
if name == "" {
return value
}
if strings.TrimSpace(value.PublishHLSPath) == "" {
value.PublishHLSPath = "./web/hls/" + name + "/index.m3u8"
}
if strings.TrimSpace(value.PublishRTSPPath) == "" {
value.PublishRTSPPath = "/live/" + name
}
if strings.TrimSpace(value.ChannelNo) == "" {
value.ChannelNo = name
}
return value
}
func outputBindingEntry(value OutputBindingEditor) (map[string]any, error) {
entry := map[string]any{}
setString(entry, "publish_hls_path", value.PublishHLSPath)
setString(entry, "publish_rtsp_path", value.PublishRTSPPath)
setString(entry, "channel_no", value.ChannelNo)
if port := strings.TrimSpace(value.PublishRTSPPort); port != "" {
parsed, err := strconv.Atoi(port)
if err != nil {
return nil, fmt.Errorf("publish rtsp port must be a number")
}
entry["publish_rtsp_port"] = parsed
}
return entry, nil
}

View File

@ -0,0 +1,77 @@
package service
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"3588AdminBackend/internal/storage"
)
func ImportStandardTemplatesFromDir(repo *storage.AssetsRepo, dir string) (int, error) {
if repo == nil {
return 0, fmt.Errorf("asset repository is not configured")
}
dir = filepath.Clean(strings.TrimSpace(dir))
if dir == "" {
return 0, fmt.Errorf("standard template dir is empty")
}
files, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
imported := 0
for _, file := range files {
if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" {
continue
}
path := filepath.Join(dir, file.Name())
body, err := os.ReadFile(path)
if err != nil {
return imported, err
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return imported, fmt.Errorf("%s: %w", path, err)
}
if raw == nil {
raw = map[string]any{}
}
name := strings.TrimSpace(firstString(raw["name"], strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))))
if name == "" {
return imported, fmt.Errorf("%s: template name is empty", path)
}
if err := validateConfigName(name); err != nil {
return imported, fmt.Errorf("%s: invalid template name %q: %w", path, name, err)
}
if !isStandardTemplateName(name) {
return imported, fmt.Errorf("%s: standard template name must start with std_", path)
}
if strings.TrimSpace(stringValue(raw["source"])) == "" {
raw["source"] = "standard"
}
body, err = marshalConfigJSON(raw)
if err != nil {
return imported, err
}
existing, err := repo.GetTemplate(name)
if err != nil {
return imported, err
}
if existing != nil &&
strings.TrimSpace(existing.Description) == strings.TrimSpace(stringValue(raw["description"])) &&
strings.TrimSpace(existing.BodyJSON) == strings.TrimSpace(string(body)) {
continue
}
if err := repo.SaveTemplate(name, stringValue(raw["description"]), string(body)); err != nil {
return imported, err
}
imported++
}
return imported, nil
}

View File

@ -200,6 +200,23 @@ func extractConfigPayload(payload any) (any, error) {
return payload, nil
}
func extractConfigPayloadForDevice(payload any, deviceID string) (any, error) {
if payload == nil {
return nil, fmt.Errorf("payload is required")
}
if m, ok := payload.(map[string]any); ok {
if configs, exists := m["configs"]; exists {
if byDevice, ok := configs.(map[string]any); ok {
if v, ok := byDevice[deviceID]; ok {
return v, nil
}
return nil, fmt.Errorf("device assignment config is missing for %s", deviceID)
}
}
}
return extractConfigPayload(payload)
}
func optionalConfigRequestBody(payload any) (io.Reader, int64, error) {
if payload == nil {
return nil, 0, nil
@ -254,7 +271,7 @@ func (s *TaskService) executeOnDevice(task *models.Task, did string) {
switch task.Type {
case "config_apply":
cfgPayload, err := extractConfigPayload(task.Payload)
cfgPayload, err := extractConfigPayloadForDevice(task.Payload, did)
if err != nil {
s.updateDeviceStatus(task.ID, did, models.TaskFailed, 0, err.Error())
return
@ -399,7 +416,7 @@ func (s *TaskService) persistConfigState(task *models.Task, did string) {
if s == nil || s.stateRepo == nil || task == nil || task.Type != "config_apply" {
return
}
meta := taskPayloadMetadata(task.Payload)
meta := taskPayloadMetadataForDevice(task.Payload, did)
overlaysJSON := "[]"
if len(meta.Overlays) > 0 {
if body, err := json.Marshal(meta.Overlays); err == nil {
@ -413,7 +430,7 @@ func (s *TaskService) appendAuditLog(task *models.Task, did string, status model
if s == nil || s.auditRepo == nil || task == nil {
return
}
meta := taskPayloadMetadata(task.Payload)
meta := taskPayloadMetadataForDevice(task.Payload, did)
details := map[string]any{
"task_id": task.ID,
"type": task.Type,
@ -449,13 +466,21 @@ type taskMetadata struct {
ConfigVersion string
}
func taskPayloadMetadata(payload any) taskMetadata {
func taskPayloadMetadataForDevice(payload any, deviceID string) taskMetadata {
var out taskMetadata
root, ok := payload.(map[string]any)
if !ok {
return out
}
configRoot, ok := root["config"].(map[string]any)
var configRoot map[string]any
if rawConfigs, ok := root["configs"].(map[string]any); ok {
if rawConfig, ok := rawConfigs[deviceID].(map[string]any); ok {
configRoot = rawConfig
}
}
if configRoot == nil {
configRoot, ok = root["config"].(map[string]any)
}
if !ok {
return out
}

View File

@ -0,0 +1,67 @@
package service
import "fmt"
type TemplateSlotGroup struct {
Inputs []TemplateSlot `json:"inputs"`
Services []TemplateSlot `json:"services"`
Outputs []TemplateSlot `json:"outputs"`
}
type TemplateSlot struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
Description string `json:"description"`
}
func parseTemplateSlots(raw map[string]any) (TemplateSlotGroup, error) {
group := TemplateSlotGroup{}
slotsMap, _ := raw["slots"].(map[string]any)
if slotsMap == nil {
return group, nil
}
var err error
if group.Inputs, err = parseTemplateSlotList(slotsMap["inputs"]); err != nil {
return TemplateSlotGroup{}, fmt.Errorf("parse input slots: %w", err)
}
if group.Services, err = parseTemplateSlotList(slotsMap["services"]); err != nil {
return TemplateSlotGroup{}, fmt.Errorf("parse service slots: %w", err)
}
if group.Outputs, err = parseTemplateSlotList(slotsMap["outputs"]); err != nil {
return TemplateSlotGroup{}, fmt.Errorf("parse output slots: %w", err)
}
return group, nil
}
func parseTemplateSlotList(raw any) ([]TemplateSlot, error) {
items, _ := raw.([]any)
if len(items) == 0 {
return nil, nil
}
out := make([]TemplateSlot, 0, len(items))
for _, item := range items {
slotMap, _ := item.(map[string]any)
if slotMap == nil {
return nil, fmt.Errorf("slot entry must be an object")
}
slot := TemplateSlot{
Name: stringValue(slotMap["name"]),
Type: stringValue(slotMap["type"]),
Required: boolValue(slotMap["required"], false),
Description: stringValue(slotMap["description"]),
}
if slot.Name == "" {
return nil, fmt.Errorf("slot name is required")
}
if err := validateConfigName(slot.Name); err != nil {
return nil, fmt.Errorf("invalid slot name %q: %w", slot.Name, err)
}
if slot.Type == "" {
return nil, fmt.Errorf("slot type is required for %s", slot.Name)
}
out = append(out, slot)
}
return out, nil
}

View File

@ -0,0 +1,34 @@
package service
import "testing"
func TestParseTemplateSlots(t *testing.T) {
raw := map[string]any{
"slots": map[string]any{
"inputs": []any{
map[string]any{"name": "video_input_main", "type": "video_source", "required": true},
},
"services": []any{
map[string]any{"name": "object_storage_main", "type": "object_storage", "required": true},
},
"outputs": []any{
map[string]any{"name": "stream_output_main", "type": "stream_publish", "required": true},
},
},
}
slots, err := parseTemplateSlots(raw)
if err != nil {
t.Fatalf("parseTemplateSlots: %v", err)
}
if len(slots.Inputs) != 1 || slots.Inputs[0].Name != "video_input_main" {
t.Fatalf("unexpected input slots: %#v", slots.Inputs)
}
if len(slots.Services) != 1 || slots.Services[0].Type != "object_storage" {
t.Fatalf("unexpected service slots: %#v", slots.Services)
}
if len(slots.Outputs) != 1 || slots.Outputs[0].Name != "stream_output_main" {
t.Fatalf("unexpected output slots: %#v", slots.Outputs)
}
}

View File

@ -18,6 +18,49 @@ type AssetRecord struct {
UpdatedAt string
}
type IntegrationServiceRecord struct {
Name string
ServiceType string
Description string
Enabled bool
BodyJSON string
CreatedAt string
UpdatedAt string
}
type VideoSourceRecord struct {
Name string
SourceType string
Area string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}
type DeviceAssignmentRecord struct {
DeviceID string
ProfileName string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}
type RecognitionUnitRecord struct {
SceneTemplateName string
Name string
DisplayName string
SiteName string
VideoSourceRef string
OutputChannel string
RTSPPort string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}
type AssetsRepo struct {
db *sql.DB
}
@ -52,6 +95,80 @@ func (r *AssetsRepo) SaveOverlay(name string, description string, bodyJSON strin
})
}
func (r *AssetsRepo) SaveIntegrationService(name string, serviceType string, description string, enabled bool, bodyJSON string) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
_, err := r.db.Exec(`
INSERT INTO integration_services(name, type, description, enabled, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM integration_services WHERE name = ?), ?), ?)
ON CONFLICT(name) DO UPDATE SET
type=excluded.type,
description=excluded.description,
enabled=excluded.enabled,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, name, serviceType, description, enabled, bodyJSON, name, now, now)
return err
}
func (r *AssetsRepo) SaveVideoSource(name string, sourceType string, area string, description string, bodyJSON string) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
_, err := r.db.Exec(`
INSERT INTO video_sources(name, source_type, area, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM video_sources WHERE name = ?), ?), ?)
ON CONFLICT(name) DO UPDATE SET
source_type=excluded.source_type,
area=excluded.area,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, name, sourceType, area, description, bodyJSON, name, now, now)
return err
}
func (r *AssetsRepo) SaveDeviceAssignment(deviceID string, profileName string, description string, bodyJSON string) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
_, err := r.db.Exec(`
INSERT INTO device_assignments(device_id, profile_name, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, COALESCE((SELECT created_at FROM device_assignments WHERE device_id = ?), ?), ?)
ON CONFLICT(device_id) DO UPDATE SET
profile_name=excluded.profile_name,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, deviceID, profileName, description, bodyJSON, deviceID, now, now)
return err
}
func (r *AssetsRepo) SaveRecognitionUnit(record RecognitionUnitRecord) error {
if r == nil || r.db == nil {
return nil
}
now := time.Now().Format(time.RFC3339)
_, err := r.db.Exec(`
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?)
ON CONFLICT(scene_template_name, name) DO UPDATE SET
display_name=excluded.display_name,
site_name=excluded.site_name,
video_source_ref=excluded.video_source_ref,
output_channel=excluded.output_channel,
rtsp_port=excluded.rtsp_port,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, record.SceneTemplateName, record.Name, record.DisplayName, record.SiteName, record.VideoSourceRef, record.OutputChannel, record.RTSPPort, record.Description, record.BodyJSON, record.SceneTemplateName, record.Name, now, now)
return err
}
func (r *AssetsRepo) ListTemplates() ([]AssetRecord, error) {
return r.listAssets("templates")
}
@ -64,6 +181,105 @@ func (r *AssetsRepo) ListOverlays() ([]AssetRecord, error) {
return r.listAssets("overlays")
}
func (r *AssetsRepo) ListIntegrationServices() ([]IntegrationServiceRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT name, type, description, enabled, body_json, created_at, updated_at
FROM integration_services
ORDER BY updated_at DESC, name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []IntegrationServiceRecord
for rows.Next() {
var item IntegrationServiceRecord
if err := rows.Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *AssetsRepo) ListVideoSources() ([]VideoSourceRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT name, source_type, area, description, body_json, created_at, updated_at
FROM video_sources
ORDER BY updated_at DESC, name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []VideoSourceRecord
for rows.Next() {
var item VideoSourceRecord
if err := rows.Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *AssetsRepo) ListDeviceAssignments() ([]DeviceAssignmentRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT device_id, profile_name, description, body_json, created_at, updated_at
FROM device_assignments
ORDER BY updated_at DESC, device_id ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DeviceAssignmentRecord
for rows.Next() {
var item DeviceAssignmentRecord
if err := rows.Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *AssetsRepo) ListRecognitionUnits() ([]RecognitionUnitRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at
FROM recognition_units
ORDER BY scene_template_name ASC, name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RecognitionUnitRecord
for rows.Next() {
var item RecognitionUnitRecord
if err := rows.Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *AssetsRepo) GetTemplate(name string) (*AssetRecord, error) {
return r.getAsset("templates", name)
}
@ -76,10 +292,126 @@ func (r *AssetsRepo) GetOverlay(name string) (*AssetRecord, error) {
return r.getAsset("overlays", name)
}
func (r *AssetsRepo) GetIntegrationService(name string) (*IntegrationServiceRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
var item IntegrationServiceRecord
err := r.db.QueryRow(`
SELECT name, type, description, enabled, body_json, created_at, updated_at
FROM integration_services
WHERE name = ?
`, name).Scan(&item.Name, &item.ServiceType, &item.Description, &item.Enabled, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *AssetsRepo) GetVideoSource(name string) (*VideoSourceRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
var item VideoSourceRecord
err := r.db.QueryRow(`
SELECT name, source_type, area, description, body_json, created_at, updated_at
FROM video_sources
WHERE name = ?
`, name).Scan(&item.Name, &item.SourceType, &item.Area, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *AssetsRepo) GetDeviceAssignment(deviceID string) (*DeviceAssignmentRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
var item DeviceAssignmentRecord
err := r.db.QueryRow(`
SELECT device_id, profile_name, description, body_json, created_at, updated_at
FROM device_assignments
WHERE device_id = ?
`, deviceID).Scan(&item.DeviceID, &item.ProfileName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *AssetsRepo) GetRecognitionUnit(sceneTemplateName string, name string) (*RecognitionUnitRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
var item RecognitionUnitRecord
err := r.db.QueryRow(`
SELECT scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at
FROM recognition_units
WHERE scene_template_name = ? AND name = ?
`, sceneTemplateName, name).Scan(&item.SceneTemplateName, &item.Name, &item.DisplayName, &item.SiteName, &item.VideoSourceRef, &item.OutputChannel, &item.RTSPPort, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *AssetsRepo) DeleteTemplate(name string) error {
return r.deleteAsset("templates", name)
}
func (r *AssetsRepo) DeleteProfile(name string) error {
return r.deleteAsset("profiles", name)
}
func (r *AssetsRepo) DeleteIntegrationService(name string) error {
if r == nil || r.db == nil {
return nil
}
_, err := r.db.Exec(`DELETE FROM integration_services WHERE name = ?`, name)
return err
}
func (r *AssetsRepo) DeleteVideoSource(name string) error {
if r == nil || r.db == nil {
return nil
}
_, err := r.db.Exec(`DELETE FROM video_sources WHERE name = ?`, name)
return err
}
func (r *AssetsRepo) DeleteDeviceAssignment(deviceID string) error {
if r == nil || r.db == nil {
return nil
}
_, err := r.db.Exec(`DELETE FROM device_assignments WHERE device_id = ?`, deviceID)
return err
}
func (r *AssetsRepo) DeleteRecognitionUnit(sceneTemplateName string, name string) error {
if r == nil || r.db == nil {
return nil
}
_, err := r.db.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ? AND name = ?`, sceneTemplateName, name)
return err
}
func (r *AssetsRepo) DeleteOverlay(name string) error {
return r.deleteAsset("overlays", name)
}
func (r *AssetsRepo) RenameTemplate(oldName string, newName string, description string, bodyJSON string) error {
if r == nil || r.db == nil {
return nil
@ -129,9 +461,9 @@ WHERE name = ?
rows, err := tx.Query(`
SELECT name, description, business_name, body_json
FROM profiles
WHERE template_name = ? OR body_json LIKE ?
`, oldName, "%\"template\":\""+oldName+"\"%")
FROM scene_templates
WHERE primary_template_name = ? OR body_json LIKE ?
`, oldName, "%\"primary_template_name\": \""+oldName+"\"%")
if err != nil {
return err
}
@ -163,14 +495,22 @@ WHERE template_name = ? OR body_json LIKE ?
}
for _, item := range updates {
if _, err := tx.Exec(`
UPDATE profiles
SET template_name = ?, body_json = ?, updated_at = ?
UPDATE scene_templates
SET primary_template_name = ?, body_json = ?, updated_at = ?
WHERE name = ?
`, newName, item.bodyJSON, now, item.name); err != nil {
return err
}
}
if _, err := tx.Exec(`
UPDATE recognition_units
SET body_json = REPLACE(body_json, ?, ?), updated_at = ?
WHERE body_json LIKE ?
`, `"`+"template"+`": "`+oldName+`"`, `"`+"template"+`": "`+newName+`"`, now, "%\"template\": \""+oldName+"\"%"); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM templates WHERE name = ?`, oldName); err != nil {
return err
}
@ -198,22 +538,115 @@ ON CONFLICT(name) DO UPDATE SET
`, record.Name, record.Description, record.BodyJSON, record.Name, now, now)
return err
case "profiles":
_, err := r.db.Exec(`
INSERT INTO profiles(name, template_name, business_name, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM profiles WHERE name = ?), ?), ?)
normalized, units, replaceUnits, err := normalizeProfileRecord(record)
if err != nil {
return err
}
tx, err := r.db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, err = tx.Exec(`
INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM scene_templates WHERE name = ?), ?), ?)
ON CONFLICT(name) DO UPDATE SET
template_name=excluded.template_name,
primary_template_name=excluded.primary_template_name,
business_name=excluded.business_name,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, record.Name, record.TemplateName, record.BusinessName, record.Description, record.BodyJSON, record.Name, now, now)
return err
`, normalized.Name, normalized.TemplateName, normalized.BusinessName, normalized.Description, normalized.BodyJSON, normalized.Name, now, now)
if err != nil {
return err
}
if replaceUnits {
if _, err := tx.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ?`, normalized.Name); err != nil {
return err
}
for _, unit := range units {
if _, err := tx.Exec(`
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?)
ON CONFLICT(scene_template_name, name) DO UPDATE SET
display_name=excluded.display_name,
site_name=excluded.site_name,
video_source_ref=excluded.video_source_ref,
output_channel=excluded.output_channel,
rtsp_port=excluded.rtsp_port,
description=excluded.description,
body_json=excluded.body_json,
updated_at=excluded.updated_at
`, unit.SceneTemplateName, unit.Name, unit.DisplayName, unit.SiteName, unit.VideoSourceRef, unit.OutputChannel, unit.RTSPPort, unit.Description, unit.BodyJSON, unit.SceneTemplateName, unit.Name, now, now); err != nil {
return err
}
}
}
return tx.Commit()
default:
return nil
}
}
func normalizeProfileRecord(record AssetRecord) (AssetRecord, []RecognitionUnitRecord, bool, error) {
raw := map[string]any{}
if strings.TrimSpace(record.BodyJSON) != "" {
if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil {
return AssetRecord{}, nil, false, err
}
}
if raw == nil {
raw = map[string]any{}
}
_, hasInstances := raw["instances"]
if !hasInstances {
return record, nil, false, nil
}
templateDoc, units, err := splitLegacyProfileDocument(struct {
Name string
TemplateName string
BusinessName string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}{
Name: record.Name,
TemplateName: record.TemplateName,
BusinessName: record.BusinessName,
Description: record.Description,
BodyJSON: record.BodyJSON,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
})
if err != nil {
return AssetRecord{}, nil, false, err
}
body, err := json.MarshalIndent(templateDoc, "", " ")
if err != nil {
return AssetRecord{}, nil, false, err
}
normalized := record
normalized.BodyJSON = string(append(body, '\n'))
outUnits := make([]RecognitionUnitRecord, 0, len(units))
for _, unit := range units {
outUnits = append(outUnits, RecognitionUnitRecord{
SceneTemplateName: record.Name,
Name: unit.Name,
DisplayName: unit.DisplayName,
SiteName: unit.SiteName,
VideoSourceRef: unit.VideoSourceRef,
OutputChannel: unit.OutputChannel,
RTSPPort: unit.RTSPPort,
Description: record.Description,
BodyJSON: unit.BodyJSON,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
})
}
return normalized, outUnits, true, nil
}
func (r *AssetsRepo) listAssets(table string) ([]AssetRecord, error) {
if r == nil || r.db == nil {
return nil, nil
@ -225,8 +658,8 @@ ORDER BY updated_at DESC, name ASC
`
if table == "profiles" {
query = `
SELECT name, description, body_json, created_at, updated_at, template_name, business_name
FROM profiles
SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
FROM scene_templates
ORDER BY updated_at DESC, name ASC
`
}
@ -258,8 +691,8 @@ WHERE name = ?
`
if table == "profiles" {
query = `
SELECT name, description, body_json, created_at, updated_at, template_name, business_name
FROM profiles
SELECT name, description, body_json, created_at, updated_at, primary_template_name, business_name
FROM scene_templates
WHERE name = ?
`
}
@ -279,6 +712,9 @@ func (r *AssetsRepo) deleteAsset(table string, name string) error {
if r == nil || r.db == nil {
return nil
}
if table == "profiles" {
table = "scene_templates"
}
_, err := r.db.Exec(`DELETE FROM `+table+` WHERE name = ?`, name)
return err
}
@ -289,6 +725,10 @@ func rewriteProfileTemplateRefs(bodyJSON string, oldName string, newName string)
return "", false, err
}
changed := false
if strings.TrimSpace(valueString(raw["primary_template_name"])) == oldName {
raw["primary_template_name"] = newName
changed = true
}
instances, _ := raw["instances"].([]any)
for _, item := range instances {
instanceMap, _ := item.(map[string]any)

View File

@ -78,7 +78,7 @@ func TestAssetsRepoRenameTemplateUpdatesProfileReferences(t *testing.T) {
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if profile == nil || profile.TemplateName != "helmet_v2" || !strings.Contains(profile.BodyJSON, `"template": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"template":"helmet_v2"`) {
if profile == nil || profile.TemplateName != "helmet_v2" || !strings.Contains(profile.BodyJSON, `"primary_template_name": "helmet_v2"`) && !strings.Contains(profile.BodyJSON, `"primary_template_name":"helmet_v2"`) {
t.Fatalf("expected profile template ref updated, got %#v", profile)
}
}
@ -102,3 +102,85 @@ func TestAssetsRepoDeleteTemplateRemovesRecord(t *testing.T) {
t.Fatalf("expected template deleted, got %#v", record)
}
}
func TestAssetsRepoStoresIntegrationServiceDisabledState(t *testing.T) {
store := openTestStore(t)
defer store.Close()
repo := NewAssetsRepo(store.DB())
if err := repo.SaveIntegrationService(
"minio_primary",
"object_storage",
"primary object store",
false,
`{"name":"minio_primary","type":"object_storage","provider":"minio","enabled":false}`,
); err != nil {
t.Fatalf("SaveIntegrationService: %v", err)
}
records, err := repo.ListIntegrationServices()
if err != nil {
t.Fatalf("ListIntegrationServices: %v", err)
}
if len(records) != 1 {
t.Fatalf("expected one integration service, got %#v", records)
}
if records[0].ServiceType != "object_storage" || records[0].Enabled {
t.Fatalf("unexpected integration service record: %#v", records[0])
}
record, err := repo.GetIntegrationService("minio_primary")
if err != nil {
t.Fatalf("GetIntegrationService: %v", err)
}
if record == nil {
t.Fatal("expected integration service record")
}
if record.ServiceType != "object_storage" || record.Enabled {
t.Fatalf("unexpected integration service fetch: %#v", record)
}
if !strings.Contains(record.BodyJSON, `"provider":"minio"`) {
t.Fatalf("expected body_json payload preserved, got %#v", record)
}
}
func TestAssetsRepoStoresVideoSource(t *testing.T) {
store := openTestStore(t)
defer store.Close()
repo := NewAssetsRepo(store.DB())
if err := repo.SaveVideoSource(
"gate_cam_01",
"rtsp",
"东门入口",
"东门主入口摄像头",
`{"name":"gate_cam_01","source_type":"rtsp","area":"东门入口","description":"东门主入口摄像头","config":{"url":"rtsp://10.0.0.1/live","resolution":"1080p","frame_size":"1920x1080","fps":"25","video_format":"h264"}}`,
); err != nil {
t.Fatalf("SaveVideoSource: %v", err)
}
records, err := repo.ListVideoSources()
if err != nil {
t.Fatalf("ListVideoSources: %v", err)
}
if len(records) != 1 {
t.Fatalf("expected one video source, got %#v", records)
}
if records[0].SourceType != "rtsp" || records[0].Area != "东门入口" {
t.Fatalf("unexpected video source record: %#v", records[0])
}
record, err := repo.GetVideoSource("gate_cam_01")
if err != nil {
t.Fatalf("GetVideoSource: %v", err)
}
if record == nil {
t.Fatal("expected video source record")
}
if record.SourceType != "rtsp" || record.Area != "东门入口" {
t.Fatalf("unexpected video source fetch: %#v", record)
}
if !strings.Contains(record.BodyJSON, `"resolution":"1080p"`) {
t.Fatalf("expected body_json payload preserved, got %#v", record)
}
}

View File

@ -2,6 +2,7 @@ package storage
import (
"database/sql"
"sort"
"time"
)
@ -74,3 +75,32 @@ WHERE device_id = ?
}
return &item, nil
}
func (r *DeviceConfigStateRepo) ListByProfileName(profileName string) ([]DeviceConfigStateRecord, error) {
if r == nil || r.db == nil {
return nil, nil
}
rows, err := r.db.Query(`
SELECT device_id, template_name, profile_name, overlays_json, config_id, config_version, last_applied_task_id, updated_at
FROM device_config_state
WHERE profile_name = ?
ORDER BY device_id ASC
`, profileName)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DeviceConfigStateRecord
for rows.Next() {
var item DeviceConfigStateRecord
if err := rows.Scan(&item.DeviceID, &item.TemplateName, &item.ProfileName, &item.OverlaysJSON, &item.ConfigID, &item.ConfigVersion, &item.LastAppliedTaskID, &item.UpdatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID })
return items, nil
}

View File

@ -1,6 +1,12 @@
package storage
import "database/sql"
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
)
const schema001 = `
CREATE TABLE IF NOT EXISTS templates (
@ -14,7 +20,7 @@ CREATE TABLE IF NOT EXISTS templates (
CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
template_name TEXT NOT NULL DEFAULT '',
primary_template_name TEXT NOT NULL DEFAULT '',
business_name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
body_json TEXT NOT NULL,
@ -29,6 +35,59 @@ CREATE TABLE IF NOT EXISTS overlays (
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS integration_services (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 0,
body_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS video_sources (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
source_type TEXT NOT NULL DEFAULT '',
area TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
body_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS scene_templates (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
primary_template_name TEXT NOT NULL DEFAULT '',
business_name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
body_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS recognition_units (
id INTEGER PRIMARY KEY,
scene_template_name TEXT NOT NULL,
name TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
site_name TEXT NOT NULL DEFAULT '',
video_source_ref TEXT NOT NULL DEFAULT '',
output_channel TEXT NOT NULL DEFAULT '',
rtsp_port TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
body_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(scene_template_name, name)
);
CREATE TABLE IF NOT EXISTS device_assignments (
device_id TEXT PRIMARY KEY,
profile_name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
body_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
hostname TEXT NOT NULL DEFAULT '',
@ -83,6 +142,286 @@ CREATE TABLE IF NOT EXISTS audit_logs (
`
func migrate(db *sql.DB) error {
_, err := db.Exec(schema001)
return err
if _, err := db.Exec(schema001); err != nil {
return err
}
if err := migrateProfilePrimaryTemplateName(db); err != nil {
return err
}
return migrateProfilesToSceneTemplates(db)
}
func migrateProfilePrimaryTemplateName(db *sql.DB) error {
hasPrimary, err := hasColumn(db, "profiles", "primary_template_name")
if err != nil {
return err
}
if hasPrimary {
return nil
}
hasLegacy, err := hasColumn(db, "profiles", "template_name")
if err != nil {
return err
}
if !hasLegacy {
return fmt.Errorf("profiles table is missing both primary_template_name and template_name")
}
tx, err := db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`ALTER TABLE profiles RENAME TO profiles_legacy_template_name`); err != nil {
return err
}
if _, err := tx.Exec(`
CREATE TABLE profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
primary_template_name TEXT NOT NULL DEFAULT '',
business_name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
body_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`); err != nil {
return err
}
if _, err := tx.Exec(`
INSERT INTO profiles(id, name, primary_template_name, business_name, description, body_json, created_at, updated_at)
SELECT id, name, template_name, business_name, description, body_json, created_at, updated_at
FROM profiles_legacy_template_name
`); err != nil {
return err
}
if _, err := tx.Exec(`DROP TABLE profiles_legacy_template_name`); err != nil {
return err
}
return tx.Commit()
}
func hasColumn(db *sql.DB, table string, column string) (bool, error) {
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var (
cid int
name string
ctype string
notnull int
dfltValue sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
func migrateProfilesToSceneTemplates(db *sql.DB) error {
if db == nil {
return nil
}
var count int
if err := db.QueryRow(`SELECT COUNT(1) FROM scene_templates`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
rows, err := db.Query(`
SELECT name, primary_template_name, business_name, description, body_json, created_at, updated_at
FROM profiles
ORDER BY created_at ASC, name ASC
`)
if err != nil {
return err
}
defer rows.Close()
type legacyProfile struct {
Name string
TemplateName string
BusinessName string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}
legacy := make([]legacyProfile, 0)
for rows.Next() {
var item legacyProfile
if err := rows.Scan(&item.Name, &item.TemplateName, &item.BusinessName, &item.Description, &item.BodyJSON, &item.CreatedAt, &item.UpdatedAt); err != nil {
return err
}
legacy = append(legacy, item)
}
if err := rows.Err(); err != nil {
return err
}
if len(legacy) == 0 {
return nil
}
tx, err := db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
now := time.Now().Format(time.RFC3339)
for _, item := range legacy {
templateDoc, units, err := splitLegacyProfileDocument(item)
if err != nil {
return err
}
templateBody, err := json.MarshalIndent(templateDoc, "", " ")
if err != nil {
return err
}
createdAt := firstNonEmpty(item.CreatedAt, now)
updatedAt := firstNonEmpty(item.UpdatedAt, createdAt)
if _, err := tx.Exec(`
INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO NOTHING
`, item.Name, item.TemplateName, item.BusinessName, item.Description, string(append(templateBody, '\n')), createdAt, updatedAt); err != nil {
return err
}
for _, unit := range units {
if _, err := tx.Exec(`
INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(scene_template_name, name) DO NOTHING
`, item.Name, unit.Name, unit.DisplayName, unit.SiteName, unit.VideoSourceRef, unit.OutputChannel, unit.RTSPPort, item.Description, unit.BodyJSON, createdAt, updatedAt); err != nil {
return err
}
}
}
return tx.Commit()
}
type migratedRecognitionUnit struct {
Name string
DisplayName string
SiteName string
VideoSourceRef string
OutputChannel string
RTSPPort string
BodyJSON string
}
func splitLegacyProfileDocument(item struct {
Name string
TemplateName string
BusinessName string
Description string
BodyJSON string
CreatedAt string
UpdatedAt string
}) (map[string]any, []migratedRecognitionUnit, error) {
raw := map[string]any{}
if strings.TrimSpace(item.BodyJSON) != "" {
if err := json.Unmarshal([]byte(item.BodyJSON), &raw); err != nil {
return nil, nil, err
}
}
if raw == nil {
raw = map[string]any{}
}
raw["name"] = item.Name
if strings.TrimSpace(item.TemplateName) != "" {
raw["primary_template_name"] = item.TemplateName
}
if strings.TrimSpace(item.BusinessName) != "" {
raw["business_name"] = item.BusinessName
}
if strings.TrimSpace(item.Description) != "" {
raw["description"] = item.Description
}
var units []migratedRecognitionUnit
if instances, ok := raw["instances"].([]any); ok {
for _, entry := range instances {
inst, _ := entry.(map[string]any)
if inst == nil {
continue
}
unitName := strings.TrimSpace(stringAny(inst["name"]))
if unitName == "" {
continue
}
sceneMeta, _ := inst["scene_meta"].(map[string]any)
inputBindings, _ := inst["input_bindings"].(map[string]any)
outputBindings, _ := inst["output_bindings"].(map[string]any)
videoSourceRef := strings.TrimSpace(bindingStringFromAny(inputBindings, "video_input_main", "video_source_ref"))
outputChannel := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "channel_no"))
if outputChannel == "" {
outputChannel = unitName
}
rtspPort := strings.TrimSpace(bindingStringFromAny(outputBindings, "stream_output_main", "publish_rtsp_port"))
if rtspPort == "" {
rtspPort = "8555"
}
unitRaw := cloneAnyMap(inst)
body, err := json.MarshalIndent(unitRaw, "", " ")
if err != nil {
return nil, nil, err
}
units = append(units, migratedRecognitionUnit{
Name: unitName,
DisplayName: strings.TrimSpace(stringAny(sceneMeta["display_name"])),
SiteName: strings.TrimSpace(stringAny(sceneMeta["site_name"])),
VideoSourceRef: videoSourceRef,
OutputChannel: outputChannel,
RTSPPort: rtspPort,
BodyJSON: string(append(body, '\n')),
})
}
}
delete(raw, "instances")
return raw, units, nil
}
func bindingStringFromAny(bindings map[string]any, slot string, field string) string {
entry, _ := bindings[slot].(map[string]any)
return strings.TrimSpace(stringAny(entry[field]))
}
func stringAny(v any) string {
switch vv := v.(type) {
case string:
return vv
case float64:
return fmt.Sprintf("%v", vv)
default:
return ""
}
}
func cloneAnyMap(in map[string]any) map[string]any {
if len(in) == 0 {
return map[string]any{}
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}

View File

@ -1,6 +1,7 @@
package storage
import (
"database/sql"
"path/filepath"
"testing"
)
@ -17,6 +18,8 @@ func TestSQLiteStoreBootstrapsSchema(t *testing.T) {
"templates",
"profiles",
"overlays",
"integration_services",
"video_sources",
"devices",
"device_config_state",
"tasks",
@ -32,3 +35,58 @@ func TestSQLiteStoreBootstrapsSchema(t *testing.T) {
}
}
}
func TestSQLiteStoreMigratesLegacyProfileTemplateColumn(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "app.db")
legacyDB, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open legacy sqlite: %v", err)
}
_, err = legacyDB.Exec(`
CREATE TABLE profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
template_name TEXT NOT NULL DEFAULT '',
business_name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
body_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO profiles(name, template_name, business_name, description, body_json, created_at, updated_at)
VALUES('line_a', 'helmet', 'gate', 'desc', '{"name":"line_a","instances":[{"name":"cam1","template":"helmet"}]}', '2026-04-29T00:00:00+08:00', '2026-04-29T00:00:00+08:00');
`)
if err != nil {
t.Fatalf("seed legacy schema: %v", err)
}
_ = legacyDB.Close()
store, err := OpenSQLite(dbPath)
if err != nil {
t.Fatalf("OpenSQLite migrate legacy: %v", err)
}
defer store.Close()
hasOld, err := hasColumn(store.DB(), "profiles", "template_name")
if err != nil {
t.Fatalf("has old column: %v", err)
}
if hasOld {
t.Fatal("expected legacy template_name column to be removed")
}
hasNew, err := hasColumn(store.DB(), "profiles", "primary_template_name")
if err != nil {
t.Fatalf("has new column: %v", err)
}
if !hasNew {
t.Fatal("expected primary_template_name column to exist after migration")
}
repo := NewAssetsRepo(store.DB())
profile, err := repo.GetProfile("line_a")
if err != nil {
t.Fatalf("GetProfile after migration: %v", err)
}
if profile == nil || profile.TemplateName != "helmet" {
t.Fatalf("expected migrated primary template value, got %#v", profile)
}
}

View File

@ -21,8 +21,8 @@ type graphNodeTypeInfo struct {
func graphNodeTypesCatalog() []graphNodeTypeInfo {
return []graphNodeTypeInfo{
nodeType("input_rtsp", "RTSP 输入", "输入", "camera", "从网络摄像机或流媒体地址读取视频流。", "source", map[string]any{"url": "${rtsp_url}"}, []graphNodeParam{
textParam("url", "RTSP 地址", "${rtsp_url}"),
nodeType("input_rtsp", "RTSP 输入", "输入", "camera", "从网络摄像机或流媒体地址读取视频流。", "source", map[string]any{"url": "${slot:video_input_main.url}"}, []graphNodeParam{
textParam("url", "RTSP 地址", "${slot:video_input_main.url}"),
numberParam("fps", "输入帧率", "1"),
numberParam("width", "宽度", "1"),
numberParam("height", "高度", "1"),

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,7 @@
doc.template = graph;
const fallbackNodeTypes = [
{ type: "input_rtsp", label: "RTSP 输入", category: "输入", icon: "camera", description: "从网络摄像机或流媒体地址读取视频流。", defaults: { id: "input_rtsp", type: "input_rtsp", role: "source", enable: true, url: "${rtsp_url}" } },
{ type: "input_rtsp", label: "RTSP 输入", category: "输入", icon: "camera", description: "从网络摄像机或流媒体地址读取视频流。", defaults: { id: "input_rtsp", type: "input_rtsp", role: "source", enable: true, url: "${slot:video_input_main.url}" } },
{ type: "input_file", label: "文件输入", category: "输入", icon: "file", description: "从本地视频文件读取帧,常用于离线验证和回放。", defaults: { id: "input_file", type: "input_file", role: "source", enable: true } },
{ type: "preprocess", label: "图像预处理", category: "处理", icon: "adjust", description: "调整尺寸、格式和硬件加速路径,为后续推理或编码准备图像。", defaults: { id: "preprocess", type: "preprocess", role: "filter", enable: true, dst_format: "rgb" } },
{ type: "ai_scrfd", label: "SCRFD 人脸检测", category: "AI 推理", icon: "scan-face", description: "使用 SCRFD 模型做人脸检测,适合固定输入尺寸场景。", defaults: { id: "ai_scrfd", type: "ai_scrfd", role: "filter", enable: true } },
@ -56,7 +56,7 @@
const coreEdgeKeys = new Set(["from", "to"]);
const paramSchemas = {
input_rtsp: [
{ key: "url", label: "RTSP 地址", type: "text", placeholder: "${rtsp_url}" },
{ key: "url", label: "RTSP 地址", type: "text", placeholder: "${slot:video_input_main.url}" },
{ key: "fps", label: "输入帧率", type: "number", step: "1" },
{ key: "width", label: "宽度", type: "number", step: "1" },
{ key: "height", label: "高度", type: "number", step: "1" },

View File

@ -27,6 +27,9 @@
--danger-soft:#242424;
--danger-soft-hover:#303030;
--danger-soft-text:#dddddd;
--assignment-low-bg:#24190d;
--assignment-busy-bg:#3a2812;
--assignment-full-bg:#4b3110;
--teal:#dddddd;
--green:#66c98f;
--amber:#d8a657;
@ -61,6 +64,9 @@ body[data-theme="blue-light"]{
--danger-soft:#f0dada;
--danger-soft-hover:#e8caca;
--danger-soft-text:#a43f3f;
--assignment-low-bg:#f4ead8;
--assignment-busy-bg:#f0dcc0;
--assignment-full-bg:#eccb96;
--teal:#2d6fb5;
--green:#245f9f;
--amber:#9b650e;
@ -95,6 +101,9 @@ body[data-theme="graphite-gold"]{
--danger-soft:#301d1c;
--danger-soft-hover:#3b2423;
--danger-soft-text:#d66a63;
--assignment-low-bg:#332514;
--assignment-busy-bg:#3f3015;
--assignment-full-bg:#57401a;
--teal:#d3a84f;
--green:#f0cf7a;
--amber:#d79a3b;
@ -116,6 +125,18 @@ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.nav-section{padding:14px 10px 6px;font-size:11px;color:var(--sidebar-muted);text-transform:uppercase;letter-spacing:.06em}
.side-nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--radius);color:var(--sidebar-text);font-size:13px;font-weight:500}
.side-nav a:hover{background:var(--sidebar-hover)}
.side-nav .nav-group{margin:0}
.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[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-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)}
.nav-icon .ui-icon{width:14px;height:14px;stroke-width:1.75}
@ -184,7 +205,18 @@ th{background:var(--surface-soft);font-size:12px;font-weight:600;color:var(--mut
.table-wrap td a{color:var(--table-link)}
.table-wrap td a:hover{color:var(--text)}
tbody tr:hover{background:var(--surface-soft)}
tbody tr.selected{background:var(--selected-row);outline:1px solid var(--primary);outline-offset:-1px}
tbody tr.selected{background:var(--selected-row)}
tbody tr.selected td{color:var(--text)}
tbody tr.selected .mono,
tbody tr.selected a,
tbody tr.selected strong{color:var(--text)}
.checkbox-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
.checkbox-card{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
.checkbox-card input{margin:0 0 4px}
.checkbox-card-title{font-size:13px}
.checkbox-card-meta{font-size:12px;color:var(--muted)}
tbody tr[data-profile-row],
tbody tr[data-nav-row]{cursor:pointer}
.device-cell{display:flex;align-items:center;gap:12px;min-width:220px}
.device-avatar{width:32px;height:32px;border-radius:var(--radius);background:var(--surface-strong);color:var(--primary);display:grid;place-items:center}
@ -232,8 +264,19 @@ tbody tr.selected{background:var(--selected-row);outline:1px solid var(--primary
.selector-card .actions{margin-top:auto}
.panel-block{border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft);padding:14px}
.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}
.service-actions-row{margin-top:16px}
.scene-config-form{margin-top:14px}
.scene-summary-details{margin-top:12px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
.scene-summary-details summary{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;font-weight:500;color:var(--muted);list-style:none}
.scene-summary-details summary::before{content:"";width:7px;height:7px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .16s ease}
.scene-summary-details summary::-webkit-details-marker{display:none}
.scene-summary-details[open] summary::before{transform:rotate(45deg)}
.scene-summary-details[open] summary{margin-bottom:10px;color:var(--text)}
.scene-summary-details .info-list{margin-top:0}
.scene-actions-row{margin-top:12px}
.field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:14px}
.field-grid label span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)}
.field-grid label>span{display:block;margin-bottom:6px;font-size:12px;color:var(--muted)}
.field-grid label>span .required-mark{display:inline;color:var(--red);font-weight:600;margin-left:4px}
.field-grid .full{grid-column:1/-1}
.field-grid input,.field-grid select,.field-grid textarea{width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--input-bg);color:var(--text);font:inherit}
.field-grid textarea{resize:vertical;min-height:120px}
@ -243,6 +286,20 @@ tbody tr.selected{background:var(--selected-row);outline:1px solid var(--primary
.info-list>div{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
.info-list span{display:block;margin-bottom:5px;font-size:12px;color:var(--muted)}
.info-list strong{display:block;font-size:13px;font-weight:400;line-height:1.45}
.detail-sheet{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.detail-item{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
.detail-item.full{grid-column:1/-1}
.detail-item span{display:block;margin-bottom:5px;font-size:12px;color:var(--muted)}
.detail-item strong{display:block;font-size:13px;font-weight:400;line-height:1.45;color:var(--text)}
.form-state-hint{margin-top:6px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.editor-state.readonly .section-title{padding-bottom:8px;border-bottom:1px solid var(--border)}
.editor-state.editing{border-color:var(--border-strong);box-shadow:0 0 0 1px rgba(255,255,255,.03),var(--shadow)}
.editor-state.editing .section-title{padding-bottom:10px;border-bottom:1px solid var(--border-strong)}
.editor-state.editing .field-grid label>span{color:var(--text)}
.editor-state.editing .field-grid input,
.editor-state.editing .field-grid select,
.editor-state.editing .field-grid textarea{border-color:var(--border-strong);background:var(--surface)}
.editor-state.editing .profile-instance-grid{margin-top:16px}
.editable-line{display:flex;align-items:center;justify-content:space-between;gap:8px}
.editable-line strong{margin:0;flex:1 1 auto}
.icon-only{display:inline-flex;align-items:center;justify-content:center;padding:0;min-width:28px;width:28px;height:28px}
@ -306,6 +363,45 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va
.select-cell{width:52px;text-align:center;vertical-align:middle}
.select-cell input[type=checkbox]{width:16px;height:16px;margin:0;accent-color:var(--primary)}
.assignment-board-page .section-title{margin-bottom:10px}
.assignment-kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:14px}
.assignment-kpi{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)}
.assignment-kpi span{display:block;font-size:12px;color:var(--muted)}
.assignment-kpi strong{display:block;margin-top:6px;font-size:18px;font-weight:400;color:var(--text)}
.assignment-action-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px}
.assignment-slider{display:flex;align-items:center;gap:10px;min-width:260px}
.assignment-slider span{font-size:12px;color:var(--muted)}
.assignment-slider strong{font-size:12px;font-weight:500;color:var(--text)}
.assignment-slider input[type=range]{width:180px}
.assignment-board-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:12px}
.assignment-device-card{padding:10px 11px;border:1px solid var(--border);border-radius:var(--radius);background:var(--assignment-low-bg)}
.assignment-device-card.state-idle{background:var(--surface-soft)}
.assignment-device-card.state-low{background:var(--assignment-low-bg)}
.assignment-device-card.state-busy{background:var(--assignment-busy-bg)}
.assignment-device-card.state-full{background:var(--assignment-full-bg)}
.assignment-device-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:8px}
.assignment-device-head h3{margin:0;font-size:13px;font-weight:500;color:var(--text)}
.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)}
.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)}
.assignment-chip-text{font-size:11px;color:var(--text)}
.assignment-chip-remove{border:0;background:transparent;color:var(--muted);padding:0;line-height:1;cursor:pointer}
.assignment-chip-remove:hover{color:var(--text)}
.assignment-device-add{display:flex;gap:6px;align-items:center;margin-top:10px}
.assignment-device-add select{flex:1 1 auto;padding:7px 8px;border:1px solid var(--border);border-radius:var(--radius);background:var(--input-bg);color:var(--text);font:inherit}
.assignment-unassigned{margin-top:16px;padding-top:4px}
@media (max-width:1400px){
.assignment-board-grid{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media (max-width:900px){
.assignment-board-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media (max-width:1024px){
.app-shell{grid-template-columns:1fr}
.sidebar{position:relative;height:auto}
@ -315,4 +411,6 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va
.stats,.detail-grid,.device-selector-grid,.quad-grid,.control-grid,.summary-strip,.info-list,.field-grid{grid-template-columns:1fr}
.hero-band{flex-direction:column;align-items:flex-start}
.batch-toolbar{flex-direction:column}
.assignment-kpis{grid-template-columns:repeat(2,minmax(0,1fr))}
.assignment-board-grid{grid-template-columns:1fr}
}

View File

@ -1,6 +1,6 @@
{{define "api"}}
<div class="card">
<div class="crumb">诊断 / 高级调试</div>
<div class="crumb">系统管理 / 日志审计 / 高级调试</div>
<h2>高级调试</h2>
</div>

View File

@ -6,10 +6,10 @@
<div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>{{.AssetOverlay.Name}}</span></h2>
</div>
<a class="btn secondary" href="/ui/assets/overlays">返回叠加项列表</a>
<a class="btn secondary" href="/ui/assets/overlays">返回调试参数列表</a>
</div>
<div class="info-list">
<div><span>叠加项</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
<div><span>调试参数</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
<div><span>目标数量</span><strong>{{.AssetOverlay.OverrideTargetNum}}</strong></div>
<div class="full"><span>描述</span><strong>{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}</strong></div>
<div class="full"><span>作用目标</span><strong>{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</strong></div>

View File

@ -3,27 +3,36 @@
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>配置叠加项列表</span></h2>
<h2 class="title-with-icon">{{icon "overlay"}}<span>调试参数列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/assets/overlays?new=1">{{icon "apply"}}<span>新增调试参数</span></a>
{{if .AssetOverlay}}
<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">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>叠加项</th>
<th>调试参数</th>
<th>描述</th>
<th>目标</th>
</tr>
</thead>
<tbody>
{{range .AssetOverlays}}
<tr>
<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 .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</td>
</tr>
{{else}}
<tr><td colspan="3"><div class="empty-state compact"><div class="empty-title">还没有叠加项</div></div></td></tr>
<tr><td colspan="3"><div class="empty-state compact"><div class="empty-title">还没有调试参数</div></div></td></tr>
{{end}}
</tbody>
</table>
@ -31,10 +40,20 @@
</div>
{{if .AssetOverlay}}
<div class="card">
<form method="post" action="/ui/assets/overlays">
<div class="card editor-state {{if .AssetOverlayEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>叠加项详情</span></h2>
<h2 class="title-with-icon">{{icon "overlay"}}<span>{{if .AssetOverlayEditing}}调试参数编辑{{else}}调试参数详情{{end}}{{if .AssetOverlay.Name}} · {{.AssetOverlay.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetOverlayEditing}}
<span class="pill run">编辑模式</span>
<span>当前调试参数正在编辑,保存后生效。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前调试参数为只读展示。</span>
{{end}}
</div>
</div>
<div class="actions compact">
<button
@ -42,23 +61,39 @@
class="btn secondary js-export-json"
data-export-url="/ui/assets/overlays/{{.AssetOverlay.Name}}/export"
data-default-filename="{{.AssetOverlay.Name}}.json"
>{{icon "apply"}}<span>另存为 JSON</span></button>
>{{icon "apply"}}<span>导出为 JSON</span></button>
</div>
</div>
<div class="info-list">
<div><span>叠加项</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
<div><span>目标数量</span><strong>{{.AssetOverlay.OverrideTargetNum}}</strong></div>
<div class="full"><span>描述</span><strong>{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}</strong></div>
<div class="full"><span>作用目标</span><strong>{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</strong></div>
<div class="full"><span>路径</span><strong class="mono">{{.AssetOverlay.Path}}</strong></div>
{{if .AssetOverlayEditing}}
<div class="field-grid">
<label><span>调试参数名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetOverlay.Name}}" autofocus /></label>
<label><span>目标数量</span><input value="{{.AssetOverlay.OverrideTargetNum}}" readonly /></label>
<label class="full"><span>描述</span><input name="description" value="{{.AssetOverlay.Description}}" /></label>
<label class="full"><span>调试参数 JSON<span class="required-mark">*</span></span><textarea class="code-input" name="json">{{.OverlayDraftJSON}}</textarea></label>
</div>
<div class="actions">
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/overlays{{if .AssetOverlay.Name}}?name={{.AssetOverlay.Name}}{{end}}">{{icon "close"}}<span>取消</span></a>
</div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>调试参数</span><strong class="mono">{{.AssetOverlay.Name}}</strong></div>
<div class="detail-item"><span>目标数量</span><strong>{{.AssetOverlay.OverrideTargetNum}}</strong></div>
<div class="detail-item full"><span>描述</span><strong>{{if .AssetOverlay.Description}}{{.AssetOverlay.Description}}{{else}}-{{end}}</strong></div>
<div class="detail-item full"><span>作用目标</span><strong>{{if .AssetOverlay.OverrideTargets}}{{range $i, $item := .AssetOverlay.OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}}</strong></div>
<div class="detail-item full"><span>路径</span><strong class="mono">{{.AssetOverlay.Path}}</strong></div>
</div>
{{end}}
</div>
{{if not .AssetOverlayEditing}}
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
<pre>{{json .AssetOverlay.Raw}}</pre>
</details>
{{end}}
</form>
{{end}}
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -1,99 +0,0 @@
{{define "asset_profile"}}
{{template "asset_tabs" .}}
{{if .AssetProfileEditor}}
<form method="post" action="/ui/assets/profiles/{{.AssetProfileEditor.Name}}">
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>业务配置</span></h2>
</div>
<a class="btn secondary" href="/ui/assets/profiles">返回业务配置列表</a>
</div>
<div class="field-grid">
<label><span>业务配置名称</span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" /></label>
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" /></label>
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" /></label>
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label>
<label><span>队列大小</span><input class="mono" name="queue_size" value="{{.AssetProfileEditor.Queue.Size}}" /></label>
<label><span>队列策略</span><input name="queue_strategy" value="{{.AssetProfileEditor.Queue.Strategy}}" /></label>
</div>
</div>
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "device"}}<span>视频通道</span></h2>
</div>
<div class="actions compact">
<span class="pill">{{len .AssetProfileEditor.Instances}} 路</span>
<button class="btn secondary" type="submit" name="add_instance" value="1">{{icon "apply"}}<span>新增通道</span></button>
</div>
</div>
<table>
<thead>
<tr>
<th>通道</th>
<th>通道显示名</th>
<th>RTSP 输入</th>
<th>HLS 输出</th>
<th>RTSP 输出</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $inst := .AssetProfileEditor.Instances}}
<tr {{if $inst.Delete}}class="muted-row"{{end}}>
<td class="mono">{{$inst.Name}}</td>
<td>{{if $inst.Delete}}<span class="pill warn">待删除</span>{{else}}{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}{{end}}</td>
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{$inst.RTSPURL}}{{end}}</td>
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{$inst.PublishHLSPath}}{{end}}</td>
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{if $inst.PublishRTSPPort}}{{$inst.PublishRTSPPort}}{{end}}{{if $inst.PublishRTSPPath}} {{$inst.PublishRTSPPath}}{{end}}{{end}}</td>
<td>
<div class="actions compact">
<a class="btn ghost" href="#profile-instance-{{$i}}">编辑</a>
{{if $inst.Delete}}
<button class="btn secondary" type="submit" name="instances[{{$i}}].delete" value="0">撤销删除</button>
{{else}}
<button class="btn secondary" type="submit" name="instances[{{$i}}].delete" value="1">删除</button>
{{end}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{range $i, $inst := .AssetProfileEditor.Instances}}
<details id="profile-instance-{{$i}}" class="card collapsible profile-instance-editor" {{if or $inst.Delete (not $inst.Name)}}open{{end}}>
<summary class="title-with-icon">{{icon "device"}}<span>{{$inst.Name}}</span></summary>
<div class="field-grid profile-instance-grid">
<input type="hidden" name="instances[{{$i}}].delete" value="{{if $inst.Delete}}1{{else}}0{{end}}" />
<input type="hidden" name="instances[{{$i}}].template" value="{{$inst.Template}}" />
<label><span>通道名</span><input name="instances[{{$i}}].name" value="{{$inst.Name}}" /></label>
<label><span>通道显示名</span><input name="instances[{{$i}}].display_name" value="{{$inst.DisplayName}}" /></label>
<label><span>通道号</span><input name="instances[{{$i}}].channel_no" value="{{$inst.ChannelNo}}" /></label>
<label class="full"><span>RTSP 输入</span><input class="mono" name="instances[{{$i}}].rtsp_url" value="{{$inst.RTSPURL}}" /></label>
<label class="full"><span>HLS 输出</span><input class="mono" name="instances[{{$i}}].publish_hls_path" value="{{$inst.PublishHLSPath}}" /></label>
<label><span>RTSP 输出端口</span><input class="mono" name="instances[{{$i}}].publish_rtsp_port" value="{{$inst.PublishRTSPPort}}" /></label>
<label><span>RTSP 输出路径</span><input class="mono" name="instances[{{$i}}].publish_rtsp_path" value="{{$inst.PublishRTSPPath}}" /></label>
<label class="full"><span>高级设置 JSON</span><textarea name="instances[{{$i}}].advanced_params" rows="5" class="code-input">{{if $inst.AdvancedParams}}{{json $inst.AdvancedParams}}{{end}}</textarea></label>
</div>
</details>
{{end}}
<div class="card">
<div class="actions">
<button type="submit">{{icon "apply"}}<span>保存</span></button>
</div>
</div>
</form>
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
<pre>{{json .AssetProfileEditor.Raw}}</pre>
</details>
{{end}}
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -1,31 +1,37 @@
{{define "asset_profiles"}}
{{template "asset_tabs" .}}
{{define "plans"}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>业务配置列表</span></h2>
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/plans?new=1">{{icon "apply"}}<span>新建场景</span></a>
{{if .SelectedProfile}}
<a class="btn secondary" href="/ui/plans?name={{.SelectedProfile}}&edit=1">编辑</a>
<form method="post" action="/ui/plans/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景配置吗?');">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>业务配置</th>
<th>场景配置</th>
<th>描述</th>
<th>视频通道</th>
<th>队列</th>
</tr>
</thead>
<tbody>
{{range .AssetProfiles}}
<tr>
<td><a class="mono" href="/ui/assets/profiles?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>
<td class="mono">{{if .QueueStrategy}}{{.QueueStrategy}} / {{.QueueSize}}{{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="3"><div class="empty-state compact"><div class="empty-title">还没有场景配置</div></div></td></tr>
{{end}}
</tbody>
</table>
@ -33,29 +39,62 @@
</div>
{{if .AssetProfileEditor}}
<form method="post" action="/ui/assets/profiles/{{.AssetProfileEditor.Name}}">
<div class="card">
{{if .AssetProfileEditing}}
<form method="post" action="{{.AssetProfileFormAction}}">
<input type="hidden" id="active-instance-input" name="active_instance" value="{{.ActiveInstanceIndex}}" />
{{end}}
<div class="card editor-state {{if .AssetProfileEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>业务配置</span></h2>
<h2 class="title-with-icon">{{icon "profile"}}<span>场景配置{{if .AssetProfileEditor.Name}} · {{.AssetProfileEditor.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetProfileEditing}}
<span class="pill run">编辑模式</span>
<span>正在编辑场景配置</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
{{end}}
</div>
</div>
<div class="actions compact">
{{if .AssetProfileEditing}}
<button type="submit">{{icon "apply"}}<span>保存场景配置</span></button>
<a class="btn secondary" href="/ui/plans{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
{{end}}
<button
type="button"
class="btn secondary js-export-json"
data-export-url="/ui/assets/profiles/{{.AssetProfileEditor.Name}}/export"
data-export-url="/ui/plans/{{.AssetProfileEditor.Name}}/export"
data-default-filename="{{.AssetProfileEditor.Name}}.json"
>{{icon "apply"}}<span>另存为 JSON</span></button>
>{{icon "apply"}}<span>导出为 JSON</span></button>
</div>
</div>
{{if .AssetProfileEditing}}
<div class="field-grid">
<label><span>业务配置名称</span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" /></label>
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" /></label>
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" /></label>
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label>
<label><span>队列大小</span><input class="mono" name="queue_size" value="{{.AssetProfileEditor.Queue.Size}}" /></label>
<label><span>队列策略</span><input name="queue_strategy" value="{{.AssetProfileEditor.Queue.Strategy}}" /></label>
<label><span>场景名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" {{if .AssetProfileEditing}}{{if not .SelectedProfile}}autofocus{{end}}{{else}}readonly{{end}} /></label>
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" {{if not .AssetProfileEditing}}readonly{{end}} /></label>
<label>
<span>调试参数</span>
<select name="overlay_name" {{if not .AssetProfileEditing}}disabled{{end}}>
<option value="">不使用</option>
{{range .AssetOverlays}}
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.OverlayName .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
{{end}}
</select>
</label>
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" {{if not .AssetProfileEditing}}readonly{{end}} /></label>
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" {{if not .AssetProfileEditing}}readonly{{end}} /></label>
</div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>场景名称</span><strong class="mono">{{if .AssetProfileEditor.Name}}{{.AssetProfileEditor.Name}}{{else}}-{{end}}</strong></div>
<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 full"><span>描述</span><strong>{{if .AssetProfileEditor.Description}}{{.AssetProfileEditor.Description}}{{else}}-{{end}}</strong></div>
</div>
{{end}}
</div>
<div class="card">
@ -65,35 +104,75 @@
</div>
<div class="actions compact">
<span class="pill">{{len .AssetProfileEditor.Instances}} 路</span>
{{if .AssetProfileEditing}}
<button class="btn secondary" type="submit" name="add_instance" value="1">{{icon "apply"}}<span>新增通道</span></button>
{{end}}
</div>
</div>
<table>
<thead>
<tr>
<th>通道</th>
<th>通道显示名</th>
<th>RTSP 输入</th>
<th>HLS 输出</th>
<th>RTSP 输出</th>
<th>模板</th>
<th>输入绑定</th>
<th>服务绑定</th>
<th>输出绑定</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $inst := .AssetProfileEditor.Instances}}
<tr {{if $inst.Delete}}class="muted-row"{{end}}>
{{$template := index $.AssetTemplateMap $inst.Template}}
<tr data-instance-row="{{$i}}" {{if eq $.ActiveInstanceIndex $i}}class="selected"{{else if $inst.Delete}}class="muted-row"{{end}}>
<td class="mono">{{$inst.Name}}</td>
<td>{{if $inst.Delete}}<span class="pill warn">待删除</span>{{else}}{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}{{end}}</td>
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{$inst.RTSPURL}}{{end}}</td>
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{$inst.PublishHLSPath}}{{end}}</td>
<td class="mono truncate-cell">{{if $inst.Delete}}-{{else}}{{if $inst.PublishRTSPPort}}{{$inst.PublishRTSPPort}}{{end}}{{if $inst.PublishRTSPPath}} {{$inst.PublishRTSPPath}}{{end}}{{end}}</td>
<td>{{if $inst.Delete}}<span class="pill warn">待删除</span>{{else}}<span class="mono">{{$inst.Template}}</span>{{end}}</td>
<td>
{{if $inst.Delete}}-{{else}}
{{if $template.Slots.Inputs}}
{{range $slot := $template.Slots.Inputs}}
<div class="stacked-meta">
<span>{{$slot.Description}}</span>
<span class="mono">{{if inputBindingRef $inst.InputBindings $slot.Name}}{{inputBindingRef $inst.InputBindings $slot.Name}}{{else}}-{{end}}</span>
</div>
{{end}}
{{else}}-{{end}}
{{end}}
</td>
<td>
{{if $inst.Delete}}-{{else}}
{{if $template.Slots.Services}}
{{range $slot := $template.Slots.Services}}
<div class="stacked-meta">
<span>{{$slot.Description}}</span>
<span class="mono">{{if serviceBindingRef $inst.ServiceBindings $slot.Name}}{{serviceBindingRef $inst.ServiceBindings $slot.Name}}{{else}}-{{end}}</span>
</div>
{{end}}
{{else}}-{{end}}
{{end}}
</td>
<td>
{{if $inst.Delete}}-{{else}}
{{if $template.Slots.Outputs}}
{{range $slot := $template.Slots.Outputs}}
<div class="stacked-meta">
<span>{{$slot.Description}}</span>
<span class="mono">
{{if outputBindingValue $inst.OutputBindings $slot.Name "publish_hls_path"}}
{{outputBindingValue $inst.OutputBindings $slot.Name "publish_hls_path"}}
{{else if outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_path"}}
{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}} {{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_path"}}
{{else}}-{{end}}
</span>
</div>
{{end}}
{{else}}-{{end}}
{{end}}
</td>
<td>
<div class="actions compact">
<a class="btn ghost" href="#profile-instance-{{$i}}">编辑</a>
{{if $inst.Delete}}
<button class="btn secondary" type="submit" name="instances[{{$i}}].delete" value="0">撤销删除</button>
{{else}}
<button class="btn secondary" type="submit" name="instances[{{$i}}].delete" value="1">删除</button>
<button type="button" class="btn ghost js-open-instance-editor" data-instance-index="{{$i}}" data-target="profile-instance-{{$i}}">编辑</button>
{{if $.AssetProfileEditing}}
<button class="btn secondary" type="submit" name="remove_instance" value="{{$i}}">删除</button>
{{end}}
</div>
</td>
@ -103,30 +182,92 @@
</table>
</div>
{{range $i, $inst := .AssetProfileEditor.Instances}}
<details id="profile-instance-{{$i}}" class="card collapsible profile-instance-editor" {{if or $inst.Delete (not $inst.Name)}}open{{end}}>
<summary class="title-with-icon">{{icon "device"}}<span>{{$inst.Name}}</span></summary>
<div class="field-grid profile-instance-grid">
<input type="hidden" name="instances[{{$i}}].delete" value="{{if $inst.Delete}}1{{else}}0{{end}}" />
<input type="hidden" name="instances[{{$i}}].template" value="{{$inst.Template}}" />
<label><span>通道名</span><input name="instances[{{$i}}].name" value="{{$inst.Name}}" /></label>
<label><span>通道显示名</span><input name="instances[{{$i}}].display_name" value="{{$inst.DisplayName}}" /></label>
<label><span>通道号</span><input name="instances[{{$i}}].channel_no" value="{{$inst.ChannelNo}}" /></label>
<label class="full"><span>RTSP 输入</span><input class="mono" name="instances[{{$i}}].rtsp_url" value="{{$inst.RTSPURL}}" /></label>
<label class="full"><span>HLS 输出</span><input class="mono" name="instances[{{$i}}].publish_hls_path" value="{{$inst.PublishHLSPath}}" /></label>
<label><span>RTSP 输出端口</span><input class="mono" name="instances[{{$i}}].publish_rtsp_port" value="{{$inst.PublishRTSPPort}}" /></label>
<label><span>RTSP 输出路径</span><input class="mono" name="instances[{{$i}}].publish_rtsp_path" value="{{$inst.PublishRTSPPath}}" /></label>
<label class="full"><span>高级设置 JSON</span><textarea name="instances[{{$i}}].advanced_params" rows="5" class="code-input">{{if $inst.AdvancedParams}}{{json $inst.AdvancedParams}}{{end}}</textarea></label>
</div>
</details>
{{end}}
<div class="card">
<div class="actions">
<button type="submit">{{icon "apply"}}<span>保存</span></button>
{{if .AssetProfileEditor.Instances}}
<div class="card editor-state {{if .AssetProfileEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "device"}}<span>通道详情{{if gt (len .AssetProfileEditor.Instances) 0}}{{with index .AssetProfileEditor.Instances .ActiveInstanceIndex}}{{if .Name}} · {{.Name}}{{end}}{{end}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetProfileEditing}}
<span class="pill run">编辑模式</span>
<span>当前通道的修改会在点击“保存场景配置”后统一生效。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前通道详情为只读展示。</span>
{{end}}
</div>
</div>
</div>
{{range $i, $inst := .AssetProfileEditor.Instances}}
{{$template := index $.AssetTemplateMap $inst.Template}}
<section id="profile-instance-{{$i}}" class="profile-instance-editor" data-instance-editor="{{$i}}" {{if ne $.ActiveInstanceIndex $i}}hidden{{end}}>
{{if $.AssetProfileEditing}}
<div class="field-grid profile-instance-grid">
<input type="hidden" name="instances[{{$i}}].template" value="{{$inst.Template}}" />
<label><span>通道名<span class="required-mark">*</span></span><input name="instances[{{$i}}].name" value="{{$inst.Name}}" {{if not $.AssetProfileEditing}}readonly{{end}} /></label>
<label><span>通道显示名</span><input name="instances[{{$i}}].display_name" value="{{$inst.DisplayName}}" {{if not $.AssetProfileEditing}}readonly{{end}} /></label>
<label><span>模板</span><input class="mono" value="{{$inst.Template}}" readonly /></label>
{{range $slot := $template.Slots.Inputs}}
<label>
<span>{{$slot.Description}}{{if $slot.Required}}<span class="required-mark">*</span>{{end}}</span>
<select name="instances[{{$i}}].input_bindings.{{$slot.Name}}.video_source_ref" {{if not $.AssetProfileEditing}}disabled{{end}}>
<option value="">未选择</option>
{{range $.AssetVideoSources}}
<option value="{{.Name}}" {{if eq (inputBindingRef $inst.InputBindings $slot.Name) .Name}}selected{{end}}>{{.Name}}{{if .Area}} - {{.Area}}{{end}}</option>
{{end}}
</select>
</label>
{{end}}
{{range $slot := $template.Slots.Services}}
<label>
<span>{{$slot.Description}}{{if $slot.Required}}<span class="required-mark">*</span>{{end}}</span>
<select name="instances[{{$i}}].service_bindings.{{$slot.Name}}.service_ref" {{if not $.AssetProfileEditing}}disabled{{end}}>
<option value="">未选择</option>
{{range $.AssetIntegrations}}{{if eq .Type $slot.Type}}
<option value="{{.Name}}" {{if eq (serviceBindingRef $inst.ServiceBindings $slot.Name) .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
{{end}}{{end}}
</select>
</label>
{{end}}
{{range $slot := $template.Slots.Outputs}}
<label>
<span>{{$slot.Description}} RTSP 端口</span>
<input class="mono" name="instances[{{$i}}].output_bindings.{{$slot.Name}}.publish_rtsp_port" value="{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}" {{if not $.AssetProfileEditing}}readonly{{end}} />
</label>
{{end}}
</div>
{{else}}
<div class="detail-grid profile-instance-grid detail-sheet">
<div class="detail-item"><span>通道名</span><strong class="mono">{{$inst.Name}}</strong></div>
<div class="detail-item"><span>通道显示名</span><strong>{{if $inst.DisplayName}}{{$inst.DisplayName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>模板</span><strong class="mono">{{$inst.Template}}</strong></div>
{{range $slot := $template.Slots.Inputs}}
<div class="detail-item">
<span>{{$slot.Description}}</span>
<strong class="mono">{{if inputBindingRef $inst.InputBindings $slot.Name}}{{inputBindingRef $inst.InputBindings $slot.Name}}{{else}}-{{end}}</strong>
</div>
{{end}}
{{range $slot := $template.Slots.Services}}
<div class="detail-item">
<span>{{$slot.Description}}</span>
<strong>{{if serviceBindingRef $inst.ServiceBindings $slot.Name}}{{serviceBindingRef $inst.ServiceBindings $slot.Name}}{{else}}-{{end}}</strong>
</div>
{{end}}
{{range $slot := $template.Slots.Outputs}}
<div class="detail-item">
<span>{{$slot.Description}} RTSP 端口</span>
<strong class="mono">{{if outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}{{outputBindingValue $inst.OutputBindings $slot.Name "publish_rtsp_port"}}{{else}}-{{end}}</strong>
</div>
{{end}}
</div>
{{end}}
</section>
{{end}}
</div>
{{end}}
{{if .AssetProfileEditing}}
</form>
{{end}}
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
@ -134,5 +275,4 @@
</details>
{{end}}
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}
{{end}}

View File

@ -13,11 +13,9 @@
<div><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
<div><span>节点数</span><strong>{{.AssetTemplate.NodeCount}}</strong></div>
<div><span>连线数</span><strong>{{.AssetTemplate.EdgeCount}}</strong></div>
<div><span>MinIO</span><strong class="mono">{{if .AssetTemplate.MinIOEndpoint}}{{.AssetTemplate.MinIOEndpoint}}{{else}}-{{end}}</strong></div>
<div><span>Bucket</span><strong>{{if .AssetTemplate.MinIOBucket}}{{.AssetTemplate.MinIOBucket}}{{else}}-{{end}}</strong></div>
<div class="full"><span>取 token 接口</span><strong class="mono">{{if .AssetTemplate.ExternalGetTokenURL}}{{.AssetTemplate.ExternalGetTokenURL}}{{else}}-{{end}}</strong></div>
<div class="full"><span>告警上报接口</span><strong class="mono">{{if .AssetTemplate.ExternalPutMessageURL}}{{.AssetTemplate.ExternalPutMessageURL}}{{else}}-{{end}}</strong></div>
<div><span>租户编码</span><strong>{{if .AssetTemplate.TenantCode}}{{.AssetTemplate.TenantCode}}{{else}}-{{end}}</strong></div>
<div><span>输入槽位</span><strong>{{len .AssetTemplate.Slots.Inputs}}</strong></div>
<div><span>服务槽位</span><strong>{{len .AssetTemplate.Slots.Services}}</strong></div>
<div><span>输出槽位</span><strong>{{len .AssetTemplate.Slots.Outputs}}</strong></div>
<div class="full"><span>描述</span><strong>{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong></div>
<div class="full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
</div>

View File

@ -3,11 +3,22 @@
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>模板列表</span></h2>
<div class="form-hint">标准模板应作为基线,复制后再做定制化修改。空白模板仅用于高级场景。</div>
<h2 class="title-with-icon">{{icon "template"}}<span>识别模板列表</span></h2>
<div class="form-hint">标准模板应作为基线,复制后再做定制化修改。</div>
</div>
<div class="actions compact">
<a class="btn ghost" href="/ui/assets/templates?mode=blank">{{icon "add"}}<span>新建空白模板</span></a>
{{if .AssetTemplate}}
{{if .AssetTemplate.ReadOnly}}
<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="/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}}
{{end}}
</div>
</div>
<div class="table-wrap">
@ -22,7 +33,7 @@
</thead>
<tbody>
{{range .AssetTemplates}}
<tr>
<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>
@ -36,101 +47,95 @@
</div>
</div>
{{if .TemplateCreateMode}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>{{if eq .TemplateCreateMode "clone"}}复制标准模板{{else}}新建空白模板{{end}}</span></h2>
<div class="form-hint">
{{if eq .TemplateCreateMode "clone"}}复制后会保留原有节点和连线结构,再进入可视化编辑。{{else}}空白模板会打开空白画布,不包含任何预置处理链。{{end}}
</div>
</div>
</div>
<form method="post" action="/ui/assets/templates/create" class="inline-form">
<input type="hidden" name="clone_source" value="{{.TemplateCloneSource}}" />
<label>
<span>模板名称</span>
<input name="name" value="{{.TemplateDraftName}}" placeholder="例如 std_workshop_face_recognition_shoe_alarm_copy" />
</label>
<label class="grow">
<span>模板描述</span>
<input name="description" value="{{.TemplateDraftDescription}}" placeholder="说明这个模板用于什么业务流程" />
</label>
<button type="submit" class="btn secondary">{{icon "apply"}}<span>{{if eq .TemplateCreateMode "clone"}}复制并编辑{{else}}创建并编辑{{end}}</span></button>
</form>
</div>
{{end}}
{{if .AssetTemplate}}
<div class="card">
<div class="card editor-state {{if .AssetTemplateEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>模板详情</span></h2>
<h2 class="title-with-icon">{{icon "template"}}<span>{{if .AssetTemplateEditing}}模板编辑{{else}}模板详情{{end}}{{if .AssetTemplate.Name}} · {{.AssetTemplate.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetTemplateEditing}}
<span class="pill run">编辑模式</span>
<span>当前模板基础信息正在编辑,结构修改请进入可视化编辑。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前模板为只读详情展示。</span>
{{end}}
</div>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/assets/templates/{{.AssetTemplate.Name}}/graph">{{icon "assets"}}<span>{{if .AssetTemplate.ReadOnly}}可视化预览{{else}}可视化编辑{{end}}</span></a>
<a class="btn ghost" href="/ui/assets/templates?clone={{.AssetTemplate.Name}}">{{icon "add"}}<span>{{if .AssetTemplate.ReadOnly}}复制标准模板{{else}}复制模板{{end}}</span></a>
<button
type="button"
class="btn secondary js-export-json"
data-export-url="/ui/assets/templates/{{.AssetTemplate.Name}}/export"
data-default-filename="{{.AssetTemplate.Name}}.json"
>{{icon "apply"}}<span>另存为 JSON</span></button>
{{if not .AssetTemplate.ReadOnly}}
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/delete" onsubmit="return confirm('确认删除这个用户模板吗?');">
<button type="submit" class="btn danger">{{icon "close"}}<span>删除模板</span></button>
</form>
{{end}}
>{{icon "apply"}}<span>导出为 JSON</span></button>
</div>
</div>
{{if .AssetTemplateEditing}}
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/rename">
<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>
<label class="full"><span>模板描述</span><input name="description" value="{{.AssetTemplate.Description}}" /></label>
</div>
<div class="detail-sheet">
<div class="detail-item"><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>节点数</span><strong>{{.AssetTemplate.NodeCount}}</strong></div>
<div class="detail-item"><span>连线数</span><strong>{{.AssetTemplate.EdgeCount}}</strong></div>
<div class="detail-item"><span>输入槽位</span><strong>{{if .AssetTemplate.Slots.Inputs}}{{len .AssetTemplate.Slots.Inputs}}{{else}}0{{end}}</strong></div>
<div class="detail-item"><span>服务槽位</span><strong>{{if .AssetTemplate.Slots.Services}}{{len .AssetTemplate.Slots.Services}}{{else}}0{{end}}</strong></div>
<div class="detail-item"><span>输出槽位</span><strong>{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}</strong></div>
<div class="detail-item full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
</div>
<div class="actions">
<button type="submit" class="btn secondary">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/templates?name={{.AssetTemplate.Name}}">{{icon "close"}}<span>取消</span></a>
</div>
</form>
{{else}}
<div class="info-list">
<div>
<span>模板名</span>
{{if .AssetTemplate.ReadOnly}}
<strong class="mono">{{.AssetTemplate.Name}}</strong>
{{else}}
<div class="editable-line">
<strong class="mono js-inline-edit-display">{{.AssetTemplate.Name}}</strong>
<button type="button" class="btn ghost icon-only js-inline-edit-toggle" data-target="template-name-edit" aria-label="修改模板名" title="修改模板名">{{icon "edit"}}</button>
</div>
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/rename" id="template-name-edit" class="inline-edit-form" hidden>
<input type="hidden" name="description" value="{{.AssetTemplate.Description}}" />
<input name="name" value="{{.AssetTemplate.Name}}" class="mono" />
<button type="submit" class="btn secondary icon-only" aria-label="保存模板名" title="保存模板名">{{icon "apply"}}</button>
<button type="button" class="btn ghost icon-only js-inline-edit-cancel" data-target="template-name-edit" aria-label="取消" title="取消">{{icon "close"}}</button>
</form>
{{end}}
</div>
<div><span>模板类型</span><strong>{{if .AssetTemplate.ReadOnly}}标准模板(只读){{else}}用户模板{{end}}</strong></div>
<div><span>来源文件</span><strong class="mono">{{if .AssetTemplate.Source}}{{.AssetTemplate.Source}}{{else}}-{{end}}</strong></div>
<div><span>节点数</span><strong>{{.AssetTemplate.NodeCount}}</strong></div>
<div><span>连线数</span><strong>{{.AssetTemplate.EdgeCount}}</strong></div>
<div><span>MinIO</span><strong class="mono">{{if .AssetTemplate.MinIOEndpoint}}{{.AssetTemplate.MinIOEndpoint}}{{else}}-{{end}}</strong></div>
<div><span>Bucket</span><strong>{{if .AssetTemplate.MinIOBucket}}{{.AssetTemplate.MinIOBucket}}{{else}}-{{end}}</strong></div>
<div class="full"><span>取 token 接口</span><strong class="mono">{{if .AssetTemplate.ExternalGetTokenURL}}{{.AssetTemplate.ExternalGetTokenURL}}{{else}}-{{end}}</strong></div>
<div class="full"><span>告警上报接口</span><strong class="mono">{{if .AssetTemplate.ExternalPutMessageURL}}{{.AssetTemplate.ExternalPutMessageURL}}{{else}}-{{end}}</strong></div>
<div><span>租户编码</span><strong>{{if .AssetTemplate.TenantCode}}{{.AssetTemplate.TenantCode}}{{else}}-{{end}}</strong></div>
<div><span>输入槽位</span><strong>{{if .AssetTemplate.Slots.Inputs}}{{len .AssetTemplate.Slots.Inputs}}{{else}}0{{end}}</strong></div>
<div><span>服务槽位</span><strong>{{if .AssetTemplate.Slots.Services}}{{len .AssetTemplate.Slots.Services}}{{else}}0{{end}}</strong></div>
<div><span>输出槽位</span><strong>{{if .AssetTemplate.Slots.Outputs}}{{len .AssetTemplate.Slots.Outputs}}{{else}}0{{end}}</strong></div>
<div class="full">
<span>描述</span>
{{if .AssetTemplate.ReadOnly}}
<strong>{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong>
{{else}}
<div class="editable-line">
<strong class="js-inline-edit-display">{{if .AssetTemplate.Description}}{{.AssetTemplate.Description}}{{else}}-{{end}}</strong>
<button type="button" class="btn ghost icon-only js-inline-edit-toggle" data-target="template-description-edit" aria-label="修改描述" title="修改描述">{{icon "edit"}}</button>
</div>
<form method="post" action="/ui/assets/templates/{{.AssetTemplate.Name}}/rename" id="template-description-edit" class="inline-edit-form" hidden>
<input type="hidden" name="name" value="{{.AssetTemplate.Name}}" />
<input name="description" value="{{.AssetTemplate.Description}}" />
<button type="submit" class="btn secondary icon-only" aria-label="保存描述" title="保存描述">{{icon "apply"}}</button>
<button type="button" class="btn ghost icon-only js-inline-edit-cancel" data-target="template-description-edit" aria-label="取消" title="取消">{{icon "close"}}</button>
</form>
{{end}}
</div>
<div class="full"><span>路径</span><strong class="mono">{{.AssetTemplate.Path}}</strong></div>
</div>
{{end}}
</div>
{{if or .AssetTemplate.Slots.Inputs .AssetTemplate.Slots.Services .AssetTemplate.Slots.Outputs}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "config"}}<span>模板槽位</span></h2>
</div>
</div>
<div class="info-list">
{{range .AssetTemplate.Slots.Inputs}}
<div class="full"><span>输入</span><strong class="mono">{{.Name}}</strong> <span class="muted">{{.Type}}</span></div>
{{end}}
{{range .AssetTemplate.Slots.Services}}
<div class="full"><span>服务</span><strong class="mono">{{.Name}}</strong> <span class="muted">{{.Type}}</span></div>
{{end}}
{{range .AssetTemplate.Slots.Outputs}}
<div class="full"><span>输出</span><strong class="mono">{{.Name}}</strong> <span class="muted">{{.Type}}</span></div>
{{end}}
</div>
</div>
{{end}}
{{if .AssetTemplate.ReadOnly}}
<div class="card">
<div class="form-hint">标准模板保留在模板目录中,作为只读基线使用。需要调整流程时,请先复制为用户模板,再进入可视化编辑保存。</div>

View File

@ -5,13 +5,16 @@
<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="/ui/assets/templates" class="nav-link{{if eq .AssetTab "templates"}} active{{end}}" role="tab" {{if eq .AssetTab "templates"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>模板</a>
<a href="/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="/ui/assets/profiles" class="nav-link{{if eq .AssetTab "profiles"}} active{{end}}" role="tab" {{if eq .AssetTab "profiles"}}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="/ui/assets/overlays" class="nav-link{{if eq .AssetTab "overlays"}} active{{end}}" role="tab" {{if eq .AssetTab "overlays"}}aria-selected="true"{{else}}aria-selected="false"{{end}}>叠加项</a>
<a href="/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="/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">
@ -28,26 +31,262 @@
{{define "assets"}}
{{template "asset_tabs" .}}
{{if eq .AssetTab "integrations"}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "service"}}<span>第三方服务列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/assets/integrations?new=1">{{icon "apply"}}<span>新增服务</span></a>
{{if .AssetIntegration.Name}}
<a class="btn secondary" href="/ui/assets/integrations?name={{.AssetIntegration.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/ui/assets/integrations/{{.AssetIntegration.Name}}/delete">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>服务名称</th>
<th>服务类型</th>
<th>描述</th>
<th>地址摘要</th>
<th>状态</th>
<th>引用数量</th>
</tr>
</thead>
<tbody>
{{range .AssetIntegrations}}
<tr data-nav-row data-nav-href="/ui/assets/integrations?name={{.Name}}"{{if and $.AssetIntegration (eq $.AssetIntegration.Name .Name)}} class="selected"{{end}}>
<td><a class="mono" href="/ui/assets/integrations?name={{.Name}}">{{.Name}}</a></td>
<td>{{.TypeLabel}}</td>
<td>{{if .Description}}{{.Description}}{{else}}-{{end}}</td>
<td class="mono">{{if .AddressSummary}}{{.AddressSummary}}{{else}}-{{end}}</td>
<td>{{if .Enabled}}启用{{else}}停用{{end}}</td>
<td>{{.RefCount}}</td>
</tr>
{{else}}
<tr><td colspan="6"><div class="empty-state compact"><div class="empty-title">还没有第三方服务</div></div></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if .AssetIntegration}}
<form method="post" action="/ui/assets/integrations">
<div class="card editor-state {{if .AssetIntegrationEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "service"}}<span>{{if .AssetIntegrationEditing}}第三方服务编辑{{else}}第三方服务详情{{end}}{{if .AssetIntegration.Name}} · {{.AssetIntegration.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetIntegrationEditing}}
<span class="pill run">编辑模式</span>
<span>当前第三方服务正在编辑,保存后生效。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前第三方服务为只读展示。</span>
{{end}}
</div>
</div>
</div>
{{if .AssetIntegrationEditing}}
<div class="field-grid">
<label><span>服务名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetIntegration.Name}}" autofocus /></label>
<label>
<span>服务类型<span class="required-mark">*</span></span>
<select name="type">
<option value="object_storage" {{if eq .AssetIntegration.Type "object_storage"}}selected{{end}}>对象存储</option>
<option value="token_service" {{if eq .AssetIntegration.Type "token_service"}}selected{{end}}>认证服务</option>
<option value="alarm_service" {{if eq .AssetIntegration.Type "alarm_service"}}selected{{end}}>告警服务</option>
</select>
</label>
<label><span>描述</span><input name="description" value="{{.AssetIntegration.Description}}" /></label>
<label><span>启用</span><select name="enabled"><option value="1" {{if .AssetIntegration.Enabled}}selected{{end}}>启用</option><option value="0" {{if not .AssetIntegration.Enabled}}selected{{end}}>停用</option></select></label>
</div>
<div class="field-grid">
<label><span>对象存储地址</span><input class="mono" name="endpoint" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{end}}" /></label>
<label><span>Bucket</span><input class="mono" name="bucket" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Bucket}}{{end}}" /></label>
<label><span>Access Key</span><input class="mono" name="access_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{end}}" /></label>
<label><span>Secret Key</span><input class="mono" name="secret_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{end}}" /></label>
</div>
<div class="field-grid">
<label><span>Token 获取地址</span><input class="mono" name="get_token_url" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.GetTokenURL}}{{end}}" /></label>
<label><span>认证用户名</span><input name="username" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Username}}{{end}}" /></label>
<label><span>认证密码</span><input name="password" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Password}}{{end}}" /></label>
<label><span>认证租户编码</span><input class="mono" name="tenant_code" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.TenantCode}}{{end}}" /></label>
</div>
<div class="field-grid">
<label><span>消息上报地址</span><input class="mono" name="put_message_url" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{end}}" /></label>
<label><span>告警用户名</span><input name="alarm_username" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Username}}{{end}}" /></label>
<label><span>告警密码</span><input name="alarm_password" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Password}}{{end}}" /></label>
<label><span>告警租户编码</span><input class="mono" name="alarm_tenant_code" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.TenantCode}}{{end}}" /></label>
</div>
<div class="actions">
<button type="submit">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/integrations?name={{.AssetIntegration.Name}}">{{icon "close"}}<span>取消</span></a>
</div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>服务名称</span><strong class="mono">{{.AssetIntegration.Name}}</strong></div>
<div class="detail-item"><span>服务类型</span><strong>{{.AssetIntegration.TypeLabel}}</strong></div>
<div class="detail-item"><span>描述</span><strong>{{if .AssetIntegration.Description}}{{.AssetIntegration.Description}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>启用状态</span><strong>{{if .AssetIntegration.Enabled}}启用{{else}}停用{{end}}</strong></div>
<div class="detail-item"><span>对象存储地址</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.Endpoint}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Bucket</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.Bucket}}{{.AssetIntegration.ObjectStorage.Bucket}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Access Key</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.AccessKey}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Secret Key</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.SecretKey}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Token 获取地址</span><strong class="mono">{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.GetTokenURL}}{{.AssetIntegration.TokenService.GetTokenURL}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>认证用户名</span><strong>{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.Username}}{{.AssetIntegration.TokenService.Username}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>认证租户编码</span><strong class="mono">{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.TenantCode}}{{.AssetIntegration.TokenService.TenantCode}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>消息上报地址</span><strong class="mono">{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.PutMessageURL}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>告警用户名</span><strong>{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.Username}}{{.AssetIntegration.AlarmService.Username}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>告警租户编码</span><strong class="mono">{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.TenantCode}}{{.AssetIntegration.AlarmService.TenantCode}}{{else}}-{{end}}</strong></div>
</div>
{{end}}
</div>
</form>
{{end}}
{{else if eq .AssetTab "video-sources"}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "device"}}<span>视频源列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/assets/video-sources?new=1">{{icon "apply"}}<span>新增视频源</span></a>
{{if .AssetVideoSource.Name}}
<a class="btn secondary" href="/ui/assets/video-sources?name={{.AssetVideoSource.Name}}&edit=1">{{icon "edit"}}<span>编辑</span></a>
<form method="post" action="/ui/assets/video-sources/{{.AssetVideoSource.Name}}/delete">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>视频源名称</th>
<th>类型</th>
<th>区域</th>
<th>输入地址</th>
<th>分辨率</th>
<th>帧率</th>
<th>引用数量</th>
</tr>
</thead>
<tbody>
{{range .AssetVideoSources}}
<tr data-nav-row data-nav-href="/ui/assets/video-sources?name={{.Name}}"{{if and $.AssetVideoSource (eq $.AssetVideoSource.Name .Name)}} class="selected"{{end}}>
<td><a class="mono" href="/ui/assets/video-sources?name={{.Name}}">{{.Name}}</a></td>
<td>{{.SourceTypeLabel}}</td>
<td>{{if .Area}}{{.Area}}{{else}}-{{end}}</td>
<td class="mono">{{if .Config.URL}}{{.Config.URL}}{{else}}-{{end}}</td>
<td>{{if .Config.Resolution}}{{.Config.Resolution}}{{else}}-{{end}}</td>
<td class="mono">{{if .Config.FPS}}{{.Config.FPS}}{{else}}-{{end}}</td>
<td>{{.RefCount}}</td>
</tr>
{{else}}
<tr><td colspan="7"><div class="empty-state compact"><div class="empty-title">还没有视频源</div></div></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if .AssetVideoSource}}
<form method="post" action="/ui/assets/video-sources">
<div class="card editor-state {{if .AssetVideoSourceEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "device"}}<span>{{if .AssetVideoSourceEditing}}视频源编辑{{else}}视频源详情{{end}}{{if .AssetVideoSource.Name}} · {{.AssetVideoSource.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetVideoSourceEditing}}
<span class="pill run">编辑模式</span>
<span>当前视频源正在编辑,保存后生效。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前视频源为只读展示。</span>
{{end}}
</div>
</div>
</div>
{{if .AssetVideoSourceEditing}}
<div class="field-grid">
<label><span>视频源名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetVideoSource.Name}}" autofocus /></label>
<label>
<span>类型<span class="required-mark">*</span></span>
<select name="source_type">
<option value="rtsp" {{if eq .AssetVideoSource.SourceType "rtsp"}}selected{{end}}>RTSP</option>
<option value="rtmp" {{if eq .AssetVideoSource.SourceType "rtmp"}}selected{{end}}>RTMP</option>
<option value="file" {{if eq .AssetVideoSource.SourceType "file"}}selected{{end}}>文件</option>
<option value="usb_camera" {{if eq .AssetVideoSource.SourceType "usb_camera"}}selected{{end}}>USB 摄像头</option>
</select>
</label>
<label><span>区域</span><input name="area" value="{{.AssetVideoSource.Area}}" /></label>
<label><span>描述</span><input name="description" value="{{.AssetVideoSource.Description}}" /></label>
</div>
<div class="field-grid">
<label class="full"><span>输入地址<span class="required-mark">*</span></span><input class="mono" name="url" value="{{.AssetVideoSource.Config.URL}}" /></label>
<label><span>标准分辨率</span><input name="resolution" value="{{.AssetVideoSource.Config.Resolution}}" /></label>
<label><span>像素尺寸</span><input class="mono" name="frame_size" value="{{.AssetVideoSource.Config.FrameSize}}" /></label>
<label><span>帧率</span><input class="mono" name="fps" value="{{.AssetVideoSource.Config.FPS}}" /></label>
<label><span>视频格式</span><input name="video_format" value="{{.AssetVideoSource.Config.VideoFormat}}" /></label>
<label><span>焦距</span><input name="focal_length" value="{{.AssetVideoSource.Config.FocalLength}}" /></label>
<label><span>安装高度</span><input name="mount_height" value="{{.AssetVideoSource.Config.MountHeight}}" /></label>
<label><span>安装角度</span><input name="mount_angle" value="{{.AssetVideoSource.Config.MountAngle}}" /></label>
</div>
<div class="actions">
<button type="submit">{{icon "apply"}}<span>保存</span></button>
<a class="btn secondary" href="/ui/assets/video-sources?name={{.AssetVideoSource.Name}}">{{icon "close"}}<span>取消</span></a>
</div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>视频源名称</span><strong class="mono">{{.AssetVideoSource.Name}}</strong></div>
<div class="detail-item"><span>类型</span><strong>{{.AssetVideoSource.SourceTypeLabel}}</strong></div>
<div class="detail-item"><span>区域</span><strong>{{if .AssetVideoSource.Area}}{{.AssetVideoSource.Area}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>描述</span><strong>{{if .AssetVideoSource.Description}}{{.AssetVideoSource.Description}}{{else}}-{{end}}</strong></div>
<div class="detail-item full"><span>输入地址</span><strong class="mono">{{if .AssetVideoSource.Config.URL}}{{.AssetVideoSource.Config.URL}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>标准分辨率</span><strong>{{if .AssetVideoSource.Config.Resolution}}{{.AssetVideoSource.Config.Resolution}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>像素尺寸</span><strong class="mono">{{if .AssetVideoSource.Config.FrameSize}}{{.AssetVideoSource.Config.FrameSize}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>帧率</span><strong class="mono">{{if .AssetVideoSource.Config.FPS}}{{.AssetVideoSource.Config.FPS}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>视频格式</span><strong>{{if .AssetVideoSource.Config.VideoFormat}}{{.AssetVideoSource.Config.VideoFormat}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>焦距</span><strong>{{if .AssetVideoSource.Config.FocalLength}}{{.AssetVideoSource.Config.FocalLength}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>安装高度</span><strong>{{if .AssetVideoSource.Config.MountHeight}}{{.AssetVideoSource.Config.MountHeight}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>安装角度</span><strong>{{if .AssetVideoSource.Config.MountAngle}}{{.AssetVideoSource.Config.MountAngle}}{{else}}-{{end}}</strong></div>
</div>
{{end}}
</div>
</form>
{{end}}
{{else}}
<div class="stats">
<div class="stat accent-teal">
<div class="k metric-label">{{icon "template"}}<span>模板</span></div>
<div class="k metric-label">{{icon "template"}}<span>识别模板</span></div>
<div class="v">{{len .AssetTemplates}}</div>
<div class="hint">{{if .ConfigSources.Root}}{{.ConfigSources.Root}}{{else}}未定位到配置仓库{{end}}</div>
<div class="hint">{{if .ConfigSources.Root}}{{.ConfigSources.Root}}{{else}}标准模板与配置均存储在本地数据库{{end}}</div>
</div>
<div class="stat accent-green">
<div class="k metric-label">{{icon "profile"}}<span>业务配置</span></div>
<div class="v">{{len .AssetProfiles}}</div>
<div class="hint">业务场景与通道参数</div>
<div class="k metric-label">{{icon "device"}}<span>视频源</span></div>
<div class="v">{{len .AssetVideoSources}}</div>
<div class="hint">可复用输入流配置</div>
</div>
<div class="stat accent-slate">
<div class="k metric-label">{{icon "overlay"}}<span>叠加项</span></div>
<div class="k metric-label">{{icon "overlay"}}<span>调试参数</span></div>
<div class="v">{{len .AssetOverlays}}</div>
<div class="hint">调试与敏感度变化</div>
</div>
<div class="stat accent-amber">
<div class="k metric-label">{{icon "release"}}<span>视频通道</span></div>
<div class="v">{{.AssetInstanceCount}}</div>
<div class="hint">业务配置中定义的通道数</div>
<div class="k metric-label">{{icon "service"}}<span>第三方服务</span></div>
<div class="v">{{len .AssetIntegrations}}</div>
<div class="hint">告警、对象存储和认证服务</div>
</div>
</div>
@ -55,7 +294,7 @@
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "template"}}<span>模板</span></h2>
<h2 class="title-with-icon">{{icon "template"}}<span>识别模板</span></h2>
</div>
</div>
<div class="asset-list">
@ -75,18 +314,18 @@
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>业务配置</span></h2>
<h2 class="title-with-icon">{{icon "device"}}<span>视频源</span></h2>
</div>
</div>
<div class="asset-list">
{{range .AssetProfiles}}
<a class="asset-row asset-link" href="/ui/assets/profiles/{{.Name}}">
<span>{{.Name}}</span>
<span class="muted small">{{len .Instances}} 路通道</span>
{{range .AssetVideoSources}}
<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>
{{else}}
<div class="empty-state compact">
<div class="empty-title">还没有业务配置</div>
<div class="empty-title">还没有视频源</div>
</div>
{{end}}
</div>
@ -95,7 +334,27 @@
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>叠加项</span></h2>
<h2 class="title-with-icon">{{icon "service"}}<span>第三方服务</span></h2>
</div>
</div>
<div class="asset-list">
{{range .AssetIntegrations}}
<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>
{{else}}
<div class="empty-state compact">
<div class="empty-title">还没有第三方服务</div>
</div>
{{end}}
</div>
</div>
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "overlay"}}<span>调试参数</span></h2>
</div>
</div>
<div class="asset-list">
@ -106,26 +365,14 @@
</a>
{{else}}
<div class="empty-state compact">
<div class="empty-title">还没有叠加项</div>
<div class="empty-title">还没有调试参数</div>
</div>
{{end}}
</div>
</div>
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "config"}}<span>资产说明</span></h2>
</div>
</div>
<div class="info-list compact-list">
<div><span>模板</span><strong>定义处理链与共享服务接入</strong></div>
<div><span>业务配置</span><strong>定义站点、视频源、输出流与通道参数</strong></div>
<div><span>叠加项</span><strong>定义调试、敏感度和运行模式差异</strong></div>
<div><span>原则</span><strong>不回到手工维护多份完整 JSON</strong></div>
</div>
</div>
</div>
{{end}}
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{template "asset_tabs_end" .}}

View File

@ -2,7 +2,7 @@
<div class="card">
<div class="section-title">
<div>
<div class="crumb">诊断 / 审计记录</div>
<div class="crumb">系统管理 / 日志审计 / 审计记录</div>
<h2 class="title-with-icon">{{icon "audit"}}<span>审计记录</span></h2>
</div>
</div>

View File

@ -4,49 +4,19 @@
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "preview"}}<span>预览</span></h2>
<h2 class="title-with-icon">{{icon "preview"}}<span>设备分配预览</span></h2>
</div>
{{if .ConfigSources.Root}}<div class="muted small mono">{{.ConfigSources.Root}}</div>{{end}}
</div>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/config-preview">
<div class="field-grid">
<label><span>模板</span>
<select name="template">
{{range .ConfigSources.Templates}}
<option value="{{.Name}}" {{if eq .Name $.SelectedTemplate}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label><span>业务配置</span>
<select name="profile">
{{range .ConfigSources.Profiles}}
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label><span>config_id</span><input name="config_id" value="{{.SelectedConfigID}}" /></label>
<label><span>config_version</span><input name="config_version" value="{{.SelectedVersion}}" placeholder="留空自动生成" /></label>
<div class="full">
<span class="muted small">配置叠加项</span>
<div class="actions" style="margin-top:6px">
{{range .ConfigSources.Overlays}}
<label class="btn ghost">
<input type="checkbox" name="overlay" value="{{.Name}}" {{if hasString $.SelectedOverlays .Name}}checked{{end}} />
{{.Name}}
</label>
{{end}}
</div>
</div>
<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>
<div><span>识别单元</span><strong>{{if .DeviceAssignment}}{{.DeviceAssignment.RecognitionCount}} 路{{else}}0 路{{end}}</strong></div>
</div>
<div class="actions">
<button type="submit">生成预览</button>
{{if .ConfigPreview}}
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-candidate">上传为候选配置</button>
<button type="submit" formaction="/ui/devices/{{.Device.DeviceID}}/config-candidate/apply">应用候选配置</button>
<input type="hidden" name="json" value="{{.ConfigPreview.JSON}}" />
{{end}}
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}#device-config">返回设备详情</a>
</div>
</form>
@ -64,8 +34,8 @@
<div><span>版本</span><strong class="mono">{{index .ConfigPreview.Metadata "config_version"}}</strong></div>
<div><span>业务名称</span><strong>{{if index .ConfigPreview.Metadata "business_name"}}{{index .ConfigPreview.Metadata "business_name"}}{{else}}-{{end}}</strong></div>
<div><span>模板</span><strong>{{index .ConfigPreview.Metadata "template"}}</strong></div>
<div><span>业务配置</span><strong>{{index .ConfigPreview.Metadata "profile"}}</strong></div>
<div><span>配置叠加项</span><strong class="mono">{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
<div><span>场景模板</span><strong>{{index .ConfigPreview.Metadata "profile"}}</strong></div>
<div><span>调试参数</span><strong class="mono">{{if index .ConfigPreview.Metadata "overlays"}}{{range $i, $name := index .ConfigPreview.Metadata "overlays"}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
<div><span>大小</span><strong class="mono">{{.ConfigPreview.Size}} bytes</strong></div>
<div class="full"><span>SHA256</span><strong class="mono">{{.ConfigPreview.Sha256}}</strong></div>
</div>
@ -77,37 +47,6 @@
</details>
{{end}}
{{if and (eq .ResultTitle "应用候选配置结果") .ConfigStatus}}
<div class="card">
<h2>应用结果摘要</h2>
<div class="row" style="margin-top:10px">
<div>
<div class="muted small">当前运行</div>
<div class="mono">{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}} / {{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}{{else}}未标记{{end}}</div>
<div class="muted small mono" style="margin-top:6px">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</div>
<div class="muted small mono" style="margin-top:6px">sha: {{shortHash .ConfigStatus.Sha256}}</div>
</div>
<div>
<div class="muted small">上一份配置</div>
<div class="mono">{{if and .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Exists .ConfigStatus.PreviousConfig.Metadata.ConfigID}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigID}} / {{if .ConfigStatus.PreviousConfig.Metadata.ConfigVersion}}{{.ConfigStatus.PreviousConfig.Metadata.ConfigVersion}}{{else}}未标记{{end}}{{else}}-{{end}}</div>
<div class="muted small mono" style="margin-top:6px">{{if and .ConfigStatus.PreviousConfig .ConfigStatus.PreviousConfig.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.PreviousConfig.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</div>
<div class="muted small mono" style="margin-top:6px">sha: {{if .ConfigStatus.PreviousConfig}}{{shortHash .ConfigStatus.PreviousConfig.Sha256}}{{end}}</div>
</div>
<div>
<div class="muted small">候选配置</div>
<div>{{if and .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}<span class="pill">仍存在</span>{{else}}<span class="pill ok">已清空</span>{{end}}</div>
</div>
<div>
<div class="muted small">视觉服务</div>
<div>{{if .ConfigStatus.MediaServer.Running}}<span class="pill ok">运行中</span>{{else}}<span class="pill bad">未运行</span>{{end}}</div>
</div>
</div>
{{if and .ConfigStatus.PreviousConfig .ConfigStatus.Sha256 .ConfigStatus.PreviousConfig.Sha256 (eq .ConfigStatus.Metadata.ConfigID .ConfigStatus.PreviousConfig.Metadata.ConfigID) (eq .ConfigStatus.Metadata.ConfigVersion .ConfigStatus.PreviousConfig.Metadata.ConfigVersion) (ne .ConfigStatus.Sha256 .ConfigStatus.PreviousConfig.Sha256)}}
<div class="muted small" style="margin-top:10px">当前运行与上一份配置回滚点的 <span class="mono">config_id/config_version</span> 相同,但文件内容不同,请以配置叠加项和 <span class="mono">sha</span> 为准。</div>
{{end}}
</div>
{{end}}
{{if .RawText}}
<div class="card">
<h2>{{if .ResultTitle}}{{.ResultTitle}}{{else}}执行结果{{end}}</h2>

View File

@ -49,7 +49,7 @@
<tr>
<td><a class="mono" href="/ui/tasks/{{.ID}}">{{.ID}}</a></td>
<td>
{{if eq .Type "config_apply"}}下发识别配置
{{if eq .Type "config_apply"}}下发设备分配
{{else if eq .Type "reload"}}重载识别服务
{{else if eq .Type "rollback"}}回滚识别配置
{{else if eq .Type "media_start"}}启动视频分析服务

View File

@ -5,11 +5,11 @@
<div class="section-title">
<div>
<h2>设备工作台</h2>
<div class="muted small">单设备查看、服务控制、配应用、模型资源和日志指标都收敛在这里完成。</div>
<div class="muted small">单设备查看、服务控制、设备分配应用、模型资源和日志指标都收敛在这里完成。</div>
</div>
<div class="actions">
<a class="btn ghost" href="/ui/devices">返回设备列表</a>
<a class="btn ghost" href="/ui/devices/{{.Device.DeviceID}}/config-preview">{{icon "preview"}}<span>打开配置预览</span></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>
@ -33,7 +33,7 @@
<div class="actions compact">
<a class="btn ghost" href="#device-overview">概览</a>
<a class="btn ghost" href="#device-runtime">运行与服务</a>
<a class="btn ghost" href="#device-config">设备配</a>
<a class="btn ghost" href="#device-config">设备</a>
<a class="btn ghost" href="#device-models">模型与资源</a>
<a class="btn ghost" href="#device-observability">日志与指标</a>
</div>
@ -54,7 +54,7 @@
<div><span>视频端口</span><strong class="mono">{{.Device.MediaPort}}</strong></div>
<div><span>最后心跳</span><strong>{{ago .Device.LastSeenMs}}</strong></div>
<div><span>版本</span><strong class="mono">{{if .Device.Version}}{{.Device.Version}}{{else}}-{{end}}</strong></div>
<div><span>当前业务配置</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.BusinessName}}{{.ConfigStatus.Metadata.BusinessName}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
<div><span>当前场景模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
<div><span>通道名</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.InstanceName}}{{.ConfigStatus.Metadata.InstanceName}}{{else if .Device.InstanceName}}{{.Device.InstanceName}}{{else}}-{{end}}</strong></div>
</div>
</div>
@ -71,8 +71,8 @@
<div><span>配置 ID</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}未标记{{end}}</strong></div>
<div><span>配置版本</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
<div><span>模板</span><strong>{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
<div><span>业务配置</span><strong>{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
<div><span>叠加项</span><strong class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
<div><span>场景模板</span><strong>{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
<div><span>调试参数</span><strong class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
<div><span>配置文件</span><strong class="mono">{{.ConfigStatus.ConfigPath}}</strong></div>
<div><span>配置 SHA</span><strong class="mono">{{shortHash .ConfigStatus.Sha256}}</strong></div>
</div>
@ -82,8 +82,8 @@
<div><span>配置 ID</span><strong class="mono">{{if .PersistedConfig.ConfigID}}{{.PersistedConfig.ConfigID}}{{else}}未标记{{end}}</strong></div>
<div><span>配置版本</span><strong class="mono">{{if .PersistedConfig.ConfigVersion}}{{.PersistedConfig.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
<div><span>模板</span><strong>{{if .PersistedConfig.TemplateName}}{{.PersistedConfig.TemplateName}}{{else}}-{{end}}</strong></div>
<div><span>业务配置</span><strong>{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
<div><span>叠加项</span><strong class="mono">{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}</strong></div>
<div><span>场景模板</span><strong>{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
<div><span>调试参数</span><strong class="mono">{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}</strong></div>
<div><span>最近下发任务</span><strong class="mono">{{if .PersistedConfig.LastAppliedTaskID}}{{.PersistedConfig.LastAppliedTaskID}}{{else}}-{{end}}</strong></div>
</div>
{{else}}
@ -106,9 +106,8 @@
</div>
<div class="info-list compact-list">
<div><span>媒体服务</span><strong>{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}运行中{{else}}未确认{{end}}</strong></div>
<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 stack">
<div class="actions service-actions-row">
<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" />
@ -130,23 +129,33 @@
<section class="panel-block" id="device-config">
<div class="panel-head">
<div>
<h3 class="title-with-icon">{{icon "apply"}}<span>设备配</span></h3>
<h3 class="title-with-icon">{{icon "apply"}}<span>设备</span></h3>
</div>
<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 class="mono">{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else if .PersistedConfig}}{{.PersistedConfig.ConfigID}}{{else}}待读取{{end}}</strong></div>
<div><span>候选配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}}{{else}}已存在{{end}}{{else}}未上传{{end}}</strong></div>
{{if .DeviceAssignment}}
<details class="scene-summary-details">
<summary>查看当前设备分配详情</summary>
<div class="info-list compact-list">
<div><span>场景模板</span><strong class="mono">{{.DeviceAssignment.ProfileName}}</strong></div>
<div><span>识别单元</span><strong>{{.DeviceAssignment.RecognitionCount}} 路</strong></div>
<div><span>说明</span><strong>{{if .DeviceAssignment.Description}}{{.DeviceAssignment.Description}}{{else}}-{{end}}</strong></div>
</div>
</details>
{{else}}
<div class="empty-state compact">
<div class="empty-title">还没有设备分配</div>
<div class="muted">请先到“设备分配”页面为这台设备指定识别单元。</div>
</div>
<div class="actions">
<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>
{{end}}
<div class="actions scene-actions-row">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/plan-apply">
<button type="submit" class="primary">下发当前设备分配</button>
</form>
<a class="btn secondary" href="/ui/devices/{{.Device.DeviceID}}/config-preview"><span>预览当前设备分配</span></a>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="rollback" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="secondary">回滚到上一份</button>
<button type="submit" class="secondary">回滚上一版</button>
</form>
</div>
</section>
@ -160,13 +169,13 @@
</div>
<div class="info-list compact-list">
<div><span>模型入口</span><strong>通过模型管理页上传到设备</strong></div>
<div><span>人脸库</span><strong>通过识别配置与资源页维护</strong></div>
<div><span>人脸库</span><strong>通过资源管理与基础配置维护</strong></div>
</div>
<div class="actions">
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/face-gallery/reload">
<button type="submit" class="secondary">重载人脸库</button>
</form>
<a class="btn ghost" href="/ui/assets">查看识别配置资产</a>
<a class="btn ghost" href="/ui/assets">查看基础配置</a>
</div>
</section>

View File

@ -0,0 +1,372 @@
{{define "device_assignments"}}
<div class="card assignment-board-page">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "devices"}}<span>设备分配</span></h2>
</div>
</div>
{{if .DeviceAssignmentBoard}}
<div class="assignment-kpis">
<div class="assignment-kpi">
<span>识别单元</span>
<strong>{{.DeviceAssignmentBoard.Stats.TotalUnits}}</strong>
</div>
<div class="assignment-kpi">
<span>设备</span>
<strong>{{.DeviceAssignmentBoard.Stats.TotalDevices}}</strong>
</div>
<div class="assignment-kpi">
<span>已分配</span>
<strong>{{.DeviceAssignmentBoard.Stats.AssignedUnits}}</strong>
</div>
<div class="assignment-kpi">
<span>未分配</span>
<strong>{{.DeviceAssignmentBoard.Stats.UnassignedUnits}}</strong>
</div>
<div class="assignment-kpi">
<span>平均负载</span>
<strong>{{printf "%.1f" .DeviceAssignmentBoard.Stats.AverageLoad}}</strong>
</div>
<div class="assignment-kpi">
<span>满载设备</span>
<strong>{{.DeviceAssignmentBoard.Stats.OverloadedDevices}}</strong>
</div>
</div>
<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">
<span>每台最多</span>
<input type="range" min="1" max="8" step="1" value="{{.MaxUnitsPerDevice}}" id="max-units-range" name="max_units_per_device" />
<strong id="max-units-value">{{.MaxUnitsPerDevice}} 路/台</strong>
</label>
<div class="actions compact">
<button type="button" class="btn secondary" id="auto-assign-btn">自动平均分配</button>
<button type="button" class="btn secondary" id="clear-assign-btn">清空分配</button>
<button type="submit">保存设备分配</button>
</div>
</div>
<div id="assignment-feedback" class="form-hint"></div>
<div class="assignment-board-grid" id="assignment-cards">
{{range .DeviceAssignmentBoard.Cards}}
<section class="assignment-device-card state-{{.Status}}" data-device-card="{{.DeviceID}}">
<div class="assignment-device-head">
<div>
<h3>{{.DeviceName}}</h3>
<span class="mono">{{.DeviceID}}</span>
</div>
<div class="assignment-device-metrics">
<strong>{{.AssignedCount}} / {{.MaxUnits}}</strong>
<span class="pill">{{.Status}}</span>
</div>
</div>
<div class="assignment-chip-list" data-assigned-list="{{.DeviceID}}">
{{range .Units}}
<span class="assignment-chip" data-unit-ref="{{.Ref}}">
<span class="assignment-chip-text">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
<button type="button" class="assignment-chip-remove" data-remove-unit="{{.Ref}}">×</button>
</span>
{{end}}
</div>
{{if lt .AssignedCount .MaxUnits}}
<div class="assignment-device-add">
<select data-add-select="{{.DeviceID}}">
<option value="">添加识别单元</option>
</select>
<button type="button" class="btn secondary" data-add-button="{{.DeviceID}}">加入</button>
</div>
{{end}}
</section>
{{end}}
</div>
<section class="assignment-unassigned">
<div class="section-title compact">
<div>
<h3>未分配识别单元</h3>
</div>
</div>
<div class="assignment-chip-list" id="assignment-unassigned-list">
{{range .DeviceAssignmentBoard.Unassigned}}
<span class="assignment-chip assignment-chip-unassigned" data-unit-ref="{{.Ref}}">
<span class="assignment-chip-text">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
</span>
{{else}}
<div class="empty-state compact"><div class="empty-title">已全部分配</div></div>
{{end}}
</div>
</section>
</form>
<script type="application/json" id="assignment-board-seed">{{.DeviceAssignmentBoardJSON}}</script>
<script>
(() => {
const seedEl = document.getElementById('assignment-board-seed');
if (!seedEl) return;
const seed = JSON.parse(seedEl.textContent || '{}');
const state = {
max: Number(seed.max_units_per_device || {{.MaxUnitsPerDevice}}),
units: {},
devices: {},
meta: {}
};
(seed.cards || []).forEach(card => {
state.meta[card.device_id] = {
device_id: card.device_id,
device_name: card.device_name,
max_units: card.max_units || state.max
};
state.devices[card.device_id] = (card.units || []).map(unit => {
state.units[unit.ref] = unit;
return unit.ref;
});
});
(seed.unassigned || []).forEach(unit => {
state.units[unit.ref] = unit;
});
const maxRange = document.getElementById('max-units-range');
const maxValue = document.getElementById('max-units-value');
const feedback = document.getElementById('assignment-feedback');
const hiddenState = document.getElementById('assignment-board-state');
const cardsContainer = document.getElementById('assignment-cards');
const unassignedContainer = document.getElementById('assignment-unassigned-list');
const form = document.getElementById('assignment-board-form');
function unitLabel(unit) {
return unit.display_name || unit.name;
}
function statusFor(count) {
if (count <= 0) return 'idle';
if (count >= state.max) return 'full';
if (count * 2 >= state.max) return 'busy';
return 'low';
}
function statusRank(status) {
return { full: 0, busy: 1, low: 2, idle: 3 }[status] ?? 4;
}
function allRefs() {
return Object.keys(state.units).sort((a, b) => {
const ua = state.units[a];
const ub = state.units[b];
if ((ua.scene_template_name || '') !== (ub.scene_template_name || '')) {
return (ua.scene_template_name || '').localeCompare(ub.scene_template_name || '');
}
return (ua.name || '').localeCompare(ub.name || '');
});
}
function assignedRefSet() {
const seen = new Set();
Object.values(state.devices).forEach(refs => refs.forEach(ref => seen.add(ref)));
return seen;
}
function unassignedRefs() {
const assigned = assignedRefSet();
return allRefs().filter(ref => !assigned.has(ref));
}
function currentProfile(deviceID) {
const refs = state.devices[deviceID] || [];
if (!refs.length) return '';
const unit = state.units[refs[0]];
return unit ? (unit.scene_template_name || '') : '';
}
function moveToUnassigned(ref) {
Object.keys(state.devices).forEach(deviceID => {
state.devices[deviceID] = (state.devices[deviceID] || []).filter(item => item !== ref);
});
}
function addToDevice(deviceID, ref) {
if (!deviceID || !ref) return;
moveToUnassigned(ref);
if (!state.devices[deviceID]) state.devices[deviceID] = [];
state.devices[deviceID].push(ref);
}
function clearAssignments() {
Object.keys(state.devices).forEach(deviceID => {
state.devices[deviceID] = [];
});
}
function autoAssign() {
clearAssignments();
const deviceIDs = Object.keys(state.devices).sort();
const units = allRefs().map(ref => state.units[ref]);
units.forEach(unit => {
let target = null;
deviceIDs.forEach(deviceID => {
const refs = state.devices[deviceID] || [];
const profile = currentProfile(deviceID);
if (profile && profile !== unit.scene_template_name) return;
if (refs.length >= state.max) return;
if (!target || refs.length < (state.devices[target] || []).length) {
target = deviceID;
}
});
if (target) {
state.devices[target].push(unit.ref);
}
});
const remaining = unassignedRefs().length;
feedback.textContent = `已按每台最多 ${state.max} 路完成自动分配${remaining > 0 ? `,剩余 ${remaining} 路未分配。` : '。'}`;
render();
}
function syncHiddenState() {
hiddenState.value = JSON.stringify({ devices: state.devices });
}
function renderStats() {
const assigned = assignedRefSet();
const totalUnits = allRefs().length;
const totalDevices = Object.keys(state.devices).length;
const average = totalDevices ? (assigned.size / totalDevices).toFixed(1) : '0.0';
const full = Object.keys(state.devices).filter(deviceID => statusFor((state.devices[deviceID] || []).length) === 'full').length;
const values = document.querySelectorAll('.assignment-kpi strong');
if (values.length >= 6) {
values[0].textContent = totalUnits;
values[1].textContent = totalDevices;
values[2].textContent = assigned.size;
values[3].textContent = totalUnits - assigned.size;
values[4].textContent = average;
values[5].textContent = full;
}
}
function renderCards() {
const cards = Object.keys(state.devices).map(deviceID => {
const refs = state.devices[deviceID] || [];
return {
deviceID,
deviceName: (state.meta[deviceID] && state.meta[deviceID].device_name) || deviceID,
refs,
status: statusFor(refs.length)
};
}).sort((a, b) => {
const rankDiff = statusRank(a.status) - statusRank(b.status);
if (rankDiff !== 0) return rankDiff;
if (b.refs.length !== a.refs.length) return b.refs.length - a.refs.length;
return a.deviceID.localeCompare(b.deviceID);
});
cardsContainer.innerHTML = '';
cards.forEach(card => {
const section = document.createElement('section');
section.className = `assignment-device-card state-${card.status}`;
const currentTemplate = currentProfile(card.deviceID);
const availableRefs = unassignedRefs().filter(ref => {
const unit = state.units[ref];
return !currentTemplate || unit.scene_template_name === currentTemplate;
});
const addControls = card.refs.length < state.max ? `
<div class="assignment-device-add">
<select data-add-select="${card.deviceID}">
<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>
</div>
` : '';
section.innerHTML = `
<div class="assignment-device-head">
<div>
<h3>${card.deviceName}</h3>
<span class="mono">${card.deviceID}</span>
</div>
<div class="assignment-device-metrics">
<strong>${card.refs.length} / ${state.max}</strong>
<span class="pill">${card.status}</span>
</div>
</div>
<div class="assignment-chip-list"></div>
${addControls}
`;
const chipList = section.querySelector('.assignment-chip-list');
card.refs.forEach(ref => {
const unit = state.units[ref];
const chip = document.createElement('span');
chip.className = 'assignment-chip';
chip.innerHTML = `<span class="assignment-chip-text">${unitLabel(unit)}</span><button type="button" class="assignment-chip-remove" data-remove-unit="${ref}">×</button>`;
chipList.appendChild(chip);
});
cardsContainer.appendChild(section);
});
}
function renderUnassigned() {
const refs = unassignedRefs();
if (!refs.length) {
unassignedContainer.innerHTML = '<div class="empty-state compact"><div class="empty-title">已全部分配</div></div>';
return;
}
unassignedContainer.innerHTML = '';
refs.forEach(ref => {
const unit = state.units[ref];
const chip = document.createElement('span');
chip.className = 'assignment-chip assignment-chip-unassigned';
chip.innerHTML = `<span class="assignment-chip-text">${unitLabel(unit)}</span>`;
unassignedContainer.appendChild(chip);
});
}
function render() {
maxValue.textContent = `${state.max} 路/台`;
renderStats();
renderCards();
renderUnassigned();
syncHiddenState();
}
maxRange.addEventListener('input', () => {
state.max = Number(maxRange.value || state.max);
render();
});
document.addEventListener('click', (event) => {
const remove = event.target.closest('[data-remove-unit]');
if (remove) {
moveToUnassigned(remove.getAttribute('data-remove-unit'));
feedback.textContent = '';
render();
return;
}
const add = event.target.closest('[data-add-button]');
if (add) {
const deviceID = add.getAttribute('data-add-button');
const select = document.querySelector(`[data-add-select="${deviceID}"]`);
if (select && select.value) {
addToDevice(deviceID, select.value);
feedback.textContent = '';
render();
}
return;
}
});
document.getElementById('auto-assign-btn').addEventListener('click', autoAssign);
document.getElementById('clear-assign-btn').addEventListener('click', () => {
clearAssignments();
feedback.textContent = '已清空当前页面中的设备分配。';
render();
});
form.addEventListener('submit', syncHiddenState);
render();
})();
</script>
{{else}}
<div class="empty-state compact"><div class="empty-title">暂无可用的设备分配数据</div></div>
{{end}}
</div>
{{end}}

View File

@ -27,23 +27,13 @@
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "config"}}<span>下发业务配置</span></h2>
<div class="muted">先选择一份已有业务配置,再为已选设备创建下发任务。</div>
<h2 class="title-with-icon">{{icon "config"}}<span>下发设备分配</span></h2>
<div class="muted">按每台设备当前保存的设备分配,批量创建下发任务。</div>
</div>
</div>
<form method="post" action="/ui/devices/batch-config">
{{range .SelectedDeviceIDs}}<input type="hidden" name="device_id" value="{{.}}" />{{end}}
<div class="field-grid">
<label class="full"><span>业务配置</span>
<select name="profile">
{{range .AssetProfiles}}
<option value="{{.Name}}" {{if eq .Name $.SelectedProfile}}selected{{end}}>{{.Name}}{{if .BusinessName}} - {{.BusinessName}}{{end}}</option>
{{end}}
</select>
</label>
</div>
<div class="actions">
<button type="submit" class="primary">创建下发任务</button>
</div>
@ -53,50 +43,34 @@
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "preview"}}<span>业务配置摘要</span></h2>
<h2 class="title-with-icon">{{icon "preview"}}<span>设备分配摘要</span></h2>
</div>
</div>
{{if .AssetProfile}}
<div class="info-list">
<div><span>业务配置</span><strong>{{.AssetProfile.Name}}</strong></div>
<div><span>业务名称</span><strong>{{if .AssetProfile.BusinessName}}{{.AssetProfile.BusinessName}}{{else}}-{{end}}</strong></div>
<div><span>关联模板</span><strong>{{if .SelectedTemplate}}{{.SelectedTemplate}}{{else}}-{{end}}</strong></div>
<div><span>视频通道</span><strong>{{len .AssetProfile.Instances}} 路</strong></div>
{{with index .AssetProfile.Instances 0}}
<div><span>首个通道</span><strong>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</strong></div>
{{end}}
{{if .AssetProfile.Description}}
<div class="full"><span>说明</span><strong>{{.AssetProfile.Description}}</strong></div>
{{end}}
</div>
{{if .AssetProfile.Instances}}
<div class="table-wrap" style="margin-top:14px">
{{if .DeviceAssignments}}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>通道</th>
<th>显示名称</th>
<th>站点</th>
<th>RTSP</th>
<th>设备</th>
<th>场景模板</th>
<th>识别单元</th>
</tr>
</thead>
<tbody>
{{range .AssetProfile.Instances}}
{{range .DeviceAssignments}}
<tr>
<td class="mono">{{if .ChannelNo}}{{.ChannelNo}}{{else}}{{.Name}}{{end}}</td>
<td>{{if .DisplayName}}{{.DisplayName}}{{else}}-{{end}}</td>
<td>{{if .SiteName}}{{.SiteName}}{{else}}-{{end}}</td>
<td class="mono">{{if .RTSPURL}}{{.RTSPURL}}{{else}}-{{end}}</td>
<td class="mono">{{.DeviceID}}</td>
<td class="mono">{{if .ProfileName}}{{.ProfileName}}{{else}}-{{end}}</td>
<td>{{.RecognitionCount}} 路</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{else}}
<div class="empty-state">
<div class="empty-title">还没有可用业务配置</div>
<div class="muted">请先到识别配置中创建业务配置,再回来下发。</div>
<div class="empty-title">还没有可用设备分配</div>
<div class="muted">请先到设备分配中完成设备与识别单元绑定,再回来下发。</div>
</div>
{{end}}
</div>

View File

@ -3,7 +3,7 @@
<div class="section-title">
<div>
<h2>单设备配置已并入设备详情</h2>
<div class="muted small">配置查看、服务控制、候选配置应用和回滚,都已经收敛到设备详情工作台。</div>
<div class="muted small">配置查看、服务控制和回滚,都已经收敛到设备详情工作台。</div>
</div>
<a class="btn ghost" href="/ui/devices">去设备列表</a>
</div>

View File

@ -38,8 +38,8 @@
</div>
<div class="info-list compact-list">
<div><span>当前模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
<div><span>业务配置</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
<div><span>候选配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}} / {{.ConfigStatus.Candidate.Metadata.ConfigVersion}}{{else}}已存在{{end}}{{else}}未上传{{end}}</strong></div>
<div><span>场景模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
<div><span>待应用配置</span><strong class="mono">{{if and .ConfigStatus .ConfigStatus.Candidate .ConfigStatus.Candidate.Exists}}{{if .ConfigStatus.Candidate.Metadata.ConfigID}}{{.ConfigStatus.Candidate.Metadata.ConfigID}} / {{.ConfigStatus.Candidate.Metadata.ConfigVersion}}{{else}}已存在{{end}}{{else}}未上传{{end}}</strong></div>
</div>
</section>
@ -56,7 +56,7 @@
<div class="actions">
<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>
<button type="submit" class="primary">应用待应用配置</button>
</form>
<form method="post" action="/ui/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="rollback" />

View File

@ -42,9 +42,9 @@
<button type="submit" name="action" value="media_restart" class="primary">重启服务</button>
<button type="submit" name="action" value="media_start" class="secondary">启动服务</button>
<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>
<a class="btn secondary" href="{{.BatchConfigURL}}">下发业务配置</a>
<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>
<a class="btn secondary" href="{{.BatchConfigURL}}">下发设备分配</a>
<a class="btn secondary" href="/ui/devices">清空选择</a>
</div>
</div>

View File

@ -2,7 +2,8 @@
<div class="card">
<div class="section-title">
<div>
<h2>诊断工作台</h2>
<h2>日志审计</h2>
<div class="muted small">按设备查看日志与运行指标,并进入审计记录和高级调试。</div>
</div>
<a class="btn ghost" href="/ui/api">高级调试</a>
</div>
@ -39,18 +40,13 @@
</div>
</div>
<div class="row">
<div class="card">
<h2>系统状态</h2>
<div class="actions" style="margin-top:12px">
<a class="btn ghost" href="/ui/system">进入系统状态</a>
</div>
</div>
<div class="card">
<h2>审计记录</h2>
<div class="actions" style="margin-top:12px">
<a class="btn ghost" href="/ui/audit">进入审计记录</a>
<div class="card">
<div class="section-title">
<div>
<h2>审计记录</h2>
</div>
<a class="btn ghost" href="/ui/audit">进入审计记录</a>
</div>
<div class="muted small">查看任务执行、设备操作和配置下发相关的审计记录。</div>
</div>
{{end}}

View File

@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{.Title}}</title>
<link rel="stylesheet" href="/ui/assets/vendor/tabler.min.css" />
<link rel="stylesheet" href="/ui/assets/style.css?v=20260429-theme16" />
<link rel="stylesheet" href="/ui/assets/graph_editor.css?v=20260429-theme16" />
<link rel="stylesheet" href="/ui/assets/style.css?v=20260429-ia02" />
<link rel="stylesheet" href="/ui/assets/graph_editor.css?v=20260429-ia01" />
</head>
<body data-theme="blue-dark">
<div class="app-shell">
@ -22,9 +22,23 @@
<div class="nav-section">主模块</div>
<a href="/ui/dashboard"><span class="nav-icon">{{icon "overview"}}</span><span>总览</span></a>
<a href="/ui/devices"><span class="nav-icon">{{icon "devices"}}</span><span>设备</span></a>
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>识别配置</span></a>
<a href="/ui/scene-templates"><span class="nav-icon">{{icon "profile"}}</span><span>场景模板</span></a>
<a href="/ui/recognition-units"><span class="nav-icon">{{icon "device"}}</span><span>识别单元</span></a>
<a href="/ui/device-assignments"><span class="nav-icon">{{icon "apply"}}</span><span>设备分配</span></a>
<a href="/ui/assets"><span class="nav-icon">{{icon "assets"}}</span><span>基础配置</span></a>
<a href="/ui/tasks"><span class="nav-icon">{{icon "task"}}</span><span>任务</span></a>
<a href="/ui/diagnostics"><span class="nav-icon">{{icon "logs"}}</span><span>诊断</span></a>
<details class="nav-group" id="system-nav-group">
<summary>
<span class="nav-icon">{{icon "system"}}</span>
<span>系统管理</span>
</summary>
<div class="nav-group-items">
<a class="nav-subitem" href="/ui/models"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>模型管理</span></a>
<a class="nav-subitem" href="/ui/resources"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资源管理</span></a>
<a class="nav-subitem" href="/ui/diagnostics"><span class="nav-icon nav-subicon">{{icon "logs"}}</span><span>日志审计</span></a>
<a class="nav-subitem" href="/ui/system"><span class="nav-icon nav-subicon">{{icon "heartbeat"}}</span><span>系统状态</span></a>
</div>
</details>
</nav>
</aside>
<div class="workspace">
@ -43,7 +57,7 @@
<button type="button" data-theme-option="graphite-gold">石墨金色</button>
</div>
</div>
<a class="topbar-icon-btn" href="/ui/diagnostics" aria-label="告警" title="告警">
<a class="topbar-icon-btn" href="/ui/diagnostics" aria-label="日志审计" title="日志审计">
{{icon "bell"}}
<span class="topbar-dot" aria-hidden="true"></span>
</a>
@ -223,6 +237,69 @@
return;
}
const instanceRow = event.target.closest("[data-instance-row]");
if (instanceRow && !event.target.closest("button, a, input, select, textarea")) {
const index = instanceRow.getAttribute("data-instance-row");
const activeInput = document.getElementById("active-instance-input");
if (activeInput && index !== null) {
activeInput.value = index;
}
document.querySelectorAll("[data-instance-editor]").forEach(function (panel) {
panel.hidden = panel.getAttribute("data-instance-editor") !== index;
});
document.querySelectorAll("[data-instance-row]").forEach(function (row) {
row.classList.toggle("selected", row.getAttribute("data-instance-row") === index);
});
return;
}
const profileRow = event.target.closest("[data-profile-row]");
if (profileRow && !event.target.closest("button, a, input, select, textarea")) {
const href = profileRow.getAttribute("data-profile-href");
if (href) {
window.location.href = href;
}
return;
}
const navRow = event.target.closest("[data-nav-row]");
if (navRow && !event.target.closest("button, a, input, select, textarea")) {
const href = navRow.getAttribute("data-nav-href");
if (href) {
window.location.href = href;
}
return;
}
const instanceEditorToggle = event.target.closest(".js-open-instance-editor");
if (instanceEditorToggle) {
event.preventDefault();
const index = instanceEditorToggle.getAttribute("data-instance-index");
const activeInput = document.getElementById("active-instance-input");
if (activeInput && index !== null) {
activeInput.value = index;
}
document.querySelectorAll("[data-instance-editor]").forEach(function (panel) {
panel.hidden = panel.getAttribute("data-instance-editor") !== index;
});
document.querySelectorAll("[data-instance-row]").forEach(function (row) {
row.classList.toggle("selected", row.getAttribute("data-instance-row") === index);
});
const targetId = instanceEditorToggle.getAttribute("data-target");
const panel = targetId ? document.getElementById(targetId) : null;
if (panel) {
panel.scrollIntoView({ block: "start", behavior: "smooth" });
const input = panel.querySelector("input:not([type=hidden]):not([readonly]), textarea, select");
if (input) {
input.focus();
if (input.tagName === "INPUT" || input.tagName === "TEXTAREA") {
input.select();
}
}
}
return;
}
const editCancel = event.target.closest(".js-inline-edit-cancel");
if (editCancel) {
event.preventDefault();
@ -236,6 +313,22 @@
}
}
});
const systemNavGroup = document.getElementById("system-nav-group");
if (systemNavGroup) {
const path = window.location.pathname || "";
if (
path === "/ui/models" ||
path === "/ui/resources" ||
path === "/ui/diagnostics" ||
path === "/ui/system" ||
path === "/ui/audit" ||
path === "/ui/api"
) {
systemNavGroup.open = true;
}
}
})();
</script>
</body>

View File

@ -2,25 +2,24 @@
<div class="card">
<div class="section-title">
<div>
<h2>模型管理工作流</h2>
<div class="muted small">面向检测、识别和人脸库模型的设备级部署入口</div>
<h2>统一模型目录</h2>
<div class="muted small">平台统一维护识别模型版本,设备页只查看已生效版本与同步状态</div>
</div>
<a class="btn ghost" href="/ui/devices">返回设备列表</a>
</div>
<div class="model-summary">
<div class="summary-item"><div class="summary-label">目标节点</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">可选择单台设备上传模型</div></div>
<div class="summary-item"><div class="summary-label">部署方式</div><div class="summary-value">单节点</div><div class="summary-hint">在本页直接上传到目标设备</div></div>
<div class="summary-item"><div class="summary-label">模型类型</div><div class="summary-value">检测/识别</div><div class="summary-hint">二进制模型文件</div></div>
<div class="summary-item"><div class="summary-label">人脸库</div><div class="summary-value">DB</div><div class="summary-hint">通过识别配置页维护</div></div>
<div class="summary-item"><div class="summary-label">模型目录</div><div class="summary-value">统一管理</div><div class="summary-hint">检测 / 识别模型统一发布</div></div>
<div class="summary-item"><div class="summary-label">发布版本</div><div class="summary-value">当前版本</div><div class="summary-hint">按场景模板与识别单元引用生效</div></div>
<div class="summary-item"><div class="summary-label">设备版本状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备版本覆盖</div></div>
<div class="summary-item"><div class="summary-label">人脸库</div><div class="summary-value">统一管理</div><div class="summary-hint">在人脸库资源中维护</div></div>
</div>
</div>
<div class="card">
<h2>设备模型管理</h2>
<h2>设备版本状态</h2>
<div class="table-wrap" style="margin-top:10px">
<table>
<thead>
<tr><th>节点</th><th>状态</th><th>管理地址</th><th>模型操作</th><th>人脸库</th></tr>
<tr><th>节点</th><th>状态</th><th>管理地址</th><th>模型版本</th><th>人脸库</th></tr>
</thead>
<tbody>
{{range .Devices}}
@ -31,20 +30,11 @@
</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="/api/devices/{{.DeviceID}}/models">查看设备模型</a>
</div>
<form method="post" action="/ui/devices/{{.DeviceID}}/models/upload" enctype="multipart/form-data" class="row compact-form">
<input name="name" placeholder="模型名" />
<input type="file" name="file" />
<button type="submit" class="btn ghost">上传模型</button>
</form>
</td>
<td><a class="btn ghost" href="/ui/devices/{{.DeviceID}}/config-ui">人脸库</a></td>
<td class="mono">待上报</td>
<td class="mono">待上报</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">暂无设备。请先在“新增设备”页扫描或手动添加。</td></tr>
<tr><td colspan="5" class="muted">暂无设备。请先在“设备”页扫描或手动添加。</td></tr>
{{end}}
</tbody>
</table>

View File

@ -0,0 +1,107 @@
{{define "recognition_units"}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "device"}}<span>识别单元列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/recognition-units?new=1">{{icon "apply"}}<span>新增识别单元</span></a>
{{if .SelectedRecognitionUnit}}
<a class="btn secondary" href="/ui/recognition-units?ref={{.SelectedRecognitionUnit}}&edit=1">编辑</a>
<form method="post" action="/ui/recognition-units/delete" onsubmit="return confirm('确认删除这个识别单元吗?');">
<input type="hidden" name="ref" value="{{.SelectedRecognitionUnit}}" />
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>识别单元</th>
<th>场景模板</th>
<th>视频源</th>
<th>输出频道号</th>
</tr>
</thead>
<tbody>
{{range .RecognitionUnits}}
<tr data-nav-row data-nav-href="/ui/recognition-units?ref={{.Ref}}" {{if eq $.SelectedRecognitionUnit .Ref}}class="selected"{{end}}>
<td><a class="mono" href="/ui/recognition-units?ref={{.Ref}}">{{.Name}}</a>{{if .DisplayName}}<div class="stacked-meta"><span>{{.DisplayName}}</span></div>{{end}}</td>
<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>
{{end}}
</tbody>
</table>
</div>
</div>
{{if .RecognitionUnit}}
<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>
<div class="form-hint form-state-hint">
{{if .RecognitionUnitEditing}}
<span class="pill run">编辑模式</span>
<span>一路视频对应一个识别单元,由设备分配决定最终在哪台设备上运行。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
{{end}}
</div>
</div>
<div class="actions compact">
{{if .RecognitionUnitEditing}}
<button type="submit">{{icon "apply"}}<span>保存识别单元</span></button>
<a class="btn secondary" href="/ui/recognition-units{{if .SelectedRecognitionUnit}}?ref={{.SelectedRecognitionUnit}}{{end}}">{{icon "close"}}<span>取消</span></a>
{{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>
<select name="scene_template_name">
{{range .AssetProfiles}}
<option value="{{.Name}}" {{if eq $.RecognitionUnit.SceneTemplateName .Name}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label><span>通道显示名</span><input name="display_name" value="{{.RecognitionUnit.DisplayName}}" /></label>
<label><span>站点名</span><input name="site_name" value="{{.RecognitionUnit.SiteName}}" /></label>
<label>
<span>视频源<span class="required-mark">*</span></span>
<select name="video_source_ref">
<option value="">未选择</option>
{{range .AssetVideoSources}}
<option value="{{.Name}}" {{if eq $.RecognitionUnit.VideoSourceRef .Name}}selected{{end}}>{{.Name}}{{if .Area}} - {{.Area}}{{end}}</option>
{{end}}
</select>
</label>
<label><span>输出频道号<span class="required-mark">*</span></span><input name="output_channel" value="{{.RecognitionUnit.OutputChannel}}" /></label>
<label><span>RTSP 端口</span><input class="mono" name="rtsp_port" value="{{.RecognitionUnit.RTSPPort}}" /></label>
</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.SceneTemplateName}}{{.RecognitionUnit.SceneTemplateName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>通道显示名</span><strong>{{if .RecognitionUnit.DisplayName}}{{.RecognitionUnit.DisplayName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>站点名</span><strong>{{if .RecognitionUnit.SiteName}}{{.RecognitionUnit.SiteName}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>视频源</span><strong class="mono">{{if .RecognitionUnit.VideoSourceRef}}{{.RecognitionUnit.VideoSourceRef}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>输出频道号</span><strong class="mono">{{if .RecognitionUnit.OutputChannel}}{{.RecognitionUnit.OutputChannel}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>RTSP 端口</span><strong class="mono">{{if .RecognitionUnit.RTSPPort}}{{.RecognitionUnit.RTSPPort}}{{else}}-{{end}}</strong></div>
</div>
{{end}}
</div>
</form>
{{end}}
{{end}}

View File

@ -0,0 +1,43 @@
{{define "resources"}}
<div class="card">
<div class="section-title">
<div>
<h2>资源管理</h2>
<div class="muted small">统一维护人脸库与通用资源,设备侧只显示当前版本与同步状态。</div>
</div>
</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>
<div class="summary-item"><div class="summary-label">通用资源</div><div class="summary-value">统一管理</div><div class="summary-hint">标定、字典与业务资源统一维护</div></div>
<div class="summary-item"><div class="summary-label">设备资源状态</div><div class="summary-value">{{len .Devices}}</div><div class="summary-hint">纳管设备资源版本覆盖</div></div>
<div class="summary-item"><div class="summary-label">同步方式</div><div class="summary-value">任务下发</div><div class="summary-hint">与设备分配、模型同步一致</div></div>
</div>
</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 .Devices}}
<tr>
<td>
<a class="mono" href="/ui/devices/{{.DeviceID}}">{{if .DeviceName}}{{.DeviceName}}{{else}}{{.DeviceID}}{{end}}</a>
<div class="muted small mono">{{.DeviceID}}</div>
</td>
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
<td class="mono">待上报</td>
<td>{{if .Online}}<span class="pill warn">待同步</span>{{else}}<span class="pill bad">设备离线</span>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">暂无设备。请先在“设备”页扫描或手动添加。</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}

View File

@ -0,0 +1,105 @@
{{define "scene_templates"}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>场景模板列表</span></h2>
</div>
<div class="actions compact">
<a class="btn secondary" href="/ui/scene-templates?new=1">{{icon "apply"}}<span>新建场景模板</span></a>
{{if .SelectedProfile}}
<a class="btn secondary" href="/ui/scene-templates?name={{.SelectedProfile}}&edit=1">编辑</a>
<form method="post" action="/ui/scene-templates/{{.SelectedProfile}}/delete" onsubmit="return confirm('确认删除这个场景模板吗?');">
<button class="btn secondary" type="submit">删除</button>
</form>
{{end}}
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>场景模板</th>
<th>识别模板</th>
<th>调试参数</th>
<th>识别单元</th>
</tr>
</thead>
<tbody>
{{range .AssetProfiles}}
<tr data-nav-row data-nav-href="/ui/scene-templates?name={{.Name}}" {{if eq $.SelectedProfile .Name}}class="selected"{{end}}>
<td><a class="mono" href="/ui/scene-templates?name={{.Name}}">{{.Name}}</a></td>
<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>
</tr>
{{else}}
<tr><td colspan="4"><div class="empty-state compact"><div class="empty-title">还没有场景模板</div></div></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if .AssetProfileEditor}}
{{if .AssetProfileEditing}}<form method="post" action="{{.AssetProfileFormAction}}">{{end}}
<div class="card editor-state {{if .AssetProfileEditing}}editing{{else}}readonly{{end}}">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "profile"}}<span>场景模板{{if .AssetProfileEditor.Name}} · {{.AssetProfileEditor.Name}}{{end}}</span></h2>
<div class="form-hint form-state-hint">
{{if .AssetProfileEditing}}
<span class="pill run">编辑模式</span>
<span>当前内容只包含模板级信息,识别单元请到“识别单元”页面维护。</span>
{{else}}
<span class="pill">查看模式</span>
<span>当前内容为只读,点击“编辑”后进入表单模式。</span>
{{end}}
</div>
</div>
<div class="actions compact">
{{if .AssetProfileEditing}}
<button type="submit">{{icon "apply"}}<span>保存场景模板</span></button>
<a class="btn secondary" href="/ui/scene-templates{{if .SelectedProfile}}?name={{.SelectedProfile}}{{end}}">{{icon "close"}}<span>取消</span></a>
{{end}}
<button type="button" class="btn secondary js-export-json" data-export-url="/ui/scene-templates/{{.AssetProfileEditor.Name}}/export" data-default-filename="{{.AssetProfileEditor.Name}}.json">{{icon "apply"}}<span>导出为 JSON</span></button>
</div>
</div>
{{if .AssetProfileEditing}}
<div class="field-grid">
<label><span>场景模板名称<span class="required-mark">*</span></span><input name="profile_name" value="{{.AssetProfileEditor.Name}}" {{if not .SelectedProfile}}autofocus{{end}} /></label>
<label><span>识别模板<span class="required-mark">*</span></span>
<select name="primary_template_name">
{{range .AssetTemplates}}
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.PrimaryTemplateName .Name}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label><span>业务名称</span><input name="business_name" value="{{.AssetProfileEditor.BusinessName}}" /></label>
<label>
<span>调试参数</span>
<select name="overlay_name">
<option value="">不使用</option>
{{range .AssetOverlays}}
<option value="{{.Name}}" {{if eq $.AssetProfileEditor.OverlayName .Name}}selected{{end}}>{{.Name}}{{if .Description}} - {{.Description}}{{end}}</option>
{{end}}
</select>
</label>
<label><span>站点名</span><input name="site_name" value="{{.AssetProfileEditor.SiteName}}" /></label>
<label><span>描述</span><input name="description" value="{{.AssetProfileEditor.Description}}" /></label>
</div>
{{else}}
<div class="detail-sheet">
<div class="detail-item"><span>场景模板名称</span><strong class="mono">{{if .AssetProfileEditor.Name}}{{.AssetProfileEditor.Name}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>识别模板</span><strong class="mono">{{if .AssetProfileEditor.PrimaryTemplateName}}{{.AssetProfileEditor.PrimaryTemplateName}}{{else}}-{{end}}</strong></div>
<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 full"><span>描述</span><strong>{{if .AssetProfileEditor.Description}}{{.AssetProfileEditor.Description}}{{else}}-{{end}}</strong></div>
</div>
{{end}}
</div>
{{if .AssetProfileEditing}}</form>{{end}}
{{end}}
{{end}}

View File

@ -1,6 +1,6 @@
{{define "system"}}
<div class="card">
<div class="crumb">诊断 / 系统状态</div>
<div class="crumb">系统管理 / 系统状态</div>
<div class="muted small" style="margin-top:8px">系统状态页负责平台健康、发现机制和接入策略查看。</div>
</div>

File diff suppressed because it is too large Load Diff

30
scripts/restart.bat Normal file
View File

@ -0,0 +1,30 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0.."
echo ^> 编译 managerd.exe ...
go build -o managerd.exe ./cmd/managerd
if %ERRORLEVEL% neq 0 (
echo 编译失败,请检查代码错误
pause
exit /b 1
)
echo ^> 停止正在运行的 managerd.exe ...
taskkill /f /im managerd.exe 2>nul
echo ^> 启动 managerd.exe ...
start /b "" managerd.exe
echo ^> 等待启动 ...
timeout /t 2 /nobreak >nul
echo ^> 检查进程状态 ...
tasklist /fi "imagename eq managerd.exe" 2>nul | findstr /i managerd >nul
if %ERRORLEVEL% equ 0 (
echo managerd 已启动
) else (
echo 启动失败
pause
exit /b 1
)

View File

@ -0,0 +1,214 @@
{
"name": "std_face_recognition_stream",
"description": "1080p 人脸识别流程,包含滑窗人脸检测、人脸识别、画面叠加与视频发布。",
"source": "configs/test_scrfd_640_recog.json",
"slots": {
"inputs": [
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
],
"services": [],
"outputs": [
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
]
},
"template": {
"executor": {
"batch_size": 2,
"run_budget": 6
},
"nodes": [
{
"id": "input_rtsp_main",
"type": "input_rtsp",
"role": "source",
"enable": true,
"url": "${slot:video_input_main.url}",
"fps": 30,
"width": 1920,
"height": 1080,
"use_ffmpeg": true,
"use_mpp": false,
"force_tcp": true,
"reconnect_sec": 5,
"reconnect_backoff_max_sec": 30
},
{
"id": "preprocess_rgb",
"type": "preprocess",
"role": "filter",
"enable": true,
"cpu_affinity": [
2
],
"dst_w": 1920,
"dst_h": 1080,
"dst_format": "rgb",
"dst_packed": true,
"resize_mode": "stretch",
"keep_ratio": false,
"rga_gate": "face_recognition_pipeline_rga",
"use_rga": true
},
{
"id": "detect_face",
"type": "ai_scrfd_sliding",
"role": "filter",
"enable": true,
"cpu_affinity": [
4
],
"infer_fps": 3,
"model_path": "./models/face_det_scrfd_500m_640_rk3588.rknn",
"model_w": 640,
"model_h": 640,
"windows": [
{
"x": 0,
"y": 0,
"w": 960,
"h": 1080
},
{
"x": 960,
"y": 0,
"w": 960,
"h": 1080
}
],
"conf_thresh": 0.5,
"nms_thresh": 0.4,
"max_faces": 50,
"debug": {
"stats": false,
"stats_interval": 30
}
},
{
"id": "recognize_face",
"type": "ai_face_recog",
"role": "filter",
"enable": true,
"cpu_affinity": [
4
],
"infer_fps": 2,
"infer_phase_ms": 120,
"model_path": "./models/face_recog_mobilefacenet_arcface_112_rk3588.rknn",
"align": true,
"emit_embedding": false,
"max_faces": 50,
"person_class_id": 0,
"track_state_max_age_ms": 1000,
"input_format": "rgb",
"input_dtype": "uint8",
"threshold": {
"accept": 0.45,
"margin": 0.05
},
"gallery": {
"backend": "sqlite",
"path": "./models/face_gallery.db",
"load_on_start": true,
"dtype": "auto"
},
"debug": {
"enabled": false,
"log_matches": false,
"min_log_interval_ms": 0
}
},
{
"id": "render_osd",
"type": "osd",
"role": "filter",
"enable": true,
"cpu_affinity": [
7
],
"draw_bbox": true,
"draw_text": true,
"draw_face_det": true,
"draw_face_recog": true,
"draw_face_bbox": true,
"line_width": 2.0,
"font_scale": 1.0,
"use_rga_bbox": false,
"labels": []
},
{
"id": "prepare_publish",
"type": "preprocess",
"role": "filter",
"enable": true,
"cpu_affinity": [
7
],
"dst_w": 1920,
"dst_h": 1080,
"dst_format": "nv12",
"resize_mode": "stretch",
"rga_gate": "face_recognition_pipeline_rga",
"use_rga": true
},
{
"id": "publish_stream",
"type": "publish",
"role": "sink",
"enable": true,
"cpu_affinity": [
3
],
"queue": {
"size": 2,
"policy": "drop_oldest"
},
"codec": "h264",
"fps": 30,
"gop": 60,
"bitrate_kbps": 4000,
"mpp_output_timeout_ms": 50,
"mpp_packet_wait_ms": 10,
"use_mpp": true,
"use_ffmpeg_mux": true,
"outputs": [
{
"proto": "hls",
"path": "${slot:stream_output_main.publish_hls_path}",
"segment_sec": 2
},
{
"proto": "rtsp_server",
"port": "${slot:stream_output_main.publish_rtsp_port}",
"path": "${slot:stream_output_main.publish_rtsp_path}"
}
]
}
],
"edges": [
[
"input_rtsp_main",
"preprocess_rgb"
],
[
"preprocess_rgb",
"detect_face"
],
[
"detect_face",
"recognize_face"
],
[
"recognize_face",
"render_osd"
],
[
"render_osd",
"prepare_publish"
],
[
"prepare_publish",
"publish_stream"
]
]
}
}

View File

@ -0,0 +1,83 @@
{
"name": "std_service_test_stream",
"description": "最简服务可用性测试流程,用于验证视频解码、预处理、编码与发布链路是否正常。",
"source": "configs/test_face_only.json",
"slots": {
"inputs": [
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
],
"services": [],
"outputs": [
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
]
},
"template": {
"executor": {
"batch_size": 1,
"run_budget": 3
},
"nodes": [
{
"id": "input_rtsp_main",
"type": "input_rtsp",
"role": "source",
"enable": true,
"url": "${slot:video_input_main.url}",
"fps": 25,
"width": 1280,
"height": 720,
"use_ffmpeg": true,
"use_mpp": false,
"force_tcp": true,
"reconnect_sec": 5,
"reconnect_backoff_max_sec": 30
},
{
"id": "prepare_publish",
"type": "preprocess",
"role": "filter",
"enable": true,
"dst_w": 1280,
"dst_h": 720,
"dst_format": "nv12",
"resize_mode": "stretch",
"use_rga": true,
"rga_gate": "service_test_pipeline_rga"
},
{
"id": "publish_stream",
"type": "publish",
"role": "sink",
"enable": true,
"codec": "h264",
"fps": 25,
"gop": 50,
"bitrate_kbps": 1500,
"use_mpp": true,
"use_ffmpeg_mux": true,
"outputs": [
{
"proto": "hls",
"path": "${slot:stream_output_main.publish_hls_path}",
"segment_sec": 2
},
{
"proto": "rtsp_server",
"port": "${slot:stream_output_main.publish_rtsp_port}",
"path": "${slot:stream_output_main.publish_rtsp_path}"
}
]
}
],
"edges": [
[
"input_rtsp_main",
"prepare_publish"
],
[
"prepare_publish",
"publish_stream"
]
]
}
}

View File

@ -0,0 +1,302 @@
{
"name": "std_workshoe_detection_stream",
"description": "1080p 劳保鞋检测流程,包含人员检测、人员跟踪、劳保鞋规则判断、画面叠加与视频发布。",
"source": "configs/full_pipeline_1080p_test_alarm.json",
"slots": {
"inputs": [
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
],
"services": [],
"outputs": [
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
]
},
"template": {
"executor": {
"batch_size": 2,
"run_budget": 6
},
"nodes": [
{
"id": "input_rtsp_main",
"type": "input_rtsp",
"role": "source",
"enable": true,
"url": "${slot:video_input_main.url}",
"fps": 30,
"width": 1920,
"height": 1080,
"use_ffmpeg": true,
"use_mpp": false,
"force_tcp": true,
"reconnect_sec": 5,
"reconnect_backoff_max_sec": 30
},
{
"id": "preprocess_rgb",
"type": "preprocess",
"role": "filter",
"enable": true,
"cpu_affinity": [
2
],
"dst_w": 1920,
"dst_h": 1080,
"dst_format": "rgb",
"dst_packed": true,
"resize_mode": "stretch",
"keep_ratio": false,
"rga_gate": "workshoe_detection_pipeline_rga",
"use_rga": true
},
{
"id": "detect_person",
"type": "ai_yolo",
"role": "filter",
"enable": true,
"cpu_affinity": [
5
],
"use_rga": true,
"rga_gate": "workshoe_detection_pipeline_rga",
"rga_max_inflight": 4,
"dst_packed": true,
"use_dma_input": true,
"infer_fps": 2,
"infer_phase_ms": 0,
"model_path": "./models/object_det_yolov8n_coco_640_rk3588.rknn",
"model_version": "v8",
"model_w": 640,
"model_h": 640,
"num_classes": 80,
"conf": 0.35,
"nms": 0.45,
"class_filter": [
0
],
"bbox_expand": {
"enable": true,
"class_id": 0,
"left": 0.06,
"right": 0.06,
"top": 0.04,
"bottom": 0.16
},
"debug": {
"stats": false,
"stats_interval": 30,
"detections": false
}
},
{
"id": "track_person",
"type": "tracker",
"role": "filter",
"enable": true,
"cpu_affinity": [
5
],
"mode": "bytetrack_lite",
"per_class": true,
"track_classes": [
0
],
"ignore_classes": [],
"high_th": 0.55,
"low_th": 0.1,
"iou_th": 0.3,
"max_age_ms": 900,
"min_hits": 1,
"max_tracks": 128
},
{
"id": "detect_shoe",
"type": "ai_shoe_det",
"role": "filter",
"enable": true,
"cpu_affinity": [
6
],
"use_rga": true,
"rga_gate": "workshoe_detection_pipeline_rga",
"rga_max_inflight": 4,
"dst_packed": true,
"use_dma_input": false,
"infer_fps": 2,
"infer_phase_ms": 150,
"model_path": "./models/shoe_det_yolov8s_workshoe_640_rk3588.rknn",
"model_w": 640,
"model_h": 640,
"conf": 0.22,
"nms": 0.45,
"v8_box_format": "cxcywh",
"append_detections": true,
"dynamic_roi": {
"enable": true,
"person_class_id": 0,
"shoe_class_id": 1,
"debug_roi_class_id": -1,
"max_rois": 3,
"min_person_height": 60,
"max_box_area_ratio": 0.6,
"y_offset": 0.7,
"width_scale": 1.6,
"height_scale": 0.4
}
},
{
"id": "rule_shoe_association",
"type": "logic_gate",
"role": "filter",
"enable": true,
"cpu_affinity": [
6
],
"mode": "person_shoe_check",
"debug": false,
"person_shoe_check": {
"person_class": 0,
"shoe_class": 1,
"violation_class": 2,
"min_person_score": 0.3,
"min_shoe_score": 0.22,
"foot_region": {
"y_offset": 0.7,
"width_scale": 1.6,
"height_scale": 0.4
},
"min_shoe_height_ratio": 0.08,
"min_shoe_area_ratio": 0.012,
"max_shoe_height_ratio": 0.14,
"max_shoe_width_ratio": 0.38,
"max_shoe_area_ratio": 0.05,
"max_shoe_roi_width_ratio": 0.45,
"max_shoe_roi_height_ratio": 0.35,
"max_shoe_roi_area_ratio": 0.1
}
},
{
"id": "rule_shoe_color",
"type": "logic_gate",
"role": "filter",
"enable": true,
"cpu_affinity": [
6
],
"mode": "ppe_boots_check",
"anchor_class": 0,
"boots_class": 1,
"violation_class": 2,
"debug": false,
"color_check": {
"enable": true
}
},
{
"id": "render_osd",
"type": "osd",
"role": "filter",
"enable": true,
"cpu_affinity": [
7
],
"draw_bbox": true,
"draw_text": true,
"line_width": 2.0,
"font_scale": 1.0,
"use_rga_bbox": false,
"labels": [
"person",
"shoe",
"non_black_shoe"
]
},
{
"id": "prepare_publish",
"type": "preprocess",
"role": "filter",
"enable": true,
"cpu_affinity": [
7
],
"dst_w": 1920,
"dst_h": 1080,
"dst_format": "nv12",
"resize_mode": "stretch",
"rga_gate": "workshoe_detection_pipeline_rga",
"use_rga": true
},
{
"id": "publish_stream",
"type": "publish",
"role": "sink",
"enable": true,
"cpu_affinity": [
3
],
"queue": {
"size": 2,
"policy": "drop_oldest"
},
"codec": "h264",
"fps": 30,
"gop": 60,
"bitrate_kbps": 4000,
"mpp_output_timeout_ms": 50,
"mpp_packet_wait_ms": 10,
"use_mpp": true,
"use_ffmpeg_mux": true,
"outputs": [
{
"proto": "hls",
"path": "${slot:stream_output_main.publish_hls_path}",
"segment_sec": 2
},
{
"proto": "rtsp_server",
"port": "${slot:stream_output_main.publish_rtsp_port}",
"path": "${slot:stream_output_main.publish_rtsp_path}"
}
]
}
],
"edges": [
[
"input_rtsp_main",
"preprocess_rgb"
],
[
"preprocess_rgb",
"detect_person"
],
[
"detect_person",
"track_person"
],
[
"track_person",
"detect_shoe"
],
[
"detect_shoe",
"rule_shoe_association"
],
[
"rule_shoe_association",
"rule_shoe_color"
],
[
"rule_shoe_color",
"render_osd"
],
[
"render_osd",
"prepare_publish"
],
[
"prepare_publish",
"publish_stream"
]
]
}
}

View File

@ -0,0 +1,552 @@
{
"name": "std_workshop_face_recognition_shoe_alarm",
"description": "1080p 车间全功能识别流程,包含人脸检测与识别、人员跟踪、劳保鞋检测、画面叠加、视频发布、告警上报以及抓拍存证。",
"source": "configs/full_pipeline_1080p_test_alarm.json",
"slots": {
"inputs": [
{"name": "video_input_main", "type": "video_source", "required": true, "description": "主视频输入"}
],
"services": [
{"name": "object_storage_main", "type": "object_storage", "required": false, "description": "抓拍与片段上传"},
{"name": "token_service_main", "type": "token_service", "required": false, "description": "认证服务"},
{"name": "alarm_service_main", "type": "alarm_service", "required": false, "description": "告警服务"}
],
"outputs": [
{"name": "stream_output_main", "type": "stream_publish", "required": true, "description": "主视频输出"}
]
},
"template": {
"executor": {
"batch_size": 2,
"run_budget": 8
},
"nodes": [
{
"id": "input_rtsp_main",
"type": "input_rtsp",
"role": "source",
"enable": true,
"url": "${slot:video_input_main.url}",
"fps": 30,
"width": 1920,
"height": 1080,
"use_ffmpeg": true,
"use_mpp": false,
"force_tcp": true,
"reconnect_sec": 5,
"reconnect_backoff_max_sec": 30
},
{
"id": "preprocess_rgb",
"type": "preprocess",
"role": "filter",
"enable": true,
"cpu_affinity": [
2
],
"dst_w": 1920,
"dst_h": 1080,
"dst_format": "rgb",
"dst_packed": true,
"resize_mode": "stretch",
"keep_ratio": false,
"rga_gate": "main_pipeline_rga",
"use_rga": true
},
{
"id": "detect_face",
"type": "ai_scrfd_sliding",
"role": "filter",
"enable": true,
"cpu_affinity": [
4
],
"infer_fps": 3,
"model_path": "./models/face_det_scrfd_500m_640_rk3588.rknn",
"model_w": 640,
"model_h": 640,
"windows": [
{
"x": 0,
"y": 0,
"w": 960,
"h": 1080
},
{
"x": 960,
"y": 0,
"w": 960,
"h": 1080
}
],
"conf_thresh": 0.5,
"nms_thresh": 0.4,
"max_faces": 50,
"debug": {
"stats": false,
"stats_interval": 30
}
},
{
"id": "recognize_face",
"type": "ai_face_recog",
"role": "filter",
"enable": true,
"cpu_affinity": [
4
],
"infer_fps": 2,
"infer_phase_ms": 120,
"model_path": "./models/face_recog_mobilefacenet_arcface_112_rk3588.rknn",
"align": true,
"emit_embedding": false,
"max_faces": 50,
"person_class_id": 0,
"track_state_max_age_ms": 1000,
"input_format": "rgb",
"input_dtype": "uint8",
"threshold": {
"accept": 0.45,
"margin": 0.05
},
"gallery": {
"backend": "sqlite",
"path": "./models/face_gallery.db",
"load_on_start": true,
"dtype": "auto"
},
"debug": {
"enabled": false,
"log_matches": false,
"min_log_interval_ms": 0
}
},
{
"id": "detect_person",
"type": "ai_yolo",
"role": "filter",
"enable": true,
"cpu_affinity": [
5
],
"use_rga": true,
"rga_gate": "main_pipeline_rga",
"rga_max_inflight": 4,
"dst_packed": true,
"use_dma_input": true,
"infer_fps": 2,
"infer_phase_ms": 0,
"model_path": "./models/object_det_yolov8n_coco_640_rk3588.rknn",
"model_version": "v8",
"model_w": 640,
"model_h": 640,
"num_classes": 80,
"conf": 0.35,
"nms": 0.45,
"class_filter": [
0
],
"bbox_expand": {
"enable": true,
"class_id": 0,
"left": 0.06,
"right": 0.06,
"top": 0.04,
"bottom": 0.16
},
"debug": {
"stats": false,
"stats_interval": 30,
"detections": false
}
},
{
"id": "track_person",
"type": "tracker",
"role": "filter",
"enable": true,
"cpu_affinity": [
5
],
"mode": "bytetrack_lite",
"per_class": true,
"track_classes": [
0
],
"ignore_classes": [],
"high_th": 0.55,
"low_th": 0.1,
"iou_th": 0.3,
"max_age_ms": 900,
"min_hits": 1,
"max_tracks": 128
},
{
"id": "detect_shoe",
"type": "ai_shoe_det",
"role": "filter",
"enable": true,
"cpu_affinity": [
6
],
"use_rga": true,
"rga_gate": "main_pipeline_rga",
"rga_max_inflight": 4,
"dst_packed": true,
"use_dma_input": false,
"infer_fps": 2,
"infer_phase_ms": 150,
"model_path": "./models/shoe_det_yolov8s_workshoe_640_rk3588.rknn",
"model_w": 640,
"model_h": 640,
"conf": 0.22,
"nms": 0.45,
"v8_box_format": "cxcywh",
"append_detections": true,
"dynamic_roi": {
"enable": true,
"person_class_id": 0,
"shoe_class_id": 1,
"debug_roi_class_id": -1,
"max_rois": 3,
"min_person_height": 60,
"max_box_area_ratio": 0.6,
"y_offset": 0.7,
"width_scale": 1.6,
"height_scale": 0.4
}
},
{
"id": "rule_shoe_association",
"type": "logic_gate",
"role": "filter",
"enable": true,
"cpu_affinity": [
6
],
"mode": "person_shoe_check",
"debug": false,
"person_shoe_check": {
"person_class": 0,
"shoe_class": 1,
"violation_class": 2,
"min_person_score": 0.3,
"min_shoe_score": 0.22,
"foot_region": {
"y_offset": 0.7,
"width_scale": 1.6,
"height_scale": 0.4
},
"min_shoe_height_ratio": 0.08,
"min_shoe_area_ratio": 0.012,
"max_shoe_height_ratio": 0.14,
"max_shoe_width_ratio": 0.38,
"max_shoe_area_ratio": 0.05,
"max_shoe_roi_width_ratio": 0.45,
"max_shoe_roi_height_ratio": 0.35,
"max_shoe_roi_area_ratio": 0.1
}
},
{
"id": "rule_shoe_color",
"type": "logic_gate",
"role": "filter",
"enable": true,
"cpu_affinity": [
6
],
"mode": "ppe_boots_check",
"anchor_class": 0,
"boots_class": 1,
"violation_class": 2,
"debug": false,
"color_check": {
"enable": true
}
},
{
"id": "prepare_publish",
"type": "preprocess",
"role": "filter",
"enable": true,
"cpu_affinity": [
7
],
"dst_w": 1920,
"dst_h": 1080,
"dst_format": "nv12",
"resize_mode": "stretch",
"rga_gate": "main_pipeline_rga",
"use_rga": true
},
{
"id": "render_osd",
"type": "osd",
"role": "filter",
"enable": true,
"cpu_affinity": [
7
],
"draw_bbox": true,
"draw_text": true,
"draw_face_det": true,
"draw_face_recog": true,
"draw_face_bbox": true,
"line_width": 2.0,
"font_scale": 1.0,
"use_rga_bbox": false,
"labels": [
"person",
"shoe",
"non_black_shoe"
]
},
{
"id": "publish_stream",
"type": "publish",
"role": "filter",
"enable": true,
"cpu_affinity": [
3
],
"queue": {
"size": 2,
"policy": "drop_oldest"
},
"codec": "h264",
"fps": 30,
"gop": 60,
"bitrate_kbps": 4000,
"mpp_output_timeout_ms": 50,
"mpp_packet_wait_ms": 10,
"use_mpp": true,
"use_ffmpeg_mux": true,
"outputs": [
{
"proto": "hls",
"path": "${slot:stream_output_main.publish_hls_path}",
"segment_sec": 2
},
{
"proto": "rtsp_server",
"port": "${slot:stream_output_main.publish_rtsp_port}",
"path": "${slot:stream_output_main.publish_rtsp_path}"
}
]
},
{
"id": "alarm_violation",
"type": "alarm",
"role": "sink",
"enable": true,
"eval_fps": 2,
"labels": [
"person",
"shoe",
"non_black_shoe"
],
"rules": [
{
"name": "non_compliant_workshoe",
"class_ids": [
2
],
"roi": {
"x": 0.0,
"y": 0.0,
"w": 1.0,
"h": 1.0
},
"min_score": 0.3,
"min_box_area_ratio": 0.0,
"require_track_id": false,
"min_duration_ms": 800,
"min_hits": 2,
"hit_window_ms": 2000,
"cooldown_ms": 15000,
"per_track_cooldown_ms": 0
}
],
"face_track_aggregation": {
"known": {
"min_hits": 3,
"hit_window_ms": 3000,
"reentry_cooldown_ms": 300000
},
"unknown": {
"min_track_age_ms": 2000,
"min_quality_hits": 4
}
},
"face_debug": {
"log_unknown_candidates": false,
"unknown_candidate_interval_ms": 0
},
"face_rules": [
{
"name": "unknown_face",
"type": "unknown",
"cooldown_ms": 7000,
"max_known_sim": 0.35,
"min_hits": 2,
"hit_window_ms": 1500,
"min_face_area_ratio": 0.001,
"min_face_aspect": 0.6,
"max_face_aspect": 1.6
},
{
"name": "known_person",
"type": "person",
"cooldown_ms": 7000,
"min_sim": 0.6,
"min_hits": 2,
"hit_window_ms": 1500,
"min_face_area_ratio": 0.001,
"min_face_aspect": 0.6,
"max_face_aspect": 1.6
}
],
"actions": {
"log": {
"enable": true,
"level": "info",
"include_detections": true,
"min_interval_ms": 2000
},
"snapshot": {
"enable": true,
"format": "jpg",
"quality": 85,
"upload": {
"type": "minio",
"endpoint": "${slot:object_storage_main.endpoint}",
"bucket": "${slot:object_storage_main.bucket}",
"region": "us-east-1",
"access_key": "${slot:object_storage_main.access_key}",
"secret_key": "${slot:object_storage_main.secret_key}"
}
},
"clip": {
"enable": true,
"pre_sec": 5,
"post_sec": 10,
"format": "mp4",
"fps": 30,
"upload": {
"type": "minio",
"endpoint": "${slot:object_storage_main.endpoint}",
"bucket": "${slot:object_storage_main.bucket}",
"region": "us-east-1",
"access_key": "${slot:object_storage_main.access_key}",
"secret_key": "${slot:object_storage_main.secret_key}"
}
},
"external_api": {
"enable": true,
"getTokenUrl": "${slot:token_service_main.get_token_url}",
"putMessageUrl": "${slot:alarm_service_main.put_message_url}",
"tenantCode": "${slot:alarm_service_main.tenant_code}",
"channelNo": "${slot:stream_output_main.channel_no}",
"timeout_ms": 3000,
"include_media_url": true,
"token_header": "X-Access-Token",
"token_json_path": "responseBody.token",
"token_cache_sec": 1200
}
}
}
],
"edges": [
[
"input_rtsp_main",
"preprocess_rgb"
],
[
"preprocess_rgb",
"detect_face"
],
[
"detect_face",
"detect_person"
],
[
"detect_person",
"track_person"
],
[
"track_person",
"recognize_face"
],
[
"recognize_face",
"detect_shoe",
{
"queue": {
"size": 16,
"strategy": "drop_oldest"
}
}
],
[
"detect_shoe",
"rule_shoe_association",
{
"queue": {
"size": 16,
"strategy": "drop_oldest"
}
}
],
[
"rule_shoe_association",
"rule_shoe_color",
{
"queue": {
"size": 16,
"strategy": "drop_oldest"
}
}
],
[
"rule_shoe_color",
"render_osd",
{
"queue": {
"size": 16,
"strategy": "drop_oldest"
}
}
],
[
"render_osd",
"prepare_publish",
{
"queue": {
"size": 32,
"strategy": "drop_oldest"
}
}
],
[
"prepare_publish",
"publish_stream",
{
"queue": {
"size": 64,
"strategy": "drop_oldest"
}
}
],
[
"publish_stream",
"alarm_violation",
{
"queue": {
"size": 64,
"strategy": "drop_oldest"
}
}
]
]
}
}