diff --git a/.gitignore b/.gitignore index 7214573..32d313a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ Thumbs.db .idea/ .vscode/ .worktrees/ +.claude/ +CLAUDE.md +managerd.exe~ +nul diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go index ed1c083..1dbec70 100644 --- a/cmd/managerd/main.go +++ b/cmd/managerd/main.go @@ -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) diff --git a/docs/superpowers/specs/2026-04-29-template-scene-binding-design.md b/docs/superpowers/specs/2026-04-29-template-scene-binding-design.md new file mode 100644 index 0000000..13d98fd --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-template-scene-binding-design.md @@ -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. diff --git a/docs/superpowers/specs/2026-04-29-video-sources-design.md b/docs/superpowers/specs/2026-04-29-video-sources-design.md new file mode 100644 index 0000000..b080c69 --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-video-sources-design.md @@ -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`,从而让视频源真正成为可复用、可维护的基础配置对象。 diff --git a/docs/superpowers/specs/2026-04-30-device-assignment-board-design.md b/docs/superpowers/specs/2026-04-30-device-assignment-board-design.md new file mode 100644 index 0000000..9daa5e5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-device-assignment-board-design.md @@ -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 路视频、多台边缘设备的部署方式。 diff --git a/internal/config/config.go b/internal/config/config.go index 480e666..16b3394 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/service/config_assets.go b/internal/service/config_assets.go index d7045eb..46b1c66 100644 --- a/internal/service/config_assets.go +++ b/internal/service/config_assets.go @@ -8,6 +8,7 @@ import ( "sort" "strings" + "3588AdminBackend/internal/models" "3588AdminBackend/internal/storage" ) @@ -17,21 +18,22 @@ var legacyBuiltinTemplateAliases = map[string]string{ } type ConfigTemplateAsset struct { - Name string `json:"name"` - Path string `json:"path"` - Origin string `json:"origin"` - ReadOnly bool `json:"read_only"` - Description string `json:"description"` - Source string `json:"source"` - NodeCount int `json:"node_count"` - EdgeCount int `json:"edge_count"` - MinIOEndpoint string `json:"minio_endpoint"` - MinIOBucket string `json:"minio_bucket"` - ExternalGetTokenURL string `json:"external_get_token_url"` - ExternalPutMessageURL string `json:"external_put_message_url"` - TenantCode string `json:"tenant_code"` - AdvancedParams map[string]any `json:"advanced_params"` - Raw map[string]any `json:"raw"` + Name string `json:"name"` + Path string `json:"path"` + Origin string `json:"origin"` + ReadOnly bool `json:"read_only"` + Description string `json:"description"` + Source string `json:"source"` + Slots TemplateSlotGroup `json:"slots"` + NodeCount int `json:"node_count"` + EdgeCount int `json:"edge_count"` + MinIOEndpoint string `json:"minio_endpoint"` + MinIOBucket string `json:"minio_bucket"` + ExternalGetTokenURL string `json:"external_get_token_url"` + ExternalPutMessageURL string `json:"external_put_message_url"` + TenantCode string `json:"tenant_code"` + AdvancedParams map[string]any `json:"advanced_params"` + Raw map[string]any `json:"raw"` } type ConfigProfileAsset struct { @@ -48,10 +50,10 @@ type ConfigProfileAsset struct { type ConfigProfileInstanceAsset struct { Name string `json:"name"` Template string `json:"template"` + VideoSourceRef string `json:"video_source_ref"` DisplayName string `json:"display_name"` DeviceCode string `json:"device_code"` SiteName string `json:"site_name"` - RTSPURL string `json:"rtsp_url"` PublishHLSPath string `json:"publish_hls_path"` PublishRTSPPort string `json:"publish_rtsp_port"` PublishRTSPPath string `json:"publish_rtsp_path"` @@ -68,29 +70,126 @@ type ConfigOverlayAsset struct { Raw map[string]any `json:"raw"` } +type ConfigIntegrationServiceAsset struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + TypeLabel string `json:"type_label"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + AddressSummary string `json:"address_summary"` + RefCount int `json:"ref_count"` + ObjectStorage *ObjectStorageConfig `json:"object_storage,omitempty"` + TokenService *TokenServiceConfig `json:"token_service,omitempty"` + AlarmService *AlarmServiceConfig `json:"alarm_service,omitempty"` + Raw map[string]any `json:"raw"` +} + +type ObjectStorageConfig struct { + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` +} + +type TokenServiceConfig struct { + GetTokenURL string `json:"get_token_url"` + Username string `json:"username"` + Password string `json:"password"` + TenantCode string `json:"tenant_code"` +} + +type AlarmServiceConfig struct { + PutMessageURL string `json:"put_message_url"` + Username string `json:"username"` + Password string `json:"password"` + TenantCode string `json:"tenant_code"` +} + +type ConfigVideoSourceAsset struct { + Name string `json:"name"` + Path string `json:"path"` + SourceType string `json:"source_type"` + SourceTypeLabel string `json:"source_type_label"` + Area string `json:"area"` + Description string `json:"description"` + RefCount int `json:"ref_count"` + SourceSummary string `json:"source_summary"` + Config VideoSourceConfig `json:"config"` + Raw map[string]any `json:"raw"` +} + +type RecognitionUnitAsset struct { + Ref string `json:"ref"` + SceneTemplateName string `json:"scene_template_name"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + SiteName string `json:"site_name"` + VideoSourceRef string `json:"video_source_ref"` + OutputChannel string `json:"output_channel"` + RTSPPort string `json:"rtsp_port"` + Description string `json:"description"` + AdvancedParams map[string]any `json:"advanced_params,omitempty"` +} + +type DeviceAssignmentAsset struct { + DeviceID string `json:"device_id"` + ProfileName string `json:"profile_name"` + Description string `json:"description"` + RecognitionUnits []string `json:"recognition_units"` + RecognitionCount int `json:"recognition_count"` +} + +type DeviceAssignmentBoardStats struct { + TotalUnits int `json:"total_units"` + TotalDevices int `json:"total_devices"` + AssignedUnits int `json:"assigned_units"` + UnassignedUnits int `json:"unassigned_units"` + AverageLoad float64 `json:"average_load"` + OverloadedDevices int `json:"overloaded_devices"` +} + +type DeviceAssignmentBoardUnit struct { + Ref string `json:"ref"` + SceneTemplateName string `json:"scene_template_name"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + VideoSourceRef string `json:"video_source_ref"` + OutputChannel string `json:"output_channel"` +} + +type DeviceAssignmentBoardCard struct { + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + ProfileName string `json:"profile_name"` + AssignedCount int `json:"assigned_count"` + MaxUnits int `json:"max_units"` + Status string `json:"status"` + Units []DeviceAssignmentBoardUnit `json:"units"` +} + +type DeviceAssignmentBoard struct { + MaxUnitsPerDevice int `json:"max_units_per_device"` + Stats DeviceAssignmentBoardStats `json:"stats"` + Cards []DeviceAssignmentBoardCard `json:"cards"` + Unassigned []DeviceAssignmentBoardUnit `json:"unassigned"` +} + +type VideoSourceConfig struct { + URL string `json:"url"` + Resolution string `json:"resolution"` + FrameSize string `json:"frame_size"` + FPS string `json:"fps"` + VideoFormat string `json:"video_format"` + FocalLength string `json:"focal_length"` + MountHeight string `json:"mount_height"` + MountAngle string `json:"mount_angle"` +} + func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, error) { items := make([]ConfigTemplateAsset, 0) seen := map[string]bool{} - root := s.mediaRepoRoot() - if root != "" { - sources, err := listConfigSources(filepath.Join(root, "configs", "templates")) - if err != nil { - if s.hasExplicitRoot() { - return nil, err - } - } else { - for _, source := range sources { - item, err := s.templateAssetFromPath(source.Name, source.Path, "builtin", true) - if err != nil { - continue - } - items = append(items, *item) - seen[item.Name] = true - } - } - } - if s != nil && s.assets != nil { records, err := s.assets.ListTemplates() if err != nil { @@ -101,7 +200,12 @@ func (s *ConfigPreviewService) ListTemplateAssets() ([]ConfigTemplateAsset, erro if name == "" || seen[name] || legacyBuiltinTemplateAliases[name] != "" { continue } - item, err := s.templateAssetFromRecord(record, "user", false) + readOnly := isStandardTemplateName(name) + origin := "user" + if readOnly { + origin = "standard" + } + item, err := s.templateAssetFromRecord(record, origin, readOnly) if err != nil { continue } @@ -124,16 +228,18 @@ func (s *ConfigPreviewService) GetTemplateAsset(name string) (*ConfigTemplateAss if err := validateConfigName(name); err != nil { return nil, err } - if path, ok := s.mediaAssetPath("templates", name); ok { - return s.templateAssetFromPath(name, path, "builtin", true) - } if s != nil && s.assets != nil { record, err := s.assets.GetTemplate(name) if err != nil { return nil, err } if record != nil { - return s.templateAssetFromRecord(*record, "user", false) + readOnly := isStandardTemplateName(name) + origin := "user" + if readOnly { + origin = "standard" + } + return s.templateAssetFromRecord(*record, origin, readOnly) } } return nil, os.ErrNotExist @@ -147,7 +253,7 @@ func (s *ConfigPreviewService) SaveTemplateAsset(name string, description string if err := validateConfigName(name); err != nil { return err } - if s.templateIsBuiltin(name) { + if isStandardTemplateName(name) { return fmt.Errorf("standard template %q is read-only; please copy it before editing", name) } return s.assets.SaveTemplate(name, description, bodyJSON) @@ -165,10 +271,10 @@ func (s *ConfigPreviewService) RenameTemplateAsset(oldName string, newName strin if err := validateConfigName(newName); err != nil { return err } - if s.templateIsBuiltin(oldName) { + if isStandardTemplateName(oldName) { return fmt.Errorf("standard template %q is read-only; please copy it before editing", oldName) } - if oldName != newName && s.templateIsBuiltin(newName) { + if oldName != newName && isStandardTemplateName(newName) { return fmt.Errorf("standard template name %q is reserved", newName) } return s.assets.RenameTemplate(oldName, newName, description, bodyJSON) @@ -182,7 +288,7 @@ func (s *ConfigPreviewService) DeleteTemplateAsset(name string) error { if err := validateConfigName(name); err != nil { return err } - if s.templateIsBuiltin(name) { + if isStandardTemplateName(name) { return fmt.Errorf("standard template %q is read-only and cannot be deleted", name) } refs, err := s.profileNamesReferencingTemplate(name) @@ -235,39 +341,27 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset return nil, err } queueMap, _ := raw["queue"].(map[string]any) - instancesRaw, _ := raw["instances"].([]any) - instances := make([]ConfigProfileInstanceAsset, 0, len(instancesRaw)) - for _, item := range instancesRaw { - instanceMap, _ := item.(map[string]any) - paramsMap, _ := instanceMap["params"].(map[string]any) - 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 + units, err := s.ListRecognitionUnits() + if err != nil { + return nil, err + } + instances := make([]ConfigProfileInstanceAsset, 0) + for _, unit := range units { + if unit.SceneTemplateName != name { + continue } + channel := firstString(unit.OutputChannel, unit.Name) instances = append(instances, ConfigProfileInstanceAsset{ - Name: stringValue(instanceMap["name"]), - Template: stringValue(instanceMap["template"]), - DisplayName: stringValue(paramsMap["display_name"]), - DeviceCode: stringValue(paramsMap["device_code"]), - SiteName: stringValue(paramsMap["site_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, + Name: unit.Name, + Template: stringValue(raw["primary_template_name"]), + VideoSourceRef: unit.VideoSourceRef, + DisplayName: unit.DisplayName, + SiteName: unit.SiteName, + PublishHLSPath: "./web/hls/" + channel + "/index.m3u8", + PublishRTSPPort: unit.RTSPPort, + PublishRTSPPath: "/live/" + channel, + ChannelNo: channel, + AdvancedParams: cloneMap(unit.AdvancedParams), }) } return &ConfigProfileAsset{ @@ -282,6 +376,294 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset }, nil } +func (s *ConfigPreviewService) DeleteProfileAsset(name string) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + if err := validateConfigName(name); err != nil { + return err + } + units, err := s.ListRecognitionUnits() + if err != nil { + return err + } + for _, unit := range units { + if unit.SceneTemplateName == name { + return fmt.Errorf("场景模板 %q 下仍有识别单元,无法删除", name) + } + } + return s.assets.DeleteProfile(name) +} + +func recognitionUnitRef(profileName string, unitName string) string { + return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName) +} + +func parseRecognitionUnitRef(ref string) (string, string, error) { + parts := strings.SplitN(strings.TrimSpace(ref), "::", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { + return "", "", fmt.Errorf("invalid recognition unit ref: %s", ref) + } + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil +} + +func (s *ConfigPreviewService) ListRecognitionUnits() ([]RecognitionUnitAsset, error) { + if s == nil || s.assets == nil { + return nil, fmt.Errorf("asset repository is not configured") + } + records, err := s.assets.ListRecognitionUnits() + if err != nil { + return nil, err + } + items := make([]RecognitionUnitAsset, 0, len(records)) + for _, record := range records { + item, err := recognitionUnitFromRecord(record) + if err != nil { + continue + } + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { + if items[i].SceneTemplateName != items[j].SceneTemplateName { + return items[i].SceneTemplateName < items[j].SceneTemplateName + } + return items[i].Name < items[j].Name + }) + return items, nil +} + +func (s *ConfigPreviewService) GetRecognitionUnit(ref string) (*RecognitionUnitAsset, error) { + sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref) + if err != nil { + return nil, err + } + record, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName) + if err != nil { + return nil, err + } + if record == nil { + return nil, os.ErrNotExist + } + return recognitionUnitFromRecord(*record) +} + +func (s *ConfigPreviewService) SaveRecognitionUnit(unit RecognitionUnitAsset, originalRef string) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + sceneTemplate := strings.TrimSpace(unit.SceneTemplateName) + if err := validateConfigName(sceneTemplate); err != nil { + return fmt.Errorf("invalid scene template: %w", err) + } + unitName := strings.TrimSpace(unit.Name) + if err := validateConfigName(unitName); err != nil { + return fmt.Errorf("invalid recognition unit name: %w", err) + } + if strings.TrimSpace(unit.VideoSourceRef) == "" { + return fmt.Errorf("video source is required") + } + originalTemplate := sceneTemplate + originalName := unitName + if strings.TrimSpace(originalRef) != "" { + if p, n, err := parseRecognitionUnitRef(originalRef); err == nil { + originalTemplate = p + originalName = n + } + } + if _, err := s.GetProfileEditor(sceneTemplate); err != nil { + return err + } + if sceneTemplate != originalTemplate { + if _, err := s.GetProfileEditor(originalTemplate); err != nil { + return err + } + } + if sceneTemplate != originalTemplate || unitName != originalName { + existing, err := s.assets.GetRecognitionUnit(sceneTemplate, unitName) + if err != nil { + return err + } + if existing != nil { + return fmt.Errorf("duplicate recognition unit name: %s", unitName) + } + } + if err := s.assets.SaveRecognitionUnit(recognitionUnitToRecord(unit)); err != nil { + return err + } + if sceneTemplate != originalTemplate || unitName != originalName { + if err := s.assets.DeleteRecognitionUnit(originalTemplate, originalName); err != nil { + return err + } + } + return nil +} + +func (s *ConfigPreviewService) loadRecognitionUnitEditors(sceneTemplateName string, templateName string) ([]ConfigProfileInstanceEditor, string, string, error) { + units, err := s.ListRecognitionUnits() + if err != nil { + return nil, "", "", err + } + instances := make([]ConfigProfileInstanceEditor, 0) + siteName := "" + deviceCode := "" + for _, unit := range units { + if unit.SceneTemplateName != sceneTemplateName { + continue + } + instances = append(instances, recognitionUnitToInstanceEditor(unit, templateName)) + if siteName == "" && strings.TrimSpace(unit.SiteName) != "" { + siteName = strings.TrimSpace(unit.SiteName) + } + if deviceCode == "" { + if code := strings.TrimSpace(stringAny(unit.AdvancedParams["device_code"])); code != "" { + deviceCode = code + } + } + } + return instances, siteName, deviceCode, nil +} + +func recognitionUnitToInstanceEditor(unit RecognitionUnitAsset, templateName string) ConfigProfileInstanceEditor { + channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name)) + rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555")) + return ConfigProfileInstanceEditor{ + Name: strings.TrimSpace(unit.Name), + Template: templateName, + VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef), + DisplayName: strings.TrimSpace(unit.DisplayName), + SiteName: strings.TrimSpace(unit.SiteName), + ChannelNo: channel, + PublishHLSPath: "./web/hls/" + channel + "/index.m3u8", + PublishRTSPPort: rtspPort, + PublishRTSPPath: "/live/" + channel, + InputBindings: map[string]InputBindingEditor{ + "video_input_main": {VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef)}, + }, + OutputBindings: map[string]OutputBindingEditor{ + "stream_output_main": { + PublishHLSPath: "./web/hls/" + channel + "/index.m3u8", + PublishRTSPPort: rtspPort, + PublishRTSPPath: "/live/" + channel, + ChannelNo: channel, + }, + }, + AdvancedParams: cloneMap(unit.AdvancedParams), + } +} + +func recognitionUnitToRecord(unit RecognitionUnitAsset) storage.RecognitionUnitRecord { + channel := strings.TrimSpace(firstString(unit.OutputChannel, unit.Name)) + if channel == "" { + channel = strings.TrimSpace(unit.Name) + } + rtspPort := strings.TrimSpace(firstString(unit.RTSPPort, "8555")) + raw := map[string]any{ + "name": strings.TrimSpace(unit.Name), + "scene_meta": map[string]any{ + "display_name": strings.TrimSpace(unit.DisplayName), + "site_name": strings.TrimSpace(unit.SiteName), + }, + "input_bindings": map[string]any{ + "video_input_main": map[string]any{ + "video_source_ref": strings.TrimSpace(unit.VideoSourceRef), + }, + }, + "output_bindings": map[string]any{ + "stream_output_main": map[string]any{ + "publish_hls_path": "./web/hls/" + channel + "/index.m3u8", + "publish_rtsp_port": rtspPort, + "publish_rtsp_path": "/live/" + channel, + "channel_no": channel, + }, + }, + } + if params := cloneMap(unit.AdvancedParams); len(params) > 0 { + raw["params"] = params + } + body, _ := marshalConfigJSON(raw) + return storage.RecognitionUnitRecord{ + SceneTemplateName: strings.TrimSpace(unit.SceneTemplateName), + Name: strings.TrimSpace(unit.Name), + DisplayName: strings.TrimSpace(unit.DisplayName), + SiteName: strings.TrimSpace(unit.SiteName), + VideoSourceRef: strings.TrimSpace(unit.VideoSourceRef), + OutputChannel: channel, + RTSPPort: rtspPort, + Description: strings.TrimSpace(unit.Description), + BodyJSON: string(body), + } +} + +func recognitionUnitFromRecord(record storage.RecognitionUnitRecord) (*RecognitionUnitAsset, error) { + raw := map[string]any{} + if strings.TrimSpace(record.BodyJSON) != "" { + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, err + } + } + sceneMeta, _ := raw["scene_meta"].(map[string]any) + params, _ := raw["params"].(map[string]any) + advanced := cloneMap(params) + if deviceCode := strings.TrimSpace(stringAny(sceneMeta["device_code"])); deviceCode != "" { + advanced["device_code"] = deviceCode + } + return &RecognitionUnitAsset{ + Ref: recognitionUnitRef(record.SceneTemplateName, record.Name), + SceneTemplateName: strings.TrimSpace(record.SceneTemplateName), + Name: strings.TrimSpace(firstString(stringAny(raw["name"]), record.Name)), + DisplayName: strings.TrimSpace(firstString(stringAny(sceneMeta["display_name"]), record.DisplayName)), + SiteName: strings.TrimSpace(firstString(stringAny(sceneMeta["site_name"]), record.SiteName)), + VideoSourceRef: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "input_bindings", "video_input_main", "video_source_ref"), record.VideoSourceRef)), + OutputChannel: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "channel_no"), record.OutputChannel)), + RTSPPort: strings.TrimSpace(firstString(bindingStringFromAnyMap(raw, "output_bindings", "stream_output_main", "publish_rtsp_port"), record.RTSPPort)), + Description: strings.TrimSpace(record.Description), + AdvancedParams: advanced, + }, nil +} + +func bindingStringFromAnyMap(raw map[string]any, bindingKey string, slot string, field string) string { + bindings, _ := raw[bindingKey].(map[string]any) + if bindings == nil { + return "" + } + return bindingStringFromBindings(bindings, slot, field) +} + +func bindingStringFromBindings(bindings map[string]any, slot string, field string) string { + entry, _ := bindings[slot].(map[string]any) + return strings.TrimSpace(stringAny(entry[field])) +} + +func (e *ConfigProfileEditor) RawTemplateName() string { + if e == nil { + return "" + } + if strings.TrimSpace(e.PrimaryTemplateName) != "" { + return strings.TrimSpace(e.PrimaryTemplateName) + } + return firstProfileTemplate(e.Instances) +} + +func (s *ConfigPreviewService) DeleteRecognitionUnit(ref string) error { + sceneTemplateName, unitName, err := parseRecognitionUnitRef(ref) + if err != nil { + return err + } + if refs, err := s.deviceAssignmentsReferencingRecognitionUnit(ref); err != nil { + return err + } else if len(refs) > 0 { + return fmt.Errorf("识别单元 %q 已被设备分配引用:%s", unitName, strings.Join(refs, ", ")) + } + item, err := s.assets.GetRecognitionUnit(sceneTemplateName, unitName) + if err != nil { + return err + } + if item == nil { + return os.ErrNotExist + } + return s.assets.DeleteRecognitionUnit(sceneTemplateName, unitName) +} + func (s *ConfigPreviewService) ListOverlayAssets() ([]ConfigOverlayAsset, error) { sources, err := s.ListSources() if err != nil { @@ -320,33 +702,802 @@ func (s *ConfigPreviewService) GetOverlayAsset(name string) (*ConfigOverlayAsset }, nil } -func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) { - if s != nil && s.assets != nil { - raw, path, ok, err := s.readRepoAssetJSON(kind, name) - if err != nil { - return nil, "", err - } - if ok { - return raw, path, nil - } +func (s *ConfigPreviewService) SaveOverlayAsset(asset ConfigOverlayAsset, raw map[string]any) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") } - root := s.mediaRepoRoot() - if root == "" { - return nil, "", fmt.Errorf("media repo path is not configured") + name := strings.TrimSpace(asset.Name) + if err := validateConfigName(name); err != nil { + return err + } + if raw == nil { + raw = map[string]any{} + } + raw["name"] = name + raw["description"] = strings.TrimSpace(asset.Description) + body, err := marshalConfigJSON(raw) + if err != nil { + return err + } + return s.assets.SaveOverlay(name, strings.TrimSpace(asset.Description), string(body)) +} + +func (s *ConfigPreviewService) DeleteOverlayAsset(name string) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") } if err := validateConfigName(name); err != nil { - return nil, "", err + return err } - path := filepath.Join(root, "configs", kind, name+".json") - body, err := os.ReadFile(path) + refs, err := s.profileNamesReferencingOverlay(name) + if err != nil { + return err + } + if len(refs) > 0 { + return fmt.Errorf("调试参数 %q 已被场景模板引用:%s", name, strings.Join(refs, ", ")) + } + return s.assets.DeleteOverlay(name) +} + +func (s *ConfigPreviewService) ListIntegrationServices() ([]ConfigIntegrationServiceAsset, error) { + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListIntegrationServices() + if err != nil { + return nil, err + } + items := make([]ConfigIntegrationServiceAsset, 0, len(records)) + for _, record := range records { + item, err := integrationServiceAssetFromRecord(record) + if err != nil { + continue + } + if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil { + item.RefCount = len(refs) + } + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + return items, nil +} + +func (s *ConfigPreviewService) ListVideoSources() ([]ConfigVideoSourceAsset, error) { + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListVideoSources() + if err != nil { + return nil, err + } + items := make([]ConfigVideoSourceAsset, 0, len(records)) + for _, record := range records { + item, err := videoSourceAssetFromRecord(record) + if err != nil { + continue + } + if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil { + item.RefCount = len(refs) + } + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + return items, nil +} + +func (s *ConfigPreviewService) GetVideoSource(name string) (*ConfigVideoSourceAsset, error) { + if s == nil || s.assets == nil { + return nil, fmt.Errorf("基础配置仓库未初始化") + } + if err := validateVideoSourceName(name); err != nil { + return nil, err + } + record, err := s.assets.GetVideoSource(name) + if err != nil { + return nil, err + } + if record == nil { + return nil, os.ErrNotExist + } + item, err := videoSourceAssetFromRecord(*record) + if err != nil { + return nil, err + } + if refs, err := s.profileNamesReferencingVideoSource(item.Name); err == nil { + item.RefCount = len(refs) + } + return item, nil +} + +func (s *ConfigPreviewService) SaveVideoSourceAsset(asset ConfigVideoSourceAsset) error { + if s == nil || s.assets == nil { + return fmt.Errorf("基础配置仓库未初始化") + } + name := strings.TrimSpace(asset.Name) + if err := validateVideoSourceName(name); err != nil { + return fmt.Errorf("视频源名称不合法:%w", err) + } + sourceType := strings.TrimSpace(asset.SourceType) + if sourceType == "" { + return fmt.Errorf("视频源类型不能为空") + } + urlValue := strings.TrimSpace(asset.Config.URL) + if urlValue == "" { + return fmt.Errorf("视频源地址不能为空") + } + raw := map[string]any{ + "name": name, + "source_type": sourceType, + "area": strings.TrimSpace(asset.Area), + "description": strings.TrimSpace(asset.Description), + "config": map[string]any{}, + } + configMap := raw["config"].(map[string]any) + setAnyString(configMap, "url", asset.Config.URL) + setAnyString(configMap, "resolution", asset.Config.Resolution) + setAnyString(configMap, "frame_size", asset.Config.FrameSize) + setAnyString(configMap, "fps", asset.Config.FPS) + setAnyString(configMap, "video_format", asset.Config.VideoFormat) + setAnyString(configMap, "focal_length", asset.Config.FocalLength) + setAnyString(configMap, "mount_height", asset.Config.MountHeight) + setAnyString(configMap, "mount_angle", asset.Config.MountAngle) + body, err := marshalConfigJSON(raw) + if err != nil { + return err + } + return s.assets.SaveVideoSource(name, sourceType, strings.TrimSpace(asset.Area), strings.TrimSpace(asset.Description), string(body)) +} + +func (s *ConfigPreviewService) DeleteVideoSource(name string) error { + if s == nil || s.assets == nil { + return fmt.Errorf("基础配置仓库未初始化") + } + if err := validateVideoSourceName(name); err != nil { + return err + } + refs, err := s.profileNamesReferencingVideoSource(name) + if err != nil { + return err + } + if len(refs) > 0 { + return fmt.Errorf("视频源 %q 已被场景模板引用:%s", name, strings.Join(refs, ", ")) + } + return s.assets.DeleteVideoSource(name) +} + +func (s *ConfigPreviewService) GetIntegrationService(name string) (*ConfigIntegrationServiceAsset, error) { + if s == nil || s.assets == nil { + return nil, fmt.Errorf("asset repository is not configured") + } + if err := validateConfigName(name); err != nil { + return nil, err + } + record, err := s.assets.GetIntegrationService(name) + if err != nil { + return nil, err + } + if record == nil { + return nil, os.ErrNotExist + } + item, err := integrationServiceAssetFromRecord(*record) + if err != nil { + return nil, err + } + if refs, err := s.profileNamesReferencingIntegrationService(item.Name); err == nil { + item.RefCount = len(refs) + } + return item, nil +} + +func (s *ConfigPreviewService) DeleteIntegrationService(name string) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + if err := validateConfigName(name); err != nil { + return err + } + refs, err := s.profileNamesReferencingIntegrationService(name) + if err != nil { + return err + } + if len(refs) > 0 { + return fmt.Errorf("third-party service %q is used by scene configs: %s", name, strings.Join(refs, ", ")) + } + return s.assets.DeleteIntegrationService(name) +} + +func (s *ConfigPreviewService) ListDeviceAssignments() ([]DeviceAssignmentAsset, error) { + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListDeviceAssignments() + if err != nil { + return nil, err + } + items := make([]DeviceAssignmentAsset, 0, len(records)) + for _, record := range records { + item, err := deviceAssignmentFromRecord(record) + if err != nil { + continue + } + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { return items[i].DeviceID < items[j].DeviceID }) + return items, nil +} + +func DefaultMaxUnitsPerDevice() int { + return 4 +} + +func (s *ConfigPreviewService) BuildDeviceAssignmentBoard(devices []*models.Device, maxUnitsPerDevice int) (*DeviceAssignmentBoard, error) { + assignments, err := s.ListDeviceAssignments() + if err != nil { + return nil, err + } + units, err := s.ListRecognitionUnits() + if err != nil { + return nil, err + } + return BuildDeviceAssignmentBoardData(devices, assignments, units, maxUnitsPerDevice), nil +} + +func BuildAutoDeviceAssignments(devices []*models.Device, units []RecognitionUnitAsset, maxUnitsPerDevice int) []DeviceAssignmentAsset { + maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice) + type card struct { + deviceID string + deviceName string + profile string + refs []string + } + cards := make([]*card, 0, len(devices)) + for _, dev := range devices { + if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { + continue + } + cards = append(cards, &card{deviceID: strings.TrimSpace(dev.DeviceID), deviceName: dev.DisplayName()}) + } + sort.Slice(cards, func(i, j int) bool { + return cards[i].deviceID < cards[j].deviceID + }) + sortedUnits := append([]RecognitionUnitAsset(nil), units...) + sort.Slice(sortedUnits, func(i, j int) bool { + if sortedUnits[i].SceneTemplateName != sortedUnits[j].SceneTemplateName { + return sortedUnits[i].SceneTemplateName < sortedUnits[j].SceneTemplateName + } + return sortedUnits[i].Name < sortedUnits[j].Name + }) + for _, unit := range sortedUnits { + var target *card + for _, candidate := range cards { + if candidate.profile != "" && candidate.profile != unit.SceneTemplateName { + continue + } + if len(candidate.refs) >= maxUnitsPerDevice { + continue + } + if target == nil || len(candidate.refs) < len(target.refs) { + target = candidate + } + } + if target == nil { + continue + } + if target.profile == "" { + target.profile = unit.SceneTemplateName + } + target.refs = append(target.refs, unit.Ref) + } + out := make([]DeviceAssignmentAsset, 0, len(cards)) + for _, card := range cards { + if len(card.refs) == 0 { + continue + } + out = append(out, DeviceAssignmentAsset{ + DeviceID: card.deviceID, + ProfileName: card.profile, + RecognitionUnits: append([]string(nil), card.refs...), + RecognitionCount: len(card.refs), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].DeviceID < out[j].DeviceID }) + return out +} + +func BuildDeviceAssignmentBoardData(devices []*models.Device, assignments []DeviceAssignmentAsset, units []RecognitionUnitAsset, maxUnitsPerDevice int) *DeviceAssignmentBoard { + maxUnitsPerDevice = normalizeMaxUnitsPerDevice(maxUnitsPerDevice) + unitMap := make(map[string]RecognitionUnitAsset, len(units)) + for _, unit := range units { + unitMap[unit.Ref] = unit + } + deviceByID := map[string]*models.Device{} + for _, dev := range devices { + if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { + continue + } + deviceByID[strings.TrimSpace(dev.DeviceID)] = dev + } + assignmentByDevice := map[string]DeviceAssignmentAsset{} + for _, assignment := range assignments { + assignmentByDevice[strings.TrimSpace(assignment.DeviceID)] = assignment + } + deviceIDs := make([]string, 0, len(deviceByID)+len(assignmentByDevice)) + seenDeviceIDs := map[string]struct{}{} + for deviceID := range deviceByID { + seenDeviceIDs[deviceID] = struct{}{} + deviceIDs = append(deviceIDs, deviceID) + } + for deviceID := range assignmentByDevice { + if _, ok := seenDeviceIDs[deviceID]; ok { + continue + } + seenDeviceIDs[deviceID] = struct{}{} + deviceIDs = append(deviceIDs, deviceID) + } + sort.Strings(deviceIDs) + + assignedRefs := map[string]struct{}{} + cards := make([]DeviceAssignmentBoardCard, 0, len(deviceIDs)) + for _, deviceID := range deviceIDs { + assignment := assignmentByDevice[deviceID] + card := DeviceAssignmentBoardCard{ + DeviceID: deviceID, + DeviceName: boardDeviceName(deviceByID[deviceID], deviceID), + ProfileName: strings.TrimSpace(assignment.ProfileName), + MaxUnits: maxUnitsPerDevice, + } + for _, ref := range assignment.RecognitionUnits { + unit, ok := unitMap[ref] + if !ok { + continue + } + card.Units = append(card.Units, boardUnitFromRecognitionUnit(unit)) + assignedRefs[ref] = struct{}{} + } + card.AssignedCount = len(card.Units) + card.Status = boardCardStatus(card.AssignedCount, maxUnitsPerDevice) + cards = append(cards, card) + } + sort.Slice(cards, func(i, j int) bool { + li := boardStatusRank(cards[i].Status) + lj := boardStatusRank(cards[j].Status) + if li != lj { + return li < lj + } + if cards[i].AssignedCount != cards[j].AssignedCount { + return cards[i].AssignedCount > cards[j].AssignedCount + } + return cards[i].DeviceID < cards[j].DeviceID + }) + + unassigned := make([]DeviceAssignmentBoardUnit, 0) + for _, unit := range units { + if _, ok := assignedRefs[unit.Ref]; ok { + continue + } + unassigned = append(unassigned, boardUnitFromRecognitionUnit(unit)) + } + sort.Slice(unassigned, func(i, j int) bool { + if unassigned[i].SceneTemplateName != unassigned[j].SceneTemplateName { + return unassigned[i].SceneTemplateName < unassigned[j].SceneTemplateName + } + return unassigned[i].Name < unassigned[j].Name + }) + + stats := DeviceAssignmentBoardStats{ + TotalUnits: len(units), + TotalDevices: len(deviceIDs), + AssignedUnits: len(assignedRefs), + UnassignedUnits: len(unassigned), + } + if stats.TotalDevices > 0 { + stats.AverageLoad = float64(stats.AssignedUnits) / float64(stats.TotalDevices) + } + for _, card := range cards { + if card.Status == "full" { + stats.OverloadedDevices++ + } + } + return &DeviceAssignmentBoard{ + MaxUnitsPerDevice: maxUnitsPerDevice, + Stats: stats, + Cards: cards, + Unassigned: unassigned, + } +} + +func deviceAssignmentFromRecord(record storage.DeviceAssignmentRecord) (*DeviceAssignmentAsset, error) { + raw := map[string]any{} + if strings.TrimSpace(record.BodyJSON) != "" { + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, err + } + } + unitRefs := make([]string, 0) + if items, ok := raw["recognition_units"].([]any); ok { + for _, item := range items { + if v := strings.TrimSpace(stringValue(item)); v != "" { + unitRefs = append(unitRefs, v) + } + } + } + return &DeviceAssignmentAsset{ + DeviceID: record.DeviceID, + ProfileName: firstString(record.ProfileName, stringValue(raw["profile_name"])), + Description: firstString(record.Description, stringValue(raw["description"])), + RecognitionUnits: unitRefs, + RecognitionCount: len(unitRefs), + }, nil +} + +func normalizeMaxUnitsPerDevice(v int) int { + if v < 1 { + return DefaultMaxUnitsPerDevice() + } + if v > 8 { + return 8 + } + return v +} + +func boardDeviceName(dev *models.Device, fallback string) string { + if dev == nil { + return fallback + } + return firstString(strings.TrimSpace(dev.DisplayName()), fallback) +} + +func boardUnitFromRecognitionUnit(unit RecognitionUnitAsset) DeviceAssignmentBoardUnit { + return DeviceAssignmentBoardUnit{ + Ref: unit.Ref, + SceneTemplateName: unit.SceneTemplateName, + Name: unit.Name, + DisplayName: unit.DisplayName, + VideoSourceRef: unit.VideoSourceRef, + OutputChannel: firstString(unit.OutputChannel, unit.Name), + } +} + +func boardCardStatus(count int, max int) string { + if count <= 0 { + return "idle" + } + if count >= max { + return "full" + } + if count*2 >= max { + return "busy" + } + return "low" +} + +func boardStatusRank(status string) int { + switch status { + case "full": + return 0 + case "busy": + return 1 + case "low": + return 2 + case "idle": + return 3 + default: + return 4 + } +} + +func (s *ConfigPreviewService) GetDeviceAssignment(deviceID string) (*DeviceAssignmentAsset, error) { + if s == nil || s.assets == nil { + return nil, fmt.Errorf("asset repository is not configured") + } + record, err := s.assets.GetDeviceAssignment(strings.TrimSpace(deviceID)) + if err != nil { + return nil, err + } + if record == nil { + return nil, os.ErrNotExist + } + return deviceAssignmentFromRecord(*record) +} + +func (s *ConfigPreviewService) SaveDeviceAssignment(asset DeviceAssignmentAsset) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + deviceID := strings.TrimSpace(asset.DeviceID) + if deviceID == "" { + return fmt.Errorf("device id is required") + } + if strings.TrimSpace(asset.ProfileName) == "" { + return fmt.Errorf("scene template is required") + } + seen := map[string]struct{}{} + for _, ref := range asset.RecognitionUnits { + profileName, _, err := parseRecognitionUnitRef(ref) + if err != nil { + return err + } + if profileName != asset.ProfileName { + return fmt.Errorf("all recognition units must belong to scene template %q", asset.ProfileName) + } + if _, ok := seen[ref]; ok { + return fmt.Errorf("duplicate recognition unit: %s", ref) + } + seen[ref] = struct{}{} + } + raw := map[string]any{ + "device_id": deviceID, + "profile_name": strings.TrimSpace(asset.ProfileName), + "description": strings.TrimSpace(asset.Description), + "recognition_units": asset.RecognitionUnits, + } + body, err := marshalConfigJSON(raw) + if err != nil { + return err + } + return s.assets.SaveDeviceAssignment(deviceID, strings.TrimSpace(asset.ProfileName), strings.TrimSpace(asset.Description), string(body)) +} + +func (s *ConfigPreviewService) SaveDeviceAssignmentBoard(assignments map[string][]string) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + existing, err := s.ListDeviceAssignments() + if err != nil { + return err + } + existingByDevice := make(map[string]DeviceAssignmentAsset, len(existing)) + for _, item := range existing { + existingByDevice[item.DeviceID] = item + } + for deviceID, refs := range assignments { + deviceID = strings.TrimSpace(deviceID) + if deviceID == "" { + continue + } + cleanRefs := make([]string, 0, len(refs)) + seen := map[string]struct{}{} + for _, ref := range refs { + ref = strings.TrimSpace(ref) + if ref == "" { + continue + } + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + cleanRefs = append(cleanRefs, ref) + } + if len(cleanRefs) == 0 { + if _, ok := existingByDevice[deviceID]; ok { + if err := s.assets.DeleteDeviceAssignment(deviceID); err != nil { + return err + } + } + continue + } + firstUnit, err := s.GetRecognitionUnit(cleanRefs[0]) + if err != nil { + return err + } + profileName := firstUnit.SceneTemplateName + for _, ref := range cleanRefs[1:] { + unit, err := s.GetRecognitionUnit(ref) + if err != nil { + return err + } + if unit.SceneTemplateName != profileName { + return fmt.Errorf("device %q contains recognition units from different scene templates", deviceID) + } + } + description := existingByDevice[deviceID].Description + if err := s.SaveDeviceAssignment(DeviceAssignmentAsset{ + DeviceID: deviceID, + ProfileName: profileName, + Description: description, + RecognitionUnits: cleanRefs, + }); err != nil { + return err + } + } + return nil +} + +func (s *ConfigPreviewService) DeleteDeviceAssignment(deviceID string) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + return s.assets.DeleteDeviceAssignment(strings.TrimSpace(deviceID)) +} + +func (s *ConfigPreviewService) deviceAssignmentsReferencingRecognitionUnit(ref string) ([]string, error) { + items, err := s.ListDeviceAssignments() + if err != nil { + return nil, err + } + out := make([]string, 0) + for _, item := range items { + for _, unitRef := range item.RecognitionUnits { + if unitRef == ref { + out = append(out, item.DeviceID) + break + } + } + } + sort.Strings(out) + return out, nil +} + +func (s *ConfigPreviewService) SaveIntegrationServiceAsset(asset ConfigIntegrationServiceAsset) error { + if s == nil || s.assets == nil { + return fmt.Errorf("asset repository is not configured") + } + name := strings.TrimSpace(asset.Name) + if err := validateConfigName(name); err != nil { + return fmt.Errorf("invalid third-party service name: %w", err) + } + serviceType := strings.TrimSpace(asset.Type) + if serviceType == "" { + return fmt.Errorf("third-party service type is required") + } + raw := map[string]any{ + "name": name, + "type": serviceType, + "description": strings.TrimSpace(asset.Description), + "enabled": asset.Enabled, + } + configMap := map[string]any{} + switch serviceType { + case "object_storage": + if asset.ObjectStorage == nil { + return fmt.Errorf("object storage config is required") + } + setAnyString(configMap, "endpoint", asset.ObjectStorage.Endpoint) + setAnyString(configMap, "bucket", asset.ObjectStorage.Bucket) + setAnyString(configMap, "access_key", asset.ObjectStorage.AccessKey) + setAnyString(configMap, "secret_key", asset.ObjectStorage.SecretKey) + for _, key := range []string{"endpoint", "bucket", "access_key", "secret_key"} { + if strings.TrimSpace(stringValue(configMap[key])) == "" { + return fmt.Errorf("object storage %s is required", key) + } + } + case "token_service": + if asset.TokenService == nil { + return fmt.Errorf("token service config is required") + } + setAnyString(configMap, "get_token_url", asset.TokenService.GetTokenURL) + setAnyString(configMap, "username", asset.TokenService.Username) + setAnyString(configMap, "password", asset.TokenService.Password) + setAnyString(configMap, "tenant_code", asset.TokenService.TenantCode) + if strings.TrimSpace(stringValue(configMap["get_token_url"])) == "" { + return fmt.Errorf("token service get_token_url is required") + } + case "alarm_service": + if asset.AlarmService == nil { + return fmt.Errorf("alarm service config is required") + } + setAnyString(configMap, "put_message_url", asset.AlarmService.PutMessageURL) + setAnyString(configMap, "username", asset.AlarmService.Username) + setAnyString(configMap, "password", asset.AlarmService.Password) + setAnyString(configMap, "tenant_code", asset.AlarmService.TenantCode) + if strings.TrimSpace(stringValue(configMap["put_message_url"])) == "" { + return fmt.Errorf("alarm service put_message_url is required") + } + default: + return fmt.Errorf("unsupported third-party service type: %s", serviceType) + } + raw["config"] = configMap + body, err := marshalConfigJSON(raw) + if err != nil { + return err + } + return s.assets.SaveIntegrationService(name, serviceType, strings.TrimSpace(asset.Description), asset.Enabled, string(body)) +} + +func (s *ConfigPreviewService) profileNamesReferencingIntegrationService(name string) ([]string, error) { + if s == nil || s.assets == nil { + return nil, nil + } + units, err := s.ListRecognitionUnits() + if err != nil { + return nil, err + } + name = strings.TrimSpace(name) + if name == "" { + return nil, nil + } + seen := map[string]struct{}{} + refs := make([]string, 0) + for _, unit := range units { + record, err := s.assets.GetRecognitionUnit(unit.SceneTemplateName, unit.Name) + if err != nil || record == nil { + continue + } + raw := map[string]any{} + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + continue + } + for _, slot := range []string{"object_storage_main", "token_service_main", "alarm_service_main"} { + if strings.TrimSpace(bindingStringFromAnyMap(raw, "service_bindings", slot, "service_ref")) == name { + if _, ok := seen[unit.SceneTemplateName]; !ok { + seen[unit.SceneTemplateName] = struct{}{} + refs = append(refs, unit.SceneTemplateName) + } + break + } + } + } + sort.Strings(refs) + return refs, nil +} + +func (s *ConfigPreviewService) profileNamesReferencingVideoSource(name string) ([]string, error) { + if s == nil || s.assets == nil { + return nil, nil + } + units, err := s.ListRecognitionUnits() + if err != nil { + return nil, err + } + name = strings.TrimSpace(name) + if name == "" { + return nil, nil + } + seen := map[string]struct{}{} + refs := make([]string, 0) + for _, unit := range units { + if strings.TrimSpace(unit.VideoSourceRef) == name { + if _, ok := seen[unit.SceneTemplateName]; !ok { + seen[unit.SceneTemplateName] = struct{}{} + refs = append(refs, unit.SceneTemplateName) + } + } + } + sort.Strings(refs) + return refs, nil +} + +func (s *ConfigPreviewService) profileNamesReferencingOverlay(name string) ([]string, error) { + if s == nil || s.assets == nil { + return nil, nil + } + records, err := s.assets.ListProfiles() + if err != nil { + return nil, err + } + name = strings.TrimSpace(name) + if name == "" { + return nil, nil + } + refs := make([]string, 0) + for _, record := range records { + var raw map[string]any + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + continue + } + for _, overlay := range profileOverlayNames(raw) { + if strings.TrimSpace(overlay) == name { + refs = append(refs, record.Name) + break + } + } + } + sort.Strings(refs) + return refs, nil +} + +func (s *ConfigPreviewService) readAssetJSON(kind string, name string) (map[string]any, string, error) { + if s == nil || s.assets == nil { + return nil, "", fmt.Errorf("asset repository is not configured") + } + raw, path, ok, err := s.readRepoAssetJSON(kind, name) if err != nil { return nil, "", err } - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - return nil, "", err + if ok { + return raw, path, nil } - return raw, path, nil + return nil, "", os.ErrNotExist } func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[string]any, string, bool, error) { @@ -385,7 +1536,7 @@ func (s *ConfigPreviewService) readRepoAssetJSON(kind string, name string) (map[ } if kind == "profiles" { if strings.TrimSpace(record.TemplateName) != "" { - raw["template_name"] = record.TemplateName + raw["primary_template_name"] = record.TemplateName } if strings.TrimSpace(record.BusinessName) != "" && stringValue(raw["business_name"]) == "" { raw["business_name"] = record.BusinessName @@ -405,16 +1556,9 @@ func cloneMap(in map[string]any) map[string]any { return out } -func (s *ConfigPreviewService) templateAssetFromPath(name string, path string, origin string, readOnly bool) (*ConfigTemplateAsset, error) { - body, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - return nil, err - } - return buildTemplateAsset(raw, path, origin, readOnly), nil +func bindingAny(bindings map[string]any, slot string, field string) any { + entry, _ := bindings[slot].(map[string]any) + return entry[field] } func (s *ConfigPreviewService) templateAssetFromRecord(record storage.AssetRecord, origin string, readOnly bool) (*ConfigTemplateAsset, error) { @@ -436,6 +1580,7 @@ func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly paramsMap, _ := raw["params"].(map[string]any) nodes, _ := templateMap["nodes"].([]any) edges, _ := templateMap["edges"].([]any) + slots, _ := parseTemplateSlots(raw) advanced := cloneMap(paramsMap) for _, key := range []string{ "minio_endpoint", @@ -456,6 +1601,7 @@ func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly ReadOnly: readOnly, Description: stringValue(raw["description"]), Source: stringValue(raw["source"]), + Slots: slots, NodeCount: len(nodes), EdgeCount: len(edges), MinIOEndpoint: stringValue(paramsMap["minio_endpoint"]), @@ -468,22 +1614,142 @@ func buildTemplateAsset(raw map[string]any, path string, origin string, readOnly } } -func (s *ConfigPreviewService) mediaAssetPath(kind string, name string) (string, bool) { - root := s.mediaRepoRoot() - if root == "" { - return "", false +func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord) (*ConfigIntegrationServiceAsset, error) { + var raw map[string]any + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, err } - path := filepath.Join(root, "configs", kind, name+".json") - if _, err := os.Stat(path); err != nil { - return "", false + if raw == nil { + raw = map[string]any{} } - return path, true + configMap, _ := raw["config"].(map[string]any) + sourceMap := raw + if len(configMap) > 0 { + sourceMap = configMap + } + item := &ConfigIntegrationServiceAsset{ + Name: firstString(raw["name"], record.Name), + Path: repoAssetPath("integration_services", record.Name), + Type: firstString(record.ServiceType, stringValue(raw["type"])), + Description: firstString(raw["description"], record.Description), + Enabled: boolValue(raw["enabled"], record.Enabled), + Raw: raw, + } + item.TypeLabel = integrationTypeLabel(item.Type) + switch item.Type { + case "object_storage": + item.ObjectStorage = &ObjectStorageConfig{ + Endpoint: stringValue(sourceMap["endpoint"]), + Bucket: stringValue(sourceMap["bucket"]), + AccessKey: firstString(sourceMap["access_key"], stringValue(sourceMap["minio_access_key"])), + SecretKey: firstString(sourceMap["secret_key"], stringValue(sourceMap["minio_secret_key"])), + } + item.AddressSummary = strings.TrimSpace(strings.Trim(strings.Join([]string{item.ObjectStorage.Endpoint, item.ObjectStorage.Bucket}, " / "), " /")) + case "token_service": + item.TokenService = &TokenServiceConfig{ + GetTokenURL: stringValue(sourceMap["get_token_url"]), + Username: stringValue(sourceMap["username"]), + Password: stringValue(sourceMap["password"]), + TenantCode: stringValue(sourceMap["tenant_code"]), + } + item.AddressSummary = item.TokenService.GetTokenURL + case "alarm_service": + item.AlarmService = &AlarmServiceConfig{ + PutMessageURL: stringValue(sourceMap["put_message_url"]), + Username: stringValue(sourceMap["username"]), + Password: stringValue(sourceMap["password"]), + TenantCode: stringValue(sourceMap["tenant_code"]), + } + item.AddressSummary = item.AlarmService.PutMessageURL + } + return item, nil +} + +func videoSourceAssetFromRecord(record storage.VideoSourceRecord) (*ConfigVideoSourceAsset, error) { + var raw map[string]any + if err := json.Unmarshal([]byte(record.BodyJSON), &raw); err != nil { + return nil, err + } + if raw == nil { + raw = map[string]any{} + } + configMap, _ := raw["config"].(map[string]any) + sourceMap := raw + if len(configMap) > 0 { + sourceMap = configMap + } + item := &ConfigVideoSourceAsset{ + Name: firstString(raw["name"], record.Name), + Path: repoAssetPath("video_sources", record.Name), + SourceType: firstString(record.SourceType, stringValue(raw["source_type"])), + Area: firstString(raw["area"], record.Area), + Description: firstString(raw["description"], record.Description), + SourceTypeLabel: videoSourceTypeLabel(firstString(record.SourceType, stringValue(raw["source_type"]))), + Config: VideoSourceConfig{ + URL: stringValue(sourceMap["url"]), + Resolution: stringValue(sourceMap["resolution"]), + FrameSize: stringValue(sourceMap["frame_size"]), + FPS: valueString(sourceMap["fps"]), + VideoFormat: stringValue(sourceMap["video_format"]), + FocalLength: stringValue(sourceMap["focal_length"]), + MountHeight: stringValue(sourceMap["mount_height"]), + MountAngle: stringValue(sourceMap["mount_angle"]), + }, + Raw: raw, + } + item.SourceSummary = item.Config.URL + return item, nil +} + +func integrationTypeLabel(v string) string { + switch strings.TrimSpace(v) { + case "object_storage": + return "对象存储" + case "token_service": + return "认证服务" + case "alarm_service": + return "告警服务" + default: + return strings.TrimSpace(v) + } +} + +func videoSourceTypeLabel(v string) string { + switch strings.TrimSpace(v) { + case "rtsp": + return "RTSP" + case "rtmp": + return "RTMP" + case "file": + return "文件" + case "usb_camera": + return "USB 摄像头" + default: + return strings.TrimSpace(v) + } +} + +func validateVideoSourceName(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("不能为空") + } + if strings.Contains(name, "..") { + return fmt.Errorf("不能包含 '..'") + } + if strings.ContainsAny(name, `/\`) { + return fmt.Errorf("不能包含 / 或 \\") + } + return nil } func (s *ConfigPreviewService) templateIsBuiltin(name string) bool { + return isStandardTemplateName(name) +} + +func isStandardTemplateName(name string) bool { name = canonicalTemplateAssetName(name) - _, ok := s.mediaAssetPath("templates", name) - return ok + return strings.HasPrefix(strings.TrimSpace(name), "std_") } func canonicalTemplateAssetName(name string) string { @@ -519,6 +1785,24 @@ func valueString(v any) string { } } +func boolValue(v any, fallback bool) bool { + switch value := v.(type) { + case bool: + return value + case float64: + return value != 0 + case int: + return value != 0 + case int64: + return value != 0 + case string: + value = strings.TrimSpace(strings.ToLower(value)) + return value == "1" || value == "true" || value == "yes" || value == "on" + default: + return fallback + } +} + func firstString(v any, fallback string) string { if got := stringValue(v); got != "" { return got diff --git a/internal/service/config_assets_test.go b/internal/service/config_assets_test.go index 1a828ea..d7621b8 100644 --- a/internal/service/config_assets_test.go +++ b/internal/service/config_assets_test.go @@ -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) } } diff --git a/internal/service/config_preview.go b/internal/service/config_preview.go index 4f8241a..ba242af 100644 --- a/internal/service/config_preview.go +++ b/internal/service/config_preview.go @@ -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"]) } diff --git a/internal/service/config_preview_test.go b/internal/service/config_preview_test.go index cd74117..b62165f 100644 --- a/internal/service/config_preview_test.go +++ b/internal/service/config_preview_test.go @@ -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 { diff --git a/internal/service/config_runtime_render.go b/internal/service/config_runtime_render.go new file mode 100644 index 0000000..bee4e55 --- /dev/null +++ b/internal/service/config_runtime_render.go @@ -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 +} diff --git a/internal/service/profile_editor.go b/internal/service/profile_editor.go index f5a9db1..eabc8ac 100644 --- a/internal/service/profile_editor.go +++ b/internal/service/profile_editor.go @@ -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 +} diff --git a/internal/service/standard_templates.go b/internal/service/standard_templates.go new file mode 100644 index 0000000..aa24f6a --- /dev/null +++ b/internal/service/standard_templates.go @@ -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 +} diff --git a/internal/service/task.go b/internal/service/task.go index 2fcccea..8ebc69c 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -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 } diff --git a/internal/service/template_slots.go b/internal/service/template_slots.go new file mode 100644 index 0000000..241dcef --- /dev/null +++ b/internal/service/template_slots.go @@ -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 +} + diff --git a/internal/service/template_slots_test.go b/internal/service/template_slots_test.go new file mode 100644 index 0000000..e2c4b85 --- /dev/null +++ b/internal/service/template_slots_test.go @@ -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) + } +} + diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go index 677a5e7..80abf65 100644 --- a/internal/storage/assets_repo.go +++ b/internal/storage/assets_repo.go @@ -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) diff --git a/internal/storage/assets_repo_test.go b/internal/storage/assets_repo_test.go index 1eb4db8..9db8377 100644 --- a/internal/storage/assets_repo_test.go +++ b/internal/storage/assets_repo_test.go @@ -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) + } +} diff --git a/internal/storage/device_config_state_repo.go b/internal/storage/device_config_state_repo.go index cefca59..8b646be 100644 --- a/internal/storage/device_config_state_repo.go +++ b/internal/storage/device_config_state_repo.go @@ -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 +} diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go index b42d66b..51b1056 100644 --- a/internal/storage/migrate.go +++ b/internal/storage/migrate.go @@ -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 "" } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 126486b..e0b3bf9 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -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) + } +} diff --git a/internal/web/graph_node_types.go b/internal/web/graph_node_types.go index 94fd14a..6cec1e7 100644 --- a/internal/web/graph_node_types.go +++ b/internal/web/graph_node_types.go @@ -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"), diff --git a/internal/web/ui.go b/internal/web/ui.go index b927b8a..1055b10 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -36,6 +36,11 @@ type UI struct { tpl *template.Template } +const ( + deviceAssignmentPreviewDevicePrefix = "demo-edge-" + deviceAssignmentPreviewDeviceCount = 8 +) + type PageData struct { Title string ContentHTML template.HTML @@ -60,6 +65,8 @@ type PageData struct { ResultTitle string SelectedTemplate string SelectedProfile string + SelectedRecognitionUnit string + SelectedAssignmentDevice string SelectedOverlays []string SelectedConfigID string SelectedVersion string @@ -71,11 +78,32 @@ type PageData struct { AssetTab string AssetTemplates []service.ConfigTemplateAsset AssetTemplate *service.ConfigTemplateAsset + AssetTemplateEditing bool AssetProfiles []service.ConfigProfileAsset AssetProfile *service.ConfigProfileAsset AssetProfileEditor *service.ConfigProfileEditor + AssetProfileEditing bool + AssetProfileFormAction string + ActiveInstanceIndex int + RecognitionUnits []service.RecognitionUnitAsset + RecognitionUnit *service.RecognitionUnitAsset + RecognitionUnitEditing bool + DeviceAssignments []service.DeviceAssignmentAsset + DeviceAssignment *service.DeviceAssignmentAsset + DeviceAssignmentEditing bool + DeviceAssignmentBoard *service.DeviceAssignmentBoard + DeviceAssignmentBoardJSON template.JS + MaxUnitsPerDevice int + AssetTemplateMap map[string]service.ConfigTemplateAsset + AssetVideoSources []service.ConfigVideoSourceAsset + AssetVideoSource *service.ConfigVideoSourceAsset + AssetVideoSourceEditing bool + AssetIntegrations []service.ConfigIntegrationServiceAsset + AssetIntegration *service.ConfigIntegrationServiceAsset + AssetIntegrationEditing bool AssetOverlays []service.ConfigOverlayAsset AssetOverlay *service.ConfigOverlayAsset + AssetOverlayEditing bool AssetInstanceCount int SelectedDeviceIDs []string SelectedDevices []*models.Device @@ -88,6 +116,7 @@ type PageData struct { TemplateDraftDescription string TemplateCloneSource string TemplateCreateMode string + OverlayDraftJSON string AuditEntries []storage.AuditLogRecord PersistedConfig *storage.DeviceConfigStateRecord DBPath string @@ -210,7 +239,7 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic "taskActionLabel": func(v any) string { switch fmt.Sprint(v) { case "config_apply": - return "下发识别配置" + return "下发设备分配" case "reload": return "重载识别服务" case "rollback": @@ -274,11 +303,11 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic "auditActionLabel": func(v string) string { switch strings.TrimSpace(v) { case "config_apply": - return "下发业务配置" + return "下发设备分配" case "reload": - return "重载配置" + return "重载运行配置" case "rollback": - return "回滚配置" + return "回滚运行配置" case "media_start": return "启动服务" case "media_restart": @@ -326,6 +355,55 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic "icon": func(name string) template.HTML { return template.HTML(tablerIconSVG(name)) }, + "slotTypeLabel": func(v string) string { + switch strings.TrimSpace(v) { + case "video_source": + return "视频源" + case "object_storage": + return "对象存储" + case "token_service": + return "认证服务" + case "alarm_service": + return "告警服务" + case "stream_publish": + return "视频输出" + default: + return strings.TrimSpace(v) + } + }, + "inputBindingRef": func(bindings map[string]service.InputBindingEditor, slot string) string { + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].VideoSourceRef) + }, + "serviceBindingRef": func(bindings map[string]service.ServiceBindingEditor, slot string) string { + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].ServiceRef) + }, + "outputBindingValue": func(bindings map[string]service.OutputBindingEditor, slot string, field string) string { + if len(bindings) == 0 { + return "" + } + item, ok := bindings[slot] + if !ok { + return "" + } + switch strings.TrimSpace(field) { + case "publish_hls_path": + return strings.TrimSpace(item.PublishHLSPath) + case "publish_rtsp_port": + return strings.TrimSpace(item.PublishRTSPPort) + case "publish_rtsp_path": + return strings.TrimSpace(item.PublishRTSPPath) + case "channel_no": + return strings.TrimSpace(item.ChannelNo) + default: + return "" + } + }, }).ParseFS(uiFS, "ui/templates/*.html") if err != nil { return nil, err @@ -433,25 +511,49 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/dashboard", u.pageDashboard) r.Get("/devices", u.pageDevices) r.Get("/devices/{id}/control", u.pageDeviceControl) + r.Get("/plans", u.redirectPlansToSceneTemplates) + r.Get("/plans/{name}", u.redirectPlanToSceneTemplate) + r.Get("/scene-templates", u.pagePlans) + r.Get("/scene-templates/{name}", u.pagePlan) + r.Post("/scene-templates/create", u.actionPlanCreate) + r.Post("/scene-templates/{name}", u.actionPlanSave) + r.Post("/scene-templates/{name}/delete", u.actionPlanDelete) + r.Get("/scene-templates/{name}/export", u.pagePlanExport) + r.Get("/recognition-units", u.pageRecognitionUnits) + r.Post("/recognition-units", u.actionRecognitionUnitSave) + r.Post("/recognition-units/delete", u.actionRecognitionUnitDelete) + r.Get("/device-assignments", u.pageDeviceAssignments) + r.Post("/device-assignments", u.actionDeviceAssignmentSave) + r.Post("/device-assignments/{id}/delete", u.actionDeviceAssignmentDelete) r.Get("/assets", u.pageAssets) + r.Get("/assets/video-sources", u.pageAssetVideoSources) + r.Post("/assets/video-sources", u.actionAssetVideoSourceSave) + r.Post("/assets/video-sources/{name}/delete", u.actionAssetVideoSourceDelete) r.Get("/assets/templates", u.pageAssetTemplates) r.Post("/assets/templates/create", u.actionAssetTemplateCreate) + r.Post("/assets/templates/{name}/clone", u.actionAssetTemplateClone) r.Get("/assets/templates/{name}", u.pageAssetTemplate) r.Post("/assets/templates/{name}/rename", u.actionAssetTemplateRename) r.Post("/assets/templates/{name}/delete", u.actionAssetTemplateDelete) r.Get("/assets/templates/{name}/graph", u.pageAssetTemplateGraph) r.Post("/assets/templates/{name}/graph", u.actionAssetTemplateGraphSave) r.Get("/assets/templates/{name}/export", u.pageAssetTemplateExport) - r.Get("/assets/profiles", u.pageAssetProfiles) - r.Get("/assets/profiles/{name}", u.pageAssetProfile) - r.Post("/assets/profiles/{name}", u.actionAssetProfileSave) - r.Get("/assets/profiles/{name}/export", u.pageAssetProfileExport) + r.Get("/assets/profiles", u.redirectAssetProfilesToPlans) + r.Get("/assets/profiles/{name}", u.redirectAssetProfileToPlan) + r.Post("/assets/profiles/{name}", u.actionPlanSave) + r.Get("/assets/profiles/{name}/export", u.pagePlanExport) + r.Get("/assets/integrations", u.pageAssetIntegrations) + r.Post("/assets/integrations", u.actionAssetIntegrationSave) + r.Post("/assets/integrations/{name}/delete", u.actionAssetIntegrationDelete) r.Get("/assets/overlays", u.pageAssetOverlays) + r.Post("/assets/overlays", u.actionAssetOverlaySave) + r.Post("/assets/overlays/{name}/delete", u.actionAssetOverlayDelete) r.Get("/assets/overlays/{name}", u.pageAssetOverlay) r.Get("/assets/overlays/{name}/export", u.pageAssetOverlayExport) r.Get("/audit", u.pageAudit) r.Get("/system", u.pageSystem) r.Get("/system/db-backup", u.pageSystemDBBackup) + r.Get("/resources", u.pageResources) r.Post("/system/db-restore", u.actionSystemDBRestore) r.Get("/api/graph-node-types", u.apiGraphNodeTypes) r.Get("/device-config", u.pageDeviceConfig) @@ -468,6 +570,7 @@ func (u *UI) Routes() (chi.Router, error) { r.Get("/devices/{id}/logs", u.pageDeviceLogs) r.Get("/devices/{id}/graphs", u.pageDeviceGraphs) r.Post("/devices/{id}/config/apply", u.actionDeviceConfigApply) + r.Post("/devices/{id}/plan-apply", u.actionDevicePlanApply) r.Get("/devices/{id}/config-ui", u.pageDeviceConfigUI) r.Get("/devices/{id}/config-friendly", u.pageDeviceConfigFriendly) r.Get("/devices/{id}/config-preview", u.pageDeviceConfigPreview) @@ -696,60 +799,39 @@ func (u *UI) pageDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { func (u *UI) actionDeviceBatchConfig(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() selectedIDs := filterSelectedDeviceIDs(u.registry.GetDevices(), r.Form["device_id"]) - req := service.ConfigPreviewRequest{Profile: strings.TrimSpace(r.FormValue("profile"))} data := u.deviceBatchConfigPageData(r, selectedIDs) - if req.Profile != "" { - data.SelectedProfile = req.Profile - } - for i := range data.AssetProfiles { - if strings.TrimSpace(data.AssetProfiles[i].Name) == data.SelectedProfile { - data.AssetProfile = &data.AssetProfiles[i] - data.SelectedTemplate = profileAssetTemplate(&data.AssetProfiles[i]) - break - } - } if len(selectedIDs) == 0 { - data.Error = "请先选择需要下发配置的设备" + data.Error = "请先选择需要下发分配的设备" u.render(w, r, "device_batch_config", data) return } - if req.Profile == "" { - req.Profile = data.SelectedProfile - } - if req.Profile == "" { - data.Error = "请先选择业务配置" - u.render(w, r, "device_batch_config", data) - return - } - if data.SelectedTemplate == "" { - data.Error = "所选业务配置缺少可用模板,无法生成下发内容" - u.render(w, r, "device_batch_config", data) - return - } - req.Template = data.SelectedTemplate if u.tasks == nil { data.Error = "task service not initialized" u.render(w, r, "device_batch_config", data) return } - - preview, err := u.preview.Render(req) - data.ConfigPreview = preview - if err != nil { - data.Error = err.Error() - u.render(w, r, "device_batch_config", data) - return + configs := make(map[string]any, len(selectedIDs)) + for _, deviceID := range selectedIDs { + preview, err := u.preview.RenderDeviceAssignment(deviceID) + if err != nil { + data.Error = err.Error() + u.render(w, r, "device_batch_config", data) + return + } + if data.ConfigPreview == nil { + data.ConfigPreview = preview + } + var configDoc any + if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { + data.Error = "生成配置 JSON 无效: " + err.Error() + u.render(w, r, "device_batch_config", data) + return + } + configs[deviceID] = configDoc } - var configDoc any - if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { - data.Error = "生成配置 JSON 无效: " + err.Error() - u.render(w, r, "device_batch_config", data) - return - } - - task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"config": configDoc}) + task, err := u.tasks.CreateTask("config_apply", selectedIDs, map[string]any{"configs": configs}) if err != nil { data.Error = err.Error() u.render(w, r, "device_batch_config", data) @@ -777,6 +859,40 @@ func (u *UI) deviceDetailPageData(dev *models.Device) PageData { data.PersistedConfig = state } } + if u.preview != nil { + if profiles, err := u.preview.ListProfileAssets(); err == nil { + data.AssetProfiles = profiles + selectedProfile := "" + if data.ConfigStatus != nil && strings.TrimSpace(data.ConfigStatus.Metadata.Profile) != "" { + selectedProfile = strings.TrimSpace(data.ConfigStatus.Metadata.Profile) + } else if data.PersistedConfig != nil && strings.TrimSpace(data.PersistedConfig.ProfileName) != "" { + selectedProfile = strings.TrimSpace(data.PersistedConfig.ProfileName) + } + if selectedProfile == "" && len(profiles) > 0 { + selectedProfile = profiles[0].Name + } + data.SelectedProfile = selectedProfile + for i := range profiles { + if strings.TrimSpace(profiles[i].Name) == selectedProfile { + data.AssetProfile = &profiles[i] + data.SelectedTemplate = profileAssetTemplate(&profiles[i]) + break + } + } + if data.AssetProfile == nil && len(profiles) > 0 { + data.AssetProfile = &profiles[0] + data.SelectedProfile = profiles[0].Name + data.SelectedTemplate = profileAssetTemplate(&profiles[0]) + } + } else if data.Error == "" { + data.Error = err.Error() + } + if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil { + data.DeviceAssignment = assignment + data.SelectedAssignmentDevice = assignment.DeviceID + data.SelectedProfile = assignment.ProfileName + } + } return data } @@ -1162,7 +1278,11 @@ func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) { } func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) { - u.render(w, r, "diagnostics", PageData{Title: "诊断", Devices: u.registry.GetDevices()}) + u.render(w, r, "diagnostics", PageData{Title: "日志审计", Devices: u.registry.GetDevices()}) +} + +func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) { + u.render(w, r, "resources", PageData{Title: "资源管理", Devices: u.registry.GetDevices()}) } func (u *UI) pageRecognition(w http.ResponseWriter, r *http.Request) { @@ -1188,21 +1308,7 @@ func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) { if data.Error == "" { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } - data.TemplateDraftName = "new_template" - data.TemplateCreateMode = strings.TrimSpace(r.URL.Query().Get("mode")) - if data.TemplateCreateMode == "blank" { - data.TemplateDraftDescription = "从空白流程开始。仅建议在明确了解节点依赖和处理链约束时使用。" - } - if cloneName := strings.TrimSpace(r.URL.Query().Get("clone")); cloneName != "" { - if item, err := u.preview.GetTemplateAsset(cloneName); err == nil && item != nil { - data.TemplateCloneSource = item.Name - data.TemplateCreateMode = "clone" - data.TemplateDraftName = item.Name + "_copy" - data.TemplateDraftDescription = item.Description - } else if data.Error == "" && err != nil { - data.Error = err.Error() - } - } + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { if item, err := u.preview.GetTemplateAsset(name); err == nil { data.AssetTemplate = item @@ -1216,6 +1322,7 @@ func (u *UI) pageAssetTemplates(w http.ResponseWriter, r *http.Request) { data.Error = err.Error() } } + data.AssetTemplateEditing = editMode && data.AssetTemplate != nil && !data.AssetTemplate.ReadOnly u.render(w, r, "asset_templates", data) } @@ -1279,6 +1386,72 @@ func (u *UI) actionAssetTemplateCreate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/assets/templates/"+url.PathEscape(name)+"/graph?msg="+urlQueryEscape("模板已创建"), http.StatusFound) } +func (u *UI) actionAssetTemplateClone(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + sourceName := chi.URLParam(r, "name") + item, err := u.preview.GetTemplateAsset(sourceName) + if err != nil || item == nil { + http.NotFound(w, r) + return + } + if !item.ReadOnly { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape("只允许从标准模板复制创建"), http.StatusFound) + return + } + targetName, err := u.nextTemplateCloneName(item.Name) + if err != nil { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + body, err := json.Marshal(item.Raw) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + doc := map[string]any{} + if err := json.Unmarshal(body, &doc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + doc["name"] = targetName + doc["description"] = item.Description + pretty, err := json.MarshalIndent(doc, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := u.preview.SaveTemplateAsset(targetName, item.Description, string(pretty)+"\n"); err != nil { + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(sourceName)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/templates?name="+url.PathEscape(targetName)+"&edit=1&msg="+urlQueryEscape("标准模板已复制,请继续编辑"), http.StatusFound) +} + +func (u *UI) nextTemplateCloneName(sourceName string) (string, error) { + base := strings.TrimSpace(sourceName) + if base == "" { + return "", fmt.Errorf("template name is required") + } + if strings.HasPrefix(base, "std_") { + base = strings.TrimPrefix(base, "std_") + } + candidate := base + "_copy" + if item, err := u.preview.GetTemplateAsset(candidate); err == nil && item != nil { + for i := 2; i < 1000; i++ { + name := fmt.Sprintf("%s_copy_%d", base, i) + item, err := u.preview.GetTemplateAsset(name) + if err != nil || item == nil { + return name, nil + } + } + return "", fmt.Errorf("无法生成可用的模板副本名称") + } + return candidate, nil +} + func (u *UI) pageAssetTemplate(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") data := u.assetPageData("templates") @@ -1303,7 +1476,7 @@ func (u *UI) pageAssetTemplateGraph(w http.ResponseWriter, r *http.Request) { data.Error = strings.TrimSpace(r.URL.Query().Get("error")) } if u.preview == nil { - data.Error = "配置资产服务未初始化" + data.Error = "基础配置服务未初始化" u.render(w, r, "asset_templates", data) return } @@ -1491,77 +1664,595 @@ func (u *UI) pageAssetTemplateExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "templates", chi.URLParam(r, "name")) } -func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) { - data := u.assetPageData("profiles") +func (u *UI) pagePlans(w http.ResponseWriter, r *http.Request) { + data := u.assetPageData("") + data.Title = "场景模板" + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" selected := strings.TrimSpace(r.URL.Query().Get("name")) - if selected == "" && len(data.AssetProfiles) > 0 { + if selected == "" && !newMode && len(data.AssetProfiles) > 0 { selected = data.AssetProfiles[0].Name } + if newMode { + editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()} + if len(data.AssetTemplates) > 0 { + editor.PrimaryTemplateName = data.AssetTemplates[0].Name + } + data.AssetProfileEditor = editor + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/create" + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0) + u.render(w, r, "scene_templates", data) + return + } if selected != "" { editor, err := u.preview.GetProfileEditor(selected) if err == nil { data.AssetProfileEditor = editor data.SelectedProfile = editor.Name + data.AssetProfileEditing = editMode + data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(editor.Name) if len(editor.Instances) > 0 && editor.Instances[0].Template != "" { data.SelectedTemplate = editor.Instances[0].Template } + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.URL.Query())) } else if data.Error == "" { data.Error = err.Error() } + } else if len(data.AssetProfiles) == 0 { + editor := &service.ConfigProfileEditor{Queue: service.DefaultConfigProfileQueue()} + if len(data.AssetTemplates) > 0 { + editor.PrimaryTemplateName = data.AssetTemplates[0].Name + } + data.AssetProfileEditor = editor + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/create" + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), 0) } - u.render(w, r, "asset_profiles", data) + u.render(w, r, "scene_templates", data) } -func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) { +func (u *UI) pageAssetProfiles(w http.ResponseWriter, r *http.Request) { + u.pagePlans(w, r) +} + +func (u *UI) pagePlan(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") data, err := u.profileEditorPageData(name) if err != nil { http.NotFound(w, r) return } - data.Title = "识别配置" - u.render(w, r, "asset_profiles", data) + data.Title = "场景模板" + data.AssetProfileEditing = strings.TrimSpace(r.URL.Query().Get("edit")) == "1" + data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name) + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(data.AssetProfileEditor.Instances), activeInstanceIndexFromValues(r.URL.Query())) + u.render(w, r, "scene_templates", data) } -func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) { +func (u *UI) pageAssetProfile(w http.ResponseWriter, r *http.Request) { + u.pagePlan(w, r) +} + +func (u *UI) pagePlanExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "profiles", chi.URLParam(r, "name")) } -func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - editor, data, err := u.profileEditorActionData(r, name) - if err != nil { - http.NotFound(w, r) - return +func (u *UI) pageAssetProfileExport(w http.ResponseWriter, r *http.Request) { + u.pagePlanExport(w, r) +} + +func (u *UI) actionPlanSave(w http.ResponseWriter, r *http.Request) { + u.actionPlanSaveWithName(w, r, chi.URLParam(r, "name")) +} + +func (u *UI) actionPlanCreate(w http.ResponseWriter, r *http.Request) { + u.actionPlanSaveWithName(w, r, "") +} + +func (u *UI) actionPlanSaveWithName(w http.ResponseWriter, r *http.Request, name string) { + var ( + editor service.ConfigProfileEditor + data PageData + err error + ) + if strings.TrimSpace(name) == "" { + data = u.assetPageData("") + data.Title = "场景模板" + _ = r.ParseForm() + editor = service.ConfigProfileEditor{ + Name: strings.TrimSpace(r.FormValue("profile_name")), + PrimaryTemplateName: strings.TrimSpace(r.FormValue("primary_template_name")), + BusinessName: strings.TrimSpace(r.FormValue("business_name")), + Description: strings.TrimSpace(r.FormValue("description")), + OverlayName: strings.TrimSpace(r.FormValue("overlay_name")), + SiteName: strings.TrimSpace(r.FormValue("site_name")), + Queue: service.DefaultConfigProfileQueue(), + Instances: parseProfileInstanceForm(r.Form), + } + data.AssetProfileEditor = &editor + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/create" + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form)) + } else { + editor, data, err = u.profileEditorActionData(r, name) + if err != nil { + http.NotFound(w, r) + return + } + data.AssetProfileEditing = true + data.AssetProfileFormAction = "/ui/scene-templates/" + url.PathEscape(name) } if err := u.preview.SaveProfileEditor(editor); err != nil { data.Error = err.Error() - u.render(w, r, "asset_profiles", data) + data.Title = "场景模板" + data.AssetProfileEditing = true + u.render(w, r, "scene_templates", data) return } - if editor.Name != name { - data.Message = "业务配置已保存,名称已更新" - } else { - data.Message = "业务配置已保存" + msg := "场景模板已保存" + if strings.TrimSpace(name) != "" && editor.Name != name { + msg = "场景模板已保存,名称已更新" } - u.render(w, r, "asset_profiles", data) + http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape(msg)+"&name="+url.PathEscape(editor.Name), http.StatusFound) +} + +func (u *UI) actionPlanDelete(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(chi.URLParam(r, "name")) + if name == "" { + http.NotFound(w, r) + return + } + if u.stateRepo != nil { + items, err := u.stateRepo.ListByProfileName(name) + if err != nil { + http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + if len(items) > 0 { + deviceIDs := make([]string, 0, len(items)) + for _, item := range items { + deviceIDs = append(deviceIDs, item.DeviceID) + } + msg := fmt.Sprintf("场景模板 %q 正被以下设备使用,无法删除:%s", name, strings.Join(deviceIDs, "、")) + http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(msg), http.StatusFound) + return + } + } + if err := u.preview.DeleteProfileAsset(name); err != nil { + http.Redirect(w, r, "/ui/scene-templates?name="+url.PathEscape(name)+"&error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/scene-templates?msg="+urlQueryEscape("场景模板已删除"), http.StatusFound) +} + +func (u *UI) actionAssetProfileSave(w http.ResponseWriter, r *http.Request) { + u.actionPlanSave(w, r) +} + +func (u *UI) redirectAssetProfilesToPlans(w http.ResponseWriter, r *http.Request) { + target := "/ui/scene-templates" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusFound) +} + +func (u *UI) redirectAssetProfileToPlan(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ui/scene-templates/"+url.PathEscape(chi.URLParam(r, "name")), http.StatusFound) +} + +func (u *UI) redirectPlansToSceneTemplates(w http.ResponseWriter, r *http.Request) { + target := "/ui/scene-templates" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusFound) +} + +func (u *UI) redirectPlanToSceneTemplate(w http.ResponseWriter, r *http.Request) { + target := "/ui/scene-templates/" + url.PathEscape(chi.URLParam(r, "name")) + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusFound) +} + +func (u *UI) pageRecognitionUnits(w http.ResponseWriter, r *http.Request) { + data := u.assetPageData("") + data.Title = "识别单元" + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" + selected := strings.TrimSpace(r.URL.Query().Get("ref")) + if selected == "" && !newMode && len(data.RecognitionUnits) > 0 { + selected = data.RecognitionUnits[0].Ref + } + data.SelectedRecognitionUnit = selected + if newMode { + defaultTemplate := "" + if len(data.AssetProfiles) > 0 { + defaultTemplate = data.AssetProfiles[0].Name + } + data.RecognitionUnit = &service.RecognitionUnitAsset{ + SceneTemplateName: defaultTemplate, + OutputChannel: "cam1", + RTSPPort: "8555", + } + data.RecognitionUnitEditing = true + } else if selected != "" { + if item, err := u.preview.GetRecognitionUnit(selected); err == nil { + data.RecognitionUnit = item + data.RecognitionUnitEditing = editMode + } else if data.Error == "" { + data.Error = err.Error() + } + } + u.render(w, r, "recognition_units", data) +} + +func (u *UI) actionRecognitionUnitSave(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + asset := service.RecognitionUnitAsset{ + SceneTemplateName: strings.TrimSpace(r.FormValue("scene_template_name")), + Name: strings.TrimSpace(r.FormValue("name")), + DisplayName: strings.TrimSpace(r.FormValue("display_name")), + SiteName: strings.TrimSpace(r.FormValue("site_name")), + VideoSourceRef: strings.TrimSpace(r.FormValue("video_source_ref")), + OutputChannel: strings.TrimSpace(r.FormValue("output_channel")), + RTSPPort: strings.TrimSpace(r.FormValue("rtsp_port")), + } + originalRef := strings.TrimSpace(r.FormValue("original_ref")) + if err := u.preview.SaveRecognitionUnit(asset, originalRef); err != nil { + data := u.assetPageData("") + data.Title = "识别单元" + data.Error = err.Error() + data.RecognitionUnit = &asset + data.RecognitionUnitEditing = true + data.SelectedRecognitionUnit = originalRef + u.render(w, r, "recognition_units", data) + return + } + ref := serviceRecognitionUnitRef(asset.SceneTemplateName, asset.Name) + http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("识别单元已保存")+"&ref="+url.QueryEscape(ref), http.StatusFound) +} + +func serviceRecognitionUnitRef(profileName string, unitName string) string { + return strings.TrimSpace(profileName) + "::" + strings.TrimSpace(unitName) +} + +func (u *UI) actionRecognitionUnitDelete(w http.ResponseWriter, r *http.Request) { + ref := strings.TrimSpace(r.FormValue("ref")) + if err := u.preview.DeleteRecognitionUnit(ref); err != nil { + http.Redirect(w, r, "/ui/recognition-units?error="+urlQueryEscape(err.Error())+"&ref="+url.QueryEscape(ref), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/recognition-units?msg="+urlQueryEscape("识别单元已删除"), http.StatusFound) +} + +func (u *UI) pageDeviceAssignments(w http.ResponseWriter, r *http.Request) { + data := u.assetPageData("") + data.Title = "设备分配" + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + u.ensureDevicesLoaded() + data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices()) + maxUnits := service.DefaultMaxUnitsPerDevice() + if value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("max_units_per_device"))); err == nil && value > 0 { + maxUnits = value + } + data.MaxUnitsPerDevice = maxUnits + if u.preview != nil { + if board, err := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); err == nil { + data.DeviceAssignmentBoard = board + if payload, marshalErr := json.Marshal(board); marshalErr == nil { + data.DeviceAssignmentBoardJSON = template.JS(payload) + } + } else if data.Error == "" { + data.Error = err.Error() + } + } + u.render(w, r, "device_assignments", data) +} + +func (u *UI) actionDeviceAssignmentSave(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + assignments, err := parseDeviceAssignmentBoardState(strings.TrimSpace(r.FormValue("board_state_json"))) + if err != nil { + data := u.assetPageData("") + data.Title = "设备分配" + u.ensureDevicesLoaded() + data.Devices = deviceAssignmentBoardDevices(u.registry.GetDevices()) + data.Error = err.Error() + maxUnits := service.DefaultMaxUnitsPerDevice() + if value, convErr := strconv.Atoi(strings.TrimSpace(r.FormValue("max_units_per_device"))); convErr == nil && value > 0 { + maxUnits = value + } + data.MaxUnitsPerDevice = maxUnits + if u.preview != nil { + if board, buildErr := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); buildErr == nil { + data.DeviceAssignmentBoard = board + if payload, marshalErr := json.Marshal(board); marshalErr == nil { + data.DeviceAssignmentBoardJSON = template.JS(payload) + } + } + } + u.render(w, r, "device_assignments", data) + return + } + if err := u.preview.SaveDeviceAssignmentBoard(filterPreviewDeviceAssignments(assignments)); err != nil { + http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error()), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("设备分配已保存"), http.StatusFound) +} + +func deviceAssignmentBoardDevices(devices []*models.Device) []*models.Device { + out := make([]*models.Device, 0, len(devices)) + seen := make(map[string]struct{}, len(devices)) + for _, dev := range devices { + if dev == nil || strings.TrimSpace(dev.DeviceID) == "" { + continue + } + deviceID := strings.TrimSpace(dev.DeviceID) + if _, ok := seen[deviceID]; ok { + continue + } + seen[deviceID] = struct{}{} + out = append(out, dev) + } + for len(out) < deviceAssignmentPreviewDeviceCount { + index := len(out) + 1 + deviceID := fmt.Sprintf("%s%02d", deviceAssignmentPreviewDevicePrefix, index) + out = append(out, &models.Device{ + DeviceID: deviceID, + DeviceName: fmt.Sprintf("测试设备 %02d", index), + Online: true, + }) + } + return out +} + +func filterPreviewDeviceAssignments(assignments map[string][]string) map[string][]string { + if len(assignments) == 0 { + return assignments + } + filtered := make(map[string][]string, len(assignments)) + for deviceID, refs := range assignments { + if strings.HasPrefix(strings.TrimSpace(deviceID), deviceAssignmentPreviewDevicePrefix) { + continue + } + filtered[deviceID] = refs + } + return filtered +} + +func (u *UI) actionDeviceAssignmentDelete(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(chi.URLParam(r, "id")) + if err := u.preview.DeleteDeviceAssignment(deviceID); err != nil { + http.Redirect(w, r, "/ui/device-assignments?error="+urlQueryEscape(err.Error())+"&device_id="+url.PathEscape(deviceID), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/device-assignments?msg="+urlQueryEscape("设备分配已删除"), http.StatusFound) +} + +func (u *UI) pageAssetVideoSources(w http.ResponseWriter, r *http.Request) { + data := u.assetPageData("video-sources") + data.Title = "基础配置" + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" + selected := strings.TrimSpace(r.URL.Query().Get("name")) + if selected != "" { + if item, err := u.preview.GetVideoSource(selected); err == nil { + data.AssetVideoSource = item + } else if data.Error == "" { + data.Error = err.Error() + } + } else if !newMode && len(data.AssetVideoSources) > 0 { + if item, err := u.preview.GetVideoSource(data.AssetVideoSources[0].Name); err == nil { + data.AssetVideoSource = item + } + } + if data.AssetVideoSource == nil { + data.AssetVideoSource = &service.ConfigVideoSourceAsset{ + SourceType: "rtsp", + SourceTypeLabel: "RTSP", + } + data.AssetVideoSourceEditing = true + } else { + data.AssetVideoSourceEditing = newMode || editMode + } + u.render(w, r, "assets", data) +} + +func (u *UI) actionAssetVideoSourceSave(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + _ = r.ParseForm() + asset := service.ConfigVideoSourceAsset{ + Name: strings.TrimSpace(r.FormValue("name")), + SourceType: strings.TrimSpace(r.FormValue("source_type")), + Area: strings.TrimSpace(r.FormValue("area")), + Description: strings.TrimSpace(r.FormValue("description")), + Config: service.VideoSourceConfig{ + URL: strings.TrimSpace(r.FormValue("url")), + Resolution: strings.TrimSpace(r.FormValue("resolution")), + FrameSize: strings.TrimSpace(r.FormValue("frame_size")), + FPS: strings.TrimSpace(r.FormValue("fps")), + VideoFormat: strings.TrimSpace(r.FormValue("video_format")), + FocalLength: strings.TrimSpace(r.FormValue("focal_length")), + MountHeight: strings.TrimSpace(r.FormValue("mount_height")), + MountAngle: strings.TrimSpace(r.FormValue("mount_angle")), + }, + } + if err := u.preview.SaveVideoSourceAsset(asset); err != nil { + data := u.assetPageData("video-sources") + data.Title = "基础配置" + data.Error = err.Error() + data.AssetVideoSource = &asset + data.AssetVideoSource.SourceTypeLabel = serviceVideoSourceTypeLabel(asset.SourceType) + data.AssetVideoSourceEditing = true + u.render(w, r, "assets", data) + return + } + http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound) +} + +func (u *UI) actionAssetVideoSourceDelete(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + name := chi.URLParam(r, "name") + if err := u.preview.DeleteVideoSource(name); err != nil { + http.Redirect(w, r, "/ui/assets/video-sources?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/video-sources?msg="+urlQueryEscape("视频源已删除"), http.StatusFound) +} + +func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) { + data := u.assetPageData("integrations") + data.Title = "基础配置" + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" + selected := strings.TrimSpace(r.URL.Query().Get("name")) + if selected != "" { + if item, err := u.preview.GetIntegrationService(selected); err == nil { + data.AssetIntegration = item + } else if data.Error == "" { + data.Error = err.Error() + } + } else if !newMode && len(data.AssetIntegrations) > 0 { + if item, err := u.preview.GetIntegrationService(data.AssetIntegrations[0].Name); err == nil { + data.AssetIntegration = item + } + } + if data.AssetIntegration == nil { + data.AssetIntegration = &service.ConfigIntegrationServiceAsset{ + Type: "object_storage", + TypeLabel: "对象存储", + Enabled: true, + ObjectStorage: &service.ObjectStorageConfig{}, + } + data.AssetIntegrationEditing = true + } else { + data.AssetIntegrationEditing = newMode || editMode + } + u.render(w, r, "assets", data) +} + +func (u *UI) actionAssetIntegrationSave(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + _ = r.ParseForm() + enabled := strings.TrimSpace(r.FormValue("enabled")) == "1" || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "true") || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "on") + asset := service.ConfigIntegrationServiceAsset{ + Name: strings.TrimSpace(r.FormValue("name")), + Type: strings.TrimSpace(r.FormValue("type")), + Description: strings.TrimSpace(r.FormValue("description")), + Enabled: enabled, + ObjectStorage: &service.ObjectStorageConfig{ + Endpoint: strings.TrimSpace(r.FormValue("endpoint")), + Bucket: strings.TrimSpace(r.FormValue("bucket")), + AccessKey: strings.TrimSpace(r.FormValue("access_key")), + SecretKey: strings.TrimSpace(r.FormValue("secret_key")), + }, + TokenService: &service.TokenServiceConfig{ + GetTokenURL: strings.TrimSpace(r.FormValue("get_token_url")), + Username: strings.TrimSpace(r.FormValue("username")), + Password: strings.TrimSpace(r.FormValue("password")), + TenantCode: strings.TrimSpace(r.FormValue("tenant_code")), + }, + AlarmService: &service.AlarmServiceConfig{ + PutMessageURL: strings.TrimSpace(r.FormValue("put_message_url")), + Username: strings.TrimSpace(r.FormValue("alarm_username")), + Password: strings.TrimSpace(r.FormValue("alarm_password")), + TenantCode: strings.TrimSpace(r.FormValue("alarm_tenant_code")), + }, + } + if err := u.preview.SaveIntegrationServiceAsset(asset); err != nil { + http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已保存")+"&name="+url.PathEscape(asset.Name), http.StatusFound) +} + +func (u *UI) actionAssetIntegrationDelete(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + name := chi.URLParam(r, "name") + if err := u.preview.DeleteIntegrationService(name); err != nil { + http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/integrations?msg="+urlQueryEscape("第三方服务已删除"), http.StatusFound) } func (u *UI) pageAssetOverlays(w http.ResponseWriter, r *http.Request) { data := u.assetPageData("overlays") + data.Message = strings.TrimSpace(r.URL.Query().Get("msg")) + if data.Error == "" { + data.Error = strings.TrimSpace(r.URL.Query().Get("error")) + } + newMode := strings.TrimSpace(r.URL.Query().Get("new")) == "1" + editMode := strings.TrimSpace(r.URL.Query().Get("edit")) == "1" if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { if item, err := u.preview.GetOverlayAsset(name); err == nil { data.AssetOverlay = item } else if data.Error == "" { data.Error = err.Error() } - } else if len(data.AssetOverlays) > 0 { + } else if !newMode && len(data.AssetOverlays) > 0 { if item, err := u.preview.GetOverlayAsset(data.AssetOverlays[0].Name); err == nil { data.AssetOverlay = item } else if data.Error == "" { data.Error = err.Error() } } + if newMode { + data.AssetOverlay = &service.ConfigOverlayAsset{ + Name: "", + Description: "", + Raw: map[string]any{ + "name": "", + "description": "", + "instance_overrides": map[string]any{"*": map[string]any{"override": map[string]any{}}}, + }, + } + data.AssetOverlayEditing = true + } else { + data.AssetOverlayEditing = editMode && data.AssetOverlay != nil + } + if data.AssetOverlay != nil { + rawJSON, err := compactJSON(data.AssetOverlay.Raw) + if err == nil { + data.OverlayDraftJSON = rawJSON + } + } u.render(w, r, "asset_overlays", data) } @@ -1581,13 +2272,63 @@ func (u *UI) pageAssetOverlayExport(w http.ResponseWriter, r *http.Request) { u.exportAssetJSON(w, r, "overlays", chi.URLParam(r, "name")) } +func (u *UI) actionAssetOverlaySave(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + _ = r.ParseForm() + name := strings.TrimSpace(r.FormValue("name")) + description := strings.TrimSpace(r.FormValue("description")) + rawText := strings.TrimSpace(r.FormValue("json")) + if rawText == "" { + rawText = "{}" + } + raw := map[string]any{} + if err := json.Unmarshal([]byte(rawText), &raw); err != nil { + data := u.assetPageData("overlays") + data.Title = "基础配置" + data.Error = "调试参数 JSON 格式不正确:" + err.Error() + data.AssetOverlayEditing = true + data.AssetOverlay = &service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw} + data.OverlayDraftJSON = rawText + u.render(w, r, "asset_overlays", data) + return + } + asset := service.ConfigOverlayAsset{Name: name, Description: description, Raw: raw} + if err := u.preview.SaveOverlayAsset(asset, raw); err != nil { + data := u.assetPageData("overlays") + data.Title = "基础配置" + data.Error = err.Error() + data.AssetOverlayEditing = true + data.AssetOverlay = &asset + data.OverlayDraftJSON = rawText + u.render(w, r, "asset_overlays", data) + return + } + http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已保存")+"&name="+url.PathEscape(name), http.StatusFound) +} + +func (u *UI) actionAssetOverlayDelete(w http.ResponseWriter, r *http.Request) { + if u.preview == nil { + http.Error(w, "config preview service is not configured", http.StatusInternalServerError) + return + } + name := chi.URLParam(r, "name") + if err := u.preview.DeleteOverlayAsset(name); err != nil { + http.Redirect(w, r, "/ui/assets/overlays?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(name), http.StatusFound) + return + } + http.Redirect(w, r, "/ui/assets/overlays?msg="+urlQueryEscape("调试参数已删除"), http.StatusFound) +} + func (u *UI) assetPageData(tab string) PageData { data := PageData{ - Title: "识别配置", + Title: "基础配置", AssetTab: tab, } if u.preview == nil { - data.Error = "配置资产服务未初始化" + data.Error = "基础配置服务未初始化" return data } sources, err := u.preview.ListSources() @@ -1597,6 +2338,10 @@ func (u *UI) assetPageData(tab string) PageData { } if items, listErr := u.preview.ListTemplateAssets(); listErr == nil { data.AssetTemplates = items + data.AssetTemplateMap = make(map[string]service.ConfigTemplateAsset, len(items)) + for _, item := range items { + data.AssetTemplateMap[item.Name] = item + } } else if data.Error == "" { data.Error = listErr.Error() } @@ -1613,6 +2358,26 @@ func (u *UI) assetPageData(tab string) PageData { } else if data.Error == "" { data.Error = listErr.Error() } + if items, listErr := u.preview.ListVideoSources(); listErr == nil { + data.AssetVideoSources = items + } else if data.Error == "" { + data.Error = listErr.Error() + } + if items, listErr := u.preview.ListIntegrationServices(); listErr == nil { + data.AssetIntegrations = items + } else if data.Error == "" { + data.Error = listErr.Error() + } + if items, listErr := u.preview.ListRecognitionUnits(); listErr == nil { + data.RecognitionUnits = items + } else if data.Error == "" { + data.Error = listErr.Error() + } + if items, listErr := u.preview.ListDeviceAssignments(); listErr == nil { + data.DeviceAssignments = items + } else if data.Error == "" { + data.Error = listErr.Error() + } return data } @@ -1635,6 +2400,31 @@ func (u *UI) profileEditorPageData(name string) (PageData, error) { return data, nil } +func clampActiveInstanceIndex(count int, preferred int) int { + if count <= 0 { + return 0 + } + if preferred < 0 { + return 0 + } + if preferred >= count { + return count - 1 + } + return preferred +} + +func activeInstanceIndexFromValues(values url.Values) int { + raw := strings.TrimSpace(values.Get("active_instance")) + if raw == "" { + return 0 + } + idx, err := strconv.Atoi(raw) + if err != nil { + return 0 + } + return idx +} + func (u *UI) profileEditorActionData(r *http.Request, name string) (service.ConfigProfileEditor, PageData, error) { data, err := u.profileEditorPageData(name) if err != nil { @@ -1642,19 +2432,21 @@ func (u *UI) profileEditorActionData(r *http.Request, name string) (service.Conf } _ = r.ParseForm() editor := service.ConfigProfileEditor{ - Name: strings.TrimSpace(r.FormValue("profile_name")), - BusinessName: strings.TrimSpace(r.FormValue("business_name")), - Description: strings.TrimSpace(r.FormValue("description")), - SiteName: strings.TrimSpace(r.FormValue("site_name")), - Queue: service.ConfigProfileQueueEditor{ - Size: strings.TrimSpace(r.FormValue("queue_size")), - Strategy: strings.TrimSpace(r.FormValue("queue_strategy")), - }, - Instances: parseProfileInstanceForm(r.Form), + Name: strings.TrimSpace(r.FormValue("profile_name")), + PrimaryTemplateName: strings.TrimSpace(r.FormValue("primary_template_name")), + BusinessName: strings.TrimSpace(r.FormValue("business_name")), + Description: strings.TrimSpace(r.FormValue("description")), + OverlayName: strings.TrimSpace(r.FormValue("overlay_name")), + SiteName: strings.TrimSpace(r.FormValue("site_name")), + Queue: service.DefaultConfigProfileQueue(), + Instances: parseProfileInstanceForm(r.Form), } if editor.Name == "" && data.AssetProfileEditor != nil { editor.Name = data.AssetProfileEditor.Name } + if editor.PrimaryTemplateName == "" && data.AssetProfileEditor != nil { + editor.PrimaryTemplateName = data.AssetProfileEditor.PrimaryTemplateName + } if editor.DeviceCode == "" && data.AssetProfileEditor != nil { editor.DeviceCode = data.AssetProfileEditor.DeviceCode } @@ -1663,6 +2455,7 @@ func (u *UI) profileEditorActionData(r *http.Request, name string) (service.Conf } data.AssetProfileEditor = &editor data.SelectedProfile = editor.Name + data.ActiveInstanceIndex = clampActiveInstanceIndex(len(editor.Instances), activeInstanceIndexFromValues(r.Form)) return editor, data, nil } @@ -1780,6 +2573,21 @@ func normalizeConfigName(name string) (string, error) { return name, nil } +func serviceVideoSourceTypeLabel(v string) string { + switch strings.TrimSpace(v) { + case "rtsp": + return "RTSP" + case "rtmp": + return "RTMP" + case "file": + return "文件" + case "usb_camera": + return "USB 摄像头" + default: + return strings.TrimSpace(v) + } +} + func compactJSON(v any) (string, error) { body, err := json.Marshal(v) if err != nil { @@ -1943,28 +2751,9 @@ func (u *UI) actionDeviceConfigPreview(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - _ = r.ParseForm() - req := service.ConfigPreviewRequest{ - Template: strings.TrimSpace(r.FormValue("template")), - Profile: strings.TrimSpace(r.FormValue("profile")), - Overlays: cleanFormList(r.Form["overlay"]), - ConfigID: strings.TrimSpace(r.FormValue("config_id")), - ConfigVersion: strings.TrimSpace(r.FormValue("config_version")), - } - if req.Template == "" { - req.Template = "std_workshop_face_recognition_shoe_alarm" - } - if req.Profile == "" { - req.Profile = "local_3588_test" - } - preview, err := u.preview.Render(req) + preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID) data := u.configPreviewPageData(dev) data.ConfigPreview = preview - data.SelectedTemplate = req.Template - data.SelectedProfile = req.Profile - data.SelectedOverlays = append([]string(nil), req.Overlays...) - data.SelectedConfigID = req.ConfigID - data.SelectedVersion = req.ConfigVersion if err != nil { data.Error = err.Error() } @@ -2050,14 +2839,17 @@ func (u *UI) configPreviewPageData(dev *models.Device) PageData { Title: "配置预览", Device: dev, ConfigSources: sources, - SelectedTemplate: "std_workshop_face_recognition_shoe_alarm", - SelectedProfile: "local_3588_test", - SelectedOverlays: []string{"face_debug"}, - SelectedConfigID: "preview_" + dev.DeviceID, } if err != nil { data.Error = err.Error() } + if dev != nil { + if assignment, err := u.preview.GetDeviceAssignment(dev.DeviceID); err == nil { + data.DeviceAssignment = assignment + data.SelectedAssignmentDevice = assignment.DeviceID + data.SelectedProfile = assignment.ProfileName + } + } status, _, statusErr := u.loadConfigStatus(dev) data.ConfigStatus = status if statusErr != nil { @@ -2104,6 +2896,31 @@ func cleanFormList(values []string) []string { return out } +func parseDeviceAssignmentBoardState(raw string) (map[string][]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("设备分配数据为空") + } + var payload struct { + Devices map[string][]string `json:"devices"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, fmt.Errorf("设备分配数据格式错误") + } + if payload.Devices == nil { + return map[string][]string{}, nil + } + out := make(map[string][]string, len(payload.Devices)) + for deviceID, refs := range payload.Devices { + deviceID = strings.TrimSpace(deviceID) + if deviceID == "" { + continue + } + out[deviceID] = cleanFormList(refs) + } + return out, nil +} + func parseAdvancedParams(raw string) map[string]any { raw = strings.TrimSpace(raw) if raw == "" { @@ -2119,6 +2936,99 @@ func parseAdvancedParams(raw string) map[string]any { return out } +func defaultProfileEditorDraft(templates []service.ConfigTemplateAsset) *service.ConfigProfileEditor { + templateName := "std_workshop_face_recognition_shoe_alarm" + if len(templates) > 0 && strings.TrimSpace(templates[0].Name) != "" { + templateName = strings.TrimSpace(templates[0].Name) + } + inst := newProfileInstanceDraft(templateName, "cam1") + return &service.ConfigProfileEditor{ + Name: "", + BusinessName: "", + Description: "", + OverlayName: "", + Queue: service.DefaultConfigProfileQueue(), + Instances: []service.ConfigProfileInstanceEditor{inst}, + } +} + +func (u *UI) actionDevicePlanApply(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + dev, ok := u.findDevice(id) + if !ok { + http.NotFound(w, r) + return + } + data := u.deviceDetailPageData(dev) + if data.DeviceAssignment == nil { + data.Error = "请先到设备分配中为该设备指定识别单元" + u.render(w, r, "device", data) + return + } + if u.tasks == nil { + data.Error = "task service not initialized" + u.render(w, r, "device", data) + return + } + preview, err := u.preview.RenderDeviceAssignment(dev.DeviceID) + data.ConfigPreview = preview + if err != nil { + data.Error = err.Error() + u.render(w, r, "device", data) + return + } + var configDoc any + if err := json.Unmarshal([]byte(preview.JSON), &configDoc); err != nil { + data.Error = "生成配置 JSON 无效: " + err.Error() + u.render(w, r, "device", data) + return + } + task, err := u.tasks.CreateTask("config_apply", []string{dev.DeviceID}, map[string]any{"config": configDoc}) + if err != nil { + data.Error = err.Error() + u.render(w, r, "device", data) + return + } + http.Redirect(w, r, "/ui/tasks/"+task.ID, http.StatusFound) +} + +func nextProfileInstanceName(instances []service.ConfigProfileInstanceEditor) string { + used := make(map[string]struct{}, len(instances)) + for _, inst := range instances { + name := strings.TrimSpace(inst.Name) + if name == "" { + continue + } + used[name] = struct{}{} + } + for i := 1; ; i++ { + candidate := fmt.Sprintf("cam%d", i) + if _, ok := used[candidate]; !ok { + return candidate + } + } +} + +func newProfileInstanceDraft(templateName string, channelName string) service.ConfigProfileInstanceEditor { + outputs := map[string]service.OutputBindingEditor{ + "stream_output_main": { + PublishHLSPath: "./web/hls/" + channelName + "/index.m3u8", + PublishRTSPPort: "8555", + PublishRTSPPath: "/live/" + channelName, + ChannelNo: channelName, + }, + } + return service.ConfigProfileInstanceEditor{ + Name: channelName, + Template: templateName, + PublishHLSPath: outputs["stream_output_main"].PublishHLSPath, + PublishRTSPPort: outputs["stream_output_main"].PublishRTSPPort, + PublishRTSPPath: outputs["stream_output_main"].PublishRTSPPath, + ChannelNo: outputs["stream_output_main"].ChannelNo, + OutputBindings: outputs, + } +} + func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEditor { indices := make([]int, 0) seen := map[int]struct{}{} @@ -2145,19 +3055,25 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd out := make([]service.ConfigProfileInstanceEditor, 0, len(indices)) for _, idx := range indices { prefix := fmt.Sprintf("instances[%d].", idx) + inputBindings := parseInputBindingForm(form, prefix) + serviceBindings := parseServiceBindingForm(form, prefix) + outputBindings := parseOutputBindingForm(form, prefix) inst := service.ConfigProfileInstanceEditor{ Name: strings.TrimSpace(form.Get(prefix + "name")), Template: strings.TrimSpace(form.Get(prefix + "template")), + VideoSourceRef: firstString(inputBindingValue(inputBindings, "video_input_main"), strings.TrimSpace(form.Get(prefix+"video_source_ref"))), DisplayName: strings.TrimSpace(form.Get(prefix + "display_name")), - RTSPURL: strings.TrimSpace(form.Get(prefix + "rtsp_url")), - PublishHLSPath: strings.TrimSpace(form.Get(prefix + "publish_hls_path")), - PublishRTSPPort: strings.TrimSpace(form.Get(prefix + "publish_rtsp_port")), - PublishRTSPPath: strings.TrimSpace(form.Get(prefix + "publish_rtsp_path")), - ChannelNo: strings.TrimSpace(form.Get(prefix + "channel_no")), + PublishHLSPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_hls_path"), strings.TrimSpace(form.Get(prefix+"publish_hls_path"))), + PublishRTSPPort: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_port"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_port"))), + PublishRTSPPath: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "publish_rtsp_path"), strings.TrimSpace(form.Get(prefix+"publish_rtsp_path"))), + ChannelNo: firstString(outputBindingFormValue(outputBindings, "stream_output_main", "channel_no"), strings.TrimSpace(form.Get(prefix+"channel_no"))), + InputBindings: inputBindings, + ServiceBindings: serviceBindings, + OutputBindings: outputBindings, AdvancedParams: parseAdvancedParams(strings.TrimSpace(form.Get(prefix + "advanced_params"))), Delete: strings.TrimSpace(form.Get(prefix+"delete")) == "1", } - if inst.Name != "" || inst.RTSPURL != "" || inst.Delete { + if inst.Name != "" || inst.VideoSourceRef != "" || len(inst.ServiceBindings) > 0 || len(inst.OutputBindings) > 0 || inst.Delete { out = append(out, inst) } } @@ -2166,10 +3082,7 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd if len(out) > 0 && strings.TrimSpace(out[0].Template) != "" { templateName = strings.TrimSpace(out[0].Template) } - out = append(out, service.ConfigProfileInstanceEditor{ - Template: templateName, - PublishRTSPPort: "8555", - }) + out = append(out, newProfileInstanceDraft(templateName, nextProfileInstanceName(out))) } if len(out) > 0 { fallbackTemplate := strings.TrimSpace(out[0].Template) @@ -2185,6 +3098,134 @@ func parseProfileInstanceForm(form url.Values) []service.ConfigProfileInstanceEd return out } +func parseInputBindingForm(form url.Values, prefix string) map[string]service.InputBindingEditor { + bindings := map[string]service.InputBindingEditor{} + for key := range form { + if !strings.HasPrefix(key, prefix+"input_bindings.") || !strings.HasSuffix(key, ".video_source_ref") { + continue + } + slot := strings.TrimPrefix(key, prefix+"input_bindings.") + slot = strings.TrimSuffix(slot, ".video_source_ref") + slot = strings.TrimSpace(slot) + if slot == "" { + continue + } + value := strings.TrimSpace(form.Get(key)) + if value == "" { + continue + } + bindings[slot] = service.InputBindingEditor{VideoSourceRef: value} + } + if len(bindings) == 0 { + return nil + } + return bindings +} + +func parseServiceBindingForm(form url.Values, prefix string) map[string]service.ServiceBindingEditor { + bindings := map[string]service.ServiceBindingEditor{} + for key := range form { + if !strings.HasPrefix(key, prefix+"service_bindings.") || !strings.HasSuffix(key, ".service_ref") { + continue + } + slot := strings.TrimPrefix(key, prefix+"service_bindings.") + slot = strings.TrimSuffix(slot, ".service_ref") + slot = strings.TrimSpace(slot) + if slot == "" { + continue + } + value := strings.TrimSpace(form.Get(key)) + if value == "" { + continue + } + bindings[slot] = service.ServiceBindingEditor{ServiceRef: value} + } + if len(bindings) == 0 { + return nil + } + return bindings +} + +func parseOutputBindingForm(form url.Values, prefix string) map[string]service.OutputBindingEditor { + bindings := map[string]service.OutputBindingEditor{} + for key := range form { + if !strings.HasPrefix(key, prefix+"output_bindings.") { + continue + } + slotField := strings.TrimPrefix(key, prefix+"output_bindings.") + dot := strings.LastIndex(slotField, ".") + if dot <= 0 { + continue + } + slot := strings.TrimSpace(slotField[:dot]) + field := strings.TrimSpace(slotField[dot+1:]) + if slot == "" || field == "" { + continue + } + value := strings.TrimSpace(form.Get(key)) + entry := bindings[slot] + switch field { + case "publish_hls_path": + entry.PublishHLSPath = value + case "publish_rtsp_port": + entry.PublishRTSPPort = value + case "publish_rtsp_path": + entry.PublishRTSPPath = value + case "channel_no": + entry.ChannelNo = value + default: + continue + } + if strings.TrimSpace(entry.PublishHLSPath) == "" && strings.TrimSpace(entry.PublishRTSPPort) == "" && + strings.TrimSpace(entry.PublishRTSPPath) == "" && strings.TrimSpace(entry.ChannelNo) == "" { + continue + } + bindings[slot] = entry + } + if len(bindings) == 0 { + return nil + } + return bindings +} + +func inputBindingValue(bindings map[string]service.InputBindingEditor, slot string) string { + if len(bindings) == 0 { + return "" + } + return strings.TrimSpace(bindings[slot].VideoSourceRef) +} + +func outputBindingFormValue(bindings map[string]service.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 firstString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + func selectedIDsFromQuery(values []string) []string { values = cleanFormList(values) if len(values) == 0 { @@ -2317,32 +3358,25 @@ func (u *UI) deviceOverviewPageData(r *http.Request, selectedIDs []string, errMs func (u *UI) deviceBatchConfigPageData(r *http.Request, selectedIDs []string) PageData { data := u.deviceOverviewPageData(r, selectedIDs, "") sources, err := u.preview.ListSources() - data.Title = "下发业务配置" + data.Title = "下发设备分配" data.ConfigSources = sources data.SelectedDevices = selectedDevicesFromIDs(data.Devices, data.SelectedDeviceIDs) - profiles, profileErr := u.preview.ListProfileAssets() - data.AssetProfiles = profiles - selectedProfile := strings.TrimSpace(r.URL.Query().Get("profile")) - if selectedProfile == "" { - selectedProfile = "local_3588_test" + assignments, assignErr := u.preview.ListDeviceAssignments() + filteredAssignments := make([]service.DeviceAssignmentAsset, 0, len(assignments)) + selectedSet := make(map[string]struct{}, len(data.SelectedDeviceIDs)) + for _, id := range data.SelectedDeviceIDs { + selectedSet[id] = struct{}{} } - for i := range profiles { - if strings.TrimSpace(profiles[i].Name) == selectedProfile { - data.AssetProfile = &profiles[i] - data.SelectedProfile = profiles[i].Name - data.SelectedTemplate = profileAssetTemplate(&profiles[i]) - break + for _, item := range assignments { + if _, ok := selectedSet[item.DeviceID]; ok { + filteredAssignments = append(filteredAssignments, item) } } - if data.AssetProfile == nil && len(profiles) > 0 { - data.AssetProfile = &profiles[0] - data.SelectedProfile = profiles[0].Name - data.SelectedTemplate = profileAssetTemplate(&profiles[0]) - } + data.DeviceAssignments = filteredAssignments if err != nil { data.Error = err.Error() - } else if profileErr != nil { - data.Error = profileErr.Error() + } else if assignErr != nil { + data.Error = assignErr.Error() } return data } @@ -2457,7 +3491,7 @@ func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action s label := row.Device.DisplayName() switch action { case "reload": - summary := "未取到当前业务配置" + summary := "未取到当前运行配置" if row.ConfigStatus != nil { meta := row.ConfigStatus.Metadata if name := strings.TrimSpace(meta.BusinessName); name != "" { @@ -2473,7 +3507,7 @@ func batchActionSummary(rows []DeviceOverviewRow, selectedIDs []string, action s } lines = append(lines, label+" -> "+summary) case "rollback": - summary := "未取到可回滚业务配置" + summary := "未取到可回滚运行配置" if row.ConfigStatus != nil && row.ConfigStatus.PreviousConfig != nil { meta := row.ConfigStatus.PreviousConfig.Metadata if name := strings.TrimSpace(meta.BusinessName); name != "" { diff --git a/internal/web/ui/assets/graph_editor.js b/internal/web/ui/assets/graph_editor.js index cc3c9d6..e04a549 100644 --- a/internal/web/ui/assets/graph_editor.js +++ b/internal/web/ui/assets/graph_editor.js @@ -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" }, diff --git a/internal/web/ui/assets/style.css b/internal/web/ui/assets/style.css index 2183c4f..f2831d2 100644 --- a/internal/web/ui/assets/style.css +++ b/internal/web/ui/assets/style.css @@ -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} } diff --git a/internal/web/ui/templates/api.html b/internal/web/ui/templates/api.html index 920b72a..e18e4c6 100644 --- a/internal/web/ui/templates/api.html +++ b/internal/web/ui/templates/api.html @@ -1,6 +1,6 @@ {{define "api"}}
| 叠加项 | +调试参数 | 描述 | 目标 |
|---|---|---|---|
| {{.Name}} | {{if .Description}}{{.Description}}{{else}}-{{end}} | {{if .OverrideTargets}}{{range $i, $item := .OverrideTargets}}{{if $i}}, {{end}}{{$item}}{{end}}{{else}}-{{end}} | |
还没有叠加项 | |||
还没有调试参数 | |||