Merge branch 'codex/third-party-services'
This commit is contained in:
commit
6eb712fcfe
4
.gitignore
vendored
4
.gitignore
vendored
@ -30,3 +30,7 @@ Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
.worktrees/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
managerd.exe~
|
||||
nul
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
329
docs/superpowers/specs/2026-04-29-video-sources-design.md
Normal file
329
docs/superpowers/specs/2026-04-29-video-sources-design.md
Normal 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`,从而让视频源真正成为可复用、可维护的基础配置对象。
|
||||
@ -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 路视频、多台边缘设备的部署方式。
|
||||
@ -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
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
415
internal/service/config_runtime_render.go
Normal file
415
internal/service/config_runtime_render.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
77
internal/service/standard_templates.go
Normal file
77
internal/service/standard_templates.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
67
internal/service/template_slots.go
Normal file
67
internal/service/template_slots.go
Normal 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
|
||||
}
|
||||
|
||||
34
internal/service/template_slots_test.go
Normal file
34
internal/service/template_slots_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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, ¬null, &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 ""
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
|
||||
1340
internal/web/ui.go
1340
internal/web/ui.go
File diff suppressed because it is too large
Load Diff
@ -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" },
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{{define "api"}}
|
||||
<div class="card">
|
||||
<div class="crumb">诊断 / 高级调试</div>
|
||||
<div class="crumb">系统管理 / 日志审计 / 高级调试</div>
|
||||
<h2>高级调试</h2>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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}}
|
||||
@ -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}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" .}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"}}启动视频分析服务
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
372
internal/web/ui/templates/device_assignments.html
Normal file
372
internal/web/ui/templates/device_assignments.html
Normal 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}}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
107
internal/web/ui/templates/recognition_units.html
Normal file
107
internal/web/ui/templates/recognition_units.html
Normal 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}}
|
||||
43
internal/web/ui/templates/resources.html
Normal file
43
internal/web/ui/templates/resources.html
Normal 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}}
|
||||
105
internal/web/ui/templates/scene_templates.html
Normal file
105
internal/web/ui/templates/scene_templates.html
Normal 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}}
|
||||
@ -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
30
scripts/restart.bat
Normal 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
|
||||
)
|
||||
214
templates/standard_templates/std_face_recognition_stream.json
Normal file
214
templates/standard_templates/std_face_recognition_stream.json
Normal 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"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
83
templates/standard_templates/std_service_test_stream.json
Normal file
83
templates/standard_templates/std_service_test_stream.json
Normal 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"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
302
templates/standard_templates/std_workshoe_detection_stream.json
Normal file
302
templates/standard_templates/std_workshoe_detection_stream.json
Normal 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"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user