commit 587ae6f0889719b1d6587541794296b2888014d2 Author: wangcong <2+wangcong@noreply.192.168.50.94> Date: Tue Oct 20 08:34:12 2026 +0800 初始化系统融合软件代码仓库 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eeb650d --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +/target/ +/data/ +/runtime/ +/output/ +/frontend/dist/ +node_modules/ +__pycache__/ +*.py[cod] +*.log +*.local.properties +.env +.env.* +!.env.example +/deployment/*.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb8ddfa --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# XTRH 总控、采集转发与多机节点 + +这是接入 PXHJ 正式试验流程的系统融合软件,包含 Java 后台服务和独立的只读监测页面。PXHJ 负责试验组织和业务控制;本软件通过 `/console/` 按装备查看真实采集数据,并保留同步质量、虚拟装备、融合视频、融合指标及节点状态的查看入口。未接入的指标和功能显示空状态,不提供演示数据。 + +采集页面复用 PXHJ 已完成解密、校验、冻结装备绑定及质量判定的数据,经现有服务签名通道全量同步;不增加 UDP 接收器,不修改采集器配置。使用方式和接入边界见 [系统融合软件页面与采集接入](docs/系统融合软件页面与采集接入.md)。主节点部署更新后,可双击 `启动系统融合软件.cmd` 打开页面,默认地址为 `http://127.0.0.1:18100/console/`。 + +底层原有演示视频任务仍用于独立验证节点调度、迁移和 PXHJ 播放链路,不作为新页面的融合视频源。采集器上传地址协商和节点原图接入已实现;仿真数据协议、仿真目标接入、图像融合和录制尚未实现。 + +同一个 JAR 通过配置运行三种角色: + +| 角色 | 功能 | 安装脚本默认 HTTP 端口 | +|---|---|---:| +| `coordinator` | 总控并默认兼任执行节点,保存上下文与路由、分配任务、处理本机视频、汇总播放地址 | 18100 | +| `gateway` | 接收采集器原始 UDP 字节,按登记来源转发,反向转送 PXHJ 的回执 | 18110 | +| `worker` | 自动发现并注册到总控,领取多个任务,接收原图或生成演示视频 | 18120 | + +采集网关的默认 UDP 入口是 `19002`。PXHJ 的现有采集入口 `19001` 保留;同机部署时两个接收端口必须不同。每个采集器使用一个固定 UDP 转发源端口,PXHJ 中对应采集器的可信来源也必须同步更新。 + +总控的控制与执行在**同一个 Java 进程、同一个 18100 端口、同一个稳定身份**中运行,不需要再为主电脑启动额外 worker 进程。主节点默认容量 2 路,普通子节点仍可部署在其他电脑。总控快照用 `nodeRole=COORDINATOR` 标识主节点、`WORKER` 标识子节点,PXHJ 可统一配置两种节点的路数。 + +## 已实现的运行规则 + +- 工作者启动后通过局域网 mDNS 发现同集群总控,认证后自动注册,不需手动添加节点。 +- 主执行节点等待自身 HTTP 端口就绪,签名校验自家总控后自动注册;同一主身份不会重复登记成普通子节点。 +- 每个部署目录持久保存节点身份;重启不会产生重复节点,启动会话变化会使旧回执失效。 +- 每台节点的容量为 0~64 路,PXHJ 可以调整;总控把准备任务也计入容量,超额任务等待。 +- 动态迁移先准备新节点,待新画面可用后切换播放地址;无空余容量时保留原输出并报告等待调整。 +- 任务来自 PXHJ 保存的试验、装备绑定和真实轮次;轮次结束撤销该轮任务,UDP 转发独立持续运行。 +- 总控使用 H2 持久化。总控重启后提高调度代次、撤销旧许可,等待 PXHJ 重新确认上下文。 +- 节点心跳失联后任务许可到期;页面不会永久保留失效播放地址。 +- 内部 HTTP 请求和响应使用共享凭证生成 HMAC 签名,含时间和一次性随机数校验;发现到的候选地址不会收到明文凭证。 +- UDP 原始字节不改变,不依赖报文文本或自造标记。未知/歧义来源丢弃,队列和内存有上限。 +- 网关保存最后确认的路由;总控暂时失联或网关重启后继续按这些路由转发。 +- 原图接入在试验准备时就可建立,不依赖正式轮次开始;采集器通过网关协商后直接上传接收节点。 +- 原图迁移先发布新旧两个目标,等新节点收到完整首帧才切换 PXHJ 取图;许可到期回退原图片上传地址。 + +当前调度依据 PXHJ 配置的容量和节点在线状态,不会测量 CPU/GPU 负载后自动修改容量。网关现阶段按采集器来源转发不透明字节,正式协议解析仍由 PXHJ 完成;网关内的数据解析、装备状态标准化、订阅分发和独立图片分片组装尚未实现。 + +图片由执行节点认证和组装,网关只转发协商。接入流程、配置和边界见 [图片直传协商与准备取图](docs/图片直传协商与准备取图.md)。原图接入与演示任务各计一路;新部署的协商开关默认关闭。本次已按授权为 RK3588 开启协商,实际部署与联调见 [开发板记录](docs/开发板图片协商联调记录.md)。 + +## 代码结构 + +按业务划分包,控制层只负责 HTTP 转接,服务层处理任务和网络业务,DAO 层只负责持久化。 + +| 业务包 | 当前主要类与职责 | +|---|---| +| `control.controller` / `control.service` | `CoordinatorController` 接口转接,`CoordinatorService` 节点、上下文、任务、容量和版本调度 | +| `gateway.controller` / `gateway.service` | `GatewayController` 网关状态接口,`GatewayService` 双向原字节 UDP、路由应用和统计 | +| `node.controller` / `node.service` / `node.config` | 媒体和原图接口、任务执行与图片认证重组、主/子执行能力与播放线程配置 | +| `discovery.service` / `discovery.util` | `Discovery`、`CoordinatorLocator` 发现与总控身份核验,`NodeAddress` 对外地址选择 | +| `communication.client` / `communication.security` | `PeerClient` 签名 HTTP 通信,`Signatures` 与 `ServiceSecurity` 签名和防重放 | +| `common.config` / `common.controller` / `common.dao` / `common.util` | `Settings`、健康和错误接口、`StateStore` H2 持久化、`NodeIdentityDao` 身份文件持久化与 `Jsons` 公共辅助 | + +应用入口为 `com.xtrh.Application`。测试也放在对应业务的 `service`、`security` 或 `client` 包中。节点会话、限时许可、任务切换、路由缓存及签名校验等关键逻辑保留中文注释。 + +`StateStore.open(Path, ObjectMapper)` 在 DAO 内统一组装文件数据库地址,业务服务只指定本角色的数据文件;`NodeIdentityDao` 自动生成并复用部署实例身份,配置层不再直接维护身份文件。发现服务的广播只提供地址线索;逻辑总控冲突在远端签名认证后按 `serviceId` 判断,同一总控的多个网卡地址不会算成多个总控。 + +注释比例按 `scripts/Measure-Comments.ps1` 统计:分母为 Java 非空源码行,分子为以注释标记开头的完整注释行,包含 Javadoc 和块注释边界,代码末尾的行内注释不计入。当前全部 XTRH 主 Java 源码为 **21.53%(594/2759)**,PXHJ 新增 `fusion` 业务源码为 **28.83%(501/1738)**。 + +PXHJ 的新增业务同样采用 `fusion.controller`、`fusion.service`、`fusion.dao`、`fusion.config`、`fusion.security` 分层,`FusionDao` 承接 SQL,接口、业务和数据访问职责分开。 + +## 构建和测试 + +要求 Java 17 或更新版本、Maven 3.9。Windows 示例: + +```powershell +.\scripts\Build-Xtrh.ps1 -JavaHome 'C:\Program Files\Java\jdk-25.0.2' -MavenPath 'D:\software\apache-maven-3.9.16\bin\mvn.cmd' +``` + +产物为 `target/xtrh-services-1.0.0.jar`。脚本先执行测试,测试失败不会返回成功。 + +`CoordinatorTest` 验证容量预留、超额等待、迁移准备和正式切换、容量不足保留输出、会话/版本隔离、轮次结束、离线租约及总控重启后的上下文确认。`GatewayTest` 使用真实 UDP 套接字验证二进制字节保持、固定转发源端口、反向回复、未知来源拒绝、总控失联及路由恢复。`ServiceSecurityTest` 和 `PeerClientTest` 验证请求篡改、重放、过期请求、伪造响应及凭证不出现在 HTTP 头中。这些是本机自动化验证,不能替代多台物理电脑、真实采集器与网络环境验收。 + +增加主节点执行能力后,XTRH 全部 **24 项测试通过**:原有 19 项,加 4 项角色能力条件与 1 项随机端口主进程执行集成测试。后者真实读取主端口 JPEG,检查自动注册、唯一主身份、任务就绪及结束撤销。上一阶段 PXHJ 融合后端 17 项和 1 项真实业务串联测试通过;业务串联使用真实 Spring 业务服务、正式接口层登录会话和 CSRF、隔离 H2,连接实际总控和两个子节点,验证保存绑定、动态迁移和正式轮次操作。真实 UDP 加密数据、心跳认证及中转配置回程也有针对性测试。详细范围见 [本机验证记录](docs/本机验证记录.md)。 + +PXHJ 全后端回归和打包已完成:报告总计 392 项测试,失败 0、错误 0、跳过 30 项;跳过项包含默认不启用的实时外部服务和达梦环境测试,前述真实业务串联已另外显式运行。独立构建输出位于 `runtime/pxhj-build`,新版后端产物为 [pxhj-server-0.1.0.jar](E:/Codex/xtrh/runtime/pxhj-build/pxhj-server-0.1.0.jar)。 + +本机多进程验证不替代多台物理电脑、真实采集器或真实融合的现场验收;上述 H2 业务串联也不代表已升级正在运行的达梦主实例。 + +## 开发板图片直传联调(2026-10-15) + +已在 `192.168.7.165` RK3588 构建、部署完整 ARM 程序并开启协商,真实 PXHJ 准备页面已经取得节点原图。板上保留原采集功能、原启动器和程序内 1 GiB 日志预算。主、A、B 对外图片地址均为 `192.168.7.222`,当前容量 2/3/3。视频源是已有文件经 RTP 输入开发板,尚未进行真实摄像机或物理多机验收。 + +联调同时修复了公开帧号被节点近期缓存淘汰后取图 404:PXHJ 先取得验签通过的完整 JPEG,才发布公开编号;短期快照限制 30 秒、4096 张和 64 MiB,预算不足可提前淘汰。后端完整测试 **411 项,失败 0、错误 0、跳过 30**,修复版已加载;详细实测与迁移状态见 [开发板图片协商联调记录](docs/开发板图片协商联调记录.md)。 + +主节点到 A、A 到 B 两次实际原图迁移均完成。修复后的 A 到 B 页面窗口 45/45 次取得新图,浏览器 HTTP 错误为 0;旧 A 私有许可撤销后,已公布的原图编号仍可返回相同字节。当前由 B 接收原图,节点容量已恢复 2/3/3,现有准备轮次、运行轮次和冻结绑定前后核对一致。 + +### 板上部署前的软件验证记录 + +上传地址协商、节点原图接收与 PXHJ 准备取图代理已经实现,本机四个 XTRH 实例及 PXHJ 后端已加载新版。执行节点仍自动注册,主节点也接收原图;图片 UDP 入口实际为 19100、19120、19121,原 PXHJ 6020 保留作回退。三个执行实例在同一台电脑上,物理多机验收尚未进行。 + +本次完整测试:XTRH 38 项全部通过;PXHJ 后端 403 项,失败和错误均为 0,跳过 30 项;嵌入式独立 Qt 验证 7 个业务用例通过,另含 init/cleanup 共 9 项,实际 C++ 与 PXHJ Java WireCodec 双向加解密通过。PXHJ 构建产物在 `runtime/pxhj-image-build`。前端构建及融合页面既有交互回归通过,页面将原图与演示画面明确区分。 + +当前全部 XTRH Java 主源码注释 **21.53%(594/2759)**,PXHJ `fusion` 主源码 **29.46%(476/1616)**。图片许可及缓存为短期内存状态,设备密钥不会持久化。 + +该轮软件部署后只读确认三个执行节点、一个网关在线,PXHJ 达梦缓存已同步,运行轮次数在升级前后均为 1。当时尚未构建和部署目标 ARM 板;后续板上部署已完成,见本节上方最新记录。流程和启用配置见 [图片直传协商与准备取图](docs/图片直传协商与准备取图.md)。 + +## 上一阶段本机部署记录(2026-10-14) + +主总控执行能力版本已部署:`runtime/coordinator` 在 HTTP 18100 同时控制和执行,主节点容量 2;`runtime/gateway` HTTP 18110 / UDP 19002;子节点 A HTTP 18120 / 容量 2,B HTTP 18121 / 容量 3。只读签名快照确认 **3 个在线执行节点、1 个在线网关,总容量 7 路**;主节点只有一条 `COORDINATOR` 记录,身份与总控一致,子节点为 `WORKER`。子节点和网关未手工配置总控 URL。 + +PXHJ 达梦主后端已通过私有连接配置接入总控。本次主节点执行能力升级只重启了 18100 主进程,保持原稳定身份、数据目录和配置;PXHJ 后端、前端、网关和两个子节点均未再次重启。实际 `biz_fusion_cache` 已自动更新为三个在线执行节点和一个在线网关,检查时缓存年龄 64 毫秒。PXHJ UDP 19001、图片 UDP 6020 与网关 UDP 19002 持续运行。 + +原有一轮 `RUNNING` 保持运行,没有自动结束或修改现有试验;未给该正式试验新增演示视频配置,真实采集器上传地址和接收路径也未切换。前端进程持续运行。用户可在 PXHJ 的融合节点管理和试验组织中的节点/视频区域统一操作。隔离验证的四个 182xx 实例已按身份停止,默认端口的四个新角色继续在线。 + +当前主、子节点均没有正式视频任务运行,实际主端口 JPEG 已在隔离随机端口集成测试中验证;未为证明功能而操作正在运行的正式试验。 + +## 部署 + +### 1. 为同一个集群生成一次私有凭证 + +```powershell +.\scripts\New-ClusterCredential.ps1 -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties' +``` + +凭证由安全随机数生成,并限制文件访问;脚本不会输出凭证内容。通过可信方式把这个私有文件复制给同集群的部署电脑。所有电脑使用同一份凭证,节点身份则由各自部署目录自动生成。不要将私有文件提交到仓库或普通共享目录。 + +### 2. 安装三种角色 + +总控和网关可以部署在同一台电脑,不同融合电脑安装 `worker`。下面以同机三个实例说明参数: + +```powershell +.\scripts\Install-Xtrh.ps1 -Role coordinator -InstallRoot 'E:\Codex\xtrh\runtime\coordinator' -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties' +.\scripts\Install-Xtrh.ps1 -Role gateway -InstallRoot 'E:\Codex\xtrh\runtime\gateway' -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties' +.\scripts\Install-Xtrh.ps1 -Role worker -InstallRoot 'E:\Codex\xtrh\runtime\worker-a' -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties' -Capacity 2 -NodeName '融合电脑A' +``` + +节点默认配置中没有固定总控 URL。启动后发现总控并注册。复制整个节点的 `data` 目录到另一台电脑会复制节点身份,应每台电脑重新安装并保留各自的 `data/identity.txt`。 + +每个安装目录包含 JAR、私有集群配置、角色配置、节点身份与 H2 数据、日志和启停脚本。重复安装时需先停止该部署实例,数据和身份不会自动删除。 + +### 总控是否参与执行 + +默认安装总控会写入 `fusion.coordinator-worker-enabled=true`,容量使用 `-Capacity` 参数,默认 2 路。要让某台总控只做调度,在安装命令中追加 `-CoordinatorOnly`,生成 `fusion.coordinator-worker-enabled=false`: + +```powershell +.\scripts\Install-Xtrh.ps1 -Role coordinator -InstallRoot 'E:\Codex\xtrh\runtime\coordinator' -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties' -CoordinatorOnly +``` + +只调度模式保留全部控制接口,同时不加载执行服务、媒体控制器和媒体线程池。此开关只影响 coordinator,独立 worker 仍有执行能力,gateway 始终不执行视频任务。已有总控重新安装前应先停止本实例,保留原数据目录和身份,避免新建第二个主节点。 + +### 3. 启动、停止和自动启动 + +```powershell +.\scripts\Start-Xtrh.ps1 -InstallRoot 'E:\Codex\xtrh\runtime\coordinator' -JavaHome 'C:\Program Files\Java\jdk-25.0.2' +.\scripts\Start-Xtrh.ps1 -InstallRoot 'E:\Codex\xtrh\runtime\gateway' -JavaHome 'C:\Program Files\Java\jdk-25.0.2' +.\scripts\Start-Xtrh.ps1 -InstallRoot 'E:\Codex\xtrh\runtime\worker-a' -JavaHome 'C:\Program Files\Java\jdk-25.0.2' + +.\scripts\Stop-Xtrh.ps1 -InstallRoot 'E:\Codex\xtrh\runtime\worker-a' + +.\scripts\Register-XtrhStartup.ps1 -InstallRoot 'E:\Codex\xtrh\runtime\worker-a' -JavaHome 'C:\Program Files\Java\jdk-25.0.2' +``` + +启动脚本后台隐藏运行,通过本实例的身份和 `/health` 校验启动结果。停止脚本同时核对 PID、启动时间、Java 路径和 JAR 路径,只结束本部署实例。自动启动脚本注册“当前用户登录后启动”的计划任务;使用 `-Remove` 删除该实例的自动启动。无人登录也要运行的部署,需按实际运维账号另行配置 Windows 服务或系统启动任务。 + +### 4. 网络条件 + +同网段需要允许 mDNS UDP `5353`,以及总控、网关和节点所用 HTTP 端口;节点播放地址必须能被 PXHJ 观看电脑访问。采集器还需能访问网关 UDP 入口,网关需能访问 PXHJ 的采集入口和路由指定的反向目标。 + +多网卡时可以在安装参数中指定 `-DiscoveryAddress` 选择发现网卡、`-AdvertisedHost` 固定本机可访问地址。跨网段不保证组播发现,可在统一安装配置中使用 `-CoordinatorUrl 'http://固定总控域名:18100'` 兜底,仍无需在 PXHJ 中逐个添加节点。 + +## 与 PXHJ 的对接 + +PXHJ 后端连接总控,使用服务凭证进行内部认证;浏览器不接触服务凭证。PXHJ 试验组织页面展示自动注册的节点、配置路数、任务状态、UDP 路由和演示视频,所有操作仍遵循 PXHJ 的登录权限和试验数据范围。 + +播放信息的当前同步流程是:节点注册对外服务地址并上报任务就绪结果 → 总控按有效任务版本生成、登记播放地址 → PXHJ 后端约每 1.5 秒通过签名 HTTP 请求读取总控快照,将播放信息和状态保存到 PXHJ 缓存表 → PXHJ 页面查询本项目后端并连接对应节点播放。迁移后任务版本和播放地址随快照更新,PXHJ 会过滤旧代次或旧修订的快照。暂未实现总控主动推送视频地址或上报回调接口。 + +PXHJ 集成代码已接入现有正式试验页面和后端,并通过隔离 H2 中真实业务服务与实际总控、节点串联的验证。当前达梦主实例已加载新版且实际同步总控状态;RK3588 图片直传及节点迁移已有实测记录,物理多机和真实融合验收尚未完成。 + +PXHJ 已通过 `optional:file:../config/fusion.local.properties` 加载私有连接配置。可由共享凭证生成这份文件,避免手工复制密钥: + +```powershell +.\scripts\Connect-Pxhj.ps1 -PxhjRoot 'E:\pxhj' -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties' -CoordinatorUrl 'http://127.0.0.1:18100' -UdpTargetHost '127.0.0.1' +``` + +此脚本只写桥接配置,重启 PXHJ 后端后生效。跨电脑部署时,`UdpTargetHost` 填写网关能访问的 PXHJ 后端地址,观看电脑也必须能访问节点的 HTTP 地址。已有配置需确认覆盖范围后使用 `-Force`;私有配置不能提交仓库。 + +总控控制接口包括: + +| 接口 | 用途 | +|---|---| +| `GET /control/info` | 校验总控身份、集群与调度代次 | +| `GET /control/snapshot` | PXHJ 汇总查询节点、任务、网关、路由 | +| `POST /control/nodes/register` | 节点自动注册 | +| `POST /control/nodes/{id}/heartbeat` | 节点报告准备结果并领取当前任务 | +| `PATCH /control/nodes/{id}/capacity` | PXHJ 修改容量和启用状态 | +| `POST /control/contexts/sync` | PXHJ 下发已保存的试验及真实轮次上下文 | +| `POST /control/image-sources/register` | PXHJ 经设备认证后请求短期原图上传许可 | +| `POST /control/image-sources/resolve` | PXHJ 查询原图任务当前有效接收节点 | +| `PUT /control/routes` | 下发登记的采集来源及转发目标 | +| `POST /control/gateways/heartbeat` | 网关报告统计并领取最新路由 | + +媒体入口为 `/media/{taskId}/{version}/stream`,任务版本和临时媒体许可用于阻止旧任务播放;按任务类型输出演示帧或接收到的原图,真实融合输出后续接入。原图私有元数据和 JPEG 接口为 `/node/images/{taskId}/{version}/latest`、`/node/images/{taskId}/{version}/frames/{frameId}`,由 PXHJ 后端签名访问。 + +软件安装不会自动开启现场设备协商。启用每个采集器前需核对原图片回退地址、网关端口及 PXHJ 来源登记;本次已按用户授权完成当前 RK3588 的配置和联调。 diff --git a/config/service.example.properties b/config/service.example.properties new file mode 100644 index 0000000..37b5ae5 --- /dev/null +++ b/config/service.example.properties @@ -0,0 +1,13 @@ +# 示例不包含私有集群凭证。正式部署使用 scripts/Install-Xtrh.ps1 生成角色配置。 +server.address=0.0.0.0 +server.port=18120 +fusion.role=worker +fusion.data-dir=./data +fusion.capacity=2 +fusion.discovery-enabled=true +# 多网卡时可指定用于 mDNS 的网卡地址。 +# fusion.discovery-address=192.168.10.21 +# 浏览器无法访问自动选择的节点地址时,可固定本机局域网地址。 +# fusion.advertised-host=192.168.10.21 +# 同网段默认自动发现总控;跨网段可由统一部署配置预置域名。 +# fusion.coordinator-url=http://fusion-control.example.internal:18100 diff --git a/docs/图片直传协商与准备取图.md b/docs/图片直传协商与准备取图.md new file mode 100644 index 0000000..e6983a6 --- /dev/null +++ b/docs/图片直传协商与准备取图.md @@ -0,0 +1,69 @@ +# 图片直传协商与准备取图 + +PXHJ 仍负责操作,采集网关负责传递协商,接收节点负责收原图。准备阶段不需要先启动正式试验,也能从接收节点取图做配置和标定。真实融合算法另行实现。 + +```mermaid +sequenceDiagram + participant D as 嵌入式采集器 + participant G as 采集网关 + participant P as PXHJ 后端 + participant C as 总控服务 + participant N as 接收节点 + D->>G: 请求图片上传地址(加密协商) + G->>P: 原字节转发 + P->>P: 按原登记来源和密钥认证 + P->>C: 登记短期原图接入任务 + C->>N: 心跳计划下发接收许可 + N->>C: 图片入口已就绪 + C-->>P: 接收节点的 IP、UDP 端口和许可期限 + P-->>G: 加密地址回复 + G-->>D: 回到专用协商套接字 + D->>N: 原有加密 JPG 分片直接上传 + P->>N: 准备页面查询原图(内部签名请求) + N-->>P: 原图元数据、JPEG + P->>P: 缓存完整验签 JPEG 后发布图片编号 + P-->>P: 沿原 image-frames 接口供页面使用 +``` + +## 使用流程 + +1. 主服务、网关和节点按现有方式部署。节点自动注册时同时上报图片入口,总控的主电脑也可以接收。 +2. PXHJ 保存采集器来源和原密钥,现有 UDP 网关映射继续负责数据、心跳与协商。采集器的专用协商套接字发送地址请求。 +3. 总控把原图接入算作一路任务,和其他任务一起受每台机器配置路数限制。没有空位时回复等待,采集器继续用原图片地址。 +4. 节点准备好端口和认证许可后,下一次协商返回上传目标。采集器直接向节点上传,完整图片不经过网关。 +5. PXHJ 的准备页面继续使用原图片查询入口。后端先向当前节点取齐精确帧并验签,再发布临时图片编号;后续按编号返回同一份快照。页面不接触内部服务凭证或设备密钥。 +6. 路数调整触发迁移时,短暂给采集器两个上传目标。PXHJ 继续读旧节点;新节点收到首帧后切换,旧目标撤销。 + +原图接入与正式试验启停独立。后续融合任务可读取这份原图接入结果,避免为准备页面再上传完整的第二份视频。目前演示任务与原图接入是两类任务,各自占用配置路数;未实现跨任务复用融合处理槽位。 + +## 配置与端口 + +| 项目 | 默认值 | 用途 | +|---|---:|---| +| 网关 UDP | 19002 | 数据转发及短小协商报文 | +| PXHJ 采集 UDP | 19001 | 原协议解析与协商认证 | +| PXHJ 图片 UDP | 6020 | 原图片接收、协商失败回退 | +| 主节点图片 UDP | 19100 | 主节点 HTTP 18100 + 1000 | +| 子节点图片 UDP | HTTP 端口 + 1000 | 各实例独立原图接收 | + +节点可用 `fusion.image-port` 或安装参数 `-ImagePort` 单独指定图片端口。`fusion.image-port=0` 为自动化测试的系统分配端口。多网卡环境使用 `-AdvertisedHost` 指定采集器和 PXHJ 均可访问的 IPv4,不能发布仅本机可见的地址。需要允许对应 UDP 和节点 HTTP 端口。 + +嵌入式新增 `[image_negotiation]`:`enabled`、`gateway_host`、`gateway_port`。默认关闭,已有设备保持原上传方式;启用前确认网关映射、节点地址和网络连通。关闭或协商失败时回到 `[image_upload]` 的原上传目标。 + +如果固定采集数据的 `fixed_json.udp_host` 改为网关,必须单独把 `image_upload.udp_host` 设置为 PXHJ 图片接收机器的 IP。它不能继续继承固定数据的网关地址,否则回退图片会投向网关的 6020,而该入口并不接收图片。 + +## 协议与边界 + +- 在原 PXHJ-FND/1 中加入 `0x14` 地址请求、`0x15` 地址回复;沿用原 SM4、HMAC-SM3、CRC 与采集器编号。图片 `0x12` 分片格式不变。 +- 请求携带协议版本、设备启动号、递增序号、请求号与时间;回复回显本次请求并附总控身份、代次、任务版本、有效期和最多两个目标。 +- 地址许可 8 秒,采集器约每 1.5 秒请求续期;有效 WAITING 立即回退,没有许可或旧许可到期时使用原图片上传地址。无效回复被忽略,不能延长既有许可。连续 30 秒没有认证协商释放来源任务。 +- 节点只接受当前任务许可的来源 IP、采集器编号和密钥。先验签解密,再组装图片;不完整、过期、CRC 或 JPEG 校验失败的帧不供页面使用。 +- 原密钥只在经过签名认证的后端请求和执行计划中传递,不写总控快照、数据库或浏览器响应。 +- UDP 缺片仍会丢帧;本阶段没有图片分片重传。直接上传分散了网关压力,但节点和链路的实际容量仍需用现场画面进行压测。 +- 当前每个登记采集器对应一路原图。原协议没有独立相机通道号,多相机标识需要后续明确协议扩展。 +- PXHJ 原图快照最长保存 30 秒,总计不超过 4096 张或 64 MiB,达到预算时可提前淘汰。快照权限沿用原图片入口;旧编号不会被替换成新图片。节点私有接口仍在任务撤销后拒绝旧版本。 +- 准备取图的路由查询、元数据、精确 JPEG 和一次允许的重试共享 1800 ms 预算,完整 HTTP 正文也受超时限制。仅已验签的精确帧 404 可刷新元数据重取一次。 + +## 验证范围 + +本机验证覆盖真实加密分片、节点收图、准备取图代理、容量和迁移、认证失败与许可撤销。2026-10-15 已在实际 RK3588 构建完整 ARM 程序、部署并开启协商,通过现有 PXHJ 准备页面取到直传原图;视频输入为已有文件经 RTP 送入真实板卡。当前结果见 [开发板图片协商联调记录](开发板图片协商联调记录.md)。真实摄像机、真实融合和多台物理电脑性能验收另行进行。 diff --git a/docs/开发板图片协商联调记录.md b/docs/开发板图片协商联调记录.md new file mode 100644 index 0000000..301b2b8 --- /dev/null +++ b/docs/开发板图片协商联调记录.md @@ -0,0 +1,86 @@ +# 开发板图片协商联调记录 + +日期:2026-10-15。本文记录实际 RK3588 开发板与现有 PXHJ 的联调。 + +## 实际部署 + +| 位置 | 服务 | 入口或配置 | +|---|---|---| +| 开发板 192.168.7.165 | ARM64 `px_JKGL` 完整程序 | 原有 RTP 视频入口 UDP 9200 | +| 电脑 192.168.7.222 | 总控兼执行节点 | HTTP 18100 / 图片 UDP 19100,容量 2 | +| 同一电脑 | 子节点 A | HTTP 18120 / 图片 UDP 19120,容量 3 | +| 同一电脑 | 子节点 B | HTTP 18121 / 图片 UDP 19121,容量 3 | +| 同一电脑 | 网关 | HTTP 18110 / 协商 UDP 19002 | +| 同一电脑 | 原 PXHJ | 前端 5178、后端 8088、采集 UDP 19001、备用图片 UDP 6020 | + +板上已开启 `image_negotiation.enabled=true`,协商指向 `192.168.7.222:19002`;图片回退地址明确为 `192.168.7.222:6020`。固定数据和心跳继续使用原来的 `19001`。完整原图经协商后直接发给接收节点,网关只中转短协商报文。 + +三个 Java 执行实例运行在同一台电脑,使用原稳定身份和数据目录;实际路数配置是 2/3/3。开发板是真实硬件,本次视频输入是已有 `1.mp4` 经 FFmpeg/RTP 送入开发板,经过板上程序解码、压缩、加密和分片。尚未进行物理摄像机、多台物理电脑、带宽容量或真实融合验收。 + +## 程序部署与回退材料 + +ARM 程序在目标板隔离目录构建后才激活,保留原有串口、RTP、启动器和程序内 1 GiB 日志保留功能。ARM 日志测试 9 项通过;图片协商测试 8 项通过,1 项可选 Java 互通夹具跳过。Windows 的 Java/C++ 互通验证已另行通过,板上实际协商和原图链路也已跑通。 + +- 当前 ARM 二进制 SHA256:`6d28db56f9cf20cde843d432dcf148e7a72a3d54e91c37996a18463b0dc00972`。 +- 板上构建、候选和原文件备份目录:`/home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-20261015-013802-b11a5ace`。 +- 本机状态证据:`runtime/board-image-route/deployment-state.json`、`live-board-check.json`、`arm-artifacts/activation-result.json`。 +- 激活与回退工具见 [嵌入式部署步骤](../scripts/嵌入式图片协商部署步骤.md)。回退需要显式运行工具,未因验证通过而自动回退。 + +没有更改设备密钥、重置节点身份或删除采集数据。PXHJ 网关登记通过已登录的正式配置接口加入当前板卡,保留原选项。 + +## 准备阶段取图 + +使用现有试验 `SY_20261012_000005` 的第一轮准备页,轮次仍为 `PENDING`,无需启动正式试验。现有视频采集模块为 `03`。 + +首次主节点收图后,真实页面取得 `remote-` 图片编号,原图接口返回 JPEG 200,页面解码尺寸为 **480 × 853**。已核对来源连接、采样时间、接收时间、长度和 SHA256,排除了旧 6020 历史图片。证据在 `runtime/board-image-real-verification/preparation-main.json` 及同名截图。 + +本次只观察准备页,没有保存视频确认、标定、试验配置或开始/结束试验。现有另一轮 `RUNNING` 保持原状态。 + +## 主节点切换到 A + +通过临时调整各节点容量触发正常迁移,没有直接修改任务归属;完成后恢复 2/3/3。 + +北京时间 **09:49:44—09:50:09** 的节点观测记录了 110 张不同新帧:主节点版本 1 为 17 张,A 节点版本 2 为 93 张。A 的第一帧接收时间为 **09:49:48.389**。新旧节点交接的两条成功帧记录相隔 232 ms,全窗口成功新帧记录最大间隔 438 ms;这是观察间隔,不是端到端延迟指标。 + +切换后旧主节点/版本 1 的私有图片接口返回经过签名核验的 404,证明旧许可已撤销。另一个三分钟基线窗口记录了 345 张主节点与 A 的原图。两组共 455 张记录已独立核对 JPEG 解码、尺寸、字节数和 SHA256,全部通过。独立审计见 `runtime/board-image-observation/audit-first-migration.json`。 + +首次页面 30 秒检查启动于这次切换完成之后,30/30 取得新图,只作为 A 稳定期证据;不把它当成覆盖主节点到 A 全过程的页面证据。节点观察工具会重查短暂无帧,记录并未保存每一次 HTTP 尝试,因此也不以工具退出 0 声称所有尝试都返回 200。 + +## 联调发现的取图窗口 + +后续 A 稳定期精确取图检查出现过 **1/30 次 404**:页面刚取得帧号,节点仅保留的最近三帧就已淘汰那一帧。该时段没有发生 A 到 B 迁移;旧文件名中含 `a-to-b` 的记录不代表第二次迁移通过。 + +PXHJ 已改为先取得完整、验签通过的原图快照,再公布这个图片编号。缓存中的每个编号对应固定字节,节点淘汰和任务切换不再影响这份快照。缓存最长保存 30 秒,同时限制 4096 张和 64 MiB,达到预算时可以提前淘汰;权限沿用原图片入口。完整 HTTP 正文受查询预算限制;批量取图结束时也会去掉本批已被预算淘汰的编号,避免公布悬空地址。 + +修复相关 24 项测试全部通过;全后端 **411 项,失败 0、错误 0、跳过 30 项**,已以达梦依赖打包并替换实际 PXHJ 后端。跳过项仍是原有外部环境测试,不能视为现场验收通过。构建日志为 `runtime/board-image-route/pxhj-snapshot-package.log`,测试报告在 `runtime/pxhj-image-build/surefire-reports`。修改仍位于原 `fusion.service` / `fusion.client` 分层,XTRH Java 注释占比 21.53%,PXHJ fusion 业务 28.83%。 + +重启后重新经正式登录恢复准备页。北京时间 **10:13:52—10:14:22**,30/30 次取得新鲜远程原图及精确 JPEG 200,30 个不同 SHA;601 次 DOM 采样均有已解码图片,浏览器 HTTP 错误、页面 JS 错误和试验业务写入均为 0。20 秒前的同一公开编号重取返回 200,SHA 与当时完全相同。证据为 `preparation-snapshot-stable-a*`,旧失败窗口原样保留。 + +## 修复后复测与 A 到 B + +使用随机请求号和数字 Unix 毫秒门闩联动浏览器采样与正常容量操作,避免人工启动先后差使页面漏掉切换瞬间。实际页面窗口为北京时间 **10:16:18.043—10:17:03.051**,包含以下完整交接: + +| 事件 | 北京时间 | 结果 | +|---|---|---| +| 浏览器先确认 A 原图,再启动门闩 | 10:16:18.043 | A / 版本 2;随后执行已授权容量调整 | +| B 收到完整首帧 | 10:16:21.255 | B / 实际版本 4,图片已就绪 | +| 观察到 B 已接管 | 10:16:21.306 | 当前原图路由切到 B | +| 旧 A 私有接口核验 | 10:16:21.530 | 版本 2 返回已验签 404,旧许可撤销 | +| 重新读取切换前公开图片编号 | 10:16:30.058 | 200,SHA 与切换前字节完全相同 | +| 浏览器窗口结束 | 10:17:03.051 | 持续取得 B 新图 | + +45 次页面检查均为新鲜远程原图、精确 JPEG 200,45 个不同 SHA,接收时间距检查最大 357 ms。899 次 DOM 采样没有观察到空图或未解码图片,实际最大采样间隔 197 ms;浏览器 HTTP 错误为 0。这是该观察窗口的结果,不承诺任意负载和网络条件下绝对不丢帧。新证据前缀为 `runtime/board-image-real-verification/preparation-snapshot-migration-a-to-b`,包含数字门闩、逐次数据、DOM、HTTP、旧图重取及汇总。 + +同时节点 35 秒窗口保存 157 张不同新帧:A 13 张、B 144 张。全部独立完成 JPEG 解码、尺寸、长度和 SHA256 核对;交接两条成功帧记录相隔 227 ms,全窗口最大成功新帧记录间隔 644 ms。接收和采样时间存在板卡/电脑钟差,不能用这些数值宣称网络延迟。节点证据为 `runtime/board-image-observation/real-a-to-b/summary.json`、`frames.jsonl` 和逐帧 JPG。 + +## 最终状态 + +- 板上协商保持开启,当前原图由 **B:192.168.7.222:19121** 接收,原图任务状态 `READY`、版本 4。 +- 主节点、A、B 和网关全部在线;容量恢复 **2/3/3**,B 占用 1 路,其他占用 0,预留为 0;没有为验证创建额外正式视频任务。 +- PXHJ 仍在现有第一轮准备页,截图见 [preparation-b.png](../runtime/board-image-real-verification/preparation-b.png)。页面原图 480 × 853,正式图片接口 200,已解码。 +- 达梦只读前后比较确认:当前准备轮次及冻结装备/采集模块绑定未变,原运行中轮次及绑定未变,仍有 1 轮运行中。正式网关路由保留为当前板卡的 31001 转发源端口,没有清除采集记录。 +- 板上程序与当前 FFmpeg 文件输入保持运行,便于继续从 PXHJ 查看;真实融合、仿真目标叠加和录像仍未实现。 + +轮次和绑定终检在 `runtime/board-image-real-verification/trial-integrity-final.json` 与 `trial-business-invariants.json`。旧帧私有接口的 404 是许可撤销验证,和此前已修复的公开原图偶发 404 含义不同。 + +网关最后约 73 秒只读增量检查中,接收、转发和回包各增加 48,错误增量、队列丢弃和未知来源均为 0;整个进程累计错误为 5,记录未给出逐条原因,因此不声称全程网关零错误。B 最终图片接收拒绝和队列丢弃均为 0。详细计数见 `real-a-to-b/gateway-post-migration-delta.json` 与 `node-final.json`,没有重置计数来隐藏历史记录。 diff --git a/docs/本机验证记录.md b/docs/本机验证记录.md new file mode 100644 index 0000000..1003f70 --- /dev/null +++ b/docs/本机验证记录.md @@ -0,0 +1,155 @@ +# Java 后台服务本机验证记录 + +## 图片直传协商版本:2026-10-15 + +本次增加采集器地址协商、节点直接认证接收原 JPG 分片,以及 PXHJ 原图片 API 的私有回源代理。正式试验未开始时原图任务也可用。网关仅转发短小协商报文,图片直接上传当前接收节点;动态迁移在候选端口就绪后短暂双发,候选收到完整首帧后切换取图。 + +| 验证 | 结果及范围 | +|---|---| +| XTRH 全部 Java 测试与打包 | 38 项,失败 0、错误 0、跳过 0 | +| PXHJ 后端全回归与达梦配置打包 | 403 项,失败 0、错误 0、跳过 30;产物在 `runtime/pxhj-image-build`,测试数据使用隔离 H2 | +| 节点原图集成 | 随机 HTTP/UDP 端口的真实主服务;未启动正式试验即可接收独立 PXHJ WireCodec oracle 生成的加密分片,HTTP JPEG 与原字节一致;匿名 401、无帧或旧版本为签名 404 | +| PXHJ 协商和准备取图 | 新增 11 项;真实 UDP、原设备密钥及来源认证、可信中转、防重放、会话权限和 opaque 原图回源;总控/节点使用随机端口签名 HTTP 替身,此项属于分段验证 | +| Windows 嵌入式独立组件 | Qt 5.15.2 / MSVC / OpenSSL,7 个业务用例,另含 init/cleanup 共 9 项通过;C++ 与实际 PXHJ Java WireCodec 双向互操作 | +| 前端 | 构建和既有 `fusion-operations-ui.mjs` 回归通过;使用正式组件及 API 拦截,原图与演示预览按类型显示 | +| 中文注释统计 | XTRH 主 Java 21.53%(594/2759);PXHJ fusion 主 Java 29.46%(476/1616) | + +安装脚本还在两个未启动的隔离目录验证:HTTP 18900 默认生成图片端口 19900;worker 显式 `-ImagePort 29920` 正确写入配置。部署产物 SHA256 已与本次构建逐一核对,四个 XTRH 实例及 PXHJ 均一致;匿名图片 API 返回 401,后端会话入口和前端页面返回 200。 + +已按原稳定身份、数据目录及配置升级本机总控 18100、网关 18110/19002、节点 18120/18121 与 PXHJ 8088 后端。图片 UDP 实际绑定 19100、19120、19121。只读签名状态确认三个执行节点、一个网关在线;升级后 PXHJ 达梦缓存年龄核对值为 955 ms,在线节点 3、网关 1。原有 RUNNING 轮次升级前后都是 1;前端进程 5178 持续运行。 + +当前节点配置容量实际为主节点 2、A 3、B 3,合计 8;这是核对时保存的管理员配置,升级没有用安装默认值覆盖。当前原图和视频运行任务为 0,设备上传路径尚未切换。实际端口和容量可以随后续操作变化。 + +没有连接或部署 ARM 板子,也没有进行物理多机、真实图片并发容量或融合算法验收。嵌入式协商默认 disabled;启用前必须独立保留 PXHJ 原图片回退地址。Windows 独立组件及本机分段协议验证不能替代目标板和现场整链联调。 + +变更前 XTRH 源码、原前端文件、部署前 JAR 和发布脚本备份在 `E:\CodexHome\backups\xtrh-image-negotiation-20261015`;PXHJ 本次实施后源码快照标记为 `pxhj-image-implementation`,不能当作实施前源码备份。 + +以下为 2026-10-14 阶段的历史记录,测试数量和进程状态以本节新版本为准。 + +验证日期:2026-10-14。工作空间:`E:\Codex\xtrh`。 + +这份记录覆盖本项目后台服务和部署脚本。验证使用一台 Windows 电脑上的多个独立 Java 进程,以及本机 UDP 套接字。真实采集器、多台物理电脑、仿真系统和真实视频融合不在这份记录的验收范围内。 + +当前正式视频融合、仿真数据协议与录制未实现。播放信息由节点先提交对外地址和任务就绪结果,总控登记有效播放地址,再由 PXHJ 后台通过签名轮询同步登记;主动推送回调未实现。真实 PXHJ 业务服务与实际总控、节点的业务串联使用隔离 H2 验证;经用户确认,主达梦后端现已加载新版并接通总控,但真实采集来源未切换。 + +## 自动化测试 + +使用 Java 25.0.2 执行 Maven,项目按 Java 17 目标编译。增加主节点执行能力后完整 `test` 共 24 项通过,失败 0、错误 0;新版已经打包并升级正式本机总控,部署结果见文末。 + +| 测试类 | 数量 | 主要验证 | +|---|---:|---| +| `CoordinatorTest` | 10 | 容量预留、超额排队、准备完成后切换、容量不足保留输出、迁移目标掉线、旧会话/版本拒绝、轮次结束与 UDP 解耦、租约到期、系统时间回退、持久化恢复和上下文重确认、未确认的空路由不作为清空指令 | +| `GatewayTest` | 3 | 1~60000 字节不透明二进制 UDP 转发、独立转发源端口、反向回执、未知来源丢弃、总控失联继续转发、重启恢复路由、未确认路由不覆盖缓存、同版本号但明确确认的空路由仍撤销原转发 | +| `ServiceSecurityTest` | 3 | 完整请求/响应签名、一次性随机数防重放、方法/地址/参数/正文篡改拒绝、过期请求拒绝 | +| `PeerClientTest` | 1 | HTTP 头不发送明文凭证,拒绝无签名、错误密钥、错误随机数、正文和状态不匹配的响应 | +| `WorkerTest` | 2 | 准备转为运行后播放连接读取当前任务,延迟到达的计划不能延长过期授权 | +| `WorkerCapabilityTest` | 4 | 总控默认有执行能力、可关闭执行能力及媒体接口/线程池、独立 worker 不受总控开关影响、gateway 无执行能力 | +| `CoordinatorWorkerIntegrationTest` | 1 | 随机端口主进程签名自注册、唯一主身份、任务 READY、同端口实际 JPEG 解码、结束撤销、拒绝伪造主身份 | + +测试报告保存在构建目录 `target/surefire-reports`,该目录不提交仓库。 + +代码已按业务及 controller / service / dao 分层,原 `Coordinator`、`Gateway`、`Worker` 对应当前的 `control.service.CoordinatorService`、`gateway.service.GatewayService`、`node.service.WorkerService`。测试放在对应 service、config、security 或 client 包内。当前报告已确认原 19 项与新增 5 项全部通过。 + +## 主总控兼任执行节点 + +新增集成测试在随机 HTTP 端口启动真实 Spring Boot 总控,容量 1,关闭 mDNS,使用独立临时目录和测试凭证。总控进程自身自动通过签名请求注册为唯一 `COORDINATOR` 执行节点,节点 ID 等于总控服务 ID,不需要额外执行进程。下发一条隔离的 PENDING 上下文后任务进入 READY,播放地址使用同一个实际随机端口,实际 HTTP MJPEG 首帧能由 ImageIO 解码;结束上下文后播放地址撤销。主身份冒充普通 WORKER、另一个身份登记 COORDINATOR 均被拒绝。 + +四项能力条件测试确认:总控默认同时有执行服务、媒体控制器和媒体线程池;关闭 `fusion.coordinator-worker-enabled` 后这些组件都不加载,控制服务保留;普通 worker 即使该开关为 false 也保持执行能力;gateway 始终无视频执行组件。 + +安装脚本在独立、未启动的测试安装目录中验证:默认总控输出执行开关 true,`-CoordinatorOnly` 输出 false,worker 使用该参数被拒绝。未启动这些安装目录的进程,也未修改正式节点容量、任务或路由。 + +PXHJ 新增业务已按 controller / service / dao / config / security 分层,针对性验证如下: + +| 测试类 | 数量 | 结果与范围 | +|---|---:|---| +| `FusionServiceTest` | 10 | 通过;权威试验配置、节点容量、状态同步和恢复等业务规则 | +| `FusionAccessTest` | 5 | 通过;正式接口权限、会话、CSRF 与允许访问的范围 | +| `FusionRelayUdpIntegrationTest` | 2 | 通过;真实 UDP 接收中的加密采集报文与心跳认证,以及动态配置的中转回程地址 | +| `FusionBusinessIntegrationTest` | 1 | 通过;真实 PXHJ 业务服务和正式接口层连接实际总控、节点的业务串联 | + +前三类合计 17 项,业务串联另 1 项。业务串联以显式启用方式运行,与默认常规回归分开核对。 + +PXHJ 全后端回归和打包已在独立目录 `runtime/pxhj-build` 完成。已读取该目录的 Surefire XML 汇总:392 项测试,失败 0、错误 0、跳过 30 项。常规回归默认跳过实时外部服务和达梦等 opt-in 测试;不能把跳过项算成已验证的真实环境能力。新版产物 [pxhj-server-0.1.0.jar](E:/Codex/xtrh/runtime/pxhj-build/pxhj-server-0.1.0.jar) 已生成。 + +回归过程中修正了已有报告测试的比较对象:对比 DAO 的原始报告事件,而不是执行 DTO 派生的 `eventParameters`;产品业务代码没有因此改动。修正后完整回归通过。 + +## PXHJ 真实业务串联 + +`FusionBusinessIntegrationTest` 显式连接隔离验证总控及两个独立 Java 节点。PXHJ 使用实际 Spring 业务服务与 MockMvc 正式接口层,执行登录、会话及 CSRF 校验;业务数据写入独立 H2,正式试验由既有业务服务创建,使用保存的固定方案、装备和冻结绑定。 + +验证过程通过:按已绑定装备配置五路演示视频,准备阶段由 A 处理两路、B 处理三路;各路返回实际 MJPEG 流,读到完整 JPEG 首帧并确认首尾标记。通过正式轮次开始接口后五路进入运行状态;在 PXHJ 接口中调整为 A 一路、B 四路,一路自动迁移,版本递增,迁移后各路仍能读取 JPEG。正式结束轮次后撤销旧播放地址,远端任务也结束。清理时恢复验证节点容量和路由。 + +这验证了真实业务实现到后台节点的链路,未启动浏览器走人工页面流程,不属于物理多机或真实视频融合验收。 + +## 结构与注释核对 + +节点身份文件操作已下沉到 `common.dao.NodeIdentityDao`;H2 文件地址由 `common.dao.StateStore.open(Path, ObjectMapper)` 组装。`common` 和 `discovery` 已整理格式与关键中文注释,解释身份与启动会话、持久化失败、未认证广播、多网卡地址选择、认证后总控去重以及服务根地址校验。本项为源码与统计核对,不等同于重构后的运行回归。 + +按 `scripts/Measure-Comments.ps1 -SourceRoots @(...) -MinimumPercent 20` 运行,当前统计如下: + +| 目录 | 完整注释行 | 非空源码行 | 比例 | +|---|---:|---:|---:| +| `common` | 69 | 290 | 23.79% | +| `discovery` | 80 | 374 | 21.39% | +| 全部 XTRH 主 Java | 429 | 1964 | 21.84% | +| 当前 PXHJ 新增 fusion 业务 | 370 | 1101 | 33.61% | + +统计包含以注释标记开头的整行注释、Javadoc 及块注释边界,不包含空行或代码末尾的行内注释。XTRH 全部主 Java 和 PXHJ 新增 fusion 业务均达到约 20% 的要求;这些数字不包含两项目的测试文件和前端文件。 + +## 独立进程与自动发现 + +通过部署脚本启动以下四个真实 Java 进程: + +| 实例 | 角色 | HTTP 端口 | 其他配置 | +|---|---|---:|---| +| coordinator | 总控 | 18200 | 隔离验证集群 | +| gateway | 采集网关 | 18210 | UDP 接收入口 19202 | +| worker-a | 节点 A | 18220 | 初始容量 2 路 | +| worker-b | 节点 B | 18221 | 初始容量 3 路 | + +网关和两个节点配置中均没有 `fusion.coordinator-url`。通过 mDNS 自动发现总控,完成带签名的身份校验和自动注册;总控快照确认两个节点、一个网关在线。中文节点名称保存与上报正确。 + +在隔离验证集群中下发五路后台演示视频任务,节点 A 处理两路、节点 B 处理三路。通过实际 HTTP 播放连接读取到 MJPEG 帧。运行中将 B 容量修改为四路、A 修改为一路,总控自动迁移任务,最终保持五路可播放,无需重启 Java 进程。 + +上述初始验证使用明确标记的本机测试上下文;后续已由前述 PXHJ 真实业务串联补充正式权限、保存绑定和轮次关联的验证,业务数据仍限于隔离 H2。 + +## UDP 与轮次结束 + +在隔离网关中配置一条本机验证路由,目的端为临时 UDP 套接字,未向 PXHJ 正式采集入口发送测试报文。 + +- 实际网关进程转发 4096 字节随机二进制报文,目的端收到的字节完全一致。 +- 目的端看到固定的独立转发源端口。 +- 目的端回复的二进制字节返回原发送套接字,返回源端口为网关入口 19202。 +- 将该测试轮次同步为 `SAVED` 后,五路媒体任务均结束;同一采集路由继续正常转发。 + +另外,`FusionRelayUdpIntegrationTest` 在隔离 PXHJ 应用中开启真实 UDP 套接字,使用正式 `WireCodec` 生成加密采集报文与加密心跳。未登记中转来源先被拒绝;登记映射后报文被接受、样本落入 H2,原采集器连接记录与密钥关联保持不变;未知来源和错误密钥被拒绝,加密心跳独立确认在线。该类第二项验证动态配置目的地址使用已登记的中转回程,原设备地址不被修改。此处验证的是实际协议实现和本机测试套接字,未使用真实硬件采集器。 + +## 新版本本机角色启动后的只读确认 + +新版本已部署到本机默认端口:总控 18100、网关 HTTP 18110 / UDP 19002、节点 A 18120 / 容量 2、节点 B 18121 / 容量 3。在主后端升级前,只读签名请求 `/control/snapshot` 的响应验证通过,两个节点和一个网关在线;网关与节点配置中均没有固定总控 URL。当时节点没有运行任务,网关路由状态为等待,尚未接管真实采集来源。主后端后续接通结果见下一节。 + +本次确认只读取状态,不下发任务、路由,不修改试验、采集器或服务进程。该默认端口集群与 `runtime/validation` 隔离验证集群使用不同私有凭证。 + +## 达梦主实例实际接通 + +用户明确允许立即重启后,仅重启 PXHJ 主后端并加载新版 JAR,前端进程保持运行。实际主后端 PID 为 38344,前端 PID 为 33616;后端 8088 的 `/api/auth/csrf` 与前端 5178 页面 HTTP 检查均返回 200,未登录 `/api/fusion/status` 返回 401。PXHJ UDP 19001 和图片 UDP 6020 恢复,网关 UDP 19002 继续运行。 + +只读查询实际达梦 `biz_fusion_cache`,检查时缓存年龄 1104 毫秒,内容包含两个在线节点和一个在线网关,证明主实例已实际签名同步总控状态。原有 `RUNNING` 轮次数仍为 1,没有自动结束或修改现有试验;原端点上传和采集接收路径未切换,也没有为正在运行的正式试验新增演示视频配置。 + +用户从 PXHJ 融合节点管理和试验组织中的节点/视频区域统一操作。主实例连接已经接通,隔离 H2 中的正式业务流程和演示视频串联已验证;真实硬件迁移、真实融合和多台物理电脑验收仍未完成。 + +## 主总控执行能力正式部署 + +本次仅重启总控 18100,新 PID 为 41276,保留原稳定身份、H2 状态和私有配置。PXHJ 后端 PID 38344、前端 PID 33616、网关及两个子节点均未再次重启。总控健康信息确认执行能力已开启。 + +实际签名快照复核通过:主节点 `nodeRole=COORDINATOR`,节点 ID 等于总控服务 ID,仅有一条主记录;主节点播放基础地址为 `http://192.168.8.237:18100`,在线、容量 2。A/B 保持 `WORKER`、容量 2/3。当前合计三个在线执行节点和一个在线网关,总容量 7,运行任务和等待任务均为 0。 + +只读达梦缓存检查时年龄 64 毫秒,在线执行节点数 3、在线网关数 1,证明 PXHJ 未重启也能同步新的主节点记录。原正式 `RUNNING` 轮次数仍为 1,未新增或变更正式试验视频任务,未调整 A/B 容量,未切换采集器上传或路由。本机主端口实际 JPEG 播放使用随机端口集成测试验证,生产总控当前没有新视频任务运行。 + +前述两节点和 1104 毫秒缓存记录属于加入主执行能力之前的阶段;当前运行状态以本节三个执行节点为准。 + +## 部署脚本 + +PowerShell 脚本语法解析通过。集群凭证生成、私有文件访问限制、已有实例重新安装、四个实例启动及健康身份校验、中文 properties 转义、按进程身份停止均已实际执行。 + +隔离验证目录 `runtime/validation` 中保留本机身份、数据库、日志与私有配置,便于复查;该目录被 `.gitignore` 排除。业务联调结束后,四个 182xx 验证实例已按身份停止;只读端口查询确认 18100、18110、18120、18121 继续监听,182xx 验证端口已释放。未验证注册登录自启任务的实际重登录行为。 diff --git a/docs/系统融合软件原型阶段建设范围及完成标准.md b/docs/系统融合软件原型阶段建设范围及完成标准.md new file mode 100644 index 0000000..e148b94 --- /dev/null +++ b/docs/系统融合软件原型阶段建设范围及完成标准.md @@ -0,0 +1,110 @@ +# 系统融合软件原型阶段建设范围及完成标准 + +## 1 原型建设目标 + +本阶段在尚未与外部设备建立通信的条件下,围绕合同提出的外部数据处理、虚拟同步、视景融合及显示要求,建设基于模拟数据和本地视频素材的交互演示原型。原型重点完成主要功能页面、示例信息展示、基本操作流程和视频播放,用于确认界面布局、信息组织及显示方式,为后续设备接入和功能开发提供依据。 + +原型以“页面可操作、模拟信息可查看、本地视频可播放、展示流程可演示”为阶段目标。演示不依赖外部设备连接:装备、定位、姿态、动作及状态信息使用预置样例,视频使用本地录制或测试素材,页面明确标识“原型演示”“模拟数据”或“演示视频”。 + +本阶段范围限定为页面与本地视频展示,不包含设备通信、真实数据采集、实时视频流接入、三维场景、三维模型展示及模型动画,也不包含多传感器位姿融合、真实数据驱动的虚拟模型同步、实装与仿真画面的自动对齐及融合渲染。相关能力列入后续建设范围。 + +## 2 原型建设内容 + +### 2.1 外部数据处理页面演示 + +建设装备及数据来源展示页面,以预置样例体现无人装备定位、姿态,车载探测设备姿态,以及侦察、打击设备动作状态等信息的展示位置和关联关系。 + +页面支持选择示例装备,查看对应的模拟参数和状态。需要说明正常、异常或未接入等显示效果时,通过预设演示状态切换呈现;这些状态不来源于设备检测。数据来源显示为“模拟数据”,设备接入情况显示为“未接入”。 + +**完成程度:** 可以选择示例装备、查看对应信息并切换预设状态;展示内容有明确的模拟标识,不依赖设备通信。 + +### 2.2 虚拟同步功能展示 + +建设虚拟同步相关信息的展示区域,以列表、表格和状态标签演示装备与虚拟实体对应关系,以及同步延迟、数据丢包率、实体映射状态等监控信息的页面布局。对应关系使用示例名称和关联信息,不加载或渲染三维模型。 + +同步延迟、数据丢包率等指标默认显示“待接入”或“—”;为展示界面效果使用样例数值时,应在该区域明确标注“模拟数据”。本阶段不进行真实指标计算,也不完成统一时间基准构建、跨节点时空同步及模型实时驱动。 + +**完成程度:** 可以演示装备选择、示例映射关系查看和监控信息查看流程,且不会将样例数值呈现为实际监测结果。 + +### 2.3 视景与视频播放 + +建设视景展示页面,将预置本地视频素材与示例通道关联,提供通道列表、播放、暂停、通道切换、播放状态提示及全屏查看功能,演示从选择通道到画面呈现的基本流程。 + +页面标注“演示视频”或“本地回放”,播放状态仅反映本地素材是否正常播放。本阶段不连接摄像机或操控终端,不将本地视频播放表述为实时视频流接入或视景融合。 + +**完成程度:** 在未连接外部设备的演示环境中,可以播放和暂停预置视频、切换示例通道并全屏查看;素材缺失或无法播放时有明确提示。 + +### 2.4 操控席与综合态势展示 + +操控席视景以单路演示视频为主,显示示例装备、通道及播放状态,支持全屏查看。综合态势页面采用示例装备列表、模拟状态信息和视频区域组合布局,支持选择装备后查看对应信息与本地视频。 + +两个显示场景可复用同一本地视频素材,在演示计算机上切换布局,体现后续使用方式。本阶段不要求连接真实操控席或综合态势显示设备,其专用接口与现场显示联调列入后续建设。 + +**完成程度:** 能够分别演示操控席视景布局和综合态势布局,选中装备与展示内容对应一致。 + +## 3 页面及基本交互 + +管理页面纳入现有 PXHJ 操作入口。原型拟设置以下四类页面或视图,可根据现有页面合并组织。 + +| 页面或视图 | 主要展示内容 | 基本交互 | +|---|---|---| +| 运行总览 | 示例任务、装备概况、未接入标识、本地视频可用状态 | 进入装备详情或视频页面 | +| 数据与同步状态 | 模拟定位姿态、示例实体关系、监控信息展示区域 | 选择示例装备、查看详情、切换预设状态 | +| 视景展示 | 示例通道、本地视频画面、演示标识、播放状态 | 选择通道、播放、暂停、切换、全屏;全屏布局用于操控席演示 | +| 综合态势展示 | 示例装备列表、模拟状态、选中装备关联的本地视频 | 选择装备、联动查看对应信息与视频、全屏展示 | + +页面重点展示业务信息,不要求填写真实设备地址或通信参数。各页面持续保留原型演示标识,避免切换页面或全屏后丢失素材来源说明。 + +本阶段所有展示均采用常规页面、列表、表格、状态标签和视频区域。综合态势页面以装备信息与视频的组合展示为完成范围,三维场景、三维模型及模型动画均不纳入本阶段交付和演示。 + +## 4 与合同功能的阶段对应关系 + +| 合同功能 | 本阶段做到的程度 | 后续建设内容 | +|---|---|---| +| 外部数据处理 | 采用预置样例完成装备、定位姿态及动作状态的页面演示 | 设备通信、真实数据接收、协议适配、校验及标准化处理 | +| 虚拟同步 | 展示示例实体关系及监控信息布局,演示查看与切换流程 | 统一时间基准、模型实时驱动、节点时空同步、真实延迟和丢包统计 | +| 视景融合 | 使用本地视频素材完成播放和视景页面演示 | 实时视频流接入、多传感器位姿融合、仿真相机绑定、画面配准及融合渲染 | +| 操控席及综合态势显示 | 在演示计算机上完成两类页面布局及本地视频呈现 | 正式融合结果输出、目标显示设备接口联调及显示性能验证 | + +上述对应关系用于明确原型阶段的建设深度。页面和播放流程的完成情况,与合同全部功能的最终实现情况分别记录。 + +## 5 原型演示流程 + +1. 在未连接外部设备的环境中打开原型,查看示例任务、装备和演示标识。 +2. 选择示例装备,查看模拟数据及示例实体对应关系。 +3. 切换预设状态,查看监控区域的信息布局和异常提示样式。 +4. 进入视景页面,选择关联本地素材的示例通道,演示播放、暂停、切换及全屏查看。 +5. 切换到综合态势页面,选择装备,查看对应模拟状态与演示视频。 +6. 使用素材不可用的测试通道展示播放异常提示,再切换到有效素材恢复播放。 + +演示以页面操作和视频展示为主,不安排尚未实现的融合算法或虚实自动配准演示。 + +## 6 阶段完成标准 + +| 检查项 | 完成标准 | +|---|---| +| 页面完整性 | 约定页面或视图均可打开,主要展示区域与导航可用 | +| 演示条件 | 未连接外部设备时,仍可完成约定的页面操作和本地视频播放 | +| 操作流程 | 示例装备选择、详情查看、预设状态切换、视频播放与暂停、通道切换和全屏显示可以完成 | +| 信息对应 | 所选示例装备、模拟数据和本地视频的关联一致 | +| 数据来源标识 | 页面及全屏视图明确标识模拟数据和演示视频;真实设备接入状态为未接入 | +| 视频播放 | 指定本地素材可播放,切换后显示对应素材;不可用时显示原因或状态 | +| 两类显示 | 可以分别展示操控席视景布局及综合态势布局 | +| 范围说明 | 原型功能清单明确已完成、演示展示和后续建设内容 | + +检查记录应注明模拟数据范围、本地视频素材、演示环境和操作结果。本阶段的完成依据为界面与交互验证,不包含设备连通性、实时采集、融合精度、同步延迟或多节点处理能力验证。 + +## 7 原型交付内容 + +- 原型阶段设计说明,明确建设内容及完成程度。 +- 可运行的交互演示页面和本地视频播放功能。 +- 预置模拟数据及演示视频素材清单。 +- 页面截图及基本操作说明。 +- 原型演示记录,注明模拟数据和本地视频来源。 +- 后续功能建设清单,列明完整数据处理、虚拟同步和真实视景融合等尚未实现的内容。 + +## 8 后续建设衔接 + +后续在已确认的页面、示例关系和显示方式基础上,先开展设备通信、真实数据与实时视频流接入,再逐步实现数据处理、统一时间基准、仿真模型驱动、传感器位姿融合、仿真相机绑定以及画面对齐和融合渲染,形成合同要求的完整功能链路。 + +本阶段仅明确后续功能需要展示的信息项和操作入口。设备协议、接口实现及联调结果列为后续建设内容,不将页面预留位置表述为已经完成接口对接。 diff --git a/docs/系统融合软件页面与采集接入.md b/docs/系统融合软件页面与采集接入.md new file mode 100644 index 0000000..22ead47 --- /dev/null +++ b/docs/系统融合软件页面与采集接入.md @@ -0,0 +1,38 @@ +# 系统融合软件页面与采集接入 + +本次将已确认的六页设计实现为原生系统融合软件控制台。页面源码位于 `frontend/`,构建产物放入 `src/main/resources/static/console/`,随 `xtrh-services` JAR 一起部署。 + +## 页面 + +| 页面 | 当前数据行为 | +|---|---| +| 数据接收 | 按试验冻结的实装选择,查看真实采集参数、来源、采样和接收时间及质量 | +| 同步质量监控 | 装备列表与查看框架已实现,尚未接入同步质量指标时显示空状态 | +| 虚拟数据下发 | 虚拟装备血量、位置等只读框架;尚无虚拟装备源时为空 | +| 融合视频展示 | 按实装选择,画面右上角保留六项融合指标;尚无真正融合源时为空 | +| 融合指标展示 | 按实装逐行展示,未接入指标为 `—` | +| 主从控制 | 展示已注册节点的身份、角色、心跳在线状态;未上报 CPU、内存、网络速率为 `—` | + +页面不会将原图、演示视频或采集参数推测成融合输出,不内置示例装备、位置、血量或曲线。 + +## 真实采集链路 + +`采集器 → 既有网关/采集入口 → PXHJ 认证、解密、协议解析 → 冻结装备与采集模块绑定 → 实时质量判定 → 签名快照推送 → 系统融合软件页面` + +PXHJ 的 `TrialFusionCollectionSnapshot` 只读取未完成的正式试验及冻结绑定。`TrialCollectionService.realLiveForFusion` 沿用原采集服务,在计算质量前排除模拟报文,固定读取实时 60 秒窗口;公开用户采集接口原有权限检查保持不变。 + +每两秒由独立单线程 `FusionCollectionPublisher` 复用 `FusionImageClient` 和原 `app.fusion.*` 私有配置,调用签名 `POST /control/collection/sync`。共享凭证只保留在后台。新快照整体替换内存中的旧快照,空列表也同步,因此结束试验后不会残留装备。 + +接收端使用大小、数量、字段、标量值及时间边界校验,保留真实零值,缺失使用 null。保留 `GOOD/MISSING/STALE/INVALID_TIME/INVALID_VALUE/AMBIGUOUS` 等质量;超过 15 秒无有效推送,最后值标记为过期。断开时不将旧值显示成正常接收。 + +## 构建和使用 + +1. 在 `frontend` 安装依赖:`pnpm install` 或 `npm install`。 +2. 运行 `scripts/Build-Xtrh.ps1`,将先测试并构建页面,再执行 Java 测试和打包。已有前端构建产物时可使用 `-SkipFrontend`。 +3. 将新 JAR 部署到既有总控,保留身份、数据和私有配置。 +4. PXHJ 桥接源码已按基线校验应用;重新打包并加载 PXHJ 后端,既有 `app.fusion.enabled` 和总控配置生效后自动推送。 +5. 从总控所在电脑打开 `http://127.0.0.1:18100/console/`。页面每两秒刷新,可搜索和选择装备;存在多个试验时可在页头切换。 + +浏览器 API 只有同源本机 GET/HEAD 查看能力。原 `/control/*` 仍需 HMAC 签名;页面没有节点容量修改、试验控制或采集器重配置操作。 + +PXHJ 桥接补丁与原文件校验清单保存在 `runtime/console-pxhj-staging/`;应用前会核对基线并备份原文件,避免覆盖其他改动。源码修改不会自动更新运行中的进程。 diff --git a/docs/系统融合软件页面接入验证.md b/docs/系统融合软件页面接入验证.md new file mode 100644 index 0000000..30969be --- /dev/null +++ b/docs/系统融合软件页面接入验证.md @@ -0,0 +1,31 @@ +# 系统融合软件页面接入验证 + +日期:2026-10-16。 + +原生页面已实现并启用,地址为 `http://127.0.0.1:18100/console/`。六页为数据接收、同步质量监控、虚拟数据下发、融合视频展示、融合指标展示、主从控制。 + +## 软件验证 + +- XTRH 完整回归 48 项通过,失败、错误和跳过均为 0。 +- PXHJ 完整回归 445 项,失败 0、错误 0、跳过 30 项外部环境测试。 +- 前端数据规则 3 项通过,生产构建通过。 +- 独立测试集群的真实 HTTP 签名同步、响应验签及页面联动检查 13 项通过。测试数据已清空,测试实例已关闭,没有向正式系统写入测试样本。 +- 六页的 1586px 和 1024px 桌面布局、导航、搜索、选择及空状态通过;不使用原图、DEMO 或生成图冒充融合视频。 + +## 已加载程序 + +获得用户“现在重启并启用”的明确授权后,PXHJ 后端 8088 与融合总控 18100 已重新加载;原有 5178 前端未停止。替换前的 JAR 保存在 `runtime/console-activation-backup-20261016-193802/`,身份、H2 状态及达梦数据保留。 + +启动前错误地使用了 PXHJ 不提供的 `/health` 地址;部署脚本的端口保护阻止了替换。随后以 `/api/auth/csrf` 和进程 JAR 路径确认真实服务并取得明确重启授权,没有在未获准时停止 PXHJ。 + +PXHJ 采集桥接源码已按基线哈希保护应用,原修改文件备份在 `runtime/console-bridge-backup-20261016-192330/`。部署后的两个 JAR 与已验证构建产物进行 SHA-256 一致性核对。 + +## 正式只读观察 + +正式 `/console/api/snapshot` 中 collection.state 为 CONNECTED,识别 5 个试验、6 台绑定装备、286 个参数,SIMULATED 数量为 0。显示的装备和参数来自正式冻结绑定,没有添加示例数据。 + +原采集网关原先未运行,现已启动;HTTP 18110 / UDP 19002,唯一既有登记路由的目标仍为 PXHJ UDP 19001,路由状态 APPLIED。不修改采集器配置或来源匹配。 + +检查窗口内网关未收到新报文,全部参数为 MISSING。软件链路已启用,新的有效采集样本到达后会经原解密与绑定流程刷新页面;当前没有把空值补成零或其他装备的数据。此记录不替代真实采集器运行、物理多机或真实融合视频验收。 + +其他五页仅保留查看功能。真实节点身份与心跳可见,CPU、内存、网络速率和未接入的虚拟/融合数据均为 `—` 或空列表。 diff --git a/frontend/design-qa.md b/frontend/design-qa.md new file mode 100644 index 0000000..3c5fbd5 --- /dev/null +++ b/frontend/design-qa.md @@ -0,0 +1,37 @@ +# 系统融合软件原生页面验收 + +final result: passed + +日期:2026-10-16。 + +## 视觉依据与状态 + +设计依据为 `E:\Codex\xtrh\output\system-fusion-design-v2\01-数据接收.png` 至 `06-主从控制.png` 六张确认稿,像素均为 1586×992。当前实现遵循用户新增要求:数据接收接真实采集,其他功能先保留查看框架,不使用演示数据。因此当前无数据时的空列表、空画面、空曲线与 `—` 是预期状态,不能以设计稿内的示例值补齐。 + +原生页面随 Java JAR 运行。最终修正版浏览器截图在 `E:\Codex\xtrh\runtime\console-ui-qa\revised\`。CSS 视口为 1586×992,deviceScaleFactor=1;截图为完整页面,不拉伸。超出一屏的空状态页面保留原始高度。另逐页检查 1024×768 视口,无整页横向溢出。 + +## 对比与修正 + +六页设计图与实现已经放在同一张比较图中,路径为 `runtime/console-ui-qa/comparison/*-compare.png`;另有 `receive-focus.png` 和 `nodes-focus.png` 检查品牌、字体、搜索和密集表头。 + +第一轮发现 [P2] 侧页的实装列表列宽与确认稿不一致、部分面板内容贴边、品牌图标缺少蓝色底面。已统一桌面列宽、补齐 20px 内边距并使用原品牌 SVG 的蓝色容器。最终修正截图已重拍并重新对比;未发现待修复 P0/P1/P2。 + +## 五项视觉检查 + +- 字体:Microsoft YaHei / Segoe UI 回退,中文标题、表头、状态与时间可读;使用界面层级而非图片文字。 +- 布局:深蓝侧栏、白色顶栏、浅灰工作区及六项导航保持一致;装备选择与详情分区明确,指标列表为整表。 +- 色彩:使用 #172c46、#276cc0、#f4f6fa 和语义状态色;未知或未接入信息使用灰色,不显示为正常。 +- 图像:使用已提供品牌资源与图标库;无真实融合视频源时为空,不把原图、DEMO 或生成画面作为业务视频。 +- 内容:品牌统一为系统融合软件;没有旧的接收统计、输出任务配置、模拟切换或下发演示操作。 + +## 浏览器和数据验证 + +实际浏览器逐页验证六页导航、空状态、1024px 布局及控制台;异常数为 0。独立测试集群上验证 13 项完整链路检查:HTTP 签名推送及响应验签、无签名拒绝、模拟采样过滤、真实零值和 false、过期标记、装备筛选/选择、参数详情、融合功能为空、全量空替换。测试数据只写入独立测试集群,已清空;没有写入正式总控或 PXHJ。 + +记录:`runtime/console-ui-qa/browser-report.json`、`revised/browser-report.json`、`interaction-report.json`。 + +Java 回归:XTRH 48/48 通过;PXHJ 445 项,失败 0、错误 0、跳过 30。前端数据规则 3/3 通过。静态资源与两个服务 JAR 均构建通过。 + +## 运行生效边界 + +已在用户明确授权后重启 PXHJ 后端和融合总控并加载新版本,正式签名同步状态为 CONNECTED。当前真实冻结上下文为 5 个试验、6 台装备、286 个参数;模拟采样数量为 0。正式页面已重新逐页检查。原有采集网关已启动,登记路由保持不变,处于 APPLIED/在线状态。当前未收到新的有效采集样本,参数质量为 MISSING,页面按原值显示为空。不能据此宣称完成真实设备采样验收。CPU、内存、网络速率与其他功能数据源仍为空。 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5e6f90e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,11 @@ + + + + + + + + 系统融合软件 + +
+ diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a24360a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "xtrh-console", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "node --test tests/*.test.mjs" + }, + "dependencies": { + "lucide-react": "1.57.0", + "react": "19.2.0", + "react-dom": "19.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "5.0.4", + "vite": "6.4.2" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..6167de3 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1124 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + lucide-react: + specifier: 1.57.0 + version: 1.57.0(react@19.2.0) + react: + specifier: 19.2.0 + version: 19.2.0 + react-dom: + specifier: 19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@vitejs/plugin-react': + specifier: 5.0.4 + version: 5.0.4(vite@6.4.2) + vite: + specifier: 6.4.2 + version: 6.4.2 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.10': + resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.10': + resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/pluginutils@1.0.0-beta.38': + resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==} + + '@rollup/rollup-android-arm-eabi@4.64.7': + resolution: {integrity: sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.64.7': + resolution: {integrity: sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.64.7': + resolution: {integrity: sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.64.7': + resolution: {integrity: sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.64.7': + resolution: {integrity: sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.64.7': + resolution: {integrity: sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.64.7': + resolution: {integrity: sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.64.7': + resolution: {integrity: sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.64.7': + resolution: {integrity: sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.64.7': + resolution: {integrity: sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.64.7': + resolution: {integrity: sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.64.7': + resolution: {integrity: sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.64.7': + resolution: {integrity: sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.64.7': + resolution: {integrity: sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.64.7': + resolution: {integrity: sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.64.7': + resolution: {integrity: sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.64.7': + resolution: {integrity: sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.64.7': + resolution: {integrity: sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.64.7': + resolution: {integrity: sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.64.7': + resolution: {integrity: sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.64.7': + resolution: {integrity: sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.64.7': + resolution: {integrity: sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.64.7': + resolution: {integrity: sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.64.7': + resolution: {integrity: sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.64.7': + resolution: {integrity: sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@vitejs/plugin-react@5.0.4': + resolution: {integrity: sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + baseline-browser-mapping@2.11.28: + resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.29.3: + resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001816: + resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + electron-to-chromium@1.5.456: + resolution: {integrity: sha512-8hboqI6xesozteFb8aa7OGknvxUtTeRJLz0FoePysUEh/oWbddykIGdgpEJRbPRg/nIMfBf2DFos+H2MtvJl2A==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.57.0: + resolution: {integrity: sha512-bXFZEucYhAzLD62Jse76AbTR6LiSLTn3Tqir2f1n8rZrWAdLodYy7jnRZSPIT89dRLk/IseMOUWG82wJtTyTTw==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.59: + resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + + rollup@4.64.7: + resolution: {integrity: sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.10 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.10 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.3 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.10 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.10 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.10': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.10': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.38': {} + + '@rollup/rollup-android-arm-eabi@4.64.7': + optional: true + + '@rollup/rollup-android-arm64@4.64.7': + optional: true + + '@rollup/rollup-darwin-arm64@4.64.7': + optional: true + + '@rollup/rollup-darwin-x64@4.64.7': + optional: true + + '@rollup/rollup-freebsd-arm64@4.64.7': + optional: true + + '@rollup/rollup-freebsd-x64@4.64.7': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.64.7': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.64.7': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.64.7': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.64.7': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.64.7': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.64.7': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.64.7': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.64.7': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.64.7': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.64.7': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.64.7': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.64.7': + optional: true + + '@rollup/rollup-linux-x64-musl@4.64.7': + optional: true + + '@rollup/rollup-openbsd-x64@4.64.7': + optional: true + + '@rollup/rollup-openharmony-arm64@4.64.7': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.64.7': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.64.7': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.64.7': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.64.7': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/estree@1.0.9': {} + + '@vitejs/plugin-react@5.0.4(vite@6.4.2)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.38 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.2 + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.11.28: {} + + browserslist@4.29.3: + dependencies: + baseline-browser-mapping: 2.11.28 + caniuse-lite: 1.0.30001816 + electron-to-chromium: 1.5.456 + node-releases: 2.0.59 + update-browserslist-db: 1.3.4(browserslist@4.29.3) + + caniuse-lite@1.0.30001816: {} + + convert-source-map@2.0.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + electron-to-chromium@1.5.456: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.57.0(react@19.2.0): + dependencies: + react: 19.2.0 + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + node-releases@2.0.59: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.30: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react@19.2.0: {} + + rollup@4.64.7: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.64.7 + '@rollup/rollup-android-arm64': 4.64.7 + '@rollup/rollup-darwin-arm64': 4.64.7 + '@rollup/rollup-darwin-x64': 4.64.7 + '@rollup/rollup-freebsd-arm64': 4.64.7 + '@rollup/rollup-freebsd-x64': 4.64.7 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.7 + '@rollup/rollup-linux-arm-musleabihf': 4.64.7 + '@rollup/rollup-linux-arm64-gnu': 4.64.7 + '@rollup/rollup-linux-arm64-musl': 4.64.7 + '@rollup/rollup-linux-loong64-gnu': 4.64.7 + '@rollup/rollup-linux-loong64-musl': 4.64.7 + '@rollup/rollup-linux-ppc64-gnu': 4.64.7 + '@rollup/rollup-linux-ppc64-musl': 4.64.7 + '@rollup/rollup-linux-riscv64-gnu': 4.64.7 + '@rollup/rollup-linux-riscv64-musl': 4.64.7 + '@rollup/rollup-linux-s390x-gnu': 4.64.7 + '@rollup/rollup-linux-x64-gnu': 4.64.7 + '@rollup/rollup-linux-x64-musl': 4.64.7 + '@rollup/rollup-openbsd-x64': 4.64.7 + '@rollup/rollup-openharmony-arm64': 4.64.7 + '@rollup/rollup-win32-arm64-msvc': 4.64.7 + '@rollup/rollup-win32-ia32-msvc': 4.64.7 + '@rollup/rollup-win32-x64-gnu': 4.64.7 + '@rollup/rollup-win32-x64-msvc': 4.64.7 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + source-map-js@1.2.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + update-browserslist-db@1.3.4(browserslist@4.29.3): + dependencies: + browserslist: 4.29.3 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@6.4.2: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.30 + rollup: 4.64.7 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + yallist@3.1.1: {} diff --git a/frontend/public/assets/brand.svg b/frontend/public/assets/brand.svg new file mode 100644 index 0000000..2019af3 --- /dev/null +++ b/frontend/public/assets/brand.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..535aa07 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,38 @@ +import React,{useCallback,useEffect,useMemo,useState} from 'react'; +import {Radio,Activity,Send,MonitorPlay,ChartNoAxesColumn,Network,ChevronRight,RefreshCw,PanelLeftClose,Gauge,AlertCircle} from 'lucide-react'; +import {Status} from './ui'; +import {realEquipment,collectionState,effectiveEquipment} from './data.mjs'; +import {ReceivePage,SyncPage} from './ReceivePages'; +import {VirtualPage,VideoPage,MetricsPage,NodesPage} from './SecondaryPages'; +const PAGES=[ + {id:'receive',name:'数据接收',description:'按装备查看接收数据与当前状态',icon:Radio}, + {id:'sync',name:'同步质量监控',description:'按装备查看同步状态与质量指标',icon:Activity}, + {id:'virtual',name:'虚拟数据下发',description:'查看虚拟装备的血量、位置与运行状态',icon:Send}, + {id:'video',name:'融合视频展示',description:'按实装查看融合视频及当前融合指标',icon:MonitorPlay}, + {id:'metrics',name:'融合指标展示',description:'以装备列表查看各实装的融合指标',icon:ChartNoAxesColumn}, + {id:'nodes',name:'主从控制',description:'查看各节点的 CPU、内存和网络运行状态',icon:Network}, +]; +const emptySnapshot={collection:{state:'WAITING',contexts:[]},nodes:[]}; +const currentRoute=()=>PAGES.find(item=>item.id===location.hash.slice(1))?.id||'receive'; + +export function App(){ + const [pageId,setPageId]=useState(currentRoute),[snapshot,setSnapshot]=useState(emptySnapshot); + const [contextId,setContextId]=useState(''),[selectedId,setSelectedId]=useState(''); + const [failed,setFailed]=useState(false),[loading,setLoading]=useState(true),[refreshKey,setRefreshKey]=useState(0),[collapsed,setCollapsed]=useState(false),[now,setNow]=useState(Date.now()); + useEffect(()=>{const handler=()=>{setPageId(currentRoute());window.scrollTo(0,0);};window.addEventListener('hashchange',handler);return()=>window.removeEventListener('hashchange',handler);},[]); + useEffect(()=>{const timer=setInterval(()=>setNow(Date.now()),1000);return()=>clearInterval(timer);},[]); + useEffect(()=>{let closed=false,timer,controller; + const poll=async()=>{controller=new AbortController();const timeout=setTimeout(()=>controller.abort(),6000);try{const response=await fetch('/console/api/snapshot',{signal:controller.signal,credentials:'same-origin',cache:'no-store',headers:{Accept:'application/json'}});if(!response.ok)throw new Error('snapshot');const body=await response.json();if(!body||!Array.isArray(body.collection?.contexts)||!Array.isArray(body.nodes))throw new Error('shape');if(!closed){setSnapshot(body);setFailed(false);}}catch{if(!closed)setFailed(true);}finally{clearTimeout(timeout);if(!closed){setLoading(false);timer=setTimeout(poll,2000);}}};poll();return()=>{closed=true;clearTimeout(timer);controller?.abort();}; + },[refreshKey]); + const state=collectionState(snapshot.collection,failed); + const contexts=snapshot.collection.contexts||[]; + const context=contexts.find(item=>item.id===contextId)||contexts[0]; + const equipments=useMemo(()=>realEquipment(context).map(item=>effectiveEquipment(item,state)),[context,state]); + const selected=equipments.find(item=>item.id===selectedId)?.id||equipments[0]?.id||''; + const page=PAGES.find(item=>item.id===pageId); + useEffect(()=>{document.title=`${page.name} · 系统融合软件`;},[page.name]); + const onSelect=useCallback(id=>setSelectedId(id),[]); + const props={equipments,selectedId:selected,onSelect}; + const date=new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}).format(new Date(now)); + return
系统融合软件{page.name}

{page.name}

{page.description}

{contexts.length>0&&}
{(failed||state==='STALE')&&
{failed?'服务暂时不可用,当前显示最后一次收到的数据。':'采集数据同步已超时,当前显示最后一次收到的数据。'}
}{page.id==='receive'&&}{page.id==='sync'&&}{page.id==='virtual'&&}{page.id==='video'&&}{page.id==='metrics'&&}{page.id==='nodes'&&}
; +} diff --git a/frontend/src/ReceivePages.jsx b/frontend/src/ReceivePages.jsx new file mode 100644 index 0000000..0997cb4 --- /dev/null +++ b/frontend/src/ReceivePages.jsx @@ -0,0 +1,18 @@ +import React,{useEffect,useState} from 'react'; +import {Activity,Radio} from 'lucide-react'; +import {Panel,Empty,EquipmentList,EquipmentHeader,Status,formatTime} from './ui'; +import {displayValue} from './data.mjs'; + +const typeNames={NUMBER:'数值数据',STATE:'状态数据',IMAGE:'图像数据'}; +export function ReceivePage({equipments,selectedId,onSelect}) { + const equipment=equipments.find(item=>item.id===selectedId); + const items=equipment?.items||[]; + const [itemKey,setItemKey]=useState(''); + useEffect(()=>setItemKey(''),[selectedId]); + const selected=items.find(item=>item.key===itemKey)||items[0]; + return
{equipment?<>{equipment.participantCode||''}}>
{items.map((item,index)=>setItemKey(item.key)} tabIndex={0} aria-selected={selected===item} onKeyDown={event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();setItemKey(item.key);}}}>)}
数据类型 / 参数最新数据接收时间接收状态
{item.label||item.key}{typeNames[item.type]||item.type||'采集数据'}{displayValue(item.value,item.unit)}
{!items.length&&}
{selected?
当前值
{displayValue(selected.value,selected.unit)}
数据来源
{selected.sourceName||'—'}
采样时间
{formatTime(selected.sampleTime)}
接收时间
{formatTime(selected.receivedAt)}
数据质量
:}
:}
; +} +export function SyncPage({equipments,selectedId,onSelect}) { + const equipment=equipments.find(item=>item.id===selectedId); + return
{equipment&&}
{[['同步延迟','ms'],['数据丢包率','%'],['同步偏差','ms']].map(([name,unit])=>
{name}— {unit}

未接入

)}
数据类型数据时间同步偏差同步状态
; +} diff --git a/frontend/src/SecondaryPages.jsx b/frontend/src/SecondaryPages.jsx new file mode 100644 index 0000000..bb379be --- /dev/null +++ b/frontend/src/SecondaryPages.jsx @@ -0,0 +1,210 @@ +import React, { useMemo, useState } from 'react'; +import { + Activity, ArrowDownToLine, ArrowUpFromLine, Cpu, HeartPulse, + Maximize, MonitorPlay, Network, Pause, Search, Server, Volume2, +} from 'lucide-react'; +import { Empty, EquipmentHeader, EquipmentIcon, EquipmentList, Panel } from './ui'; +import './secondary.css'; + +const FUSION_METRICS = [ + ['帧率', 'fps'], ['码率', 'Mbps'], ['丢帧率', '%'], + ['同步偏差', 'ms'], ['网络带宽', 'Mbps'], ['运行时长', 'min'], +]; + +function equipmentId(equipment) { + return String(equipment?.equipmentId ?? equipment?.id ?? equipment?.code ?? ''); +} + +function equipmentName(equipment) { + return equipment?.equipmentName || equipment?.name || equipment?.code || equipmentId(equipment) || '未命名装备'; +} + +function equipmentCode(equipment) { + return equipment?.equipmentCode || equipment?.code || equipmentId(equipment) || '—'; +} + +function SearchField({ value, onChange, placeholder, label }) { + return ; +} + +function BlankValue({ unit }) { + return —{unit && {unit}}; +} + +function UnavailableTag({ children }) { + return {children}; +} + +function TableEmpty({ colSpan, title, description, icon }) { + return + + ; +} + +export function VirtualPage() { + const [query, setQuery] = useState(''); + + return
+ +
+ +
+
+ +
+
+ +
+
+ +

未选择虚拟装备

装备编号 —

+ 暂无状态 +
+ + +
+
+
+ + + +
+ + + +
虚拟装备血量位置(经度 / 纬度)高程状态
+
+
+
+
; +} + +export function VideoPage({ equipments = [], selectedId, onSelect }) { + const selected = equipments.find((equipment) => equipmentId(equipment) === String(selectedId ?? '')); + + return
+ +
+ {selected + ? + :
} + +
+
+
+ +
+ {selected ? '暂无画面' : '未选择装备'} + + + + +
+ +
+
; +} + +export function MetricsPage({ equipments = [] }) { + const [query, setQuery] = useState(''); + const filtered = useMemo(() => { + const value = query.trim().toLocaleLowerCase(); + return value ? equipments.filter((equipment) => `${equipmentName(equipment)} ${equipmentCode(equipment)}`.toLocaleLowerCase().includes(value)) : equipments; + }, [equipments, query]); + + return
+ +
+ +
+
+ + {FUSION_METRICS.map(([label, unit]) => )} + + {filtered.length ? filtered.map((equipment, index) => + + {FUSION_METRICS.map(([label]) => )} + + ) : } + +
装备名称{label}({unit})状态
{equipmentName(equipment)}{equipmentCode(equipment)}
暂无指标
+
+

未接入融合指标的装备显示为 —。

+
+

可在融合视频展示页查看对应装备的画面与融合指标。

+
; +} + +function NodeStatus({ node }) { + const state = node.online === true ? 'online' : node.online === false ? 'offline' : 'unknown'; + return {state === 'online' ? '在线' : state === 'offline' ? '离线' : '状态未知'}; +} + +function nodeRole(node) { + return node.nodeRole === 'COORDINATOR' ? '主节点' : node.nodeRole === 'WORKER' ? '从节点' : '—'; +} + +export function NodesPage({ nodes = [] }) { + const [query, setQuery] = useState(''); + const filtered = useMemo(() => { + const value = query.trim().toLocaleLowerCase(); + return value ? nodes.filter((node) => `${node.name || ''} ${node.nodeId || ''}`.toLocaleLowerCase().includes(value)) : nodes; + }, [nodes, query]); + + return +
+ +
+
+ + + {filtered.length ? filtered.map((node, index) => + + + + + ) : } +
节点名称角色内存使用情况运行状态
{nodeRole(node)}
+
+
+

+ {filtered.length ?
{filtered.map((node, index) =>
+
)}
:

暂无节点连接信息

} +
+

资源使用情况与网络速率未上报时显示为 —。

+
; +} diff --git a/frontend/src/data.mjs b/frontend/src/data.mjs new file mode 100644 index 0000000..0b97d90 --- /dev/null +++ b/frontend/src/data.mjs @@ -0,0 +1,28 @@ +const simulated = item => item?.simulated === true || /SIMULATED|模拟|演示/i.test([item?.quality,item?.sourceType,item?.sourceName].join(' ')); + +export function realEquipment(context) { + return (Array.isArray(context?.equipment) ? context.equipment : []).filter(item => !simulated(item)).map(item => ({ + ...item, + id: String(item.id || item.equipmentId || ''), + items: (Array.isArray(item.items) ? item.items : []).filter(value => !simulated(value)), + })).filter(item => item.id); +} + +export function displayValue(value, unit = '') { + if (value === null || value === undefined || value === '') return '—'; + if (typeof value === 'object') return '—'; + const text = typeof value === 'boolean' ? (value ? '是' : '否') : String(value); + return unit ? `${text} ${unit}` : text; +} + +export function collectionState(collection, failed) { + if (failed) return 'ERROR'; + return ['CONNECTED','STALE','WAITING'].includes(collection?.state) ? collection.state : 'WAITING'; +} + +export function effectiveEquipment(equipment, state) { + if (!equipment) return undefined; + if (state === 'CONNECTED') return equipment; + return {...equipment, state: equipment.lastReceivedAt ? 'STALE' : 'NOT_RECEIVED', + items:(equipment.items||[]).map(item=>['GOOD','VALID'].includes(item.quality)?{...item,quality:'STALE'}:item)}; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..9d45f25 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,6 @@ +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {App} from './App'; +import './styles.css'; + +createRoot(document.getElementById('root')).render(); diff --git a/frontend/src/secondary.css b/frontend/src/secondary.css new file mode 100644 index 0000000..eb2024c --- /dev/null +++ b/frontend/src/secondary.css @@ -0,0 +1,137 @@ +.secondary-equipment-layout { display: grid; grid-template-columns: 296px minmax(0, 1fr); gap: 18px; align-items: stretch; } +.secondary-detail-stack { display: flex; flex-direction: column; gap: 14px; min-width: 0; } +.secondary-search { display: flex; align-items: center; gap: 10px; width: 330px; max-width: 100%; min-height: 40px; padding: 0 12px; color: #647e9f; border: 1px solid #d6e2f0; border-radius: 6px; background: #fff; } +.secondary-search:focus-within { border-color: #3280d4; box-shadow: 0 0 0 3px #2b7bd316; } +.secondary-search input { width: 100%; min-width: 0; height: 38px; padding: 0; color: #203753; font: inherit; font-size: 13px; border: 0; outline: 0; background: transparent; } +.secondary-search input::placeholder { color: #7d91ab; } +.secondary-list-search { padding: 0 18px 18px; } +.secondary-list-search .secondary-search { width: auto; } +.secondary-panel-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 18px; } +.secondary-muted, .secondary-blank { color: #8294ac; } +.secondary-blank { white-space: nowrap; font-variant-numeric: tabular-nums; } +.secondary-blank small { margin-left: 8px; color: #8294ac; font-size: 12px; } +.secondary-unavailable { display: inline-flex; align-items: center; gap: 7px; color: #8393a9; white-space: nowrap; font-size: 13px; } +.secondary-unavailable i, .node-state i { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; background: currentColor; } +.secondary-table-scroll { width: 100%; overflow-x: auto; } +.secondary-table { width: 100%; border-spacing: 0; border-collapse: collapse; table-layout: fixed; color: #294360; font-size: 13px; } +.secondary-table th { height: 48px; padding: 12px 14px; color: #5c7798; background: #f4f8fc; border: 1px solid #e1eaf4; font-weight: 500; text-align: left; } +.secondary-table td { height: 83px; padding: 17px 14px; border: 1px solid #e1eaf4; vertical-align: middle; } +.secondary-table th svg { display: inline-block; margin: 0 6px -2px 0; } +.secondary-table tbody tr:hover { background: #f9fbfe; } +.secondary-table .secondary-empty-cell { height: 230px; padding: 30px 16px; text-align: center; } +.secondary-table .secondary-empty-cell:hover { background: #fff; } +.secondary-column-unit { white-space: nowrap; } +.secondary-panel-note { margin: 20px 0 0; color: #7f94ae; font-size: 12px; line-height: 1.6; } + +.virtual-list-panel { min-height: 650px; } +.virtual-list-empty { display: grid; place-items: center; padding: 80px 18px 28px; } +.virtual-unselected-header { display: flex; align-items: center; gap: 18px; padding: 23px; min-height: 100px; border: 1px solid #dfe8f3; border-radius: 8px; background: #fff; } +.virtual-equipment-icon { display: grid; place-items: center; width: 48px; height: 48px; color: #a2b5cc; background: #f2f6fb; border-radius: 9px; } +.virtual-unselected-header h2, .video-unselected-header h2 { margin: 0 0 8px; font-size: 21px; font-weight: 600; color: #566d89; } +.virtual-unselected-header p, .video-unselected-header p { margin: 0; color: #8396ae; font-size: 13px; } +.virtual-unselected-header p span { margin-left: 12px; } +.virtual-unselected-header > .secondary-unavailable { margin-left: auto; } +.virtual-state-body { padding: 0; } +.virtual-health-row { display: flex; align-items: center; gap: 20px; padding: 20px 22px; background: #f9fbfe; border: 1px solid #e5edf6; border-radius: 5px; } +.virtual-health-row > svg { color: #9badc2; } +.virtual-health-label { color: #66809f; font-size: 14px; } +.virtual-health-row strong { color: #8195ad; font-size: 23px; font-weight: 500; white-space: nowrap; } +.virtual-health-track { flex: 1; height: 10px; margin-left: 8px; border-radius: 6px; background: #e7edf5; } +.virtual-position-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 19px 36px; padding: 23px 12px 4px; } +.virtual-position-item { display: grid; grid-template-columns: 84px 1fr; gap: 12px; font-size: 14px; } +.virtual-position-item > span:first-child { color: #6783a3; } +.virtual-overview-table { min-width: 620px; } +.virtual-overview-table th:first-child { width: 23%; } +.virtual-overview-table th:nth-child(3) { width: 33%; } + +.video-main-panel { min-width: 0; overflow: hidden; } +.video-unselected-header { display: flex; align-items: center; gap: 18px; min-height: 91px; color: #91a6bf; } +.video-main-panel > .panel-header:empty { display: none; } +.video-stage { position: relative; display: flex; flex-direction: column; min-height: 480px; aspect-ratio: 16 / 10; overflow: hidden; background: #101d2c; border-radius: 6px; } +.video-empty-frame { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; padding: 180px 24px 36px; color: #5f7893; text-align: center; } +.video-empty-frame h3 { margin: 16px 0 9px; color: #c1cede; font-size: 19px; font-weight: 500; } +.video-empty-frame p { margin: 0; color: #7d92a9; font-size: 13px; line-height: 1.7; } +.video-metrics-overlay { position: absolute; top: 18px; right: 18px; width: 222px; padding: 15px 17px; color: #dfe7f0; border: 1px solid #52667c50; border-radius: 7px; background: #223246e8; } +.video-overlay-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 11px; border-bottom: 1px solid #a4b4c12b; } +.video-overlay-heading strong { font-size: 14px; } +.video-overlay-heading > span { max-width: 102px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9fb2c7; font-size: 11px; } +.video-metrics-overlay dl { display: flex; flex-direction: column; gap: 10px; margin: 12px 0 0; font-size: 12px; } +.video-metrics-overlay dl > div { display: flex; justify-content: space-between; align-items: center; gap: 15px; } +.video-metrics-overlay dt { color: #c1cede; } +.video-metrics-overlay dd { margin: 0; color: #a9bbcf; white-space: nowrap; } +.video-metrics-overlay dd small { margin-left: 6px; color: #8098b3; font-size: 10px; } +.video-control-bar { display: flex; align-items: center; gap: 16px; min-height: 48px; padding: 0 16px; color: #8195ad; background: #1c2a3a; } +.video-control-bar .secondary-unavailable { color: #879bb2; font-size: 12px; } +.video-control-divider { height: 21px; width: 1px; background: #47596d; } +.video-control-bar button { display: grid; place-items: center; min-width: 25px; padding: 5px 0; border: 0; color: #768b9f; background: none; } +.video-control-bar button:disabled { cursor: not-allowed; opacity: .55; } +.video-volume-track { width: 96px; height: 3px; border-radius: 3px; background: #3f5369; } +.video-resolution { margin-left: auto; font-size: 11px; white-space: nowrap; } + +.fusion-metrics-panel { min-height: 550px; } +.fusion-metrics-table { min-width: 1000px; } +.fusion-metrics-table th:first-child { width: 215px; } +.fusion-metrics-table th:last-child { width: 105px; } +.fusion-metrics-table td:not(:first-child), .fusion-metrics-table th:not(:first-child) { text-align: center; } +.fusion-metrics-table td { height: 93px; } +.fusion-equipment-name { display: flex; align-items: center; gap: 14px; } +.fusion-equipment-name > svg { color: #287dcb; flex-shrink: 0; } +.fusion-equipment-name > div { min-width: 0; } +.fusion-equipment-name strong { display: block; color: #1b395c; font-size: 15px; font-weight: 600; word-break: break-word; } +.fusion-equipment-name span { display: block; margin-top: 7px; color: #7e94b0; font-size: 12px; word-break: break-all; } +.fusion-metrics-footer { margin: 22px 0 0; color: #7b93b0; font-size: 13px; } + +.node-status-table { min-width: 1020px; } +.node-status-table th:first-child { width: 250px; } +.node-status-table th:nth-child(2) { width: 90px; } +.node-status-table th:nth-child(4) { width: 180px; } +.node-status-table th:last-child { width: 115px; } +.node-status-table td { height: 96px; } +.node-identity { display: flex; align-items: center; gap: 15px; } +.node-identity > svg { flex-shrink: 0; color: #267bd1; } +.node-identity > div { min-width: 0; } +.node-identity strong { display: block; color: #193a5c; font-size: 15px; font-weight: 600; word-break: break-word; } +.node-identity span { display: block; margin-top: 7px; color: #7a91ad; font-size: 11px; overflow-wrap: anywhere; } +.node-state { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; white-space: nowrap; } +.node-state-online { color: #0da76a; } +.node-state-offline, .node-state-unknown { color: #8092aa; } +.node-network-panel { margin-top: 24px; padding: 18px; border: 1px solid #e0e9f4; border-radius: 6px; } +.node-network-panel h3 { display: flex; align-items: center; gap: 9px; margin: 0 0 18px; padding-bottom: 14px; border-bottom: 1px solid #e6edf6; font-size: 15px; font-weight: 600; color: #234363; } +.node-network-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; } +.node-network-item { display: flex; align-items: center; gap: 11px; min-width: 0; padding: 7px 0; } +.node-network-item > svg { flex-shrink: 0; color: #287dc9; } +.node-network-item > div { min-width: 0; } +.node-network-item strong { display: block; color: #294664; font-size: 13px; font-weight: 600; word-break: break-word; } +.node-network-item > div > span { display: block; margin-top: 6px; color: #8294aa; font-size: 11px; } +.node-network-item > .node-state { margin-left: auto; } +.node-network-empty { margin: 28px 0; color: #8b9bb0; text-align: center; font-size: 13px; } + +@media (max-width: 1220px) { + .secondary-equipment-layout { grid-template-columns: 260px minmax(0, 1fr); } + .video-stage { min-height: 490px; } + .video-empty-frame { padding-top: 230px; } + .node-network-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .virtual-health-row { gap: 13px; padding: 18px 14px; } + .virtual-health-row strong { font-size: 19px; } +} +@media (max-width: 950px) { + .secondary-equipment-layout { grid-template-columns: 1fr; } + .virtual-list-panel { min-height: 240px; } + .virtual-list-empty { padding: 25px 18px; } + .video-stage { aspect-ratio: 16 / 10; min-height: 440px; } + .video-empty-frame { padding-top: 180px; } +} +@media (max-width: 650px) { + .virtual-unselected-header { gap: 12px; padding: 17px; flex-wrap: wrap; } + .virtual-unselected-header > .secondary-unavailable { margin-left: 60px; } + .virtual-position-grid { grid-template-columns: 1fr; } + .virtual-health-row { flex-wrap: wrap; } + .virtual-health-track { min-width: 70px; } + .video-stage { min-height: 480px; } + .video-metrics-overlay { width: 200px; right: 12px; top: 12px; } + .video-empty-frame { padding-top: 250px; } + .video-control-bar { gap: 12px; padding: 0 12px; } + .video-volume-track, .video-resolution { display: none; } + .video-control-bar button:last-child { margin-left: auto; } + .node-network-grid { grid-template-columns: 1fr; } +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..ac16881 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,10 @@ +@import './secondary.css'; +:root{font-family:'Microsoft YaHei','Segoe UI',sans-serif;color:#263e5b;background:#f4f6fa;font-synthesis:none;font-size:14px;font-weight:400;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased} +*{box-sizing:border-box}body{margin:0;min-width:360px}button,input,select{font:inherit}button,a,input,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.45}a{color:inherit;text-decoration:none}h1,h2,h3,p{margin:0}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,tr:focus-visible{outline:2px solid #276cc0;outline-offset:3px}button{border:0}input,select{color:#314d70}input::placeholder{color:#8d9bb1}svg{flex-shrink:0}time,.mono{font-variant-numeric:tabular-nums}.muted{color:#8797ac;font-size:12px} +.sidebar{width:246px;background:#172c46;color:#becddd;position:fixed;inset:0 auto 0 0;display:flex;flex-direction:column;padding:27px 13px 24px;z-index:10}.brand{display:flex;align-items:center;gap:12px;padding:0 12px 30px;color:#fff;position:relative;white-space:nowrap}.brand img{width:43px;height:43px}.brand strong{font-size:18px;font-weight:600}.collapse-toggle{display:grid;place-items:center;width:23px;height:25px;background:#ffffff06;color:#9eb3cc;border:1px solid #ffffff20;border-radius:4px;position:absolute;right:0;bottom:9px}.sidebar nav{padding-top:16px}.nav-link{display:flex;align-items:center;gap:20px;padding:16px 16px;margin:6px 0 12px;border-radius:7px;font-size:15px;white-space:nowrap;min-height:54px}.nav-link:hover{background:#ffffff0b}.nav-link.active{background:#2c5d97;color:#fff;font-weight:600}.nav-link svg{color:#a5bdd6}.nav-link.active svg{color:#f2f8ff}.sidebar-foot{margin-top:auto;display:flex;gap:19px;align-items:center;border-top:1px solid #ffffff14;padding:25px 17px 3px;font-size:14px;color:#bdcede}.main-shell{margin-left:246px;min-height:100vh;display:flex;flex-direction:column;min-width:0}.topbar{height:64px;min-height:64px;padding:0 29px;display:flex;align-items:center;justify-content:space-between;gap:18px;background:#fff;border-bottom:1px solid #e1e8f0;font-size:13px}.breadcrumb{display:flex;align-items:center;gap:13px;color:#7e90a9;white-space:nowrap}.breadcrumb strong{color:#344c6b;font-weight:600}.service-state{display:flex;gap:22px;align-items:center;white-space:nowrap}.service-state time{border-left:1px solid #d7e1ed;padding-left:22px;color:#6e84a3;font-size:12px}main{padding:28px 29px 0;flex:1;min-width:0;max-width:1900px;width:100%;margin:auto}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:22px;min-height:70px}.page-heading h1{font-size:27px;line-height:1.35;font-weight:650;color:#1f3653;letter-spacing:.2px}.page-heading p{margin-top:8px;color:#7b8fa8;line-height:1.5;font-size:14px}.heading-actions{display:flex;gap:12px;align-items:center;justify-content:flex-end}.refresh-button{display:flex;gap:7px;align-items:center;padding:9px 12px;min-height:38px;background:#fff;border:1px solid #dce5ef;border-radius:5px;color:#6b80a0;font-size:12px}.refresh-button:hover{border-color:#276cc0;color:#276cc0}.context-select{display:flex;align-items:center;gap:10px;color:#7a8ca4;font-size:12px}.context-select select{max-width:260px;border:1px solid #d9e3ef;border-radius:5px;background:#fff;padding:10px 28px 10px 11px;min-width:120px}.panel{background:#fff;border:1px solid #dfe7f1;border-radius:7px;min-width:0;overflow:hidden}.panel-title{padding:19px 20px 15px;display:flex;align-items:center;justify-content:space-between;gap:15px;min-height:56px}.panel-title h2{font-size:17px;color:#253f5f;font-weight:600}.panel-extra{font-size:12px;color:#8193ab}.equipment-layout{display:grid;grid-template-columns:290px minmax(0,1fr);gap:12px;min-height:650px;align-items:stretch}.equipment-list{min-height:650px}.equipment-list .panel-title{padding-bottom:14px}.search-input{display:flex;align-items:center;gap:10px;border:1px solid #cfdbeb;border-radius:5px;min-height:42px;padding:0 12px;margin:4px 20px 20px;color:#708baa;min-width:0}.search-input input{background:transparent;width:100%;min-width:0;border:0;outline:none!important;padding:10px 0;font-size:13px}.search-input:focus-within{border-color:#4384c7;box-shadow:0 0 0 2px #276cc014}.equipment-rows{border-top:1px solid #e5ecf4}.equipment-row{display:flex;align-items:center;gap:14px;min-height:92px;width:100%;padding:18px 18px 18px 23px;background:#fff;color:#2873bf;text-align:left;border-bottom:1px solid #e3ebf4;border-left:3px solid transparent}.equipment-row:hover{background:#f6faff}.equipment-row.selected{background:#eaf3ff;border-left-color:#3284df}.equipment-row-copy{min-width:0;flex:1}.equipment-row-copy strong{display:block;font-weight:600;color:#273f61;font-size:15px;overflow-wrap:anywhere}.equipment-row-copy small{display:block;color:#8198b4;font-size:12px;margin-top:5px;overflow-wrap:anywhere}.equipment-row .status{font-size:11px;gap:5px;max-width:86px;white-space:normal;line-height:1.45}.status{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;line-height:1.45}.status i{width:7px;height:7px;flex:none;background:currentColor;border-radius:50%}.status-good{color:#14965c}.status-warn{color:#bf881c}.status-bad{color:#c25b48}.status-muted{color:#879bb6}.detail-stack{display:flex;flex-direction:column;gap:12px;min-width:0}.equipment-header{display:flex;align-items:center;justify-content:space-between;padding:24px 25px;gap:18px;min-height:106px}.equipment-heading{display:flex;align-items:center;gap:23px;min-width:0;color:#2778c2}.equipment-heading h2{font-size:24px;color:#213b60;font-weight:600;overflow-wrap:anywhere}.equipment-heading p{font-size:12px;color:#8297b4;margin-top:7px;line-height:1.5}.equipment-heading p span{margin:0 11px}.equipment-heading>.status{background:#eaf7f0;padding:5px 9px;border-radius:3px;margin-left:3px}.equipment-updated{font-size:12px;color:#8b9db5;white-space:nowrap}.equipment-updated time{color:#607c9f;margin-left:9px}.table-scroll{overflow:auto;max-width:100%}.data-table{border-collapse:collapse;width:100%;text-align:left;font-size:13px}.data-table th{background:#f6f8fc;color:#7c91ad;font-size:12px;font-weight:500;padding:14px 19px;border-block:1px solid #e5ecf4;white-space:nowrap}.data-table td{border-bottom:1px solid #e5ecf4;padding:19px;vertical-align:middle;color:#426185;font-variant-numeric:tabular-nums}.data-table tr:last-child td{border-bottom:0}.data-table tr.selected{background:#f3f8ff}.data-table td strong{font-weight:500;color:#334f72}.data-table td small{display:block;font-size:11px;color:#8d9eb3;margin-top:5px}.receive-table tbody tr{cursor:pointer}.receive-table tbody tr:hover{background:#f6faff}.receive-table td.parameter-value{overflow-wrap:anywhere;min-width:130px;max-width:450px}.receive-table td:first-child{min-width:145px}.receive-table td time{white-space:nowrap}.value-details{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px 20px;margin:0;padding:14px 24px 25px}.value-details>div{min-width:0;border-left:1px solid #e2ebf6;padding-left:16px}.value-details dt{color:#8095b1;font-size:12px;margin-bottom:11px}.value-details dd{margin:0;font-size:16px;color:#2e4c73;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:12px;min-height:190px;padding:34px 24px;color:#94a9c0}.empty-state strong{font-size:15px;font-weight:500;color:#6d88a7}.empty-state p{max-width:450px;font-size:12px;line-height:1.8;color:#8c9fb6}.equipment-list .empty-state{min-height:240px;padding-inline:18px}.unselected-panel{min-height:650px;display:flex;align-items:center;justify-content:center}.unselected-panel .empty-state{min-height:400px}.sync-values{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px;padding:0 20px 22px}.sync-values>div{border:1px solid #e1eaf5;padding:17px 18px}.sync-values span{font-size:13px;color:#7289a8}.sync-values strong{display:block;font-size:27px;font-weight:600;margin-top:12px;color:#5d7ba0}.sync-values small{font-size:12px;font-weight:400}.sync-values p{margin-top:8px;font-size:11px;color:#91a3ba}.source-notice{display:flex;align-items:center;gap:8px;background:#fff7e8;border:1px solid #f2e3c5;color:#a27f3f;font-size:12px;padding:11px 15px;border-radius:5px;margin-bottom:16px;line-height:1.7}.app-footer{padding:23px 29px;font-size:11px;color:#92a5be;display:flex;align-items:center;justify-content:space-between;gap:12px}.nav-collapsed .sidebar{width:76px;padding-inline:10px}.nav-collapsed .main-shell{margin-left:76px}.nav-collapsed .brand{padding:0 6px 28px}.nav-collapsed .brand img{width:43px;height:43px}.nav-collapsed .brand strong,.nav-collapsed .nav-link span,.nav-collapsed .sidebar-foot span{display:none}.nav-collapsed .nav-link{padding:15px;justify-content:center}.nav-collapsed .sidebar-foot{padding:24px 17px 3px}.nav-collapsed .collapse-toggle{bottom:-4px;right:16px} +.brand img{background:#4b92d5;border-radius:10px;padding:5px}.sidebar nav{padding-top:3px}.secondary-equipment-layout{grid-template-columns:290px minmax(0,1fr);gap:12px}.video-unselected-header{padding:23px}.virtual-state-body{padding:0 20px 20px}.virtual-overview-panel>.secondary-table-scroll,.fusion-metrics-panel>.secondary-table-scroll,.node-page>.secondary-table-scroll{width:calc(100% - 40px);margin-inline:20px}.secondary-panel-toolbar{margin-inline:20px}.node-page>.node-network-panel{margin:24px 20px 0}.secondary-panel-note{margin:20px}.fusion-metrics-panel,.node-page{padding-bottom:18px} +@media(min-width:1550px){.equipment-layout,.secondary-equipment-layout{grid-template-columns:350px minmax(0,1fr)}.equipment-row{padding-block:22px}.equipment-row .status{font-size:12px}.equipment-header{min-height:111px}.equipment-list,.equipment-layout{min-height:730px}.nav-link{font-size:16px}.brand strong{font-size:19px}.page-heading h1{font-size:29px}.data-table{font-size:14px}} +@media(max-width:1280px){.sidebar{width:210px;padding-inline:10px}.main-shell{margin-left:210px}.brand{gap:9px;padding-inline:8px}.brand img{width:36px;height:36px}.brand strong{font-size:16px}.nav-link{font-size:13px;gap:15px;padding-inline:12px}.equipment-layout{grid-template-columns:250px minmax(0,1fr)}.equipment-row{gap:10px;padding-inline:14px}.equipment-row .status{font-size:10px;max-width:65px}.equipment-row-copy strong{font-size:13px}.equipment-row-copy small{font-size:11px}.equipment-heading{gap:15px}.equipment-heading h2{font-size:21px}.equipment-header{padding:20px;flex-wrap:wrap;gap:12px}.equipment-updated{width:100%;padding-left:61px}.topbar{padding-inline:22px}main{padding:24px 22px 0}.data-table td,.data-table th{padding-inline:14px}.service-state{gap:12px}.service-state time{padding-left:12px}.value-details{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:1000px){.service-state time{display:none}.topbar{gap:10px}.equipment-layout{grid-template-columns:220px minmax(0,1fr)}.equipment-row{flex-wrap:wrap;column-gap:9px;padding-block:14px;min-height:86px}.equipment-row .status{margin-left:35px;max-width:none;width:100%;font-size:10px}.equipment-list .panel-title h2{font-size:15px}.search-input{margin-inline:14px;padding-inline:9px}.equipment-heading>.status{margin-left:0}.equipment-heading{flex-wrap:wrap;gap:12px}.equipment-heading>svg{width:34px}.equipment-updated{padding-left:46px}.panel-title h2{font-size:15px}.heading-actions{flex-wrap:wrap}.context-select>span{display:none}.context-select select{max-width:180px}.refresh-button span{display:none}.sync-values{gap:9px;padding-inline:14px}.sync-values>div{padding:13px 10px}.sync-values span{font-size:11px}} +@media(max-width:800px){.sidebar{width:68px;padding-inline:8px}.main-shell{margin-left:68px}.brand{padding:0 7px 30px}.brand img{width:37px;height:37px}.brand strong,.nav-link span,.sidebar-foot span{display:none}.collapse-toggle{display:none}.nav-link{padding:14px;justify-content:center}.sidebar-foot{justify-content:center;padding-inline:0}.equipment-layout{grid-template-columns:210px minmax(0,1fr)}main{padding:20px 17px 0}.topbar{padding-inline:17px}.page-heading h1{font-size:24px}.page-heading p{font-size:12px}.page-heading{align-items:flex-start;gap:12px}.equipment-heading h2{font-size:19px}.equipment-heading p{font-size:11px}.equipment-header{padding:18px 16px}.value-details{gap:20px 12px;padding-inline:15px}.value-details dd{font-size:14px}} +@media(max-width:650px){.equipment-layout{grid-template-columns:1fr;min-height:0}.equipment-list{min-height:0}.equipment-list .equipment-rows{display:flex;overflow:auto}.equipment-row{min-width:220px;width:220px;border-left:0;border-top:3px solid transparent}.equipment-row.selected{border-top-color:#3284df}.equipment-list .empty-state{min-height:140px}.unselected-panel{min-height:320px}.page-heading{flex-direction:column}.heading-actions{width:100%;justify-content:flex-end}.context-select{flex:1}.context-select select{max-width:100%;width:100%}.service-state{display:none}.breadcrumb{font-size:12px}.topbar{height:56px;min-height:56px}.detail-stack{gap:12px}.equipment-updated{padding-left:0}.sync-values{grid-template-columns:1fr}.sync-values>div{display:flex;align-items:center;justify-content:space-between;gap:10px}.sync-values strong,.sync-values p{margin:0}.app-footer{padding:20px 17px}.app-footer span:first-child{display:none}.nav-collapsed .sidebar{width:68px}.nav-collapsed .main-shell{margin-left:68px}} diff --git a/frontend/src/ui.jsx b/frontend/src/ui.jsx new file mode 100644 index 0000000..48e1954 --- /dev/null +++ b/frontend/src/ui.jsx @@ -0,0 +1,39 @@ +import React, {useMemo, useState} from 'react'; +import {Search, Truck, Plane, Inbox} from 'lucide-react'; + +const labels = { + RECEIVING:'接收正常',GOOD:'正常',CONNECTED:'服务连接正常',STALE:'数据已过期', + NOT_RECEIVED:'未接收',MISSING:'未采到',ERROR:'数据异常',WAITING:'等待数据同步', + INVALID_TIME:'时间异常',INVALID_VALUE:'数值异常',AMBIGUOUS:'来源不明确', + online:'在线',offline:'离线',UNKNOWN:'未接入', +}; +export function Status({state='UNKNOWN', label}) { + const tone = ['RECEIVING','GOOD','CONNECTED','online'].includes(state) ? 'good' : ['STALE','INVALID_TIME','AMBIGUOUS'].includes(state) ? 'warn' : ['ERROR','INVALID_VALUE'].includes(state) ? 'bad' : 'muted'; + return {label || labels[state] || labels.UNKNOWN}; +} +export function Panel({title,children,extra,className=''}) { + return
{(title||extra)&&

{title}

{extra&&
{extra}
}
}{children}
; +} +export function Empty({title='暂无数据',description,icon:Icon=Inbox}) { + return
{title}{description&&

{description}

}
; +} +export function EquipmentIcon({equipment,size=25}) { + const air=/无人机|航空|飞行|UAV/i.test(`${equipment?.name||''} ${equipment?.code||''} ${equipment?.type||''}`); + const Icon=air?Plane:Truck; + return ; +} +export function EquipmentList({equipments=[],selectedId,onSelect,title='实装列表',emptyText='暂无已绑定装备'}) { + const [search,setSearch]=useState(''); + const filtered=useMemo(()=>equipments.filter(item=>`${item.name||''} ${item.code||''}`.toLowerCase().includes(search.trim().toLowerCase())),[equipments,search]); + return
{filtered.map(item=>)}
{!filtered.length&&}
; +} +export function EquipmentHeader({equipment,statusLabel}) { + if(!equipment)return null; + return

{equipment.name||equipment.code||'未命名装备'}

{equipment.code||'—'}·{equipment.type||'实装装备'}

最近接收
; +} +export function formatTime(value) { + if(value===null||value===undefined||value==='')return '—'; + const date=new Date(value); + if(Number.isNaN(date.getTime()))return '—'; + return new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}).format(date); +} diff --git a/frontend/tests/data.test.mjs b/frontend/tests/data.test.mjs new file mode 100644 index 0000000..681f2dd --- /dev/null +++ b/frontend/tests/data.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {realEquipment,displayValue,effectiveEquipment,collectionState} from '../src/data.mjs'; + +test('real zero is preserved and missing values never become zero',()=>{ + assert.equal(displayValue(0,'m'),'0 m'); + assert.equal(displayValue(null,'m'),'—'); + assert.equal(displayValue(false),'否'); + assert.equal(displayValue({value:15}),'—'); +}); +test('simulated samples are excluded without borrowing another equipment value',()=>{ + const rows=realEquipment({equipment:[{id:'real',items:[{key:'a',value:0,quality:'GOOD'},{key:'b',value:15,quality:'SIMULATED'},{key:'c',value:7,sourceName:'模拟采集'},{key:'d',value:8,simulated:true}]},{id:'demo',sourceType:'SIMULATED',items:[]}]}); + assert.equal(rows.length,1); + assert.deepEqual(rows[0].items.map(item=>item.key),['a']); + assert.equal(rows[0].items[0].value,0); +}); +test('bridge failure stops showing last equipment data as fresh',()=>{ + assert.equal(collectionState({state:'CONNECTED'},true),'ERROR'); + assert.equal(effectiveEquipment({id:'e',state:'RECEIVING',lastReceivedAt:123},'ERROR').state,'STALE'); + assert.equal(effectiveEquipment({id:'e',state:'RECEIVING',lastReceivedAt:null},'STALE').state,'NOT_RECEIVED'); +}); diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs new file mode 100644 index 0000000..a9077e4 --- /dev/null +++ b/frontend/vite.config.mjs @@ -0,0 +1,9 @@ +import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + base: '/console/', + plugins: [react()], + build: {outDir: '../src/main/resources/static/console', emptyOutDir: true}, + server: {host: '127.0.0.1', port: 4178, strictPort: true}, +}); diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..eceed87 --- /dev/null +++ b/pom.xml @@ -0,0 +1,14 @@ + + 4.0.0 + org.springframework.bootspring-boot-starter-parent3.5.11 + com.xtrhxtrh-services1.0.0 + 17 + + org.springframework.bootspring-boot-starter-web + com.h2databaseh2runtime + org.jmdnsjmdns3.5.12 + org.bouncycastlebcprov-jdk18on1.80 + org.springframework.bootspring-boot-starter-testtest + + org.springframework.bootspring-boot-maven-plugin + diff --git a/scripts/Activate-ConsoleCollection.ps1 b/scripts/Activate-ConsoleCollection.ps1 new file mode 100644 index 0000000..f32fc19 --- /dev/null +++ b/scripts/Activate-ConsoleCollection.ps1 @@ -0,0 +1,43 @@ +[CmdletBinding()] +param([switch]$RestartRunningServices) +$ErrorActionPreference='Stop' +if(-not $RestartRunningServices){throw '此步骤会短暂停止PXHJ采集并刷新融合总控。需明确确认后使用 -RestartRunningServices。'} +$projectRoot=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$pxhjRoot='E:\pxhj' +$coordinatorRoot=Join-Path $projectRoot 'runtime/coordinator' +$sourcePxhj=Join-Path $projectRoot 'runtime/console-pxhj-build/pxhj-server-0.1.0.jar' +$targetPxhj=Join-Path $pxhjRoot 'backend/target/pxhj-server-0.1.0.jar' +$sourceXtrh=Join-Path $projectRoot 'target/xtrh-services-1.0.0.jar' +foreach($file in @($sourcePxhj,$sourceXtrh)){if(-not(Test-Path -LiteralPath $file)){throw '缺少已验证构建产物。'}} +$listeners=@(Get-NetTCPConnection -State Listen -LocalPort 8088 -ErrorAction SilentlyContinue) +$backendPid=0 +$backendTicks=0 +if($listeners.Count){ + $backendPid=[int]$listeners[0].OwningProcess + $process=Get-Process -Id $backendPid + $backendTicks=$process.StartTime.ToUniversalTime().Ticks + $command=Get-CimInstance Win32_Process -Filter ('ProcessId='+$backendPid) + $jar=[regex]::Match([string]$command.CommandLine,'(?i)-jar\s+(?:"(?

[^"]+)"|(?

\S+))').Groups['p'].Value + if([IO.Path]::GetFullPath($jar) -ne [IO.Path]::GetFullPath($targetPxhj)){throw '8088不是目标PXHJ进程,未停止任何程序。'} +} +$backup=Join-Path $projectRoot ('runtime/console-activation-backup-'+(Get-Date -Format 'yyyyMMdd-HHmmss')) +New-Item -ItemType Directory -Force -Path $backup | Out-Null +Copy-Item -LiteralPath $targetPxhj -Destination (Join-Path $backup 'pxhj-server.jar') +Copy-Item -LiteralPath (Join-Path $coordinatorRoot 'xtrh-services.jar') -Destination (Join-Path $backup 'xtrh-services.jar') +if($backendPid){ + $current=Get-Process -Id $backendPid -ErrorAction Stop + if($current.StartTime.ToUniversalTime().Ticks -ne $backendTicks){throw 'PXHJ进程身份已变化,未停止。'} + Stop-Process -Id $backendPid + Wait-Process -Id $backendPid -Timeout 15 -ErrorAction SilentlyContinue +} +& (Join-Path $projectRoot 'scripts/Stop-Xtrh.ps1') -InstallRoot $coordinatorRoot +Copy-Item -LiteralPath $sourcePxhj -Destination $targetPxhj +Copy-Item -LiteralPath $sourceXtrh -Destination (Join-Path $coordinatorRoot 'xtrh-services.jar') +foreach($pair in @(@{source=$sourcePxhj;target=$targetPxhj},@{source=$sourceXtrh;target=(Join-Path $coordinatorRoot 'xtrh-services.jar')})){ + if((Get-FileHash -LiteralPath $pair.source).Hash -ne (Get-FileHash -LiteralPath $pair.target).Hash){throw '加载前JAR校验失败。'} +} +$env:JAVA_HOME='C:\Program Files\Java\jdk-25.0.2' +$env:PATH=$env:JAVA_HOME+'\bin;'+$env:PATH +& (Join-Path $projectRoot 'scripts/Start-Xtrh.ps1') -InstallRoot $coordinatorRoot -JavaHome $env:JAVA_HOME +& (Join-Path $pxhjRoot 'scripts/start-dev.ps1') -SkipBuild -BackendOnly +Write-Output ('新页面和真实采集桥接已加载;原包备份:'+$backup) diff --git a/scripts/Activate-EmbeddedImageRoute.py b/scripts/Activate-EmbeddedImageRoute.py new file mode 100644 index 0000000..e55c818 --- /dev/null +++ b/scripts/Activate-EmbeddedImageRoute.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""核验并切换已构建图片协商候选;默认预览,--apply执行既已授权的板端切换。""" +import argparse +import contextlib +import importlib.util +import io +import json +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess +import sys +import time +import uuid + + +# 操作者负责SSH接入,此工具没有网络配置、口令或自动连接行为。 +loader = importlib.util.spec_from_file_location("collector_stage", Path(__file__).with_name("Stage-EmbeddedImageRoute.py")) +helper = importlib.util.module_from_spec(loader) +loader.loader.exec_module(helper) + + +def test_result(path, minimum, optional_interop=False): + # 不根据built布尔值省略原始QtTest结果;失败或其他跳过不能进入现网。 + text = path.read_text(encoding="utf-8") + result = re.search(r"Totals:\s+(\d+) passed,\s+(\d+) failed,\s+(\d+) skipped", text) + if not result: + raise RuntimeError("QtTest result summary is missing: " + path.name) + passed, failed, skipped = map(int, result.groups()) + if passed < minimum or failed or skipped > (1 if optional_interop else 0): + raise RuntimeError("QtTest results did not meet deployment requirements: " + path.name) + # ARM没有Windows生成的Java夹具时仅允许这一项跳过,原图/租约等业务不能跳过。 + if skipped and "javaAndCppWireCodecInteroperateWithRealEncryptedFrames" not in text: + raise RuntimeError("Unexpected skipped test: " + path.name) + return {"passed": passed, "failed": failed, "skipped": skipped} + + +def preflight(args): + root, directory, record = helper.stage_record(args) + if not record.get("built"): + raise RuntimeError("Full ARM candidate build has not passed") + # 先确认在用版本从stage后没有被别的部署修改,完整核验在停止进程前完成。 + with contextlib.redirect_stdout(io.StringIO()): + helper.verify(args) + # 源码/pro也必须与完整构建时的记录一致,避免把未构建的改动一起装入现网。 + checks = dict(record["candidatePatchHashes"]) + checks["px_Collect.pro"] = record["candidateProjectSha256"] + checks["bin/px_JKGL"] = record["candidateBinarySha256"] + for relative, checksum in checks.items(): + candidate = directory / "source" / relative + # 只能从本stage取候选,路径和内容均受校验,不能替换为任意二进制文件。 + if candidate.is_symlink() or not candidate.resolve().is_relative_to(directory) \ + or not candidate.is_file() or helper.digest(candidate) != checksum: + raise RuntimeError("Candidate hash or path mismatch: " + relative) + config = directory / "candidate-config.ini" + if config.is_symlink() or helper.digest(config) != record["candidateConfigSha256"]: + raise RuntimeError("Candidate configuration hash mismatch") + # 这里只采信stage内本次两套结果,不借用历史Windows或旧板的测试报告。 + tests = {"logging": test_result(directory / "logging-tests.txt", 9), + "imageRoute": test_result(directory / "image_route-tests.txt", 8, True)} + launcher = root / "start-network-simulation.sh" + # 保留当前启动器:它维护串口PTY和collector.pid,不能用历史拷贝替换。 + if launcher.is_symlink() or not launcher.is_file() or not os.access(str(launcher), os.X_OK): + raise RuntimeError("Original network simulation launcher is invalid") + if re.search(r">>\s*logs/run\.log", launcher.read_text(encoding="utf-8")): + # 已有内置日志不能回退到旧的无限run.log重定向策略。 + raise RuntimeError("Original launcher still contains legacy unlimited run.log redirection") + # 配置不是源码白名单的一项,但目的路径同样必须留在当前collector根目录内。 + for relative in checks.keys() | {"config.ini"}: + destination = root / relative + if destination.is_symlink() or not destination.parent.resolve().is_relative_to(root): + raise RuntimeError("Unsafe live destination: " + relative) + return root, directory, record, checks, tests, helper.digest(launcher) + + +def stop_collector(root): + # 只停止真实exe属于该工程的采集程序;串口桥、平台与其他同名进程保留。 + original = helper.process_ids(root) + for pid in original: + os.kill(pid, signal.SIGTERM) + # 使用单调期限,让系统校时不会延长停止等待或误触发提前强制退出。 + deadline = time.monotonic() + 5 + while set(original) & set(helper.process_ids(root)) and time.monotonic() < deadline: + time.sleep(0.1) + for pid in set(original) & set(helper.process_ids(root)): + os.kill(pid, signal.SIGKILL) + time.sleep(0.1) + if helper.process_ids(root): + raise RuntimeError("Collector has another restart owner; no files were replaced") + + +def install(source, destination): + # 同目录临时文件+原子替换,避免正在部署时出现截断二进制或半写INI。 + # copy2保留已校验候选的权限;生产父目录与路径已在preflight核对。 + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(destination.name + ".image-route-" + uuid.uuid4().hex) + try: + shutil.copy2(source, temporary) + os.replace(str(temporary), str(destination)) + finally: + if temporary.exists(): + # 只清理本次创建的单个临时文件,不递归处理任何目录。 + temporary.unlink() + + +def launch(root, directory, launcher_hash): + launcher = root / "start-network-simulation.sh" + if helper.digest(launcher) != launcher_hash: + raise RuntimeError("Original launcher changed during activation") + # 启动器原样运行,自行管理串口桥与collector.pid;不修改原配置或网络。 + with (directory / "activation-launcher.log").open("wb") as output: + subprocess.run([str(launcher)], cwd=str(root), stdin=subprocess.DEVNULL, stdout=output, + stderr=subprocess.STDOUT, check=True, timeout=15) + + +def healthy(root, since, previous_status, timeout): + status_path = root / "runtime/native-log-status.json" + deadline = time.monotonic() + timeout + # 每次健康失效重置稳定窗口,短暂启动后立即崩溃不会算成功。 + stable_since = None + while time.monotonic() < deadline: + pids = helper.process_ids(root) + try: + stamp = status_path.stat() + status = json.loads(status_path.read_text(encoding="utf-8")) + # 必须是本次进程新写且持续刷新的状态,不能把旧启动遗留JSON当成健康。 + valid = pids and stamp.st_mtime_ns != previous_status and stamp.st_mtime >= since - 0.5 \ + and -2 <= time.time() - stamp.st_mtime <= 3 \ + and status.get("owner") == "px_JKGL" and status.get("limitBytes") == 1024 ** 3 \ + and status.get("failedWrites") == 0 and status.get("withinLimit") is True + except (FileNotFoundError, json.JSONDecodeError): + valid = False + if valid: + if stable_since is None: + stable_since = time.monotonic() + # 观察至少两秒稳定状态,不仅依据启动脚本退出码或瞬时PID。 + if time.monotonic() - stable_since >= 2: + return {"collectorPids": pids, "nativeLogging": {name: status[name] + for name in ("owner", "limitBytes", "failedWrites", "withinLimit")}} + else: + stable_since = None + time.sleep(0.25) + # 图片接收/迁移是后续真实业务验证,此处不把进程健康当作图片已经到达。 + raise RuntimeError("Candidate collector did not establish stable native log health") + + +def activate(args): + root, directory, record, checks, tests, launcher_hash = preflight(args) + if not args.apply: + # 默认仅给核验结果,不发送信号、不启动程序、不安装源码或配置。 + print(json.dumps({"activationReady": True, "apply": False, "stage": str(directory), + "candidateBinarySha256": record["candidateBinarySha256"], "tests": tests})) + return + # 原日志状态文件保持原样,靠新写入校验;无需删除历史状态或日志文件。 + status_path = root / "runtime/native-log-status.json" + old_status = status_path.stat().st_mtime_ns if status_path.exists() else None + changed = False + try: + stop_collector(root) + # changed在首次写入前置位,任何部分安装失败都进入对应stage自动回退。 + changed = True + for relative in checks: + install(directory / "source" / relative, root / relative) + install(directory / "candidate-config.ini", root / "config.ini") + # 状态文件写入时间与启动前时刻同时比较,防止沿用上次进程的健康JSON。 + since = time.time() + launch(root, directory, launcher_hash) + result = healthy(root, since, old_status, args.health_timeout) + result.update({"activated": True, "stage": str(directory), "tests": tests, + "candidateBinarySha256": record["candidateBinarySha256"]}) + # 激活结果不包含配置正文或密钥;原图及迁移业务验收仍需PXHJ另行验证。 + helper.write_json(directory / "activation-result.json", result) + print(json.dumps(result)) + except BaseException as failure: + # 安装失败与业务切换失败都留下独立记录,root可辨别回退是否真正成功。 + restored = False + try: + if changed: + # 回退工具先校验候选/原文件归属,不会覆盖无关后续变更。 + command = [sys.executable, str(Path(__file__).with_name("Rollback-EmbeddedImageRoute.py")), + "--root", str(root), "--stage", str(directory), "--launcher", "start-network-simulation.sh", "--apply"] + with (directory / "activation-rollback.log").open("wb") as output: + subprocess.run(command, stdout=output, stderr=subprocess.STDOUT, check=True, timeout=45) + restored = True + elif not helper.process_ids(root): + # 尚未写文件时只启动原程序,不能为预检失败修改任何生产文件。 + launch(root, directory, launcher_hash) + restored = True + except BaseException: + # 保留独立回退日志供root处理,不能把回退失败伪装成成功。 + restored = False + helper.write_json(directory / "activation-error.json", {"activationFailed": True, + "rollbackSucceeded": restored, "reason": str(failure)}) + raise RuntimeError("Activation failed; rollbackSucceeded=" + str(restored) + + "; inspect private stage activation logs") from None + + +def main(): + # --apply承接用户已经给出的部署授权,默认预览便于操作者核对具体stage。 + # 不需要再次登录设备或改变SSH策略,所有执行仍在已确认板端本地进行。 + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path("/home/Tronlong/SY26-PX-Collect")) + parser.add_argument("--stage", type=Path, required=True) + parser.add_argument("--apply", action="store_true") + parser.add_argument("--health-timeout", type=int, default=15) + args = parser.parse_args() + if not 3 <= args.health_timeout <= 60: + parser.error("health-timeout must be between 3 and 60 seconds") + activate(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/Apply-CollectionConsoleBridge.ps1 b/scripts/Apply-CollectionConsoleBridge.ps1 new file mode 100644 index 0000000..c6d5eaa --- /dev/null +++ b/scripts/Apply-CollectionConsoleBridge.ps1 @@ -0,0 +1,31 @@ +[CmdletBinding()] +param([string]$PxhjRoot = 'E:\pxhj') +$ErrorActionPreference = 'Stop' +$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$staging = Join-Path $projectRoot 'runtime/console-pxhj-staging' +$manifest = Get-Content -LiteralPath (Join-Path $staging 'baseline-manifest.json') -Raw | ConvertFrom-Json +$targetRoot = [IO.Path]::GetFullPath($PxhjRoot).TrimEnd('\') +$backupRoot = Join-Path $projectRoot ('runtime/console-bridge-backup-' + (Get-Date -Format 'yyyyMMdd-HHmmss')) +foreach ($entry in $manifest.files) { + $target = [IO.Path]::GetFullPath((Join-Path $targetRoot $entry.path)) + if (-not $target.StartsWith($targetRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { throw '补丁路径超出目标项目。' } + $staged = Join-Path $staging $entry.path + if ((Get-FileHash -LiteralPath $staged -Algorithm SHA256).Hash -ne $entry.sha256) { throw ('补丁文件校验失败:' + $entry.path) } + if (Test-Path -LiteralPath $target) { + $currentHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash + $baseline = @($manifest.modifiedBaseline | Where-Object path -eq $entry.path) + if ($currentHash -ne $entry.sha256 -and ($baseline.Count -eq 0 -or $currentHash -ne $baseline[0].sha256)) { throw ('目标文件已变化,未覆盖:' + $entry.path) } + } +} +foreach ($entry in $manifest.files) { + $target = Join-Path $targetRoot $entry.path + if (Test-Path -LiteralPath $target) { + $backup = Join-Path $backupRoot $entry.path + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $backup) | Out-Null + Copy-Item -LiteralPath $target -Destination $backup + } + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null + Copy-Item -LiteralPath (Join-Path $staging $entry.path) -Destination $target +} +Write-Output ('采集桥接源码已应用。备份目录:' + $backupRoot) +Write-Output '运行中的后端不会自动加载新源码。' diff --git a/scripts/Build-Console.ps1 b/scripts/Build-Console.ps1 new file mode 100644 index 0000000..4450bc8 --- /dev/null +++ b/scripts/Build-Console.ps1 @@ -0,0 +1,20 @@ +[CmdletBinding()] +param([string]$NodePath) +$ErrorActionPreference = 'Stop' +$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$frontendRoot = Join-Path $projectRoot 'frontend' +if (-not $NodePath) { + $nodeCommand = Get-Command node.exe -ErrorAction SilentlyContinue + if ($nodeCommand) { $NodePath = $nodeCommand.Source } +} +if (-not $NodePath -or -not (Test-Path -LiteralPath $NodePath)) { throw '请安装 Node.js 20.19 或更新版本,或使用 -NodePath 指定 node.exe。' } +$vite = Join-Path $frontendRoot 'node_modules/vite/bin/vite.js' +if (-not (Test-Path -LiteralPath $vite)) { throw '缺少页面构建依赖。请先在 frontend 目录运行 pnpm install 或 npm install。' } +Push-Location $frontendRoot +try { + & $NodePath '--test' 'tests/data.test.mjs' + if ($LASTEXITCODE -ne 0) { throw '页面数据规则测试失败。' } + & $NodePath $vite 'build' + if ($LASTEXITCODE -ne 0) { throw '页面构建失败。' } +} finally { Pop-Location } +Write-Output '系统融合软件页面已构建到原生 JAR 静态资源目录。' diff --git a/scripts/Build-Xtrh.ps1 b/scripts/Build-Xtrh.ps1 new file mode 100644 index 0000000..dfc2171 --- /dev/null +++ b/scripts/Build-Xtrh.ps1 @@ -0,0 +1,18 @@ +[CmdletBinding()] +param([string]$JavaHome, [string]$MavenPath, [string]$NodePath, [switch]$SkipFrontend) +. (Join-Path $PSScriptRoot 'Common.ps1') +$projectRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$java = Get-XtrhJava -JavaHome $JavaHome +$env:JAVA_HOME = Split-Path -Parent (Split-Path -Parent $java) +if (-not $MavenPath) { + $maven = Get-Command mvn.cmd -ErrorAction SilentlyContinue + if ($maven) { $MavenPath = $maven.Source } +} +if (-not $MavenPath -or -not (Test-Path -LiteralPath $MavenPath)) { throw '请通过 -MavenPath 指定 mvn.cmd,或将 Maven 加入 PATH。' } +if (-not $SkipFrontend) { & (Join-Path $PSScriptRoot 'Build-Console.ps1') -NodePath $NodePath } +Push-Location $projectRoot +try { + & $MavenPath '-B' 'verify' + if ($LASTEXITCODE -ne 0) { throw 'Java 构建或测试失败。' } +} finally { Pop-Location } +Write-Output ('构建完成:' + (Join-Path $projectRoot 'target/xtrh-services-1.0.0.jar')) diff --git a/scripts/Common.ps1 b/scripts/Common.ps1 new file mode 100644 index 0000000..477f28f --- /dev/null +++ b/scripts/Common.ps1 @@ -0,0 +1,69 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-XtrhJava { + param([string]$JavaHome) + if (-not $JavaHome) { $JavaHome = $env:JAVA_HOME } + if ($JavaHome) { + $candidate = Join-Path $JavaHome 'bin/java.exe' + if (Test-Path -LiteralPath $candidate) { return (Resolve-Path -LiteralPath $candidate).Path } + throw 'JAVA_HOME 中没有找到 bin/java.exe。' + } + $command = Get-Command java.exe -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + throw '请先安装 Java 17 或更新版本,并设置 JAVA_HOME。' +} + +function Protect-XtrhPrivateFile { + param([Parameter(Mandatory)][string]$Path) + # 私有凭证只允许当前部署用户、SYSTEM 和本机管理员读取,避免共享目录泄露。 + # 读取现有对象后仅修改访问规则;不改所有者和审计规则,普通部署用户也可执行。 + $access = New-Object System.Security.AccessControl.FileSecurity($Path, [System.Security.AccessControl.AccessControlSections]::Access) + $access.SetAccessRuleProtection($true, $false) + foreach ($rule in $access.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])) { + [void]$access.RemoveAccessRuleSpecific($rule) + } + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + foreach ($sid in @($identity, (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')), (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')))) { + $access.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'Allow'))) + } + $file = Get-Item -LiteralPath $Path + if ($file.PSObject.Methods['SetAccessControl']) { $file.SetAccessControl($access) } + else { [System.IO.FileSystemAclExtensions]::SetAccessControl($file, $access) } +} + +function Write-XtrhText { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Content) + [System.IO.File]::WriteAllText($Path, $Content, (New-Object System.Text.UTF8Encoding($false))) +} + +function ConvertTo-XtrhPropertyValue { + param([Parameter(Mandatory)][string]$Value) + # Java properties 的字符编码可能因加载器不同而变化,中文节点名用 Unicode 转义保存。 + $builder = New-Object System.Text.StringBuilder + foreach ($character in $Value.ToCharArray()) { + if ([int]$character -gt 127) { [void]$builder.Append(('\u{0:x4}' -f [int]$character)) } + elseif ($character -eq '\') { [void]$builder.Append('\\') } + else { [void]$builder.Append($character) } + } + return $builder.ToString() +} + +function Get-XtrhOwnedProcess { + param([Parameter(Mandatory)][string]$InstallRoot, [Parameter(Mandatory)]$Record) + $process = Get-Process -Id ([int]$Record.pid) -ErrorAction SilentlyContinue + if (-not $process) { return $null } + $live = Get-CimInstance Win32_Process -Filter ('ProcessId=' + [int]$Record.pid) + $expectedJar = [System.IO.Path]::GetFullPath((Join-Path $InstallRoot 'xtrh-services.jar')) + $javaPath = [System.IO.Path]::GetFullPath([string]$Record.javaPath) + # PID 会被操作系统复用;同时校验启动时间、Java 路径和 -jar 参数才能结束进程。 + if (-not $live -or $process.StartTime.ToUniversalTime().Ticks -ne [long]$Record.startTicks -or + [System.IO.Path]::GetFullPath([string]$live.ExecutablePath) -ne $javaPath) { + throw '进程身份不匹配;已拒绝结束该 PID。' + } + $match = [regex]::Match([string]$live.CommandLine, '(?i)(?:^|\s)-jar\s+(?:"(?[^"]+)"|(?\S+))') + if (-not $match.Success -or [System.IO.Path]::GetFullPath($match.Groups['jar'].Value) -ne $expectedJar) { + throw '进程启动文件与本部署目录不一致;已拒绝结束该 PID。' + } + return $process +} diff --git a/scripts/Connect-Pxhj.ps1 b/scripts/Connect-Pxhj.ps1 new file mode 100644 index 0000000..d22f7a4 --- /dev/null +++ b/scripts/Connect-Pxhj.ps1 @@ -0,0 +1,32 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$PxhjRoot, + [Parameter(Mandatory)][string]$CredentialFile, + [string]$CoordinatorUrl = 'http://127.0.0.1:18100', + [string]$UdpTargetHost = '127.0.0.1', + [switch]$Force +) +. (Join-Path $PSScriptRoot 'Common.ps1') +$address = $null +if (-not [Uri]::TryCreate($CoordinatorUrl, [UriKind]::Absolute, [ref]$address) -or $address.Scheme -notin @('http', 'https') -or + -not $address.Host -or $address.UserInfo -or $address.Query -or $address.Fragment -or $address.AbsolutePath -ne '/') { + throw 'CoordinatorUrl 必须是完整的总控根地址。' +} +if ($UdpTargetHost -notmatch '^[a-zA-Z0-9_.:-]{1,200}$') { throw 'UdpTargetHost 必须是网关能够访问的 PXHJ 接收地址。' } +$root = [System.IO.Path]::GetFullPath($PxhjRoot) +if (-not (Test-Path -LiteralPath (Join-Path $root 'backend/src/main/resources/application.yml'))) { throw 'PxhjRoot 中没有找到 PXHJ 后端配置。' } +$credentialContent = Get-Content -LiteralPath $CredentialFile -Raw +$tokenMatch = [regex]::Match($credentialContent, '(?m)^fusion\.token=([^\r\n]{24,})\r?$') +$clusterMatch = [regex]::Match($credentialContent, '(?m)^fusion\.cluster-id=([a-zA-Z0-9_-]{1,64})\r?$') +if (-not $tokenMatch.Success -or -not $clusterMatch.Success) { throw '集群凭证文件格式不正确。' } +$configFolder = Join-Path $root 'config' +$privateFile = Join-Path $configFolder 'fusion.local.properties' +if ((Test-Path -LiteralPath $privateFile) -and -not $Force) { throw 'PXHJ 私有融合配置已存在;需要覆盖时显式使用 -Force。' } +[System.IO.Directory]::CreateDirectory($configFolder) | Out-Null +$content = @('app.fusion.enabled=true', ('app.fusion.coordinator-url=' + $CoordinatorUrl.TrimEnd('/')), + ('app.fusion.cluster-id=' + $clusterMatch.Groups[1].Value), ('app.fusion.token=' + $tokenMatch.Groups[1].Value), + ('app.fusion.udp-target-host=' + $UdpTargetHost)) -join "`n" +Write-XtrhText -Path $privateFile -Content ($content + "`n") +Protect-XtrhPrivateFile -Path $privateFile +# 只生成私有桥接配置,不擅自重启正在运行的 PXHJ 或修改采集器上传地址。 +Write-Output ('PXHJ 总控连接配置已写入:' + $privateFile + '。后端重启后加载。') diff --git a/scripts/Image-Verification.Common.ps1 b/scripts/Image-Verification.Common.ps1 new file mode 100644 index 0000000..9012da5 --- /dev/null +++ b/scripts/Image-Verification.Common.ps1 @@ -0,0 +1,89 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Initialize-XtrhImageVerification { + param([Parameter(Mandatory)][string]$CredentialFile) + # 私有文件只读入内存。日志、stdout、命令参数和联调记录都不携带凭证正文。 + $privateText = [IO.File]::ReadAllText([IO.Path]::GetFullPath($CredentialFile)) + $match = [regex]::Match($privateText, '(?m)^fusion\.token\s*=\s*([^\r\n]+)') + if (-not $match.Success -or $match.Groups[1].Value.Trim().Length -lt 16) { throw '集群私有凭证文件无效。' } + $script:imageVerificationCredential = $match.Groups[1].Value.Trim() + $handler = [Net.Http.HttpClientHandler]::new() + $handler.AllowAutoRedirect = $false + $handler.UseCookies = $false + $script:imageVerificationClient = [Net.Http.HttpClient]::new($handler, $true) + $script:imageVerificationClient.Timeout = [TimeSpan]::FromSeconds(5) + $script:imageVerificationClient.MaxResponseContentBufferSize = 8 * 1024 * 1024 +} + +function Get-XtrhImageDigest { + param([Parameter(Mandatory)][AllowEmptyCollection()][byte[]]$Bytes) + $sha = [Security.Cryptography.SHA256]::Create() + try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() } + finally { $sha.Dispose() } +} + +function Get-XtrhImageSignature { + param([Parameter(Mandatory)][string]$Canonical) + $mac = [Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($script:imageVerificationCredential)) + try { return [BitConverter]::ToString($mac.ComputeHash([Text.Encoding]::UTF8.GetBytes($Canonical))).Replace('-', '').ToLowerInvariant() } + finally { $mac.Dispose() } +} + +function Invoke-XtrhImageSigned { + param([Parameter(Mandatory)][string]$BaseUrl, [Parameter(Mandatory)][string]$Path, + [ValidateSet('GET','POST','PATCH')][string]$Method = 'GET', $Payload = $null) + $base = [Uri]$BaseUrl + if ($base.Scheme -notin @('http','https') -or $base.UserInfo -or $base.Query -or $base.Fragment -or $base.AbsolutePath -ne '/') { + throw '服务根地址格式无效。' + } + if ($Path -notmatch '^/' -or $Path -match '[\r\n]') { throw '签名接口路径无效。' } + # 空数组不能通过 PowerShell 表达式管道返回,否则会被展开为 null。 + [byte[]]$body = [byte[]]::new(0) + if ($null -ne $Payload) { $body = [Text.Encoding]::UTF8.GetBytes(($Payload | ConvertTo-Json -Depth 12 -Compress)) } + $nonce = [Guid]::NewGuid().ToString() + $time = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds().ToString() + $digest = Get-XtrhImageDigest -Bytes $body + $signature = Get-XtrhImageSignature -Canonical "request`n$Method`n$Path`n$time`n$nonce`n$digest" + $request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $BaseUrl.TrimEnd('/') + $Path) + try { + [void]$request.Headers.TryAddWithoutValidation('X-Xtrh-Time', $time) + [void]$request.Headers.TryAddWithoutValidation('X-Xtrh-Nonce', $nonce) + [void]$request.Headers.TryAddWithoutValidation('X-Xtrh-Signature', $signature) + $request.Content = [Net.Http.ByteArrayContent]::new($body) + $request.Content.Headers.ContentType = [Net.Http.Headers.MediaTypeHeaderValue]::new('application/json') + try { $response = $script:imageVerificationClient.SendAsync($request).GetAwaiter().GetResult() } + catch { throw '服务请求失败,未输出私有请求头或响应内容。' } + try { + [byte[]]$bytes = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() + $status = [int]$response.StatusCode + $expected = Get-XtrhImageSignature -Canonical ("response`n$nonce`n$status`n" + (Get-XtrhImageDigest -Bytes $bytes)) + $headers = [Collections.Generic.IEnumerable[string]]$null + if (-not $response.Headers.TryGetValues('X-Xtrh-Response-Signature', [ref]$headers)) { throw '响应缺少服务身份签名。' } + $actual = @($headers)[0] + # 固定长度与逐字节累计比较;JPEG 必须签其原始字节,不能按文本转换。 + $different = $actual.Length -bxor $expected.Length + if ($actual.Length -eq $expected.Length) { + for ($i = 0; $i -lt $expected.Length; $i++) { $different = $different -bor ([int]$actual[$i] -bxor [int]$expected[$i]) } + } + if ($different -ne 0) { throw '响应服务身份认证失败。' } + return [pscustomobject]@{ Status = $status; Bytes = $bytes; ContentType = [string]$response.Content.Headers.ContentType } + } finally { $response.Dispose() } + } finally { $request.Dispose() } +} + +function ConvertFrom-XtrhImageJson { + param([Parameter(Mandatory)]$Response) + if ($Response.Status -ne 200) { throw ('签名服务接口返回 HTTP ' + $Response.Status) } + return [Text.Encoding]::UTF8.GetString($Response.Bytes) | ConvertFrom-Json +} + +function Get-XtrhImageSnapshot { + param([Parameter(Mandatory)][string]$CoordinatorUrl) + return ConvertFrom-XtrhImageJson (Invoke-XtrhImageSigned -BaseUrl $CoordinatorUrl -Path '/control/snapshot') +} + +function Close-XtrhImageVerification { + if ($null -ne $script:imageVerificationClient) { $script:imageVerificationClient.Dispose() } + $script:imageVerificationCredential = '' +} diff --git a/scripts/Install-Xtrh.ps1 b/scripts/Install-Xtrh.ps1 new file mode 100644 index 0000000..4615b89 --- /dev/null +++ b/scripts/Install-Xtrh.ps1 @@ -0,0 +1,69 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('coordinator', 'gateway', 'worker')][string]$Role, + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)][string]$CredentialFile, + [string]$JarPath, + [int]$Port = 0, + [int]$Capacity = 2, + [int]$UdpPort = 19002, + [int]$ImagePort = 0, + [string]$NodeName = '', + [string]$AdvertisedHost = '', + [string]$DiscoveryAddress = '', + [string]$CoordinatorUrl = '', + [switch]$CoordinatorOnly +) +. (Join-Path $PSScriptRoot 'Common.ps1') +if ($CoordinatorOnly -and $Role -ne 'coordinator') { throw 'CoordinatorOnly 只能用于 coordinator 角色。' } +if ($Capacity -lt 0 -or $Capacity -gt 64) { throw 'Capacity 必须在 0 到 64 之间。' } +if ($UdpPort -lt 1025 -or $UdpPort -gt 65535) { throw 'UdpPort 必须在 1025 到 65535 之间。' } +if (-not $Port) { $Port = @{coordinator = 18100; gateway = 18110; worker = 18120}[$Role] } +if ($Port -lt 1025 -or $Port -gt 65535) { throw 'Port 必须在 1025 到 65535 之间。' } +if ($Role -ne 'gateway' -and -not $CoordinatorOnly) { + # 图片直达执行节点;同机多实例采用不同入口,避免争抢 PXHJ 原有 6020。 + if (-not $ImagePort) { $ImagePort = $Port + 1000 } + if ($ImagePort -lt 1025 -or $ImagePort -gt 65535) { throw 'ImagePort 必须在 1025 到 65535 之间。' } +} +foreach ($value in @($NodeName, $AdvertisedHost, $DiscoveryAddress, $CoordinatorUrl)) { + if ($value -match '[\r\n]') { throw '配置值不能包含换行。' } +} +if ($CoordinatorUrl -and $CoordinatorUrl -notmatch '^https?://[^\s/?#]+/?$') { throw 'CoordinatorUrl 必须为服务根地址。' } +if (-not $JarPath) { $JarPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'target/xtrh-services-1.0.0.jar' } +if (-not (Test-Path -LiteralPath $JarPath)) { throw '未找到构建产物,请先运行 Build-Xtrh.ps1。' } +if (-not (Test-Path -LiteralPath $CredentialFile)) { throw '未找到私有集群凭证文件。' } +$root = [System.IO.Path]::GetFullPath($InstallRoot) +$processFile = Join-Path $root 'service-process.json' +if (Test-Path -LiteralPath $processFile) { + $record = Get-Content -LiteralPath $processFile -Raw | ConvertFrom-Json + if (Get-XtrhOwnedProcess -InstallRoot $root -Record $record) { throw '部署实例正在运行,请先运行 Stop-Xtrh.ps1。' } +} +$credentialContent = Get-Content -LiteralPath $CredentialFile -Raw +if ($credentialContent -notmatch '(?m)^fusion\.token=.{24,}$' -or $credentialContent -notmatch '(?m)^fusion\.cluster-id=[a-zA-Z0-9_-]{1,64}\r?$') { + throw '集群凭证文件内容不符合格式。' +} +foreach ($folder in @($root, (Join-Path $root 'config'), (Join-Path $root 'logs'), (Join-Path $root 'data'), (Join-Path $root 'scripts'))) { + [System.IO.Directory]::CreateDirectory($folder) | Out-Null +} +Copy-Item -LiteralPath $JarPath -Destination (Join-Path $root 'xtrh-services.jar') -Force +$privateFile = Join-Path $root 'config/cluster.local.properties' +Write-XtrhText -Path $privateFile -Content $credentialContent +Protect-XtrhPrivateFile -Path $privateFile +$lines = @('server.address=0.0.0.0', "server.port=$Port", "fusion.role=$Role", 'fusion.data-dir=./data', + "fusion.capacity=$Capacity", "fusion.gateway-port=$UdpPort", 'fusion.discovery-enabled=true') +if ($Role -eq 'coordinator') { + # 总控默认在同一个进程中执行任务;只调度模式需显式关闭执行能力。 + $lines += ('fusion.coordinator-worker-enabled=' + (-not $CoordinatorOnly).ToString().ToLowerInvariant()) +} +if ($Role -ne 'gateway' -and -not $CoordinatorOnly) { $lines += "fusion.image-port=$ImagePort" } +if ($NodeName) { $lines += ('fusion.node-name=' + (ConvertTo-XtrhPropertyValue -Value $NodeName)) } +if ($AdvertisedHost) { $lines += "fusion.advertised-host=$AdvertisedHost" } +if ($DiscoveryAddress) { $lines += "fusion.discovery-address=$DiscoveryAddress" } +# 默认没有固定总控地址,节点在局域网自动发现;跨网段时才使用统一域名兜底。 +if ($CoordinatorUrl) { $lines += "fusion.coordinator-url=$CoordinatorUrl" } +Write-XtrhText -Path (Join-Path $root 'config/service.local.properties') -Content (($lines -join "`n") + "`n") +Write-XtrhText -Path (Join-Path $root 'deployment.json') -Content (@{role = $Role; port = $Port} | ConvertTo-Json) +foreach ($name in @('Common.ps1', 'Start-Xtrh.ps1', 'Stop-Xtrh.ps1', 'Register-XtrhStartup.ps1')) { + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination (Join-Path $root "scripts/$name") -Force +} +Write-Output ('部署完成:' + $root + ',角色:' + $Role + ',HTTP 端口:' + $Port) diff --git a/scripts/Measure-Comments.ps1 b/scripts/Measure-Comments.ps1 new file mode 100644 index 0000000..28b2227 --- /dev/null +++ b/scripts/Measure-Comments.ps1 @@ -0,0 +1,26 @@ +[CmdletBinding()] +param( + [string[]]$SourceRoots = @((Join-Path (Split-Path -Parent $PSScriptRoot) 'src/main/java')), + [double]$MinimumPercent = 20, + [switch]$Detailed +) +$ErrorActionPreference = 'Stop' +$rows = foreach ($root in $SourceRoots) { + foreach ($file in Get-ChildItem -LiteralPath $root -Filter '*.java' -File -Recurse) { + $lines = [IO.File]::ReadAllLines($file.FullName) + $nonEmpty = @($lines | Where-Object { $_.Trim().Length -gt 0 }) + $comments = @($nonEmpty | Where-Object { $_ -match '^\s*(//|/\*|\*|\*/)' }) + [pscustomobject]@{ + File = $file.FullName + NonEmpty = $nonEmpty.Count + CommentLines = $comments.Count + Percent = if ($nonEmpty.Count) { [Math]::Round(100.0 * $comments.Count / $nonEmpty.Count, 2) } else { 0 } + } + } +} +$total = ($rows | Measure-Object NonEmpty -Sum).Sum +$commentTotal = ($rows | Measure-Object CommentLines -Sum).Sum +$percent = if ($total) { 100.0 * $commentTotal / $total } else { 0 } +if ($Detailed) { $rows | Format-Table -AutoSize } +Write-Output ('Java 注释占比:{0:N2}%({1} 注释行 / {2} 非空源码行)' -f $percent, $commentTotal, $total) +if ($percent -lt $MinimumPercent) { throw ('尚未达到约定的 {0}% 注释行要求。' -f $MinimumPercent) } diff --git a/scripts/New-ClusterCredential.ps1 b/scripts/New-ClusterCredential.ps1 new file mode 100644 index 0000000..39ce186 --- /dev/null +++ b/scripts/New-ClusterCredential.ps1 @@ -0,0 +1,19 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string]$CredentialFile, [string]$ClusterId = 'pxhj-fusion') +. (Join-Path $PSScriptRoot 'Common.ps1') +if ($ClusterId -notmatch '^[a-zA-Z0-9_-]{1,64}$') { throw 'ClusterId 仅允许字母、数字、下划线和短横线。' } +$credentialPath = [System.IO.Path]::GetFullPath($CredentialFile) +if (Test-Path -LiteralPath $credentialPath) { throw '凭证文件已存在;不会覆盖现有集群凭证。' } +[System.IO.Directory]::CreateDirectory((Split-Path -Parent $credentialPath)) | Out-Null +$bytes = New-Object byte[] 32 +$random = [System.Security.Cryptography.RandomNumberGenerator]::Create() +try { $random.GetBytes($bytes) } finally { $random.Dispose() } +$credential = [Convert]::ToBase64String($bytes) +$stream = [System.IO.File]::Open($credentialPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) +try { + $content = [System.Text.Encoding]::UTF8.GetBytes("fusion.cluster-id=$ClusterId`nfusion.token=$credential`n") + $stream.Write($content, 0, $content.Length) +} finally { $stream.Dispose() } +Protect-XtrhPrivateFile -Path $credentialPath +# 凭证内容不输出、不放进命令行;将此私有文件经可信方式复制到同一集群的部署电脑。 +Write-Output ('集群凭证已生成并限制访问:' + $credentialPath) diff --git a/scripts/Observe-OriginalImage.ps1 b/scripts/Observe-OriginalImage.ps1 new file mode 100644 index 0000000..222748a --- /dev/null +++ b/scripts/Observe-OriginalImage.ps1 @@ -0,0 +1,207 @@ +[CmdletBinding()] +param( + [string]$CoordinatorUrl = 'http://127.0.0.1:18100', + [string]$CredentialFile = (Join-Path (Split-Path -Parent $PSScriptRoot) 'runtime/cluster.local.properties'), + [ValidatePattern('^[0-9a-fA-F-]{36}$')][string]$SourceEndpointId, + [ValidateRange(1,3600)][int]$DurationSeconds = 30, + [ValidateRange(100,5000)][int]$PollMilliseconds = 500, + [string]$OutputDirectory = (Join-Path (Split-Path -Parent $PSScriptRoot) 'runtime/image-observation'), + [switch]$InventoryOnly, + [switch]$AllowPending, + [switch]$QuietFrames, + [switch]$PrepareMigration, + [switch]$Migrate, + [string]$FromNodeId, + [string]$ToNodeId +) +. (Join-Path $PSScriptRoot 'Image-Verification.Common.ps1') + +function Get-ImageTask { + param($Snapshot) + $matches = @($Snapshot.tasks | Where-Object { $_.mediaKind -eq 'ORIGINAL_IMAGE' -and $_.sourceEndpointId -eq $SourceEndpointId }) + if ($matches.Count -gt 1) { throw '来源出现多个原图任务,已停止联调。' } + if ($matches.Count -eq 0) { return $null } + return $matches[0] +} + +function Write-ImageRecord { + param($Record) + # 白名单记录不含 playUrl/mediaKey、私有设备密钥、集群凭证或 PXHJ 会话。 + [IO.File]::AppendAllText($script:journalFile, ($Record | ConvertTo-Json -Depth 5 -Compress) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) +} + +function Write-ImageTaskState { + param($Snapshot) + $task = Get-ImageTask -Snapshot $Snapshot + $status = if ($null -eq $task) { 'NOT_REGISTERED' } else { [string]$task.status } + $nodeId = if ($null -ne $task -and $task.PSObject.Properties['nodeId']) { [string]$task.nodeId } else { '' } + $version = if ($null -ne $task -and $task.PSObject.Properties['version']) { [long]$task.version } else { 0L } + $ready = $null -ne $task -and $task.PSObject.Properties['imageReady'] -and [bool]$task.imageReady + $stateKey = $status + '/' + $nodeId + '/' + $version + '/' + $ready + if ($stateKey -eq $script:lastTaskState) { return } + $record = [ordered]@{ event = 'task-state'; observedTime = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + endpointId = $SourceEndpointId; status = $status; nodeId = $nodeId; version = $version; imageReady = $ready } + [IO.File]::AppendAllText((Join-Path $script:observationRoot 'task-states.jsonl'), + ($record | ConvertTo-Json -Compress) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) + Write-Information ('原图任务状态:' + $status + ',node=' + $nodeId + ',version=' + $version + ',imageReady=' + $ready) -InformationAction Continue + $script:lastTaskState = $stateKey +} + +function Read-CurrentOriginalFrame { + param($Snapshot) + $task = Get-ImageTask -Snapshot $Snapshot + if ($null -eq $task -or -not $task.PSObject.Properties['nodeId'] -or -not $task.PSObject.Properties['version']) { return $null } + $node = @($Snapshot.nodes | Where-Object { $_.nodeId -eq $task.nodeId }) + if ($node.Count -ne 1 -or -not $node[0].online) { return $null } + $path = '/node/images/' + [Uri]::EscapeDataString([string]$task.taskId) + '/' + [long]$task.version + $metadataResponse = Invoke-XtrhImageSigned -BaseUrl $node[0].baseUrl -Path ($path + '/latest') + if ($metadataResponse.Status -eq 404) { return $null } + $metadata = ConvertFrom-XtrhImageJson -Response $metadataResponse + if ($metadata.endpointId -ne $SourceEndpointId -or [int]$metadata.bytes -lt 4 -or [int]$metadata.bytes -gt 2097152 -or + [int]$metadata.width -lt 1 -or [int]$metadata.width -gt 4096 -or [int]$metadata.height -lt 1 -or [int]$metadata.height -gt 4096) { + throw '原图元数据与请求来源或大小限制不一致。' + } + $frameResponse = Invoke-XtrhImageSigned -BaseUrl $node[0].baseUrl -Path ($path + '/frames/' + [Uri]::EscapeDataString([string]$metadata.frameId)) + # 迁移或近期帧淘汰可使取图返回 404,重新查询即可,不能借用其他版本的图片。 + if ($frameResponse.Status -eq 404) { return $null } + if ($frameResponse.Status -ne 200 -or $frameResponse.ContentType -notmatch '^image/jpeg(?:;|$)' -or + $frameResponse.Bytes.Length -ne [int]$metadata.bytes -or $frameResponse.Bytes[0] -ne 255 -or $frameResponse.Bytes[1] -ne 216 -or + $frameResponse.Bytes[-2] -ne 255 -or $frameResponse.Bytes[-1] -ne 217) { throw '节点未返回一致的签名 JPEG。' } + $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + $record = [ordered]@{ + observedTime = $now; endpointId = $SourceEndpointId; taskId = $task.taskId; version = [long]$task.version + nodeId = $task.nodeId; frameId = $metadata.frameId; sourceTime = [long]$metadata.sourceTime + receivedTime = [long]$metadata.receivedTime; ageMs = $now - [long]$metadata.sourceTime + width = [int]$metadata.width; height = [int]$metadata.height; bytes = $frameResponse.Bytes.Length + sha256 = Get-XtrhImageDigest -Bytes $frameResponse.Bytes; responseAuthenticated = $true + } + # 图像按帧号缓存,只落业务图片与白名单元数据,绝不保存原始总控完整快照。 + if ($record.frameId -ne $script:lastFrameId -or $record.nodeId -ne $script:lastNodeId -or $record.version -ne $script:lastVersion) { + [IO.File]::WriteAllBytes((Join-Path $script:observationRoot ('frame-' + [Guid]::Parse($record.frameId).ToString() + '.jpg')), $frameResponse.Bytes) + Write-ImageRecord -Record $record + if (-not $QuietFrames) { + Write-Information ('原图已验证:node=' + $record.nodeId + ',version=' + $record.version + ',sourceTime=' + $record.sourceTime + + ',receivedTime=' + $record.receivedTime + ',ageMs=' + $record.ageMs + ',bytes=' + $record.bytes) -InformationAction Continue + } + $script:lastFrameId = $record.frameId; $script:lastNodeId = $record.nodeId; $script:lastVersion = $record.version + $script:verifiedFrames++ + if ($record.ageMs -ge -5000 -and $record.ageMs -le 5000) { $script:freshFrames++ } + } + return [pscustomobject]$record +} + +function Get-MigrationPlan { + param($Snapshot) + if (-not $SourceEndpointId -or -not $FromNodeId -or -not $ToNodeId -or $FromNodeId -eq $ToNodeId) { + throw '迁移准备需提供来源 endpoint、不同的起点和终点 nodeId。' + } + $source = @($Snapshot.nodes | Where-Object { $_.nodeId -eq $FromNodeId }) + $target = @($Snapshot.nodes | Where-Object { $_.nodeId -eq $ToNodeId }) + if ($source.Count -ne 1 -or $target.Count -ne 1 -or -not $source[0].online -or -not $target[0].online -or + -not $target[0].enabled -or [int]$target[0].imageInputPort -lt 1) { throw '迁移节点未在线或目标原图接入未就绪。' } + # 强制指定目标需要临时限制其他节点,因此只允许当前来源这一项活任务存在。 + # 用户正在运行的其他试验或其他原图通道存在时拒绝操作,避免改变无关调度。 + $foreign = @($Snapshot.tasks | Where-Object { $_.status -ne 'ENDED' -and + (-not $_.PSObject.Properties['sourceEndpointId'] -or $_.sourceEndpointId -ne $SourceEndpointId) }) + if ($foreign.Count -gt 0) { throw '存在其他活任务,不能通过全节点容量执行定向迁移。' } + $changes = @($Snapshot.nodes | Where-Object online | ForEach-Object { + [pscustomobject]@{ nodeId = $_.nodeId; savedCapacity = [int]$_.capacity + temporaryCapacity = $(if ($_.nodeId -eq $ToNodeId) { [Math]::Max(1, [int]$_.capacity) } else { 0 }) } + }) + return [pscustomobject]@{ coordinatorId = $Snapshot.serviceId; endpointId = $SourceEndpointId + fromNodeId = $FromNodeId; toNodeId = $ToNodeId; capacities = $changes } +} + +function Set-TemporaryCapacity { + param([string]$NodeId, [int]$Capacity) + $response = Invoke-XtrhImageSigned -BaseUrl $CoordinatorUrl -Path ('/control/nodes/' + [Uri]::EscapeDataString($NodeId) + '/capacity') ` + -Method PATCH -Payload @{ capacity = $Capacity } + if ($response.Status -ne 200) { throw ('节点容量变更失败,HTTP ' + $response.Status) } +} + +$script:observationRoot = [IO.Path]::GetFullPath($OutputDirectory) +[void][IO.Directory]::CreateDirectory($script:observationRoot) +$script:journalFile = Join-Path $script:observationRoot 'frames.jsonl' +$script:lastFrameId = ''; $script:lastNodeId = ''; $script:lastVersion = 0L; $script:verifiedFrames = 0; $script:freshFrames = 0 +$script:lastTaskState = '' +$changed = [Collections.Generic.List[object]]::new() +$plan = $null +Initialize-XtrhImageVerification -CredentialFile $CredentialFile +try { + $initial = Get-XtrhImageSnapshot -CoordinatorUrl $CoordinatorUrl + $inventory = [ordered]@{ observedTime = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(); epoch = $initial.epoch + nodes = @($initial.nodes | Select-Object nodeId,name,nodeRole,online,baseUrl,imageHost,imageInputPort,capacity,running,reserved) + routes = @($initial.routes | Select-Object endpointId,sourceHost,sourcePort,relayPort,targetHost,targetPort) + tasks = @($initial.tasks | Select-Object taskId,mediaKind,sourceEndpointId,collectorId,desired,status,nodeId,version,imageReady,lastFrameTime) } + [IO.File]::WriteAllText((Join-Path $script:observationRoot 'inventory.json'), ($inventory | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) + if ($InventoryOnly) { + Write-Output ('只读库存已保存:在线节点 ' + @($initial.nodes | Where-Object online).Count + ',任务 ' + @($initial.tasks).Count + ',UDP 路由 ' + @($initial.routes).Count) + return + } + if (-not $SourceEndpointId) { throw '持续观测需提供正式采集连接的 source endpointId。' } + if ($PrepareMigration -or $Migrate) { + $plan = Get-MigrationPlan -Snapshot $initial + [IO.File]::WriteAllText((Join-Path $script:observationRoot 'migration-plan.json'), ($plan | ConvertTo-Json -Depth 6), [Text.UTF8Encoding]::new($false)) + if ($PrepareMigration -and -not $Migrate) { Write-Output '迁移计划与原容量已保存,未执行任何变更。'; return } + } + if ($Migrate) { + $active = Get-ImageTask -Snapshot $initial + if ($null -eq $active -or -not $active.PSObject.Properties['nodeId'] -or $active.nodeId -ne $FromNodeId) { + throw '来源任务当前不在指定起点,拒绝执行迁移。' + } + # 先给目标留足接管空间,再限制其他节点;旧接收端等目标收到新鲜图后才撤销。 + foreach ($change in @($plan.capacities | Sort-Object { if ($_.nodeId -eq $ToNodeId) { 0 } else { 1 } })) { + if ($change.savedCapacity -eq $change.temporaryCapacity) { continue } + $changed.Add($change) + Set-TemporaryCapacity -NodeId $change.nodeId -Capacity $change.temporaryCapacity + } + } + $deadline = [DateTimeOffset]::UtcNow.AddSeconds($DurationSeconds) + $migrationObserved = $false + $oldVersionRevoked = $false + while ([DateTimeOffset]::UtcNow -lt $deadline) { + $snapshot = Get-XtrhImageSnapshot -CoordinatorUrl $CoordinatorUrl + Write-ImageTaskState -Snapshot $snapshot + $frame = Read-CurrentOriginalFrame -Snapshot $snapshot + if ($Migrate -and $null -ne $frame -and $frame.nodeId -eq $ToNodeId -and $frame.ageMs -ge -5000 -and $frame.ageMs -le 5000) { + $migrationObserved = $true + } + if ($Migrate -and $migrationObserved -and -not $oldVersionRevoked) { + # 新节点接管后旧节点需及时释放许可;首个心跳之前可能仍可读,窗口内持续检查。 + $oldNode = @($initial.nodes | Where-Object { $_.nodeId -eq $FromNodeId })[0] + $oldPath = '/node/images/' + [Uri]::EscapeDataString([string]$active.taskId) + '/' + [long]$active.version + '/latest' + $oldResponse = Invoke-XtrhImageSigned -BaseUrl $oldNode.baseUrl -Path $oldPath + if ($oldResponse.Status -eq 404) { + $oldVersionRevoked = $true + $revokedRecord = [ordered]@{ event = 'old-version-revoked'; observedTime = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + endpointId = $SourceEndpointId; nodeId = $FromNodeId; version = [long]$active.version; signedHttpStatus = 404 } + [IO.File]::AppendAllText((Join-Path $script:observationRoot 'migration-events.jsonl'), + ($revokedRecord | ConvertTo-Json -Compress) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) + Write-Information '旧节点分配已撤销:私有原图查询返回签名 404。' -InformationAction Continue + } elseif ($oldResponse.Status -ne 200) { throw '旧节点未明确返回有效图片或已撤销状态。' } + } + Start-Sleep -Milliseconds $PollMilliseconds + } + if ($script:verifiedFrames -eq 0) { + # 部署前允许记录尚未登记,不将空窗口宣称为取图验收通过;迁移必须取得新图。 + if ($AllowPending -and -not $Migrate -and $script:lastTaskState.StartsWith('NOT_REGISTERED/')) { + Write-Output '观测窗口结束:来源尚未登记,未得到原图,原图接入尚未验证。' + return + } + throw '观测期间没有取得经过签名验证的原图。' + } + if ($script:freshFrames -eq 0) { throw '只读到了旧缓存,观测期间没有收到五秒内的新鲜原图。' } + if ($Migrate -and -not $migrationObserved) { throw '观测期间未证明目标节点已接管新鲜原图。' } + if ($Migrate -and -not $oldVersionRevoked) { throw '目标已接管,但未证明旧节点版本已撤销。' } + Write-Output ('原图观测完成:验证 ' + $script:verifiedFrames + ' 个新帧,记录已保存。') +} finally { + # 仅 -Migrate 会进入恢复分支;默认观测与 -PrepareMigration 都没有容量写操作。 + # 目标/原节点先恢复,再恢复其余节点,使用保存的正式原值,不推断历史默认容量。 + $restoreFailed = $false + foreach ($change in @($changed | Sort-Object { if ($_.nodeId -eq $FromNodeId) { 0 } else { 1 } })) { + try { Set-TemporaryCapacity -NodeId $change.nodeId -Capacity $change.savedCapacity } + catch { $restoreFailed = $true; Write-Warning ('容量恢复失败:node=' + $change.nodeId + ',原值=' + $change.savedCapacity + ';请使用 migration-plan.json 恢复。') } + } + Close-XtrhImageVerification + if ($restoreFailed) { throw '部分原容量未恢复,请按保存的迁移计划处理。' } +} diff --git a/scripts/Open-Console.ps1 b/scripts/Open-Console.ps1 new file mode 100644 index 0000000..39937f8 --- /dev/null +++ b/scripts/Open-Console.ps1 @@ -0,0 +1,12 @@ +[CmdletBinding()] +param([string]$InstallRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'runtime/coordinator'), [string]$JavaHome, [switch]$OpenBrowser) +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($InstallRoot) +$deployment = Get-Content -LiteralPath (Join-Path $root 'deployment.json') -Raw | ConvertFrom-Json +if ($deployment.role -ne 'coordinator') { throw '页面入口需要总控部署目录。' } +& (Join-Path $PSScriptRoot 'Start-Xtrh.ps1') -InstallRoot $root -JavaHome $JavaHome +$url = 'http://127.0.0.1:' + $deployment.port + '/console/#receive' +$page = Invoke-WebRequest -UseBasicParsing -Uri $url.Split('#')[0] -TimeoutSec 5 +if ([int]$page.StatusCode -ne 200) { throw '页面未就绪,请先部署带页面的新版本。' } +Write-Output ('系统融合软件:' + $url) +if ($OpenBrowser) { Start-Process $url } diff --git a/scripts/Register-XtrhStartup.ps1 b/scripts/Register-XtrhStartup.ps1 new file mode 100644 index 0000000..be03a16 --- /dev/null +++ b/scripts/Register-XtrhStartup.ps1 @@ -0,0 +1,24 @@ +[CmdletBinding()] +param([string]$InstallRoot = (Split-Path -Parent $PSScriptRoot), [string]$JavaHome, [switch]$Remove) +. (Join-Path $PSScriptRoot 'Common.ps1') +$root = [System.IO.Path]::GetFullPath($InstallRoot) +$sha = [System.Security.Cryptography.SHA256]::Create() +try { $hash = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($root.ToLowerInvariant()))).Replace('-', '').Substring(0, 12) } +finally { $sha.Dispose() } +$taskName = 'PXHJ-XTRH-' + $hash +if ($Remove) { + if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false } + Write-Output ('已移除此部署实例的自动启动:' + $taskName) + return +} +$java = Get-XtrhJava -JavaHome $JavaHome +$resolvedJavaHome = Split-Path -Parent (Split-Path -Parent $java) +$startScript = Join-Path $root 'scripts/Start-Xtrh.ps1' +$arguments = '-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $startScript + '" -InstallRoot "' + $root + '" -JavaHome "' + $resolvedJavaHome + '"' +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments -WorkingDirectory $root +$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user +$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) +Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null +Write-Output ('已配置当前用户登录后后台自动启动:' + $taskName) diff --git a/scripts/Rollback-EmbeddedImageRoute.py b/scripts/Rollback-EmbeddedImageRoute.py new file mode 100644 index 0000000..d98b749 --- /dev/null +++ b/scripts/Rollback-EmbeddedImageRoute.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""回退指定图片协商stage;默认只检查,只有显式--apply才由操作者在板上执行。""" +import argparse +import importlib.util +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import time +import uuid + + +# 复用相同路径/哈希校验,不引入SSH、密码或远端自动连接。 +loader = importlib.util.spec_from_file_location("collector_stage", Path(__file__).with_name("Stage-EmbeddedImageRoute.py")) +helper = importlib.util.module_from_spec(loader) +loader.loader.exec_module(helper) + + +def atomic_restore(source, destination, root): + # 临时文件必须和目的文件同目录,os.replace不会跨文件系统失去原子性。 + # 禁止符号链接目的地或借父目录链接逃出当前采集工程。 + if destination.is_symlink() or not destination.parent.resolve().is_relative_to(root): + raise RuntimeError("Unsafe rollback target") + temporary = destination.with_name(destination.name + ".rollback-" + uuid.uuid4().hex) + shutil.copy2(source, temporary) + os.replace(str(temporary), str(destination)) + + +def main(): + # 回退授权来自本次部署操作,--apply是动作入口,不是新的用户审批。 + # root可以先审阅默认检查输出,再按既有授权直接执行。 + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path("/home/Tronlong/SY26-PX-Collect")) + parser.add_argument("--stage", type=Path, required=True) + parser.add_argument("--launcher", choices=("start-network-simulation.sh", "start.sh"), default="start-network-simulation.sh") + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + root, directory, record = helper.stage_record(args) + # 使用板上当前原启动器,不重写串口桥或PID管理逻辑。 + launcher = root / args.launcher + if launcher.is_symlink() or not launcher.is_file() or not os.access(str(launcher), os.X_OK): + raise RuntimeError("Original collector launcher is invalid") + if not record.get("built"): + raise RuntimeError("Stage has no successfully built candidate") + # 完整原配置快照恢复JPEG质量、帧率及其他原设置,不只把协商开关关掉。 + # 设备密钥不在此备份范围,回退不能替换或改写密钥。 + backups = dict(record["previousPatchHashes"]) + backups.update({"px_Collect.pro": record["previousProjectSha256"], + "bin/px_JKGL": record["previousBinarySha256"], "config.ini": record["previousConfigSha256"]}) + # 不凭文件名认为当前版本属于本次部署,每项都要匹配原版或候选哈希。 + candidates = dict(record["candidatePatchHashes"]) + candidates.update({"px_Collect.pro": record["candidateProjectSha256"], + "bin/px_JKGL": record["candidateBinarySha256"], "config.ini": record["candidateConfigSha256"]}) + # 只允许回退本stage候选或原版本;其他人后续更新的版本不能被历史备份覆盖。 + # 先校验全部文件和备份,再停止进程,不能边校验边执行部分回退。 + for relative, previous in backups.items(): + live = root / relative + if live.is_symlink() or not live.parent.resolve().is_relative_to(root): + raise RuntimeError("Unsafe live path: " + relative) + current = helper.digest(live) if live.is_file() else None + if current not in (previous, candidates[relative]): + raise RuntimeError("Unrelated live update detected: " + relative) + preserved = directory / "backup" / relative + if previous is not None and (preserved.is_symlink() or not preserved.resolve().is_relative_to(directory) + or not preserved.is_file() or helper.digest(preserved) != previous): + raise RuntimeError("Rollback backup hash mismatch: " + relative) + for relative, checksum in record["preservedHashes"].items(): + if helper.digest(root / relative) != checksum: + raise RuntimeError("Unrelated preserved source changed: " + relative) + # 默认只输出非敏感回退范围,审阅时不会停进程或读取密钥。 + if not args.apply: + print(json.dumps({"rollbackReady": True, "apply": False, "stage": str(directory), + "collectorPids": helper.process_ids(root), "files": list(backups)})) + return + # 进程列表使用真实exe路径,当前串口桥即使同属工程也不会被停止。 + original_pids = helper.process_ids(root) + # 仅停止真实exe路径指向本工程的采集程序,不动串口桥、系统服务或其他同名进程。 + for pid in original_pids: + os.kill(pid, signal.SIGTERM) + deadline = time.monotonic() + 5 + while set(original_pids) & set(helper.process_ids(root)) and time.monotonic() < deadline: + time.sleep(0.1) + for pid in set(original_pids) & set(helper.process_ids(root)): + os.kill(pid, signal.SIGKILL) + time.sleep(0.1) + if helper.process_ids(root): + # 自动守护重启需要操作者处理其owner,工具不擅自停systemd或改变开机项。 + raise RuntimeError("Collector restarted by another owner; live files were not replaced") + # 先确认原进程全部退出,再原子替换文件,避免恢复过程中旧程序继续发包。 + for relative, previous in backups.items(): + live = root / relative + if previous is None: + # 只移除本stage新增且哈希匹配的源码文件;不递归删除任何目录。 + if live.exists(): + live.unlink() + else: + live.parent.mkdir(parents=True, exist_ok=True) + atomic_restore(directory / "backup" / relative, live, root) + # 使用当前已核对的启动入口,保留原native日志策略,不恢复旧run.log重定向脚本。 + # 原启动脚本负责库路径和offscreen参数,回退不修改任何启动脚本内容。 + # stdout/stderr交给原native日志接管,禁止再次追加到无界run.log。 + child = None + if args.launcher == "start.sh": + child = subprocess.Popen([str(launcher)], cwd=str(root), stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + else: + # 网络模拟启动器自管collector.pid,不能把短命shell的PID写成采集PID。 + subprocess.run([str(launcher)], cwd=str(root), stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=15) + time.sleep(2) + running = helper.process_ids(root) + if (child is not None and child.poll() is not None) or not running: + raise RuntimeError("Original collector could not restart; backups remain available") + # 原网络模拟启动器识别PID文件,避免下一次调用误启动第二个采集进程。 + pid_file = root / "logs/collector.pid" + if child is not None and pid_file.exists() and not pid_file.is_symlink(): + pid_file.write_text(str(child.pid) + "\n", encoding="ascii") + # 此处只确认原进程重新出现,真实图片/采集/试验链路由root继续验证。 + print(json.dumps({"rolledBack": True, "collectorPids": running, "stage": str(directory)})) + + +if __name__ == "__main__": + main() diff --git a/scripts/Stage-EmbeddedImageRoute.py b/scripts/Stage-EmbeddedImageRoute.py new file mode 100644 index 0000000..e5b5c61 --- /dev/null +++ b/scripts/Stage-EmbeddedImageRoute.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +"""准备嵌入式图片协商候选版本;本工具不停止进程、不替换在用程序、不修改在用配置。""" +import argparse +import configparser +import hashlib +import io +import ipaddress +import json +import os +from pathlib import Path +import platform +import re +import shutil +import subprocess +import tarfile +import time +import uuid + + +# 本次仅交付图片地址协商代码;其他业务源码必须以当前开发板为基线。 +# 不整份上传历史工程,避免覆盖最新日志保留、串口或RTP收流修改。 +PATCH_FILES = ( + "util/PlatformWireCodec.h", "util/PlatformWireCodec.cpp", + "util/PlatformImageRouteClient.h", "util/PlatformImageRouteClient.cpp", + "util/PlatformImageSender.h", "util/PlatformImageSender.cpp", +) +# 测试只运行临时目录与loopback,不使用实际设备密钥和采集端口。 +TEST_FILES = ( + "tests/image_route/native_image_route_check.pro", + "tests/image_route/native_image_route_check.cpp", + "tests/logging/native_logging_check.pro", + "tests/logging/native_logging_check.cpp", +) +ALLOWED = set(PATCH_FILES + TEST_FILES) +# 这些文件只记录哈希,以验证部署保留现有功能;本工具不会覆盖它们。 +PRESERVED = ( + "main.cpp", "util/log.cpp", "util/log.h", "util/CollectorLogRetention.cpp", + "util/CollectorLogRetention.h", "start.sh", + "Go2Controller/network/rtpreceiver.cpp", "Fly2Controller/network/RtpReceiver.cpp", + "util/ModuleVideoReceiver.cpp", "CJ2Controller/serialworker.cpp", "mainwindow.cpp", +) + + +def digest(path): + # 分块计算,避免相机SDK等大文件一次读入内存。 + value = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + value.update(block) + return value.hexdigest() + + +def write_json(path, value): + # 部署记录留在私有stage;不输出配置全文、密钥文件或进程环境。 + path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8") + + +def package(args): + # 使用当前工作空间源码,禁止调用旧prepare-board-deployment脚本重建全量包。 + # 包只携带本次代码与测试,不携带平台IP、上传配置、密钥或旧可执行文件。 + # 压缩包不含旧可执行文件,必须经实际ARM工具链重新构建。 + # 不读取本机现网INI,候选IP必须在板端stage时显式指定。 + source = args.source.resolve(strict=True) + # 同名产物不覆盖,方便将每次候选与原始校验记录一一对应。 + if args.package.exists(): + raise RuntimeError("Package already exists; choose a new output filename") + args.package.parent.mkdir(parents=True, exist_ok=True) + manifest = {"protocol": "COLLECTOR_IMAGE_ROUTE", "format": 1, "files": []} + # 精确白名单不包含任何ini/密钥/运行目录,也不递归打包未知文件。 + with tarfile.open(str(args.package), "w:gz") as archive: + for relative in PATCH_FILES + TEST_FILES: + path = source / relative + if path.is_symlink() or not path.resolve(strict=True).is_relative_to(source): + raise RuntimeError("Unexpected source path: " + relative) + data = path.read_bytes() + item = tarfile.TarInfo(relative) + item.size = len(data) + item.mode = 0o644 + archive.addfile(item, io.BytesIO(data)) + manifest["files"].append({"path": relative, "sha256": hashlib.sha256(data).hexdigest()}) + data = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8") + item = tarfile.TarInfo("image-route-manifest.json") + item.size = len(data) + item.mode = 0o644 + archive.addfile(item, io.BytesIO(data)) + print(json.dumps({"package": str(args.package.resolve()), "files": len(ALLOWED), + "sha256": digest(args.package)}, ensure_ascii=False)) + + +def live_root(args): + # 路径必须是已存在工程,禁止根目录及符号链接冒充部署目录。 + if args.root.is_symlink(): + raise RuntimeError("Collector root cannot be a symbolic link") + # 所有后续路径由规范化根目录拼接,不接收任意远端目标路径。 + root = args.root.resolve(strict=True) + if root == Path(root.anchor) or not (root / "px_Collect.pro").is_file(): + raise RuntimeError("Collector project root is invalid") + return root + + +def process_ids(root): + target = str(root / "bin/px_JKGL") + # 没有/proc时只用于本地安全测试,不声称发现了实际板端进程。 + matches = [] + # 仅核对可执行文件真实路径,不用进程名字匹配其他同名程序。 + for entry in Path("/proc").iterdir() if Path("/proc").is_dir() else []: + if not entry.name.isdigit(): + continue + try: + if os.readlink(str(entry / "exe")).replace(" (deleted)", "") == target: + matches.append(int(entry.name)) + except (FileNotFoundError, PermissionError): + pass + return sorted(matches) + + +def inspect(args): + # 不主动建立SSH,操作者负责将工具上传到已确认的目标板再运行。 + # inspect不改网络、时钟、配置、库或进程,输出可供root比对的哈希。 + # 输出源码哈希供当前板与本机差异核对,不能把历史记录当成现网事实。 + root = live_root(args) + # 地址和目标路径来自root确认,inspect只给非敏感状态与源码哈希。 + print(json.dumps({"root": str(root), "architecture": platform.machine(), + "collectorPids": process_ids(root), "binaryPresent": (root / "bin/px_JKGL").is_file(), + "qmake": shutil.which("qmake"), "make": shutil.which("make"), + "preservedSources": {name: digest(root / name) for name in PRESERVED if (root / name).is_file()}, + "sourceHashes": {name: digest(root / name) if (root / name).is_file() else None for name in PATCH_FILES}, + "projectSha256": digest(root / "px_Collect.pro")}, ensure_ascii=False, indent=2)) + + +def ignore_files(directory, names): + # 不在stage复制在用配置和密钥;测试自行生成临时密钥。 + # 对bin/runtime/logs/build的排除也避免复制采集历史和巨量日志。 + ignored = [] + for name in names: + lower = name.lower() + if name in {"bin", "runtime", "logs", "build", "debug", "release", ".git", "__pycache__"} \ + or name.startswith(("Makefile", "moc_", ".qmake")) \ + or lower.endswith((".ini", ".o", ".obj", ".user", ".pyc")) \ + or ("key" in lower and lower.endswith((".json", ".properties"))): + ignored.append(name) + return ignored + + +def payload(args): + # 逐个读取白名单文件,不调用tar.extract,拒绝目录穿越、链接或重复条目。 + # 文件总数和每项大小均受限,未知条目即使名称看似安全也不接受。 + with tarfile.open(str(args.package), "r:gz") as archive: + names = archive.getnames() + if len(names) != len(set(names)) or set(names) != ALLOWED | {"image-route-manifest.json"}: + raise RuntimeError("Unexpected archive contents") + content = {} + for name in names: + item = archive.getmember(name) + if not item.isfile() or item.size <= 0 or item.size > 2 * 1024 * 1024: + raise RuntimeError("Invalid archive entry: " + name) + content[name] = archive.extractfile(item).read() + # manifest也受大小和类型约束,只有白名单完整且逐文件哈希匹配才继续。 + manifest = json.loads(content.pop("image-route-manifest.json")) + if manifest.get("protocol") != "COLLECTOR_IMAGE_ROUTE" or manifest.get("format") != 1: + raise RuntimeError("Unknown package format") + if len(manifest.get("files", [])) != len(ALLOWED): + raise RuntimeError("Manifest entry count is invalid") + checks = {item["path"]: item["sha256"] for item in manifest["files"]} + if set(checks) != ALLOWED or any(hashlib.sha256(data).hexdigest() != checks[name] + for name, data in content.items()): + raise RuntimeError("Package hash validation failed") + return content + + +def project_append(project): + text = project.read_text(encoding="utf-8") + # 保留板上已有依赖/DEFINES,尤其GST、日志保留与hiredis真实库位置。 + # 仅补缺少的HEADERS/SOURCES,不拿本机历史pro覆盖当前ARM工程。 + # 注释提到文件名不算真实注册;追加动作仅发生在stage副本。 + uncommented = "\n".join(line.split("#", 1)[0] for line in text.splitlines()) + # 六个编译单元的类型由白名单后缀决定,不接受manifest提供qmake指令。 + additions = [] + for relative in PATCH_FILES: + if not re.search(r"(? +python3 Stage-EmbeddedImageRoute.py verify --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID> +``` + +`stage`输出真实候选路径。stage目录权限700,配置及其备份权限600。构建必须在ARM64目标环境,完整Qt应用、GStreamer、相机SDK、OpenSSL和hiredis依赖全部保留;不存在去掉依赖勉强通过的分支。`make -o Makefile`仅防止板时钟引起Makefile重复生成,不省略正常编译。新目录使用自己的qmake缓存。 + +完整程序构建成功后检查动态库,并执行内置日志和图片协商隔离QtTest。只有全部成功才记为 `built=true`。候选主程序不会在此阶段启动,不抢真实6000/9200端口。跨语言加解密已在Windows独立验证;ARM默认缺少Java互操作夹具时该可选测试会标记跳过,其他业务断言仍执行。 + +候选程序在 `/source/bin/px_JKGL`,候选配置在 `/candidate-config.ini`,完整回退材料在 `/backup`,日志和哈希在该stage内。`verify`若发现现网源码、程序或配置在stage后变化会停止,root应重新核对,不能覆盖新修改。 + +## 切换与回退 + +root先核对真实PXHJ网关路由已经授权该采集端,再切换。候选配置只新增/启用地址协商以及独立PXHJ原图fallback IP;不修改fixed_json、heartbeat、串口/RTP或密钥文件。图片目的地址不能从fixed_json网关地址推断。保持原图片帧率、质量和尺寸。 + +实际切换前,运行 `Activate-EmbeddedImageRoute.py` 默认预览。它核验live未变、候选bin/config/源码/pro哈希及两套QtTest结果;显式 `--apply` 才停止本工程采集进程、原子安装候选并调用当前 `start-network-simulation.sh`。原串口桥和PID管理方式保留,禁止恢复旧的无限增长 `run.log` 重定向。启动后核对新写入且连续稳定的native日志状态(owner、1GiB预算、failedWrites=0、withinLimit=true)。失败自动调用该stage回退。准备原图可见、来源身份不变、迁移首帧与租约回退由真实PXHJ继续验收。 + +```sh +python3 Activate-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID> +python3 Activate-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID> --apply +``` + +回退先只检查,所有路径和哈希通过后由root显式执行: + +```sh +python3 Rollback-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID> +python3 Rollback-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID> --apply +``` + +回退仅恢复本stage拥有的6份源码、pro、程序和config;默认无写操作。检测到其他部署后的更新、备份哈希改变或未知进程守护重启时停止。默认重用当前 `start-network-simulation.sh`,串口桥与collector.pid由原启动器管理;显式 `--launcher start.sh` 才选择直接启动。不会修改网络、systemd、设备密钥、原始采集数据或试验记录。 + +此文档和候选包属于部署准备;实际ARM构建、切换及准备/迁移验收需要分别记录真实结果,不能用本机测试替代。 diff --git a/src/main/java/com/xtrh/Application.java b/src/main/java/com/xtrh/Application.java new file mode 100644 index 0000000..23fee42 --- /dev/null +++ b/src/main/java/com/xtrh/Application.java @@ -0,0 +1,18 @@ +package com.xtrh; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 同一份 Java 程序按配置运行总控、网关或节点,业务操作统一由 PXHJ 发起。 + * 启动入口保留在根包,使 Spring 能扫描各业务模块中的组件。 + */ +@SpringBootApplication +@EnableScheduling +public class Application { + public static void main(String[] args) { + System.setProperty("java.awt.headless", "true"); + SpringApplication.run(Application.class, args); + } +} diff --git a/src/main/java/com/xtrh/common/config/SchedulerConfiguration.java b/src/main/java/com/xtrh/common/config/SchedulerConfiguration.java new file mode 100644 index 0000000..874e300 --- /dev/null +++ b/src/main/java/com/xtrh/common/config/SchedulerConfiguration.java @@ -0,0 +1,20 @@ +package com.xtrh.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +/** + * 主节点同时管理集群与执行视频,心跳、调度和画面生成不能占用同一条定时线程。 + * 定时任务仍使用各自的固定延迟,业务状态同步由对应服务的锁保护。 + */ +@Configuration +public class SchedulerConfiguration { + @Bean(name = "taskScheduler") + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(4); + scheduler.setThreadNamePrefix("xtrh-scheduled-"); + return scheduler; + } +} diff --git a/src/main/java/com/xtrh/common/config/Settings.java b/src/main/java/com/xtrh/common/config/Settings.java new file mode 100644 index 0000000..af1cda3 --- /dev/null +++ b/src/main/java/com/xtrh/common/config/Settings.java @@ -0,0 +1,98 @@ +package com.xtrh.common.config; + +import com.xtrh.common.dao.NodeIdentityDao; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; +import org.springframework.boot.web.context.WebServerInitializedEvent; +import org.springframework.context.event.EventListener; + +import java.net.InetAddress; +import java.nio.file.Path; +import java.util.Set; +import java.util.UUID; + +/** + * 将部署配置集中转换为服务启动参数,业务层不需要重复读取环境变量。 + * 配置不完整时直接拒绝启动,避免节点以错误集群身份接收任务。 + * 本类不依赖总控或发现服务,网关也能独立加载已保存的转发配置。 + */ +@Component +public class Settings { + public final String role, cluster, token, id, bootId, name, coordinator, advertisedHost, bindHost; + public final Path directory; + public final int capacity, gatewayPort, targetPort; + // HTTP 就绪事件发布实际端口,随机端口部署不能继续上报配置中的 0。 + public volatile int port; + private volatile boolean serverReady; + public final long leaseMs; + public final boolean discoveryEnabled; + public final boolean supportsWorker; + public final String discoveryAddress; + + public Settings(Environment env) throws Exception { + // 三种角色共用一个 JAR;安装脚本分别指定角色、端口和独立数据目录。 + role = env.getProperty("fusion.role", "coordinator"); + if (!Set.of("coordinator", "worker", "gateway").contains(role)) { + throw new IllegalArgumentException("Unknown service role"); + } + // 主节点默认兼任执行节点;仅总控部署可关闭执行能力,子节点仍保持执行职责。 + supportsWorker = workerEnabled(env); + + // 集群名称用于筛选发现线索,凭证则用于请求和响应签名,二者用途不同。 + // 凭证从私有部署配置读取,不能通过公共健康接口或节点状态返回。 + cluster = env.getProperty("fusion.cluster-id", "pxhj-fusion"); + token = env.getProperty("fusion.token", ""); + if (token.length() < 24) { + throw new IllegalArgumentException("Configure a shared service credential with at least 24 characters"); + } + + // 稳定身份由 DAO 保存;每次进程启动重新生成 bootId,以隔离旧进程回执。 + // 不在配置层执行文件读写细节,身份文件损坏或无法保存时让启动失败。 + directory = Path.of(env.getProperty("fusion.data-dir", "./data/" + role)) + .toAbsolutePath().normalize(); + id = NodeIdentityDao.loadOrCreate(directory); + bootId = UUID.randomUUID().toString(); + + name = env.getProperty("fusion.node-name", InetAddress.getLocalHost().getHostName()); + // 同网段部署默认不填写总控 URL,保留为空以使用自动发现。 + coordinator = env.getProperty("fusion.coordinator-url", "").replaceAll("/+$", ""); + advertisedHost = env.getProperty("fusion.advertised-host", ""); + bindHost = env.getProperty("server.address", "0.0.0.0"); + + // 容量是管理员确认的最大并行路数,不代表程序已经测量过显卡能力。 + capacity = env.getProperty("fusion.capacity", Integer.class, 2); + if (capacity < 0 || capacity > 64) { + throw new IllegalArgumentException("Capacity must be between 0 and 64"); + } + + // HTTP 管理、网关 UDP 接入和 PXHJ 接收端口各自配置,不能混为同一入口。 + port = env.getProperty("server.port", Integer.class, 18100); + gatewayPort = env.getProperty("fusion.gateway-port", Integer.class, 19002); + targetPort = env.getProperty("fusion.forward-target-port", Integer.class, 19001); + + // 授权有效期交给业务服务用单调时钟计时;这里仅校验部署值范围。 + leaseMs = env.getProperty("fusion.lease-ms", Long.class, 8000L); + if (leaseMs < 3000 || leaseMs > 120000) { + throw new IllegalArgumentException("Invalid lease duration"); + } + + // 多网卡环境可限定发现网卡;不指定时由发现服务尝试可用组播网卡。 + discoveryEnabled = env.getProperty("fusion.discovery-enabled", Boolean.class, true); + discoveryAddress = env.getProperty("fusion.discovery-address", ""); + } + + /** 能力判断由配置与条件组件共用,避免启动了媒体接口却没有执行服务。 */ + public static boolean workerEnabled(Environment env) { + String role = env.getProperty("fusion.role", "coordinator"); + return role.equals("worker") || (role.equals("coordinator") + && env.getProperty("fusion.coordinator-worker-enabled", Boolean.class, true)); + } + + /** 本机执行端等待总控 HTTP 已就绪,再按实际端口自注册。 */ + @EventListener public void serverStarted(WebServerInitializedEvent event) { + port = event.getWebServer().getPort(); + serverReady = true; + } + + public boolean httpReady() { return serverReady; } +} diff --git a/src/main/java/com/xtrh/common/controller/ApiErrors.java b/src/main/java/com/xtrh/common/controller/ApiErrors.java new file mode 100644 index 0000000..6141c7b --- /dev/null +++ b/src/main/java/com/xtrh/common/controller/ApiErrors.java @@ -0,0 +1,27 @@ +package com.xtrh.common.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +/** + * 将已知业务异常转换为现有 HTTP 契约,业务判断仍由服务层完成。 + * 错误响应不能伪装成成功,调用方据状态码决定是否修正配置或重新注册。 + */ +@RestControllerAdvice +public class ApiErrors { + // 参数、容量或地址不合法时返回 400,避免客户端反复重试同一错误输入。 + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity invalid(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + + // 旧会话或当前状态冲突返回 409,节点需要重新核对总控状态。 + // 此处保留错误消息契约,不输出异常堆栈、凭证或请求头。 + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity conflict(IllegalStateException e) { + return ResponseEntity.status(409).body(Map.of("message", e.getMessage())); + } +} diff --git a/src/main/java/com/xtrh/common/controller/HealthController.java b/src/main/java/com/xtrh/common/controller/HealthController.java new file mode 100644 index 0000000..735250a --- /dev/null +++ b/src/main/java/com/xtrh/common/controller/HealthController.java @@ -0,0 +1,32 @@ +package com.xtrh.common.controller; + +import com.xtrh.common.config.Settings; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 提供无凭证的进程身份查询,供安装脚本确认启动的是本部署实例。 + * 不返回集群凭证、设备密钥、试验配置或播放许可。 + * 健康响应只代表进程存在,任务就绪和总控存储状态需查询正式控制接口。 + */ +@RestController +public class HealthController { + private final Settings settings; + + public HealthController(Settings settings) { + this.settings = settings; + } + + // 控制层只组装公开的身份信息,不触发注册、调度或数据库更新。 + @GetMapping("/health") + public Object health() { + return Map.of( + "service", "xtrh-services", + "role", settings.role, + "workerEnabled", settings.supportsWorker, + "clusterId", settings.cluster, + "serviceId", settings.id); + } +} diff --git a/src/main/java/com/xtrh/common/dao/NodeIdentityDao.java b/src/main/java/com/xtrh/common/dao/NodeIdentityDao.java new file mode 100644 index 0000000..b1fce0d --- /dev/null +++ b/src/main/java/com/xtrh/common/dao/NodeIdentityDao.java @@ -0,0 +1,25 @@ +package com.xtrh.common.dao; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.UUID; + +/** + * 负责部署实例身份的文件持久化。 + * 每台电脑使用独立数据目录,重启复用原身份,重新安装也无需手动登记节点。 + */ +public final class NodeIdentityDao { + private NodeIdentityDao() {} + + /** 首次安装生成身份;已有文件格式错误时停止启动,避免错误身份覆盖集群中的其他节点。 */ + public static String loadOrCreate(Path directory) throws Exception { + Files.createDirectories(directory); + Path identity = directory.resolve("identity.txt"); + // CREATE_NEW 避免并发启动时覆盖另一个进程刚生成的身份。 + if (!Files.exists(identity)) Files.writeString(identity, UUID.randomUUID().toString(), StandardOpenOption.CREATE_NEW); + String value = Files.readString(identity).trim(); + if (!value.matches("[a-zA-Z0-9_-]{8,64}")) throw new IllegalArgumentException("Invalid persisted service identity"); + return value; + } +} diff --git a/src/main/java/com/xtrh/common/dao/StateStore.java b/src/main/java/com/xtrh/common/dao/StateStore.java new file mode 100644 index 0000000..f3155c2 --- /dev/null +++ b/src/main/java/com/xtrh/common/dao/StateStore.java @@ -0,0 +1,65 @@ +package com.xtrh.common.dao; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.xtrh.common.util.Jsons; + +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; + +/** + * 仅负责读取和保存完整状态快照,不在 DAO 中决定任务分配或轮次状态。 + * H2 文件锁阻止两个进程同时占用同一状态文件,避免并行写入产生双总控。 + */ +public final class StateStore implements AutoCloseable { + private final Connection connection; + private final ObjectMapper mapper; + + // 文件数据库的 JDBC 地址由 DAO 统一组装,业务服务只选择本角色的数据文件。 + // 使用绝对规范路径,避免工作目录变化后误建另一份总控或网关状态。 + public static StateStore open(Path databaseFile, ObjectMapper mapper) throws Exception { + String databasePath = databaseFile.toAbsolutePath().normalize().toString().replace('\\', '/'); + return new StateStore("jdbc:h2:file:" + databasePath + ";DB_CLOSE_ON_EXIT=FALSE", mapper); + } + + public StateStore(String url, ObjectMapper mapper) throws Exception { + this.mapper = mapper; + connection = DriverManager.getConnection(url, "sa", ""); + + // 固定主键只保存一个当前快照;初始化失败向上传递,不降级到临时内存状态。 + try (var statement = connection.createStatement()) { + statement.execute( + "CREATE TABLE IF NOT EXISTS fusion_state (id INT PRIMARY KEY, payload CLOB NOT NULL)"); + } + } + + // 新数据库由业务服务补齐结构;已有内容读取失败不能当成首次部署。 + public ObjectNode load() throws Exception { + try (var statement = connection.createStatement(); + var result = statement.executeQuery("SELECT payload FROM fusion_state WHERE id=1")) { + return result.next() + ? (ObjectNode) mapper.readTree(result.getString(1)) + : Jsons.object(); + } + } + + // 一条 MERGE 原子替换快照,避免分别保存节点与任务造成不一致。 + // 写入失败交由总控服务标记存储异常并停止续发任务许可。 + public void save(ObjectNode value) { + try (var statement = connection.prepareStatement( + "MERGE INTO fusion_state (id,payload) KEY(id) VALUES(1,?)")) { + // 使用参数绑定保存 JSON,配置内容不会被拼接成 SQL。 + statement.setString(1, mapper.writeValueAsString(value)); + statement.executeUpdate(); + } catch (Exception e) { + // 保留底层原因供服务端排查,不能静默跳过持久化后继续宣称保存成功。 + throw new IllegalStateException("Cannot persist coordinator state", e); + } + } + + @Override + public void close() throws Exception { + connection.close(); + } +} diff --git a/src/main/java/com/xtrh/common/util/Jsons.java b/src/main/java/com/xtrh/common/util/Jsons.java new file mode 100644 index 0000000..7fe14a0 --- /dev/null +++ b/src/main/java/com/xtrh/common/util/Jsons.java @@ -0,0 +1,69 @@ +package com.xtrh.common.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * 公共 JSON 构造及输入校验工具,不依赖数据库或 Spring 容器。 + * 统一保留字段和错误消息规则,控制层与后台通信共用相同校验口径。 + */ +public final class Jsons { + private Jsons() {} + + public static ObjectNode object() { + return JsonNodeFactory.instance.objectNode(); + } + + public static ArrayNode array() { + return JsonNodeFactory.instance.arrayNode(); + } + + // 参数错误向上传递,交由统一 HTTP 异常处理器返回失败。 + public static void require(boolean valid, String message) { + if (!valid) { + throw new IllegalArgumentException(message); + } + } + + /** + * 必填标识去除首尾空白并限制长度;不截断输入,避免两个标识意外变成同一值。 + * 字段是否允许空值仍由业务服务决定,不能在工具中猜测试验或设备关系。 + */ + public static String required(JsonNode node, String key) { + String value = node.path(key).asText("").trim(); + require(!value.isEmpty() && value.length() <= 200, "Invalid " + key); + return value; + } + + /** + * 来源服务地址必须是 HTTP/HTTPS 根地址,具体控制或媒体路径由程序追加。 + * 拒绝嵌入用户凭证、查询参数和片段,避免缓存地址夹带身份或改变请求签名路径。 + * 地址合法不等于可信,发现服务仍需验证远端响应签名、集群和总控身份。 + */ + public static String baseUrl(String value) { + URI uri = URI.create(value); + require( + Set.of("http", "https").contains(uri.getScheme()) + && uri.getHost() != null + && uri.getUserInfo() == null + && uri.getRawQuery() == null + && uri.getFragment() == null + && (uri.getPath().isEmpty() || uri.getPath().equals("/")), + "Invalid service address"); + return value.replaceAll("/+$", ""); + } + + // 返回当前对象的引用,服务层须在自己的同步范围内修改,向外展示时另做快照复制。 + public static List rows(ObjectNode root, String key) { + List result = new ArrayList<>(); + root.path(key).elements().forEachRemaining(n -> result.add((ObjectNode) n)); + return result; + } +} diff --git a/src/main/java/com/xtrh/communication/client/PeerClient.java b/src/main/java/com/xtrh/communication/client/PeerClient.java new file mode 100644 index 0000000..1a0e237 --- /dev/null +++ b/src/main/java/com/xtrh/communication/client/PeerClient.java @@ -0,0 +1,58 @@ +package com.xtrh.communication.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xtrh.common.config.Settings; +import com.xtrh.common.util.Jsons; +import com.xtrh.communication.security.Signatures; +import org.springframework.stereotype.Component; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.UUID; + +/** + * 后台服务之间的签名 HTTP 客户端,供自动发现、注册与领取任务共用。 + * 发送方和接收方都证明持有集群凭证,网络请求不传输明文密钥。 + * 浏览器通过 PXHJ 会话操作,不能直接获得这个服务客户端的凭证。 + */ +@Component +public class PeerClient { + private final Settings settings; + private final ObjectMapper mapper; + // 连接和请求都有时限,总控不可用时尽快进入重试,不占住心跳线程。 + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); + + public PeerClient(Settings settings, ObjectMapper mapper) { + this.settings = settings; + this.mapper = mapper; + } + + /** + * 签名必须使用实际发送的原始字节,不能签完以后再重新序列化 JSON。 + * nonce 将响应绑定到本次请求,旧响应不能冒充新的任务计划。 + */ + public JsonNode request(String base, String path, String method, JsonNode body) throws Exception { + byte[] bytes = body == null ? new byte[0] : mapper.writeValueAsBytes(body); + String nonce = UUID.randomUUID().toString(); + String time = Long.toString(System.currentTimeMillis()); + // 根地址先校验;查询参数、HTTP 方法与完整正文随后一起进入签名。 + var builder = HttpRequest.newBuilder(URI.create(Jsons.baseUrl(base) + path)) + .timeout(Duration.ofSeconds(3)) + .header("X-Xtrh-Time", time) + .header("X-Xtrh-Nonce", nonce) + .header("X-Xtrh-Signature", Signatures.request(settings.token, method, path, time, nonce, bytes)) + .header("Content-Type", "application/json"); + builder.method(method, HttpRequest.BodyPublishers.ofByteArray(bytes)); + var response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); + // 先验证响应来源再读取业务内容,发现广播中的伪造服务无法下发有效任务。 + String expected = Signatures.response(settings.token, nonce, response.statusCode(), response.body()); + String actual = response.headers().firstValue("X-Xtrh-Response-Signature").orElse(null); + if (!Signatures.equal(expected, actual)) throw new IllegalStateException("Peer response authentication failed"); + // 非成功状态不作为计划使用;由调用服务执行重注册或延后重试。 + if (response.statusCode() / 100 != 2) throw new IllegalStateException("Peer request failed: HTTP " + response.statusCode()); + return mapper.readTree(response.body()); + } +} diff --git a/src/main/java/com/xtrh/communication/security/ServiceSecurity.java b/src/main/java/com/xtrh/communication/security/ServiceSecurity.java new file mode 100644 index 0000000..c7264a2 --- /dev/null +++ b/src/main/java/com/xtrh/communication/security/ServiceSecurity.java @@ -0,0 +1,132 @@ +package com.xtrh.communication.security; + +import com.xtrh.common.config.Settings; +import com.xtrh.console.security.ConsoleRequestPolicy; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingResponseWrapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 为所有内部控制接口统一校验签名、防止重放并签署响应。 + * 这套凭证只用于后台服务互认;PXHJ 用户登录和权限仍由原平台负责。 + * 媒体流由节点服务单独检查任务版本及临时媒体许可。 + */ +@Component +public class ServiceSecurity extends OncePerRequestFilter { + private final Settings settings; + private final boolean consoleEnabled; + // 验证成功的 nonce 才进入缓存,原子插入确保并发重放最多接受一次。 + private final ConcurrentHashMap nonces = new ConcurrentHashMap<>(); + + public ServiceSecurity(Settings settings) { this(settings, true); } + + @Autowired + public ServiceSecurity(Settings settings, Environment environment) { + this(settings, environment.getProperty("fusion.console-enabled", Boolean.class, true)); + } + + public ServiceSecurity(Settings settings, boolean consoleEnabled) { + this.settings = settings; + this.consoleEnabled = consoleEnabled; + } + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { + String path = req.getRequestURI(); + if (consoleEnabled && settings.role.equals("coordinator") + && (path.equals("/console") || path.startsWith("/console/"))) { + res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + boolean read = req.getMethod().equals("GET") || req.getMethod().equals("HEAD"); + boolean safePath = !path.contains("..") && !path.contains("\\") && !path.contains("%") && !path.contains(";"); + boolean api = path.equals("/console/api") || path.startsWith("/console/api/"); + if (!read || !safePath || (api && !ConsoleRequestPolicy.localRead(req))) { + res.setStatus(403); + res.setContentType("application/json"); + res.getWriter().write("{\"message\":\"Console read access denied\"}"); + return; + } + chain.doFilter(req, res); + return; + } + // 健康入口只返回身份;媒体入口在业务层验证许可,错误分发不重复认证。 + if (path.equals("/health") || path.startsWith("/media/") || path.equals("/error")) { + chain.doFilter(req, res); + return; + } + String supplied = req.getHeader("X-Xtrh-Signature"); + String time = req.getHeader("X-Xtrh-Time"); + String nonce = req.getHeader("X-Xtrh-Nonce"); + long timestamp; + try { timestamp = Long.parseLong(time); } catch (Exception e) { timestamp = 0; } + // 先检查格式和一分钟时间窗,再读取正文,尽早拒绝不符合协议的控制请求。 + if (supplied == null || !supplied.matches("[0-9a-f]{64}") || nonce == null + || !nonce.matches("[a-zA-Z0-9-]{16,64}") || Math.abs(System.currentTimeMillis() - timestamp) > 60000) { + reject(res, "Service authentication required"); + return; + } + // 有界读取保护内存;签名绑定方法、路径、查询参数和实际收到的全部正文。 + byte[] body = req.getInputStream().readNBytes(2 * 1024 * 1024 + 1); + String requestPath = path + (req.getQueryString() == null ? "" : "?" + req.getQueryString()); + String expected = Signatures.request(settings.token, req.getMethod(), requestPath, time, nonce, body); + if (body.length > 2 * 1024 * 1024 || !Signatures.equal(expected, supplied) + || nonces.putIfAbsent(nonce, timestamp) != null) { + reject(res, "Invalid service signature"); + return; + } + // 超出认证时间窗的 nonce 已不能再次成功使用,可以移除以避免无限积累。 + long cutoff = System.currentTimeMillis() - 60000; + nonces.entrySet().removeIf(entry -> entry.getValue() < cutoff); + // 验签已经读过输入流;提供同一份字节给 Spring,避免控制层收到空正文。 + HttpServletRequest wrapped = replayable(req, body); + ContentCachingResponseWrapper response = new ContentCachingResponseWrapper(res); + chain.doFilter(wrapped, response); + // 发现广播只提供地址线索;只有验证响应签名才能确认服务属于本集群。 + response.setHeader("X-Xtrh-Response-Signature", Signatures.response(settings.token, nonce, response.getStatus(), response.getContentAsByteArray())); + response.copyBodyToResponse(); + } + + /** 失败只返回固定原因,绝不把密钥或待签名的正文写入响应。 */ + private void reject(HttpServletResponse response, String message) throws IOException { + response.setStatus(401); + response.setContentType("application/json"); + response.getWriter().write("{\"message\":\"" + message + "\"}"); + } + + /** + * 每次获取输入流时复用已认证正文,业务层解析的数据与签名校验的数据一致。 + * 内部控制接口采用同步 JSON 读取,不支持非阻塞流监听。 + */ + private HttpServletRequest replayable(HttpServletRequest request, byte[] body) { + return new HttpServletRequestWrapper(request) { + @Override public ServletInputStream getInputStream() { + ByteArrayInputStream input = new ByteArrayInputStream(body); + return new ServletInputStream() { + public int read() { return input.read(); } + public boolean isFinished() { return input.available() == 0; } + public boolean isReady() { return true; } + public void setReadListener(ReadListener listener) { throw new UnsupportedOperationException(); } + }; + } + @Override public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } + }; + } +} diff --git a/src/main/java/com/xtrh/communication/security/Signatures.java b/src/main/java/com/xtrh/communication/security/Signatures.java new file mode 100644 index 0000000..93f5f24 --- /dev/null +++ b/src/main/java/com/xtrh/communication/security/Signatures.java @@ -0,0 +1,54 @@ +package com.xtrh.communication.security; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; + +/** + * 服务请求与响应的签名规则,必须与 PXHJ 后端保持一致。 + * 请求和响应使用不同前缀,避免把某一方向的签名直接复用到另一方向。 + * 正文只取字节摘要参与签名,不会改变被传输的数据。 + */ +public final class Signatures { + private Signatures() {} + + /** 空正文也计算摘要,使 GET 与有正文的请求遵循同一套规则。 */ + public static String digest(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + /** + * 每次创建独立 Mac,避免多个心跳线程并发使用非线程安全的算法实例。 + * 密钥只存在于进程私有配置中,HTTP 头仅发送计算结果。 + */ + private static String hmac(String token, String canonical) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(token.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + /** 字段之间以换行分隔,双方按照相同顺序生成待签名内容。 */ + public static String request(String token, String method, String path, String time, String nonce, byte[] body) { + return hmac(token, "request\n" + method + "\n" + path + "\n" + time + "\n" + nonce + "\n" + digest(body)); + } + + /** 响应绑定原请求的随机数、状态码和正文,错误响应不能冒充成功响应。 */ + public static String response(String token, String nonce, int status, byte[] body) { + return hmac(token, "response\n" + nonce + "\n" + status + "\n" + digest(body)); + } + + /** 使用固定时间比较原语,避免直接字符串比较泄露匹配前缀信息。 */ + public static boolean equal(String expected, String actual) { + return actual != null && MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), actual.getBytes(StandardCharsets.US_ASCII)); + } +} diff --git a/src/main/java/com/xtrh/console/controller/ConsoleCollectionController.java b/src/main/java/com/xtrh/console/controller/ConsoleCollectionController.java new file mode 100644 index 0000000..ab46b3c --- /dev/null +++ b/src/main/java/com/xtrh/console/controller/ConsoleCollectionController.java @@ -0,0 +1,20 @@ +package com.xtrh.console.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.xtrh.console.service.ConsoleCollectionStore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** 签名服务入口仅更新内存查看快照,与总控任务、路由和节点许可解耦。 */ +@RestController +@ConditionalOnProperty(name = "fusion.role", havingValue = "coordinator", matchIfMissing = true) +public class ConsoleCollectionController { + private final ConsoleCollectionStore collection; + + public ConsoleCollectionController(ConsoleCollectionStore collection) { this.collection = collection; } + + @PostMapping("/control/collection/sync") + public Object sync(@RequestBody JsonNode input) { return collection.sync(input); } +} diff --git a/src/main/java/com/xtrh/console/controller/ConsoleController.java b/src/main/java/com/xtrh/console/controller/ConsoleController.java new file mode 100644 index 0000000..ef458a0 --- /dev/null +++ b/src/main/java/com/xtrh/console/controller/ConsoleController.java @@ -0,0 +1,46 @@ +package com.xtrh.console.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.xtrh.console.service.ConsoleCollectionStore; +import com.xtrh.control.service.CoordinatorService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import static com.xtrh.common.util.Jsons.object; + +/** 原生控制台仅查看状态,不能创建任务、修改容量或操作采集设备。 */ +@Controller +@ConditionalOnExpression("'${fusion.role:coordinator}' == 'coordinator' && ${fusion.console-enabled:true}") +public class ConsoleController { + private final ConsoleCollectionStore collection; + private final CoordinatorService coordinator; + + public ConsoleController(ConsoleCollectionStore collection, CoordinatorService coordinator) { + this.collection = collection; + this.coordinator = coordinator; + } + + @GetMapping("/console") public String redirect() { return "redirect:/console/"; } + @GetMapping("/console/") public String index() { return "forward:/console/index.html"; } + + @ResponseBody + @GetMapping("/console/api/snapshot") + public ObjectNode snapshot() { + ObjectNode result = object().put("serverTime", System.currentTimeMillis()); + result.set("collection", collection.snapshot()); + ArrayNode nodes = result.putArray("nodes"); + for (JsonNode source : coordinator.snapshot().path("nodes")) { + ObjectNode node = object(); + for (String field : new String[]{"nodeId", "name", "nodeRole", "online", "lastHeartbeat"}) + if (source.has(field)) node.set(field, source.path(field).deepCopy()); + // 旧节点尚未上报资源遥测;在线心跳不能推算 CPU、内存或网络利用率。 + node.putNull("cpu").putNull("memory").putNull("network"); + nodes.add(node); + } + return result; + } +} diff --git a/src/main/java/com/xtrh/console/security/ConsoleRequestPolicy.java b/src/main/java/com/xtrh/console/security/ConsoleRequestPolicy.java new file mode 100644 index 0000000..1e2708a --- /dev/null +++ b/src/main/java/com/xtrh/console/security/ConsoleRequestPolicy.java @@ -0,0 +1,34 @@ +package com.xtrh.console.security; + +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 本机只读查看入口不接受跨站请求或可用于 DNS 重绑定的任意 Host。 */ +public final class ConsoleRequestPolicy { + private static final Pattern HOST = Pattern.compile("(?i)(localhost|127\\.0\\.0\\.1|\\[::1\\])(?::([0-9]{1,5}))?"); + private ConsoleRequestPolicy() {} + + public static boolean localRead(HttpServletRequest request) { + if (!Set.of("GET", "HEAD").contains(request.getMethod())) return false; + if (!Set.of("127.0.0.1", "::1", "0:0:0:0:0:0:0:1").contains(request.getRemoteAddr())) return false; + String host = request.getHeader("Host"); + if (host == null) return false; + Matcher matcher = HOST.matcher(host); + if (!matcher.matches()) return false; + int port = matcher.group(2) == null ? (request.isSecure() ? 443 : 80) : Integer.parseInt(matcher.group(2)); + if (port < 1 || port > 65535 || port != request.getLocalPort()) return false; + if ("cross-site".equals(request.getHeader("Sec-Fetch-Site"))) return false; + String origin = request.getHeader("Origin"); + if (origin == null) return true; + try { + URI uri = URI.create(origin); + return uri.getScheme().equals(request.getScheme()) && uri.getRawAuthority() != null + && uri.getRawAuthority().equalsIgnoreCase(host) && uri.getRawUserInfo() == null + && (uri.getRawPath() == null || uri.getRawPath().isEmpty()) + && uri.getRawQuery() == null && uri.getRawFragment() == null; + } catch (IllegalArgumentException | NullPointerException failure) { return false; } + } +} diff --git a/src/main/java/com/xtrh/console/service/ConsoleCollectionStore.java b/src/main/java/com/xtrh/console/service/ConsoleCollectionStore.java new file mode 100644 index 0000000..6e88770 --- /dev/null +++ b/src/main/java/com/xtrh/console/service/ConsoleCollectionStore.java @@ -0,0 +1,158 @@ +package com.xtrh.console.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.function.LongSupplier; + +import static com.xtrh.common.util.Jsons.*; + +/** + * 接收 PXHJ 已解析的只读采集快照,与任务调度、许可和 H2 状态完全分开。 + * 只保留白名单标量;模拟数据、凭证及媒体许可不会进入浏览器数据集合。 + */ +@Service +@ConditionalOnProperty(name = "fusion.role", havingValue = "coordinator", matchIfMissing = true) +public class ConsoleCollectionStore { + private static final int MAX_BYTES = 1_500_000; + private static final long STALE_MS = 15_000; + private static final Set QUALITIES = Set.of("GOOD", "VALID", "STALE", "MISSING", "AMBIGUOUS", + "INVALID_TIME", "INVALID_VALUE", "WAITING", "OFFLINE", "UNKNOWN", "ERROR", "UNAVAILABLE"); + private final ObjectMapper mapper; + private final LongSupplier clock; + private ArrayNode contexts = array(); + private long observedAt; + private long lastSyncedAt; + + @Autowired + public ConsoleCollectionStore(ObjectMapper mapper) { this(mapper, System::currentTimeMillis); } + + public ConsoleCollectionStore(ObjectMapper mapper, LongSupplier clock) { + this.mapper = mapper; + this.clock = clock; + } + + /** 整体校验成功后原子替换;失败与迟到快照不会破坏上次有效数据。 */ + public synchronized ObjectNode sync(JsonNode input) { + require(input != null && input.isObject(), "Invalid collection snapshot"); + require("PXHJ".equals(text(input, "source", 16, true)), "Invalid collection source"); + require(!simulated(input), "Simulated collection is not accepted"); + try { require(mapper.writeValueAsBytes(input).length <= MAX_BYTES, "Collection snapshot too large"); } + catch (java.io.IOException failure) { throw new IllegalArgumentException("Invalid collection snapshot"); } + long received = clock.getAsLong(); + long observed = timestamp(input, "observedAt", true); + require(observed <= received + 60_000, "Collection time is in the future"); + require(observed >= observedAt, "Outdated collection snapshot"); + JsonNode sourceContexts = boundedArray(input, "contexts", 32); + ArrayNode accepted = array(); + int equipmentCount = 0, itemCount = 0; + Set contextIds = new HashSet<>(); + for (JsonNode context : sourceContexts) { + require(context.isObject(), "Invalid context"); + String contextId = text(context, "id", 200, true); + require(contextIds.add(contextId), "Duplicate collection context"); + ObjectNode copy = object().put("id", contextId).put("name", text(context, "name", 200, false)) + .put("roundId", text(context, "roundId", 200, false)); + ArrayNode equipment = copy.putArray("equipment"); + Set equipmentIds = new HashSet<>(); + for (JsonNode row : boundedArray(context, "equipment", 128)) { + require(row.isObject() && ++equipmentCount <= 512, "Too many collection equipment"); + String id = text(row, "id", 200, true); + require(equipmentIds.add(id), "Duplicate collection equipment"); + if (simulated(row)) continue; + ObjectNode target = object().put("id", id) + .put("participantCode", text(row, "participantCode", 200, false)) + .put("code", text(row, "code", 200, false)).put("name", text(row, "name", 200, false)) + .put("type", text(row, "type", 200, false)).put("state", text(row, "state", 64, false)); + nullableTime(target, row, "lastReceivedAt"); + ArrayNode items = target.putArray("items"); + Set keys = new HashSet<>(); + for (JsonNode item : boundedArray(row, "items", 256)) { + require(item.isObject() && ++itemCount <= 4096, "Too many collection items"); + String key = text(item, "key", 200, true); + require(keys.add(key), "Duplicate collection item"); + if (simulated(item) || sensitive(key)) continue; + ObjectNode value = object().put("key", key).put("label", text(item, "label", 200, false)) + .put("type", text(item, "type", 64, false)).put("unit", text(item, "unit", 32, false)) + .put("sourceName", text(item, "sourceName", 200, false)) + .put("sourceType", text(item, "sourceType", 64, false)); + String quality = text(item, "quality", 64, false).toUpperCase(Locale.ROOT); + value.put("quality", QUALITIES.contains(quality) ? quality : "UNKNOWN"); + JsonNode sample = item.get("value"); + require(sample == null || sample.isNull() || sample.isNumber() || sample.isTextual() || sample.isBoolean(), "Collection values must be scalar"); + require(sample == null || !sample.isTextual() || sample.textValue().length() <= 4096, "Collection value too long"); + require(sample == null || !sample.isFloatingPointNumber() || Double.isFinite(sample.doubleValue()), "Invalid collection number"); + if (sample == null || sample.isNull()) value.putNull("value"); else value.set("value", sample.deepCopy()); + nullableTime(value, item, "receivedAt"); + nullableTime(value, item, "sampleTime"); + items.add(value); + } + equipment.add(target); + } + accepted.add(copy); + } + contexts = accepted; + observedAt = observed; + lastSyncedAt = received; + return object().put("accepted", true).put("lastSyncedAt", lastSyncedAt).put("observedAt", observedAt); + } + + /** 失联后保留最后值并标记过期,不以空值或假在线状态覆盖真实采集记录。 */ + public synchronized ObjectNode snapshot() { + long now = clock.getAsLong(); + String state = lastSyncedAt == 0 ? "WAITING" + : now - lastSyncedAt > STALE_MS || now - observedAt > STALE_MS ? "STALE" : "CONNECTED"; + ObjectNode out = object().put("state", state); + if (lastSyncedAt == 0) out.putNull("lastSyncedAt").putNull("observedAt"); + else out.put("lastSyncedAt", lastSyncedAt).put("observedAt", observedAt); + out.set("contexts", contexts.deepCopy()); + return out; + } + + private static JsonNode boundedArray(JsonNode node, String key, int limit) { + JsonNode value = node.path(key); + require(value.isArray() && value.size() <= limit, "Invalid or excessive " + key); + return value; + } + + private static String text(JsonNode node, String key, int limit, boolean required) { + JsonNode value = node.get(key); + require(value == null || value.isNull() || value.isTextual(), "Invalid " + key); + String result = value == null || value.isNull() ? "" : value.textValue().trim(); + require(result.length() <= limit && (!required || !result.isEmpty()), "Invalid " + key); + return result; + } + + private static long timestamp(JsonNode node, String key, boolean required) { + JsonNode value = node.get(key); + if (value == null || value.isNull()) { require(!required, "Missing " + key); return 0; } + require(value.isIntegralNumber() && value.canConvertToLong() && value.longValue() > 0, "Invalid " + key); + return value.longValue(); + } + + private static void nullableTime(ObjectNode target, JsonNode source, String key) { + long value = timestamp(source, key, false); + if (value == 0) target.putNull(key); else target.put(key, value); + } + + private static boolean simulated(JsonNode row) { + if (row.path("simulated").asBoolean(false)) return true; + for (String key : Set.of("quality", "sourceName", "sourceType", "state")) { + String value = row.path(key).asText("").toUpperCase(Locale.ROOT); + if (value.contains("SIMULAT") || value.contains("DEMO") || value.contains("模拟") || value.contains("演示")) return true; + } + return false; + } + + private static boolean sensitive(String key) { + return key.toLowerCase(Locale.ROOT).matches(".*(password|passwd|secret|credential|token|mediakey|privatekey|sm4key|hmackey|sessionid|bootid).*"); + } +} diff --git a/src/main/java/com/xtrh/control/controller/CoordinatorController.java b/src/main/java/com/xtrh/control/controller/CoordinatorController.java new file mode 100644 index 0000000..18cfbfb --- /dev/null +++ b/src/main/java/com/xtrh/control/controller/CoordinatorController.java @@ -0,0 +1,31 @@ +package com.xtrh.control.controller; + +import com.xtrh.control.service.CoordinatorService; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.*; + +/** + * 总控 HTTP 入口:只接收参数和返回结果,调度与持久化由服务层处理。 + * 内部接口认证统一交给服务签名过滤器,避免各入口出现不同认证规则。 + */ +@RestController +@RequestMapping("/control") +@ConditionalOnProperty(name="fusion.role", havingValue="coordinator", matchIfMissing=true) +public class CoordinatorController { + private final CoordinatorService service; + public CoordinatorController(CoordinatorService service){this.service=service;} + @GetMapping("/info") public Object info(){return service.info();} + @GetMapping("/snapshot") public Object snapshot(){return service.snapshot();} + @PostMapping("/nodes/register") public Object register(@RequestBody JsonNode input){return service.register(input);} + @PostMapping("/nodes/{id}/heartbeat") public Object heartbeat(@PathVariable String id,@RequestBody JsonNode input){return service.heartbeat(id,input);} + @PatchMapping("/nodes/{id}/capacity") public Object capacity(@PathVariable String id,@RequestBody JsonNode input){return service.capacity(id,input);} + // 试验和轮次状态以 PXHJ 保存的数据为准,浏览器不直接访问总控接口。 + @PostMapping("/contexts/sync") public Object context(@RequestBody JsonNode input){return service.context(input);} + // 原图接入不依赖试验启动;密钥只经过内部签名接口进入短期内存许可。 + @PostMapping("/image-sources/register") public Object imageSource(@RequestBody JsonNode input){return service.imageSource(input);} + @PostMapping("/image-sources/resolve") public Object imageRoutes(@RequestBody JsonNode input){return service.imageRoutes(input);} + @PutMapping("/routes") public Object routes(@RequestBody JsonNode input){return service.routes(input);} + @PostMapping("/gateways/heartbeat") public Object gateway(@RequestBody JsonNode input){return service.gateway(input);} +} diff --git a/src/main/java/com/xtrh/control/service/CoordinatorService.java b/src/main/java/com/xtrh/control/service/CoordinatorService.java new file mode 100644 index 0000000..fe758b2 --- /dev/null +++ b/src/main/java/com/xtrh/control/service/CoordinatorService.java @@ -0,0 +1,394 @@ +package com.xtrh.control.service; + +import com.xtrh.common.util.Jsons; +import com.xtrh.common.config.Settings; +import com.xtrh.common.dao.StateStore; + +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.node.*; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import jakarta.annotation.PreDestroy; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.function.LongSupplier; +import static com.xtrh.common.util.Jsons.*; + +/** + * 总控业务服务:根据 PXHJ 的正式试验状态、节点许可和配置路数组织任务。 + * 所有状态操作在同一把锁内执行,保证两个并发心跳不会重复占用同一个空位。 + * controller 不决定分配结果;DAO 只负责完整状态的保存和读取。 + */ +@Service +@ConditionalOnProperty(name="fusion.role", havingValue="coordinator", matchIfMissing=true) +public class CoordinatorService { + private final StateStore store; + private final ObjectNode state; + // 展示时间与授权计时分开:墙上时钟用于日志,单调时钟用于判断节点是否失联。 + private final LongSupplier clock; + private final LongSupplier leaseClock; + private final Map leaseRefresh=new HashMap<>(); + private final long leaseMs; + private final String cluster; + // 来源许可与试验快照隔离,采集器密钥不会写入 StateStore 或公开节点列表。 + private final ImageSourceRegistry imageSources = new ImageSourceRegistry(); + // 保存失败后停止继续派发,避免内存中的新授权与磁盘中的旧状态发生分歧。 + private boolean storageHealthy = true; + + @org.springframework.beans.factory.annotation.Autowired + public CoordinatorService(Settings settings, ObjectMapper mapper) throws Exception { + this(StateStore.open(settings.directory.resolve("coordinator"), mapper), + System::currentTimeMillis, ()->System.nanoTime()/1_000_000, settings.leaseMs, settings.cluster); + state.put("serviceId",settings.id);persist(); + } + + /** 测试可以提供可控时钟;实际部署使用系统单调时钟,不依赖设备报来的时间。 */ + CoordinatorService(StateStore store, LongSupplier clock, long leaseMs, String cluster) throws Exception { + this(store,clock,clock,leaseMs,cluster); + } + CoordinatorService(StateStore store,LongSupplier clock,LongSupplier leaseClock,long leaseMs,String cluster) throws Exception { + this.store=store; this.clock=clock;this.leaseClock=leaseClock; this.leaseMs=leaseMs; this.cluster=cluster; + state=store.load(); + // 五类业务状态共同保存为原子快照,恢复时先补齐首次部署缺失的集合。 + for(String key:List.of("nodes","contexts","tasks","gateways","routes")) if(!state.path(key).isObject()) state.putObject(key); + // 重启后提升总控代次并撤销旧授权,等 PXHJ 重新确认正式试验后再派发,防止复活已结束任务。 + state.put("epoch", state.path("epoch").asLong()+1); + if(!state.hasNonNull("serviceId"))state.put("serviceId",UUID.randomUUID().toString()); + if(!state.has("routesConfirmed"))state.put("routesConfirmed",state.has("routeVersion")); + // 保留节点容量和身份,重新生成会话,让重启前迟到的准备回执无法重新获得授权。 + for(ObjectNode n:rows(state,"nodes")) n.put("lastHeartbeat",0).put("sessionId",UUID.randomUUID().toString()); + for(ObjectNode t:rows(state,"tasks")) {t.putNull("active");t.putNull("candidate");t.put("reason","RECONCILING");} + // 原磁盘记录只说明曾经运行过;没有 PXHJ 的当前确认不能推断试验仍在进行。 + for(ObjectNode c:rows(state,"contexts")) c.put("confirmed",false); + // 原图是短期接入任务;重启后等下一次认证协商重新创建,避免遗留接收槽位。 + for(ObjectNode t:rows(state,"tasks"))if(original(t))t.put("desired","STOPPED"); + for(ObjectNode c:rows(state,"contexts"))if(c.path("kind").asText().equals("ORIGINAL_IMAGE"))map("contexts").remove(c.path("trialId").asText()); + for(ObjectNode g:rows(state,"gateways")) g.put("lastHeartbeat",0); + persist(); + } + + private long now(){return clock.getAsLong();} + private ObjectNode map(String key){return (ObjectNode)state.path(key);} + private ObjectNode node(String id){var n=map("nodes").get(id);if(n==null)throw new IllegalArgumentException("Unknown node");return (ObjectNode)n;} + // 授权按单调时钟到期;系统校时只影响展示时间,不延长失联节点的许可。 + private boolean live(ObjectNode n){Long last=leaseRefresh.get(n.path("nodeId").asText());long age=last==null?Long.MAX_VALUE:leaseClock.getAsLong()-last;return age>=0&&age=0&&suggested<=64,"Invalid capacity"); + ObjectNode n=map("nodes").has(id)?node(id):object().put("nodeId",id).put("capacity",suggested).put("enabled",true); + // 注册可重复调用;同一启动号沿用会话,避免网络重试生成重复节点或打断在途任务。 + if(!boot.equals(n.path("bootId").asText())) n.put("sessionId",UUID.randomUUID().toString()); + n.put("bootId",boot).put("name",required(input,"name")).put("nodeRole",nodeRole).put("baseUrl",url).put("lastHeartbeat",now()); + // 旧版本节点仍可执行演示任务,只有明确上报有效图片入口的节点才能接原图。 + int imagePort=input.path("imageInputPort").asInt(0); + String imageHost=input.path("imageHost").asText(""); + require(imagePort==0||(imagePort>0&&imagePort<=65535&&ImageSourceRegistry.ipv4(imageHost)),"Invalid node image input"); + n.put("imageInputPort",imagePort).put("imageHost",imageHost); + map("nodes").set(id,n);leaseRefresh.put(id,leaseClock.getAsLong());schedule();persist(); + return object().put("nodeId",id).put("sessionId",n.path("sessionId").asText()).put("epoch",state.path("epoch").asLong()).put("leaseMs",leaseMs); + } + + /** + * 心跳既续期节点许可,也提交候选任务的准备结果。 + * 回复仅包含当前节点、当前会话仍有效的任务,节点以此撤销不再属于自己的任务。 + */ + public synchronized ObjectNode heartbeat(String id,JsonNode input){ + ObjectNode n=node(id); + if(!required(input,"sessionId").equals(n.path("sessionId").asText())||!required(input,"bootId").equals(n.path("bootId").asText())) + throw new IllegalStateException("Node session superseded; register again"); + n.put("lastHeartbeat",now());leaseRefresh.put(id,leaseClock.getAsLong());schedule(); + // 先移除失效分配,再检查回执;旧节点的迟到结果不能覆盖迁移后的新版本。 + for(JsonNode report:input.path("reports")){ + var entry=map("tasks").get(report.path("taskId").asText());if(entry==null)continue; + ObjectNode t=(ObjectNode)entry,candidate=assignment(t,"candidate"); + // 原图首帧状态属于具体分配版本;旧节点的迟到图片不能宣布新版本就绪。 + if(original(t))for(String key:List.of("active","candidate")){ + ObjectNode a=assignment(t,key); + if(a!=null&&valid(a)&&a.path("nodeId").asText().equals(id)&&a.path("version").asLong()==report.path("version").asLong()){ + a.put("imageReady",report.path("imageReady").asBoolean()); + a.put("lastFrameTime",report.path("lastFrameTime").asLong()); + } + } + if(t.path("desired").asText().equals("STOPPED")||candidate==null||!candidate.path("nodeId").asText().equals(id) + ||candidate.path("version").asLong()!=report.path("version").asLong()||!valid(candidate))continue; + // 新节点已经生成可播放画面后才切换 active;准备期间旧节点继续承担播放。 + if(report.path("ready").asBoolean()){ + candidate.put("ready",true); + // 首次接入先开放上传地址;迁移则继续双发,收到新节点首帧后才切换 PXHJ 取图。 + if(!original(t)||assignment(t,"active")==null||report.path("imageReady").asBoolean()){ + candidate.put("activatedAt",now());t.set("active",candidate.deepCopy());t.putNull("candidate");t.put("reason",""); + } + }else if(report.hasNonNull("error")){ + // 准备失败只撤销候选;旧 active 保留,冷却后重试,防止失败任务占住预留容量。 + t.putNull("candidate");t.put("reason","MEDIA_PREPARE_FAILED").put("lastFailure",now()); + } + } + schedule();persist(); + var result=object().put("leaseMs",leaseMs).put("epoch",state.path("epoch").asLong());var plans=result.putArray("tasks"); + // 迁移期间两个节点分别拿到运行和预热计划,完成接管后旧节点不再收到该版本。 + for(ObjectNode t:rows(state,"tasks"))for(String key:List.of("active","candidate")){ + ObjectNode a=assignment(t,key);if(a==null||!a.path("nodeId").asText().equals(id)||!valid(a))continue; + ObjectNode plan=t.deepCopy();plan.remove(List.of("active","candidate"));plan.set("assignment",a.deepCopy());plan.put("preparing",key.equals("candidate"));plans.add(plan); + // 密钥仅发送给当前有效的执行节点,不进入任务快照、持久化或浏览器 API。 + if(original(t))plan.set("imageAccess",imageSources.access(t.path("sourceEndpointId").asText())); + } + return result; + } + + /** 路数是管理员配置的最大并发任务数;当前阶段不根据 CPU/GPU 自动推算。 */ + public synchronized ObjectNode capacity(String id,JsonNode input){ + require(input.path("capacity").isIntegralNumber(),"Capacity must be an integer");int value=input.path("capacity").asInt(); + require(value>=0&&value<=64,"Capacity must be between 0 and 64"); + var n=node(id);n.put("capacity",value);if(input.has("enabled"))n.put("enabled",input.path("enabled").asBoolean()); + schedule();persist();return snapshot(); + } + + /** 只接收通过签名认证的 PXHJ 后台提供的已保存试验与正式轮次状态。 */ + public synchronized ObjectNode context(JsonNode input){ + String trialId=required(input,"trialId"),roundId=required(input,"roundId"),name=required(input,"name"); + String phase=required(input,"roundStatus");require(Set.of("PENDING","RUNNING","SAVED").contains(phase),"Invalid round state"); + require(input.path("channels").isArray()&&input.path("channels").size()<=128,"Invalid channels"); + // 通道身份必须唯一,同一装备可以对应多路,但重复通道不能形成重复调度任务。 + var seen=new HashSet(); + for(JsonNode channel:input.path("channels")){require(seen.add(required(channel,"channelId")),"Duplicate channel");required(channel,"equipmentId");required(channel,"name");} + String revision=input.path("configurationVersion").asText("0"); + var c=map("contexts").has(trialId)?(ObjectNode)map("contexts").get(trialId):object(); + boolean enabled=input.path("enabled").asBoolean(true); + c.put("trialId",trialId).put("roundId",roundId).put("name",name).put("roundStatus",phase).put("configurationVersion",revision) + .put("enabled",enabled).put("confirmed",true).put("confirmedAt",now()); + c.set("channels",input.path("channels").deepCopy());c.set("bindings",input.path("bindings").deepCopy());map("contexts").set(trialId,c); + Set wanted=new HashSet<>(); + // 稳定任务号绑定试验、轮次和通道;同步重试复用任务,新一轮生成独立任务。 + if(enabled&&!phase.equals("SAVED"))for(JsonNode channel:input.path("channels")){ + String id=UUID.nameUUIDFromBytes((trialId+"/"+roundId+"/"+channel.path("channelId").asText()).getBytes(StandardCharsets.UTF_8)).toString();wanted.add(id); + var t=map("tasks").has(id)?(ObjectNode)map("tasks").get(id):object().put("taskId",id).put("versionCounter",0).put("createdAt",now()); + t.put("trialId",trialId).put("roundId",roundId).put("channelId",channel.path("channelId").asText()).put("equipmentId",channel.path("equipmentId").asText()) + .put("name",channel.path("name").asText()).put("trialName",name).put("desired",phase.equals("RUNNING")?"RUNNING":"READY").put("mediaKind","DEMO_VIDEO"); + map("tasks").set(id,t); + } + // 正式结束、关闭功能或删除通道都会撤销旧任务;此处不触碰独立的采集网关路由。 + for(ObjectNode t:rows(state,"tasks"))if(t.path("trialId").asText().equals(trialId)&&!wanted.contains(t.path("taskId").asText())){ + t.put("desired","STOPPED");t.putNull("active");t.putNull("candidate");t.put("reason",""); + } + schedule();persist();return snapshot(); + } + + // 运行任务和预热任务都占用路数,迁移时不能把新节点的容量重复分给其他任务。 + private int load(String id){int count=0;for(ObjectNode t:rows(state,"tasks"))for(String key:List.of("active","candidate")){ + ObjectNode a=assignment(t,key);if(a!=null&&a.path("nodeId").asText().equals(id))count++; + }return count;} + /** 空位按已占用比例选择,容量不同的电脑可承担不同路数;迁移排除原节点。 */ + private ObjectNode available(String exclude,ObjectNode task){ + return rows(state,"nodes").stream().filter(n->live(n)&&n.path("enabled").asBoolean()&&!n.path("nodeId").asText().equals(exclude) + &&load(n.path("nodeId").asText())0&&ImageSourceRegistry.ipv4(n.path("imageHost").asText())))) + .min(Comparator.comparingDouble((ObjectNode n)->(double)load(n.path("nodeId").asText())/Math.max(1,n.path("capacity").asInt())) + .thenComparing(n->n.path("nodeId").asText())).orElse(null); + } + /** + * 每次分配提升任务版本并生成新的媒体许可。 + * 候选准备完成之前不替换 active,因此 PXHJ 可以继续播放已就绪的旧地址。 + */ + private void assign(ObjectNode t,ObjectNode n){ + long version=t.path("versionCounter").asLong()+1;t.put("versionCounter",version); + t.set("candidate",object().put("nodeId",n.path("nodeId").asText()).put("sessionId",n.path("sessionId").asText()) + .put("version",version).put("mediaKey",UUID.randomUUID().toString()).put("assignedAt",now())); + t.put("reason",assignment(t,"active")==null?"PREPARING":"MIGRATING"); + } + /** + * 调度顺序:撤销失效许可、处理容量缩减,再为等待任务寻找空位。 + * 同一次调度把所有候选预热任务计入容量,避免并发抢占和超发。 + */ + private void schedule(){ + expireImageSources(); + // 同等条件下先处理较早创建的任务,让容量不足时的等待顺序可预测。 + List tasks=rows(state,"tasks");tasks.sort(Comparator.comparingLong(t->t.path("createdAt").asLong())); + for(ObjectNode t:tasks){ + var context=map("contexts").get(t.path("trialId").asText()); + if(t.path("desired").asText().equals("STOPPED")||context==null||!context.path("confirmed").asBoolean()){t.putNull("active");t.putNull("candidate");continue;} + for(String key:List.of("active","candidate")){ObjectNode a=assignment(t,key);if(a!=null&&!valid(a)){t.putNull(key);t.put("reason","NODE_OFFLINE");}} + ObjectNode candidate=assignment(t,"candidate"); + // 预热超时释放预留槽位,已有输出仍然保留;错误状态向 PXHJ 汇总展示。 + if(candidate!=null&&now()-candidate.path("assignedAt").asLong()>30000){t.putNull("candidate");t.put("reason","PREPARE_TIMEOUT").put("lastFailure",now());} + } + for(ObjectNode n:rows(state,"nodes")){ + String id=n.path("nodeId").asText();int effective=n.path("enabled").asBoolean()?n.path("capacity").asInt():0; + int active=(int)tasks.stream().filter(t->t.path("active").path("nodeId").asText().equals(id)).count(); + var incoming=tasks.stream().filter(t->t.path("candidate").path("nodeId").asText().equals(id)).toList(); + // 管理员减少容量后先撤销多余的预热任务,再逐步迁出正在播放的任务。 + for(int i=Math.max(0,effective-active);it.path("active").path("nodeId").asText().equals(id)).toList(); + // 降低路数时先寻找接管节点;集群没有空位则标记调整中,保留现有视频直到能迁出。 + int surplus=Math.max(0,owned.size()-effective); + for(int i=owned.size()-1;i>=0&&surplus>0;i--,surplus--){var t=owned.get(i);if(assignment(t,"candidate")!=null)continue; + ObjectNode target=available(id,t);if(target==null)t.put("reason","CAPACITY_WAIT_KEEP_RUNNING");else assign(t,target); + } + } + // 没有 active/candidate 的任务才参与新分配;容量不足保持排队,不强行一机一路。 + for(ObjectNode t:tasks)if(!t.path("desired").asText().equals("STOPPED")&&assignment(t,"active")==null&&assignment(t,"candidate")==null){ + var c=map("contexts").get(t.path("trialId").asText());if(c==null||!c.path("confirmed").asBoolean())continue; + if(now()-t.path("lastFailure").asLong()>3000){ObjectNode target=available(null,t);if(target==null)t.put("reason","CAPACITY_WAIT");else assign(t,target);} + } + } + + private static boolean original(JsonNode task){return task.path("mediaKind").asText().equals("ORIGINAL_IMAGE");} + private static String imageTaskId(String endpoint){return UUID.nameUUIDFromBytes(("original-image/"+endpoint).getBytes(StandardCharsets.UTF_8)).toString();} + + /** 采集器在线时原图随时可接入,不以正式轮次启动作为试验准备取图的前提。 */ + public synchronized ObjectNode imageSource(JsonNode input){ + expireImageSources(); + String endpoint=required(input,"endpointId"),name=required(input,"name"); + ObjectNode oldAccess=imageSources.access(endpoint); + imageSources.register(input,leaseClock.getAsLong()); + ObjectNode access=imageSources.access(endpoint); + String contextId="collector-image:"+endpoint,id=imageTaskId(endpoint); + map("contexts").set(contextId,object().put("kind","ORIGINAL_IMAGE").put("trialId",contextId) + .put("roundId","standby").put("name","采集器原图接入").put("roundStatus","PENDING").put("confirmed",true)); + ObjectNode t=map("tasks").has(id)?(ObjectNode)map("tasks").get(id):object().put("taskId",id).put("versionCounter",0).put("createdAt",now()); + // 原连接或密钥变化时撤销旧接收许可,再分配新版本,防止继续使用旧设备凭据。 + if(oldAccess!=null&&!oldAccess.equals(access)){t.putNull("active");t.putNull("candidate");} + t.put("trialId",contextId).put("roundId","standby").put("channelId","original-image").put("equipmentId",endpoint) + .put("name",name+"原图").put("trialName","试验准备原图").put("desired","READY").put("mediaKind","ORIGINAL_IMAGE") + .put("sourceEndpointId",endpoint).put("sourceHost",access.path("sourceHost").asText()).put("collectorId",access.path("collectorId").asInt()); + map("tasks").set(id,t);schedule();persist();return imageRoute(t); + } + + /** PXHJ 在已有图片 API 后面查询有效接收节点,浏览器无需知道集群内部地址。 */ + public synchronized ObjectNode imageRoutes(JsonNode input){ + require(input.path("endpointIds").isArray()&&input.path("endpointIds").size()<=64,"Invalid image endpoints"); + schedule();ObjectNode result=object();ArrayNode routes=result.putArray("routes"); + Set seen=new HashSet<>(); + for(JsonNode value:input.path("endpointIds")){ + String endpoint=value.asText();require(endpoint.matches("[0-9a-fA-F-]{36}"),"Invalid image endpoint"); + if(!seen.add(endpoint))continue;JsonNode entry=map("tasks").get(imageTaskId(endpoint));if(entry==null)continue; + ObjectNode a=assignment((ObjectNode)entry,"active");if(!valid(a))continue; + routes.add(object().put("endpointId",endpoint).put("taskId",entry.path("taskId").asText()) + .put("version",a.path("version").asLong()).put("nodeBaseUrl",node(a.path("nodeId").asText()).path("baseUrl").asText())); + } + return result; + } + + // 迁移期间最多发布两个地址;bulk 图片不经过网关,网关只运送短小的协商报文。 + private ObjectNode imageRoute(ObjectNode t){ + ObjectNode result=object().put("status","WAITING").put("coordinatorId",state.path("serviceId").asText()) + .put("epoch",state.path("epoch").asLong()).put("version",t.path("versionCounter").asLong()).put("leaseMs",8000); + ArrayNode targets=result.putArray("targets"); + for(String key:List.of("active","candidate")){ + ObjectNode a=assignment(t,key);if(!valid(a)||!a.path("ready").asBoolean())continue; + ObjectNode n=node(a.path("nodeId").asText()); + if(n.path("imageInputPort").asInt()>0&&ImageSourceRegistry.ipv4(n.path("imageHost").asText())) + targets.add(object().put("host",n.path("imageHost").asText()).put("port",n.path("imageInputPort").asInt())); + } + if(!targets.isEmpty())result.put("status","READY");return result; + } + + /** 连续三十秒没有经过认证的协商就释放原图任务,独立试验和 UDP 路由保持原状。 */ + private void expireImageSources(){ + for(String endpoint:imageSources.expire(leaseClock.getAsLong())){ + // 保留不含密钥的版本计数,采集器恢复后不可能回到同一总控代次中的旧许可。 + JsonNode entry=map("tasks").get(imageTaskId(endpoint)); + if(entry instanceof ObjectNode task){task.put("desired","STOPPED");task.putNull("active");task.putNull("candidate");} + map("contexts").remove("collector-image:"+endpoint); + } + } + + /** + * 登记的是原始报文的来源与转发地址,总控不解析采集模块专有协议。 + * 稳定的中转源端口供 PXHJ 关联原连接,保留原采集器登记和原密钥。 + */ + public synchronized ObjectNode routes(JsonNode input){ + require(input.path("routes").isArray()&&input.path("routes").size()<=255,"Invalid routes"); + var updated=object();Set ports=new HashSet<>();Set ids=new HashSet<>();List approved=new ArrayList<>(); + for(JsonNode route:input.path("routes")){ + String id=required(route,"endpointId");required(route,"sourceHost");required(route,"targetHost"); + require(ids.add(id),"Duplicate endpoint identity"); + int source=route.path("sourcePort").asInt(),relay=route.path("relayPort").asInt(),target=route.path("targetPort").asInt(); + require(source>=0&&source<=65535&&relay>1024&&relay<=65535&&target>0&&target<=65535&&ports.add(relay),"Invalid or duplicate route ports"); + require(relay!=19002,"Relay port conflicts with the default gateway input"); + // 不允许把仍在使用的源端口换给另一采集器,否则旧回执可能发到错误设备。 + for(ObjectNode old:rows(state,"routes"))require(old.path("relayPort").asInt()!=relay||old.path("endpointId").asText().equals(id),"Relay port belongs to another endpoint; allocate a different port"); + // 来源端口为 0 表示该地址的任意端口,不能与同地址的另一登记同时匹配。 + for(JsonNode previous:approved)require(!previous.path("sourceHost").asText().equals(route.path("sourceHost").asText()) + ||(source!=0&&previous.path("sourcePort").asInt()!=0&&source!=previous.path("sourcePort").asInt()),"Overlapping source mappings"); + approved.add(route); + updated.set(id,route.deepCopy()); + } + state.set("routes",updated);state.put("routeVersion",state.path("routeVersion").asLong()+1).put("routesConfirmed",true);persist();return snapshot(); + } + + /** 网关上报实际 UDP 出口和统计;总控仅返回已由 PXHJ 确认的路由版本。 */ + public synchronized ObjectNode gateway(JsonNode input){ + require(cluster.equals(required(input,"clusterId")),"Cluster mismatch");String id=required(input,"gatewayId"); + var report=(ObjectNode)input.deepCopy();report.remove("clusterId");report.put("lastHeartbeat",now());map("gateways").set(id,report); + var result=object().put("routeVersion",state.path("routeVersion").asLong()).put("routesConfirmed",state.path("routesConfirmed").asBoolean());result.set("routes",array().addAll(rows(state,"routes")));persist();return result; + } + + /** + * PXHJ 使用的业务快照,隐藏内部节点会话,提供当前任务状态及可用播放地址。 + * 地址由已注册节点和有效任务构造,不能直接相信节点自行提交的任意播放 URL。 + */ + public synchronized ObjectNode snapshot(){ + ObjectNode result=object().put("clusterId",cluster).put("serverTime",now()).put("serviceId",state.path("serviceId").asText()).put("epoch",state.path("epoch").asLong()).put("revision",state.path("revision").asLong()).put("storageHealthy",storageHealthy); + ArrayNode nodes=result.putArray("nodes"),tasks=result.putArray("tasks"),gateways=result.putArray("gateways"); + int capacity=0,active=0,queued=0; + for(ObjectNode n:rows(state,"nodes")){ + ObjectNode copy=n.deepCopy();copy.remove(List.of("sessionId","bootId"));copy.put("online",live(n)); + // 兼容升级前的子节点记录;角色由稳定身份确认,前端无需猜测名称或端口。 + copy.put("nodeRole",n.path("nodeId").asText().equals(state.path("serviceId").asText())?"COORDINATOR":"WORKER"); + int used=0,reserved=0;for(ObjectNode t:rows(state,"tasks")){if(t.path("active").path("nodeId").asText().equals(n.path("nodeId").asText()))used++;if(t.path("candidate").path("nodeId").asText().equals(n.path("nodeId").asText()))reserved++;} + copy.put("running",used).put("reserved",reserved).put("adjusting",used+reserved>(n.path("enabled").asBoolean()?n.path("capacity").asInt():0));nodes.add(copy); + if(live(n)&&n.path("enabled").asBoolean())capacity+=n.path("capacity").asInt(); + } + for(ObjectNode t:rows(state,"tasks")){ + if(original(t)&&t.path("desired").asText().equals("STOPPED"))continue; + ObjectNode copy=t.deepCopy(),a=assignment(t,"active");copy.remove(List.of("active","candidate")); + String status=t.path("desired").asText().equals("STOPPED")?"ENDED":assignment(t,"candidate")!=null?(a==null?"PREPARING":"MIGRATING") + :a!=null?(t.path("desired").asText().equals("RUNNING")?"RUNNING":"READY"):"QUEUED"; + copy.put("status",status); + // MIGRATING 状态也保留旧 active 的地址,只有接管成功才对外切换版本。 + if(a!=null&&valid(a)){ + String url=node(a.path("nodeId").asText()).path("baseUrl").asText()+"/media/"+t.path("taskId").asText()+"/"+a.path("version").asLong()+"/stream?key="+a.path("mediaKey").asText(); + copy.put("nodeId",a.path("nodeId").asText()).put("version",a.path("version").asLong()) + .put("playProtocol",original(t)?"MJPEG_ORIGINAL":"MJPEG_DEMO").put("playUrl",url);active++; + if(original(t))copy.put("imageReady",a.path("imageReady").asBoolean()).put("lastFrameTime",a.path("lastFrameTime").asLong()); + } + if(status.equals("QUEUED"))queued++;tasks.add(copy); + } + for(ObjectNode g:rows(state,"gateways")){var copy=g.deepCopy();copy.put("online",now()-g.path("lastHeartbeat").asLong() entries = new HashMap<>(); + private record Entry(ObjectNode access, long refreshed) {} + + /** 来源 IP 与采集器编号取自 PXHJ 保存的连接,浏览器不能自行指定密钥。 */ + String register(JsonNode input, long monotonic) { + String endpoint = required(input,"endpointId"); + require(endpoint.matches("[0-9a-fA-F-]{36}") && UUID.fromString(endpoint).toString().equalsIgnoreCase(endpoint), "Invalid endpoint identity"); + String host = required(input,"sourceHost"); + require(ipv4(host), "An IPv4 image source is required"); + require(input.path("collectorId").isIntegralNumber()&&input.path("collectorId").canConvertToInt(), "Invalid collector identity"); + int collector = input.path("collectorId").asInt(); + require(collector > 0 && collector <= 255, "Invalid collector identity"); + String sm4 = required(input,"sm4Key"), hmac = required(input,"hmacKey"); + // 校验长度后重新编码,拒绝不完整密钥;异常消息始终不包含提交的密钥内容。 + try { + byte[] a=Base64.getDecoder().decode(sm4), b=Base64.getDecoder().decode(hmac); + require(a.length == 16 && b.length >= 32 && b.length <= 64, "Invalid collector key lengths"); + sm4=Base64.getEncoder().encodeToString(a);hmac=Base64.getEncoder().encodeToString(b); + } catch (IllegalArgumentException ex) { throw new IllegalArgumentException("Invalid collector keys"); } + // FND 的采集器编号只有一个字节,同时限制来源缓存数量,避免无限注册占用内存。 + require(entries.containsKey(endpoint) || entries.size() < 255, "Image source capacity exceeded"); + // Base64 仅用于 JSON 携带二进制密钥,传输身份校验仍由内部签名接口负责。 + ObjectNode access=object().put("endpointId",endpoint).put("sourceHost",host).put("collectorId",collector) + .put("sm4Key",sm4).put("hmacKey",hmac); + entries.put(endpoint,new Entry(access,monotonic)); + return endpoint; + } + + /** 仅返回副本,避免生成节点计划时意外修改后续协商使用的凭据。 */ + ObjectNode access(String endpoint) { + Entry e=entries.get(endpoint);return e==null?null:e.access.deepCopy(); + } + + // 使用单调时钟回收失联来源,停止长期占用可配置的节点路数。 + Set expire(long monotonic) { + Set expired=new HashSet<>(); + // 非法时钟倒退同样撤销许可,不能靠回退计时给旧上传目标延长寿命。 + entries.entrySet().removeIf(e->{long age=monotonic-e.getValue().refreshed; + boolean old=age<0||age>=SOURCE_LEASE_MS;if(old)expired.add(e.getKey());return old;}); + return expired; + } + + /** 协商协议只发布数值 IPv4,避免把未验证域名变成采集器新的外联地址。 */ + static boolean ipv4(String value) { + if(value==null)return false;String[] parts=value.split("\\.",-1);if(parts.length!=4)return false; + for(String part:parts){if(!part.matches("0|[1-9][0-9]{0,2}"))return false; + int n=Integer.parseInt(part);if(n>255)return false;} + int first=Integer.parseInt(parts[0]); + return first>0&&first<224&&!value.equals("255.255.255.255"); + } +} diff --git a/src/main/java/com/xtrh/discovery/service/CoordinatorLocator.java b/src/main/java/com/xtrh/discovery/service/CoordinatorLocator.java new file mode 100644 index 0000000..455db34 --- /dev/null +++ b/src/main/java/com/xtrh/discovery/service/CoordinatorLocator.java @@ -0,0 +1,137 @@ +package com.xtrh.discovery.service; + +import com.xtrh.common.config.Settings; +import com.xtrh.common.util.Jsons; +import com.xtrh.communication.client.PeerClient; +import com.xtrh.discovery.util.NodeAddress; +import org.springframework.stereotype.Component; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * 将配置、历史地址和 mDNS 线索转换为已认证的总控连接。 + * 与发现服务分开,避免未经认证的广播记录直接改变当前任务归属。 + * 重连间隔由调用方负责,本类只判断一次查找是否成功或存在冲突。 + * 磁盘缓存只保存地址,远端公开的逻辑身份用于当次验证和去重。 + */ +@Component +public class CoordinatorLocator { + private final Settings settings; + private final Discovery discovery; + private final PeerClient peer; + private volatile String connected = ""; + private volatile String status = "WAITING"; + + public CoordinatorLocator(Settings settings, Discovery discovery, PeerClient peer) { + this.settings = settings; + this.discovery = discovery; + this.peer = peer; + } + + public synchronized String locate() throws Exception { + // 主执行端只向自家总控注册;使用本机控制入口,不依赖总控监听自己的发现广播。 + if (settings.role.equals("coordinator")) { + if (!connected.isEmpty()) return connected; + String local = NodeAddress.controlUrl(settings); + var info = peer.request(local, "/control/info", "GET", null); + if (!settings.cluster.equals(info.path("clusterId").asText()) + || !settings.id.equals(info.path("serviceId").asText()) + || !info.path("role").asText().equals("coordinator") + || !info.path("storageHealthy").asBoolean()) { + throw new IllegalStateException("Local coordinator identity mismatch"); + } + connected = local; + status = "CONNECTED"; + return connected; + } + // 复用地址不跳过后续心跳的响应签名校验,也不自动延长任务执行许可。 + if (!connected.isEmpty()) { + return connected; + } + + // 显式部署入口和上次成功地址优先,但缓存不免除每次重连时的签名验证。 + // 顺序集合避免重复请求同一地址,也保持固定入口的优先级。 + Set candidates = new LinkedHashSet<>(); + if (!settings.coordinator.isBlank()) { + candidates.add(settings.coordinator); + } + Path cache = settings.directory.resolve("coordinator-url.txt"); + if (Files.exists(cache)) { + // 历史地址可能随 IP 调整失效,读取它不会直接把状态改为已连接。 + candidates.add(Files.readString(cache).trim()); + } + + for (String url : candidates) { + try { + // 固定入口或缓存成功时优先恢复,不再根据未认证广播随机选择其他地址。 + // PeerClient 校验响应签名后才返回内容,私有凭证不会发送给候选服务器。 + var info = peer.request(url, "/control/info", "GET", null); + if (!settings.cluster.equals(info.path("clusterId").asText()) + || info.path("serviceId").asText().isBlank() + || !info.path("storageHealthy").asBoolean()) { + continue; + } + + // 只缓存合法根地址;总控存储失败时不接受新连接,避免继续领取无持久化保障的许可。 + connected = Jsons.baseUrl(url); + Files.writeString(cache, connected); + status = "CONNECTED"; + return connected; + } catch (Exception ignored) { + // 固定或缓存地址暂时不可用时继续自动发现,不把连接失败当成已注册。 + } + } + + // 发现线索全部验证后再按认证得到的 serviceId 去重。 + // 同一总控多网卡的多个 IP 可以合并;两个不同有效总控不能随机选择。 + Map verified = new LinkedHashMap<>(); + // 同一名称的公告也可能来自不同服务,冲突判断使用签名响应中的逻辑 ID。 + // 无有效签名的伪造公告不能挤占正常总控,也不能触发误报双总控。 + for (String url : discovery.candidates()) { + try { + var info = peer.request(url, "/control/info", "GET", null); + if (settings.cluster.equals(info.path("clusterId").asText()) + && !info.path("serviceId").asText().isBlank() + && info.path("storageHealthy").asBoolean()) { + verified.putIfAbsent(info.path("serviceId").asText(), Jsons.baseUrl(url)); + } + } catch (Exception ignored) { + // 未认证或不可达的公告不进入冲突集合,避免陌生广播阻止正常注册。 + } + } + + if (verified.size() > 1) { + // 尚未绑定时发现两个已认证总控,保持冲突状态,不保存任意一个候选。 + status = "CONFLICT"; + throw new IllegalStateException("Multiple authenticated coordinators discovered"); + } + if (verified.size() == 1) { + // 唯一有效身份可以对应多个 IP,只缓存本次验证成功的服务根地址。 + connected = verified.values().iterator().next(); + Files.writeString(cache, connected); + status = "CONNECTED"; + return connected; + } + + // 找不到总控时不自建试验上下文,网关可独立沿用已确认路由继续转发。 + status = "WAITING"; + // 失败返回异常,由网关或节点按各自重试周期再次调用。 + throw new IllegalStateException("Waiting for authenticated coordinator discovery"); + } + + public synchronized void disconnect() { + // 调用方发现请求失败后清掉连接,下次重新验证固定地址、缓存和发现线索。 + // 保留历史地址文件,短暂网络故障恢复后仍可先验证原入口。 + connected = ""; + status = "WAITING"; + } + + public String status() { + return status; + } +} diff --git a/src/main/java/com/xtrh/discovery/service/Discovery.java b/src/main/java/com/xtrh/discovery/service/Discovery.java new file mode 100644 index 0000000..435b2bb --- /dev/null +++ b/src/main/java/com/xtrh/discovery/service/Discovery.java @@ -0,0 +1,189 @@ +package com.xtrh.discovery.service; + +import com.xtrh.common.config.Settings; +import jakarta.annotation.PreDestroy; +import org.springframework.boot.web.context.WebServerInitializedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import javax.jmdns.JmDNS; +import javax.jmdns.ServiceEvent; +import javax.jmdns.ServiceInfo; +import javax.jmdns.ServiceListener; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * 通过局域网广播获取总控候选地址,不在发现层建立信任或下发任务。 + * mDNS 的集群名称和 serviceId 均可能被伪造,最终由 Locator 验证签名响应。 + * 多块网卡发现同一总控时按逻辑身份收集地址,不把不同 IP 当成不同总控。 + * 服务类型与协议版本只用于缩小候选范围,不构成服务认证。 + * 候选缓存不承载试验、节点容量或执行许可,业务状态仍以总控为准。 + */ +@Component +public class Discovery { + public static final String TYPE = "_xtrh-control._tcp.local."; + + private final Settings settings; + // 回调与关闭操作可能同时访问引擎列表,使用可安全遍历的并发容器。 + private final List engines = new CopyOnWriteArrayList<>(); + // 地址按公告身份汇集;真正的逻辑身份仍需从已签名的控制响应中确认。 + private final Map> discovered = new ConcurrentHashMap<>(); + private volatile String status = "STARTING"; + private volatile boolean closed; + + public Discovery(Settings settings) { + this.settings = settings; + } + + @EventListener + public void started(WebServerInitializedEvent event) { + // 禁用发现只关闭组播路径,Locator 仍可尝试统一配置的入口和历史地址。 + if (!settings.discoveryEnabled) { + status = "DISABLED"; + return; + } + + // 等 HTTP 服务确定真实端口再发布;发现初始化不阻塞服务启动或 UDP 转发。 + // 后台线程不领取任务,注册必须等候选地址通过正式 HTTP 身份校验。 + // 安装时 HTTP 端口可以改动,公告使用事件中的实际监听端口。 + Thread thread = new Thread( + () -> start(event.getWebServer().getPort()), "xtrh-discovery"); + thread.setDaemon(true); + thread.start(); + } + + private void start(int port) { + try { + // 发现只覆盖组播能够到达的网段;跨 VLAN 部署由统一入口配置兜底。 + // 不同网卡可以进入不同网络,因此不能只尝试主机名解析出的一个地址。 + List addresses = new ArrayList<>(); + if (!settings.discoveryAddress.isBlank()) { + // 多网卡部署可以指定一块网卡,避免发现报文发送到不相关网络。 + // 显式地址仍由 JmDNS 检查可用性,失败保持未发现状态。 + addresses.add(InetAddress.getByName(settings.discoveryAddress)); + } else { + // 自动选择已启用、非回环且支持组播的 IPv4 地址,不假定第一块网卡可用。 + // 当前协议公告使用 IPv4,链路本地地址不作为跨电脑服务入口。 + for (NetworkInterface card : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (!card.isUp() || card.isLoopback() || !card.supportsMulticast()) { + continue; + } + for (InetAddress address : Collections.list(card.getInetAddresses())) { + if (address instanceof Inet4Address && !address.isLinkLocalAddress()) { + addresses.add(address); + } + } + } + } + + // 无合适组播网卡时保留主机地址尝试;网络隔离部署仍可使用固定总控入口。 + if (addresses.isEmpty()) { + addresses.add(InetAddress.getLocalHost()); + } + + for (InetAddress address : addresses) { + // 服务关闭后不再为尚未处理的网卡建立发现套接字。 + if (closed) { + return; + } + try { + // 每个地址建立发现引擎;并发回调使用线程安全集合收集候选信息。 + // 引擎名称由部署实例身份派生,重启后仍能对应同一台节点。 + JmDNS dns = JmDNS.create(address, "xtrh-" + settings.id.substring(0, 8)); + engines.add(dns); + if (settings.role.equals("coordinator")) { + // 广播只有公开线索,不包含私有凭证、试验配置或播放许可。 + // 身份字段只便于关联公告,不能用它替代签名响应认证。 + dns.registerService(ServiceInfo.create( + TYPE, "PXHJ-" + settings.id, port, 0, 0, + Map.of( + "clusterId", settings.cluster, + "serviceId", settings.id, + "protocol", "1"))); + } else { + dns.addServiceListener(TYPE, new ServiceListener() { + public void serviceAdded(ServiceEvent event) { + // 新服务先解析完整记录,不能直接使用仅包含名称的公告。 + dns.requestServiceInfo(event.getType(), event.getName()); + } + + public void serviceRemoved(ServiceEvent event) { + // 注销公告只清除发现线索;当前控制连接是否失效由心跳确认。 + // 缓存入口仍可在重连时尝试,避免单次组播事件终止正式业务。 + String id = event.getInfo().getPropertyString("serviceId"); + if (id != null) { + discovered.remove(id); + } + } + + public void serviceResolved(ServiceEvent event) { + ServiceInfo info = event.getInfo(); + // 先筛除其他集群和接口版本;通过此筛选仍然不代表已经认证。 + // 接收公告不执行配置、不修改任务归属,也不发送服务凭证。 + if (!settings.cluster.equals(info.getPropertyString("clusterId")) + || !"1".equals(info.getPropertyString("protocol"))) { + return; + } + + Set urls = ConcurrentHashMap.newKeySet(); + // 一个公告可能含多个可达 IP,留给 Locator 逐个验证。 + for (Inet4Address host : info.getInet4Addresses()) { + urls.add("http://" + host.getHostAddress() + ":" + info.getPort()); + } + String id = info.getPropertyString("serviceId"); + if (id != null && !urls.isEmpty()) { + // 多网卡回调合并地址,避免相同总控因重复公告被请求多次。 + // 公告中的身份只作为缓存键,认证去重在 Locator 完成。 + discovered.computeIfAbsent(id, key -> ConcurrentHashMap.newKeySet()) + .addAll(urls); + } + } + }); + } + } catch (Exception ignored) { + // 某块网卡失败时继续尝试其他网卡,不停止已经建立的发现引擎。 + } + } + // ACTIVE 只表示发现引擎存在,并不宣称节点已注册或总控已经可信。 + status = engines.isEmpty() ? "UNAVAILABLE" : "ACTIVE"; + } catch (Exception e) { + status = "UNAVAILABLE"; + } + } + + // 未认证广播只能给线索;多个有效总控的判断必须放在响应签名验证之后。 + // 返回去重后的稳定顺序,避免同一轮查找重复试探相同服务地址。 + public List candidates() { + return discovered.values().stream() + .flatMap(Set::stream) + .distinct() + .sorted() + .toList(); + } + + public String status() { + return status; + } + + @PreDestroy + public void close() { + // 发现引擎只负责组播套接字,任务与数据库生命周期由各业务服务管理。 + closed = true; + for (JmDNS dns : engines) { + try { + dns.close(); + } catch (Exception ignored) { + // 逐个关闭网卡引擎,单个关闭错误不能阻止其他组播套接字释放。 + } + } + } +} diff --git a/src/main/java/com/xtrh/discovery/util/NodeAddress.java b/src/main/java/com/xtrh/discovery/util/NodeAddress.java new file mode 100644 index 0000000..92d4795 --- /dev/null +++ b/src/main/java/com/xtrh/discovery/util/NodeAddress.java @@ -0,0 +1,87 @@ +package com.xtrh.discovery.util; + +import com.xtrh.common.config.Settings; + +import java.net.InetSocketAddress; +import java.net.InetAddress; +import java.net.Inet4Address; +import java.net.NetworkInterface; +import java.net.Socket; +import java.net.URI; +import java.util.Collections; + +/** + * 选择节点对外登记的地址,不把本机回环地址默认当成跨电脑播放地址。 + * 多网卡时根据到已认证总控的实际网络路由选择本机地址,而非主机名的首个解析结果。 + */ +public final class NodeAddress { + private NodeAddress() {} + + public static String host(Settings settings, String coordinator) throws Exception { + if (!settings.advertisedHost.isBlank()) { + // 特殊网络或播放域名由部署配置覆盖,最终注册仍执行服务根地址校验。 + return settings.advertisedHost; + } + + // 主执行端通过本机入口领取任务,对外登记地址必须另外选择,供其他电脑播放。 + if (settings.role.equals("coordinator")) return localHost(settings); + URI uri = URI.create(coordinator); + try (Socket route = new Socket()) { + // 连接只用于查询操作系统选出的本机接口,关闭后不会持续占用控制连接。 + // HTTPS 未显式写端口时按 443 查询路由,HTTP 按 80。 + route.connect(new InetSocketAddress( + uri.getHost(), + uri.getPort() > 0 ? uri.getPort() : (uri.getScheme().equals("https") ? 443 : 80)), + 1000); + InetAddress local = route.getLocalAddress(); + // 同电脑的独立子节点可能连接 localhost,同样不能把回环默认发布给远端观看者。 + return local.isLoopbackAddress() ? localHost(settings) : local.getHostAddress(); + } + } + + /** 自注册只访问本实例已绑定的控制入口,网络发送仍走正常签名认证。 */ + public static String controlUrl(Settings settings) { + if (!settings.httpReady()) throw new IllegalStateException("Waiting for local HTTP server"); + String host = wildcard(settings.bindHost) ? "127.0.0.1" : settings.bindHost; + return httpUrl(host, settings.port); + } + + // 发布实际 HTTP 端口;主节点的控制和媒体入口共用同一端口,不增开第二个执行进程。 + public static String url(Settings settings, String coordinator) throws Exception { + return httpUrl(host(settings, coordinator), settings.port); + } + + /** 对外地址优先尊重显式绑定与发现网卡,多网卡部署仍可用 advertised-host 指定。 */ + private static String localHost(Settings settings) throws Exception { + if (!wildcard(settings.bindHost)) return settings.bindHost; + if (!settings.discoveryAddress.isBlank()) { + InetAddress configured = InetAddress.getByName(settings.discoveryAddress); + if (!configured.isLoopbackAddress() && !configured.isAnyLocalAddress()) return configured.getHostAddress(); + } + // 只考虑启用的真实 IPv4 网卡,避免默认发布回环、链路本地或虚拟接口的地址。 + var interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); + InetAddress preferred = null; + try { preferred = InetAddress.getLocalHost(); } catch (Exception ignored) { } + String first = null; + for (NetworkInterface card : interfaces) { + if (!card.isUp() || card.isLoopback() || card.isVirtual()) continue; + for (InetAddress address : Collections.list(card.getInetAddresses())) { + if (!(address instanceof Inet4Address) || address.isLoopbackAddress() || address.isLinkLocalAddress()) continue; + if (address.equals(preferred)) return address.getHostAddress(); + if (first == null) first = address.getHostAddress(); + } + } + // 无局域网网卡的纯本机环境仍可运行;跨电脑使用须发布观看端可访问的地址。 + return first == null ? "127.0.0.1" : first; + } + + private static boolean wildcard(String host) { + return host.isBlank() || host.equals("0.0.0.0") || host.equals("::") || host.equals("[::]"); + } + + // IPv6 字面量需要方括号,确保绑定特定网卡时仍生成合法 URL。 + private static String httpUrl(String host, int port) { + String authority = host.contains(":") && !host.startsWith("[") ? "[" + host + "]" : host; + return "http://" + authority + ":" + port; + } +} diff --git a/src/main/java/com/xtrh/gateway/controller/GatewayController.java b/src/main/java/com/xtrh/gateway/controller/GatewayController.java new file mode 100644 index 0000000..0c6ba06 --- /dev/null +++ b/src/main/java/com/xtrh/gateway/controller/GatewayController.java @@ -0,0 +1,27 @@ +package com.xtrh.gateway.controller; + +import com.xtrh.gateway.service.GatewayService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 网关 HTTP 入口仅返回转发状态与统计信息。 + * 路由由经过认证的总控计划同步,不能从此入口任意修改。 + * UDP socket、队列与双向转发均封装在业务服务中。 + */ +@RestController +@ConditionalOnProperty(name = "fusion.role", havingValue = "gateway") +public class GatewayController { + private final GatewayService gateway; + + public GatewayController(GatewayService gateway) { + this.gateway = gateway; + } + + // 状态快照不含采集报文与共享凭证,供总控和运行检查读取。 + @GetMapping("/gateway/status") + public Object status() { + return gateway.status(); + } +} diff --git a/src/main/java/com/xtrh/gateway/service/GatewayService.java b/src/main/java/com/xtrh/gateway/service/GatewayService.java new file mode 100644 index 0000000..e085053 --- /dev/null +++ b/src/main/java/com/xtrh/gateway/service/GatewayService.java @@ -0,0 +1,432 @@ +package com.xtrh.gateway.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.xtrh.common.config.Settings; +import com.xtrh.common.dao.StateStore; +import com.xtrh.communication.client.PeerClient; +import com.xtrh.discovery.service.CoordinatorLocator; +import com.xtrh.discovery.util.NodeAddress; +import jakarta.annotation.PreDestroy; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketTimeoutException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static com.xtrh.common.util.Jsons.array; +import static com.xtrh.common.util.Jsons.object; +import static com.xtrh.common.util.Jsons.require; +import static com.xtrh.common.util.Jsons.required; + +/** + * 独立采集接入服务:接收原始 UDP 报文、识别来源并双向转送。 + * 总控只负责同步路由,视频轮次结束或总控失联不停止已确认的转发。 + * 不解密或重组采集协议,正式协议认证及业务解析仍由 PXHJ 完成。 + */ +@Service +@ConditionalOnProperty(name = "fusion.role", havingValue = "gateway") +public class GatewayService { + // 采用 IPv4 UDP 的最大有效负载上限,不截取其中的采集协议字段。 + private static final int MAX_DATAGRAM_BYTES = 65507; + // 字节限额与报文数限额配合,大报文不能绕过队列长度限制。 + private static final long MAX_BUFFERED_BYTES = 16L * 1024 * 1024; + + // 收包时保留来源及匹配的路由,发送线程无需重复猜测采集器身份。 + // 字节数组专属这一报文,与接收循环复用的缓冲区不共享。 + private record Packet(byte[] bytes, InetSocketAddress source, Relay relay) {} + + private final Settings settings; + private final CoordinatorLocator locator; + private final PeerClient peer; + // 路由持久化经 DAO 完成;业务服务不包含表结构或 SQL 语句。 + private final StateStore store; + private final DatagramSocket ingress; + // 上行接收与发送解耦,避免目标发送迟缓阻塞所有采集器的接收。 + private final ArrayBlockingQueue queue = new ArrayBlockingQueue<>(2048); + // 计数描述本次进程运行,重启恢复的是路由,而不是历史报文统计。 + // 原子计数可同时由上行线程和各采集器回传线程更新。 + // 未登记、队列丢弃和发送错误分别计数,PXHJ 可区分数据缺失原因。 + private final AtomicLong received = new AtomicLong(), forwarded = new AtomicLong(); + private final AtomicLong unknown = new AtomicLong(), overflow = new AtomicLong(); + private final AtomicLong errors = new AtomicLong(), buffered = new AtomicLong(); + // 发布只读列表快照,收包线程遍历期间不受路由增删影响。 + private volatile List relays = List.of(); + private volatile boolean closed; + private volatile long routeVersion = -1; + private volatile JsonNode appliedRoutes = array(); + // connection 描述总控连接;routeState 描述转发配置,二者不互相替代。 + // WAITING 不代表数据线程停止,已经确认的转发可以继续运行。 + private volatile String connection = "WAITING", routeState = "WAITING"; + private long retryAfter; + + /** + * 固定采集入口与各采集器的独立转发端口分别绑定。 + * 启动先恢复最后确认路由,再开启收发线程,支持断网重启后的转发。 + * 路由文件锁由 DAO 管理,防止两个网关误用同一个数据目录。 + */ + public GatewayService(Settings settings, CoordinatorLocator locator, + PeerClient peer, ObjectMapper mapper) throws Exception { + this.settings = settings; + this.locator = locator; + this.peer = peer; + store = StateStore.open(settings.directory.resolve("gateway"), mapper); + ingress = new DatagramSocket(null); + // 增大内核收包缓冲区以应对短时突发,实际可用大小仍由操作系统决定。 + // 内核缓冲区和业务队列是两层容量,业务队列满时会显式累计丢弃数。 + ingress.setReceiveBufferSize(4 * 1024 * 1024); + ingress.bind(new InetSocketAddress(settings.gatewayPort)); + ObjectNode saved = store.load(); + if (saved.path("routes").isArray()) { + apply(saved.path("routes"), saved.path("routeVersion").asLong(), false); + } + daemon("xtrh-udp-receive", this::receive); + daemon("xtrh-udp-forward", this::forward); + } + + // UDP 循环独立于总控心跳,关闭服务时由 closed 标记和 socket 关闭退出。 + private static void daemon(String name, Runnable runnable) { + Thread thread = new Thread(runnable, name); + thread.setDaemon(true); + thread.start(); + } + + /** + * 协商分类只识别完整 FND 外层,普通二进制报文碰巧含类型字节仍透明转发。 + * 帧边界校验用于避免误分类,不能代替 PXHJ 的 CRC、HMAC 和密钥校验。 + * 使用报文实际长度校验帧尾,接收循环的复用缓冲区尾部不属于本次报文。 + */ + private static boolean frameType(byte[] bytes, int length, int type) { + if (length < 109 || length > 1133 || Byte.toUnsignedInt(bytes[8]) != type) { + return false; + } + ByteBuffer header = ByteBuffer.wrap(bytes, 0, length); + return header.getInt(0) == 0x5AA5FAFB && header.getInt(4) == length + && header.getInt(length - 4) == 0xA55AFCFD; + } + + /** + * 仅接收唯一匹配的登记来源;未登记和歧义来源统一丢弃并计数。 + * 接收缓冲区循环复用,入队前复制有效字节,防止下一报文覆盖旧内容。 + * 队列同时限制报文数和累计字节数,避免高频或大报文耗尽内存。 + */ + private void receive() { + byte[] buffer = new byte[MAX_DATAGRAM_BYTES]; + while (!closed) { + try { + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + ingress.receive(packet); + received.incrementAndGet(); + InetSocketAddress source = (InetSocketAddress) packet.getSocketAddress(); + Relay match = matchSource(source); + if (match == null) { + unknown.incrementAndGet(); + continue; + } + // 不改原始密文、报文长度和采样时间,PXHJ 继续按既有协议解析。 + byte[] bytes = Arrays.copyOf(buffer, packet.getLength()); + long reserved = buffered.addAndGet(bytes.length); + if (reserved > MAX_BUFFERED_BYTES || !queue.offer(new Packet(bytes, source, match))) { + buffered.addAndGet(-bytes.length); + overflow.incrementAndGet(); + } + } catch (Exception failure) { + if (!closed) { + errors.incrementAndGet(); + } + } + } + } + + // 同一来源若匹配多条路由就拒绝转发,防止把数据归入错误采集器。 + private Relay matchSource(InetSocketAddress source) { + // 匹配依据来源 IP 与登记端口,内容中的模块编号仍交给 PXHJ 校验。 + // 动态源端口由各 Relay 的 sourcePort=0 明确允许,不放宽来源 IP。 + Relay match = null; + for (Relay relay : relays) { + if (relay.matches(source)) { + if (match != null) { + return null; + } + match = relay; + } + } + return match; + } + + /** + * 每个采集器通过自己的稳定源端口转发,PXHJ 可保持独立身份映射。 + * 单个报文发送失败只增加错误计数,后续报文及其他路由继续执行。 + * 出队报文无论成功与否都释放字节额度,避免错误路径泄漏缓存容量。 + */ + private void forward() { + // 上行保持单个发送循环的出队顺序,不为每个报文创建临时线程。 + // 空队列等待有时间上限,便于在关闭标记变化后退出循环。 + while (!closed) { + try { + Packet packet = queue.poll(1, TimeUnit.SECONDS); + if (packet == null) { + continue; + } + try { + packet.relay.send(packet.bytes, packet.source); + forwarded.incrementAndGet(); + } catch (Exception failure) { + errors.incrementAndGet(); + } finally { + buffered.addAndGet(-packet.bytes.length); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + } + } + + /** + * 路由变更先校验地址并准备新 socket,再发布新的列表快照。 + * 相同采集器及相同转发端口复用 socket,保留回传线程与端口身份。 + * 准备失败时关闭本次新开的端口,不关闭仍在服务的旧路由。 + */ + private synchronized void apply(JsonNode routes, long version, boolean save) throws Exception { + Map existing = new HashMap<>(); + for (Relay relay : relays) { + existing.put(relay.id, relay); + } + List next = new ArrayList<>(), opened = new ArrayList<>(); + Map updates = new HashMap<>(); + try { + for (JsonNode route : routes) { + // 转发端口不能占用采集入口,否则会破坏收包与回传的端口分工。 + require(route.path("relayPort").asInt() != ingress.getLocalPort(), + "Relay port conflicts with gateway input"); + String id = required(route, "endpointId"); + ObjectNode config = (ObjectNode) route.deepCopy(); + InetAddress.getByName(required(route, "sourceHost")); + InetAddress.getByName(required(route, "targetHost")); + Relay old = existing.get(id); + if (old != null && old.route.path("relayPort").asInt() == route.path("relayPort").asInt()) { + next.add(old); + updates.put(old, config); + } else { + Relay fresh = new Relay(config); + opened.add(fresh); + next.add(fresh); + } + } + // 保存确认内容与版本,重启恢复时无需依赖总控马上在线。 + ObjectNode snapshot = object().put("routeVersion", version); + snapshot.set("routes", routes.deepCopy()); + if (save) { + store.save(snapshot); + } + for (Map.Entry entry : updates.entrySet()) { + entry.getKey().update(entry.getValue()); + } + List previous = relays; + // 旧列表不再修改;新列表发布后才关闭已撤销的旧 socket。 + relays = List.copyOf(next); + routeVersion = version; + appliedRoutes = routes.deepCopy(); + routeState = "APPLIED"; + for (Relay old : previous) { + if (!next.contains(old)) { + old.close(); + } + } + } catch (Exception failure) { + for (Relay relay : opened) { + relay.close(); + } + routeState = "APPLY_FAILED"; + throw failure; + } + } + + /** + * 自动发现总控后以稳定网关标识和本次启动标识报告状态。 + * 只有 PXHJ 已确认的计划才替换本地路由,未确认的空计划不是撤销。 + * 版本及内容同时比较,避免总控存储重建后复用版本号导致漏更新。 + */ + @Scheduled(fixedDelay = 1000) + public void heartbeat() { + // 重试退避约束发现与 HTTP 通信频率,不占用 UDP 收发线程。 + if (System.currentTimeMillis() < retryAfter) { + return; + } + try { + String coordinator = locator.locate(); + ObjectNode input = status().put("clusterId", settings.cluster) + .put("gatewayId", settings.id).put("bootId", settings.bootId) + .put("name", settings.name).put("baseUrl", NodeAddress.url(settings, coordinator)); + JsonNode plan = peer.request(coordinator, "/control/gateways/heartbeat", "POST", input); + // 认证及响应验签由通信客户端统一完成,业务层只处理可信计划。 + long version = plan.path("routeVersion").asLong(); + if (plan.path("routesConfirmed").asBoolean() + && (version != routeVersion || !appliedRoutes.equals(plan.path("routes")))) { + apply(plan.path("routes"), version, true); + } + connection = "CONNECTED"; + } catch (Exception failure) { + // 控制连接故障仅触发重新发现,已经确认的 UDP 线程继续服务。 + locator.disconnect(); + connection = "WAITING"; + retryAfter = System.currentTimeMillis() + 1500; + } + } + + // 控制层读取统计快照,不接触 socket、队列或可变路由对象。 + public ObjectNode status() { + ObjectNode result = object().put("connection", connection).put("routeState", routeState) + .put("routeVersion", routeVersion).put("inputPort", ingress.getLocalPort()) + .put("received", received.get()).put("forwarded", forwarded.get()) + .put("unknownSource", unknown.get()).put("queueDropped", overflow.get()) + .put("errors", errors.get()).put("bufferedBytes", buffered.get()); + ArrayNode entries = result.putArray("routeStatus"); + for (Relay relay : relays) { + entries.add(relay.status()); + } + return result; + } + + // 先关闭 socket 唤醒阻塞接收,再关闭持久化连接。 + @PreDestroy + public void close() throws Exception { + closed = true; + ingress.close(); + for (Relay relay : relays) { + relay.close(); + } + store.close(); + } + + /** + * 单个采集器的转发会话,负责稳定端口、目标连接和独立回传线程。 + * 上行经统一队列,下行从该 socket 直接回到原采集器入口。 + * 私有会话仅供服务使用,HTTP 控制器无法访问其可变状态。 + */ + private final class Relay { + private final String id; + private final DatagramSocket socket; + private volatile ObjectNode route; + private volatile InetAddress sourceAddress; + private volatile InetSocketAddress lastSource; + // 图片地址协商使用稳定 socket;采集数据和心跳可能来自另一个临时端口。 + // 只按外层类型区分回程,不在网关解密;身份认证仍由 PXHJ 完成。 + private volatile InetSocketAddress lastNegotiationSource; + private final AtomicLong sent = new AtomicLong(), replies = new AtomicLong(); + + Relay(ObjectNode config) throws Exception { + id = config.path("endpointId").asText(); + socket = new DatagramSocket(null); + try { + // 专属转发端口是 PXHJ 识别原采集器的映射,不随视频任务迁移。 + socket.bind(new InetSocketAddress(config.path("relayPort").asInt())); + socket.setSoTimeout(1000); + update(config); + daemon("xtrh-relay-" + id, this::reply); + } catch (Exception failure) { + socket.close(); + throw failure; + } + } + + /** + * UDP connect 限定回传来源为配置的 PXHJ 地址及端口。 + * 来源配置变化时清空最近设备地址,避免回执发到旧采集器。 + * 更新和发送共用对象锁,避免发送期间切换目标 socket 连接。 + */ + synchronized void update(ObjectNode config) throws Exception { + sourceAddress = InetAddress.getByName(config.path("sourceHost").asText()); + if (route == null || !route.path("sourceHost").equals(config.path("sourceHost")) + || !route.path("sourcePort").equals(config.path("sourcePort"))) { + lastSource = null; + lastNegotiationSource = null; + } + socket.disconnect(); + socket.connect(new InetSocketAddress(config.path("targetHost").asText(), + config.path("targetPort").asInt())); + route = config; + } + + // sourcePort 为零允许采集器临时源端口,来源 IP 仍必须匹配。 + boolean matches(InetSocketAddress source) { + return source.getAddress().equals(sourceAddress) + && (route.path("sourcePort").asInt() == 0 + || route.path("sourcePort").asInt() == source.getPort()); + } + + // 记住最近真实来源,动态源端口的配置及回执才能准确返回。 + synchronized void send(byte[] bytes, InetSocketAddress source) throws Exception { + // 类型字节只用于选择回程 socket,不能据此新增路由或认定报文可信。 + // 协商请求不能覆盖既有配置回执的设备端口,普通上行也不能覆盖协商端口。 + if (frameType(bytes, bytes.length, 0x14)) { + lastNegotiationSource = source; + } else { + lastSource = source; + } + socket.send(new DatagramPacket(bytes, bytes.length)); + sent.incrementAndGet(); + } + + /** + * 回传保持原字节,覆盖采集器配置下发及接收回执等双向报文。 + * 优先使用最近来源,没有来源记录时仅使用明确登记的设备端口。 + * 各采集器独立等待回复,单路异常不会阻塞其他采集器的回传。 + */ + void reply() { + byte[] buffer = new byte[MAX_DATAGRAM_BYTES]; + while (!closed && !socket.isClosed()) { + try { + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + socket.receive(packet); + // 协商回复只能回最近一次协商请求来源,不能误发给心跳临时端口。 + // 尚未收到协商请求时丢弃该类回传;设备端仍需校验密文、请求ID和租约。 + boolean negotiation = frameType(buffer, packet.getLength(), 0x15); + InetSocketAddress destination = negotiation ? lastNegotiationSource : lastSource; + if (!negotiation && destination == null && route.path("sourcePort").asInt() > 0) { + destination = new InetSocketAddress(sourceAddress, route.path("sourcePort").asInt()); + } + if (destination != null) { + // 配置请求和回执与数据报文同样透明转送,不重新编码协议。 + // 经固定入口回传,采集器看到的回复端口与其上传目标一致。 + ingress.send(new DatagramPacket(buffer, packet.getLength(), destination)); + replies.incrementAndGet(); + } + } catch (SocketTimeoutException ignored) { + // 周期醒来检查关闭标记,空闲等待不属于转发错误。 + } catch (Exception failure) { + if (!socket.isClosed()) { + errors.incrementAndGet(); + } + } + } + } + + // 每路分别统计上行与回传,便于定位异常而不暴露报文内容。 + ObjectNode status() { + return object().put("endpointId", id).put("relayPort", socket.getLocalPort()) + .put("relaySourceHost", socket.getLocalAddress().getHostAddress()) + .put("forwarded", sent.get()).put("replies", replies.get()); + } + + void close() { + socket.close(); + } + } +} diff --git a/src/main/java/com/xtrh/node/config/AsyncMediaConfiguration.java b/src/main/java/com/xtrh/node/config/AsyncMediaConfiguration.java new file mode 100644 index 0000000..f433a85 --- /dev/null +++ b/src/main/java/com/xtrh/node/config/AsyncMediaConfiguration.java @@ -0,0 +1,36 @@ +package com.xtrh.node.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Conditional; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 为视频长连接配置独立的异步输出线程池。 + * 流输出不占用普通 HTTP 请求线程,状态与管理接口仍可响应。 + * 播放连接数与任务路数分别管理,一路视频可能有多个观看者。 + */ +@Configuration +@Conditional(WorkerCapabilityCondition.class) +public class AsyncMediaConfiguration implements WebMvcConfigurer { + // 不设置等待队列,观看线程达到上限时避免长连接无限积压。 + @Bean + public ThreadPoolTaskExecutor mediaExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(64); + executor.setQueueCapacity(0); + executor.setThreadNamePrefix("xtrh-media-"); + executor.initialize(); + return executor; + } + + // 连接寿命由任务许可与客户端控制,禁用 MVC 默认请求超时。 + // 实际授权仍在服务层逐帧校验,关闭超时不会延长任务许可。 + @Override + public void configureAsyncSupport(AsyncSupportConfigurer configurer) { + configurer.setTaskExecutor(mediaExecutor()).setDefaultTimeout(0); + } +} diff --git a/src/main/java/com/xtrh/node/config/WorkerCapabilityCondition.java b/src/main/java/com/xtrh/node/config/WorkerCapabilityCondition.java new file mode 100644 index 0000000..b637571 --- /dev/null +++ b/src/main/java/com/xtrh/node/config/WorkerCapabilityCondition.java @@ -0,0 +1,17 @@ +package com.xtrh.node.config; + +import com.xtrh.common.config.Settings; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * 执行能力可同时存在于主节点和子节点,UDP 网关保持独立转发职责。 + * 服务与控制器共用条件,主节点关闭执行能力时不会留下无服务支持的媒体入口。 + */ +public final class WorkerCapabilityCondition implements Condition { + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + return Settings.workerEnabled(context.getEnvironment()); + } +} diff --git a/src/main/java/com/xtrh/node/controller/NodeImageController.java b/src/main/java/com/xtrh/node/controller/NodeImageController.java new file mode 100644 index 0000000..31aac32 --- /dev/null +++ b/src/main/java/com/xtrh/node/controller/NodeImageController.java @@ -0,0 +1,50 @@ +package com.xtrh.node.controller; + +import com.xtrh.node.config.WorkerCapabilityCondition; +import com.xtrh.node.service.ImageIngressService; +import com.xtrh.node.service.WorkerService; +import org.springframework.context.annotation.Conditional; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import static com.xtrh.common.util.Jsons.object; + +/** + * 节点原图私有查询入口,供 PXHJ 后台取图,不向浏览器分发集群凭证。 + * ServiceSecurity 对请求和包括 JPEG 在内的原始响应字节执行 HMAC 校验。 + * 没有原图、分配过期、版本迁移或帧已淘汰统一返回签名 JSON 404。 + */ +@RestController +@Conditional(WorkerCapabilityCondition.class) +public class NodeImageController { + private final WorkerService worker; + + public NodeImageController(WorkerService worker) { this.worker = worker; } + + // 控制器不保存图片状态,任务租约、帧缓存和撤销均交由服务层统一判断。 + + // 元数据只包含业务来源和图片尺寸;设备密钥、服务凭证和许可均不返回。 + @GetMapping("/node/images/{taskId}/{version}/latest") + public ResponseEntity latest(@PathVariable String taskId, @PathVariable long version) { + ImageIngressService.Frame frame = worker.originalFrame(taskId, version, null); + return frame == null ? missing() : ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(frame.metadata()); + } + + // 帧号锁定最近缓存中的一张图,HTTP 输出不重编码,保留设备 JPEG 原字节。 + @GetMapping("/node/images/{taskId}/{version}/frames/{frameId}") + public ResponseEntity frame(@PathVariable String taskId, @PathVariable long version, @PathVariable String frameId) { + ImageIngressService.Frame frame = worker.originalFrame(taskId, version, frameId); + return frame == null ? missing() : ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.IMAGE_JPEG).body(frame.jpeg()); + } + + // 固定错误不暴露任务密钥,也不允许浏览器缓存暂时无图的结果。 + private ResponseEntity missing() { + return ResponseEntity.status(404).cacheControl(CacheControl.noStore()) + .body(object().put("message", "Original image is not available")); + } +} diff --git a/src/main/java/com/xtrh/node/controller/WorkerController.java b/src/main/java/com/xtrh/node/controller/WorkerController.java new file mode 100644 index 0000000..335a3de --- /dev/null +++ b/src/main/java/com/xtrh/node/controller/WorkerController.java @@ -0,0 +1,70 @@ +package com.xtrh.node.controller; + +import com.xtrh.node.service.WorkerService; +import com.xtrh.node.config.WorkerCapabilityCondition; +import org.springframework.context.annotation.Conditional; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.nio.charset.StandardCharsets; + +/** + * 节点 HTTP 入口只负责查询与流输出,业务许可由服务层校验。 + * 播放地址包含任务号和版本,迁移时由总控向 PXHJ 回传新地址。 + * 主节点兼任执行端时在同一端口提供媒体入口,UDP 网关不承担视频任务。 + */ +@RestController +@Conditional(WorkerCapabilityCondition.class) +public class WorkerController { + private final WorkerService worker; + + public WorkerController(WorkerService worker) { + this.worker = worker; + } + + // 状态快照由服务生成,控制层不读取或修改内部任务集合。 + @GetMapping("/worker/status") + public Object status() { + return worker.status(); + } + + /** + * 输出浏览器可直接读取的 MJPEG 流,每个分段是一张完整 JPEG。 + * 先检查授权再返回成功响应,避免无效地址建立空的长连接。 + * 不缓存实时画面,防止浏览器复用结束或迁移前的内容。 + */ + @GetMapping("/media/{taskId}/{version}/stream") + public ResponseEntity stream( + @PathVariable String taskId, @PathVariable long version, @RequestParam String key) { + worker.frame(taskId, version, key); + StreamingResponseBody body = output -> { + try { + while (true) { + // 每帧重新取得当前状态,准备转运行后不再引用旧任务对象。 + byte[] frame = worker.frame(taskId, version, key); + if (frame != null) { + String header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " + + frame.length + "\r\n\r\n"; + output.write(header.getBytes(StandardCharsets.US_ASCII)); + output.write(frame); + output.write("\r\n".getBytes(StandardCharsets.US_ASCII)); + output.flush(); + } + Thread.sleep(250); + } + } catch (InterruptedException interrupted) { + // 容器关闭线程时保留中断标记,流循环随之结束。 + Thread.currentThread().interrupt(); + } catch (IllegalArgumentException inactiveTask) { + // 迁移、结束或许可到期后停止旧流,不能继续发送缓存画面。 + } + }; + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header("Content-Type", "multipart/x-mixed-replace; boundary=frame").body(body); + } +} diff --git a/src/main/java/com/xtrh/node/protocol/ImageWireDecoder.java b/src/main/java/com/xtrh/node/protocol/ImageWireDecoder.java new file mode 100644 index 0000000..28176e1 --- /dev/null +++ b/src/main/java/com/xtrh/node/protocol/ImageWireDecoder.java @@ -0,0 +1,76 @@ +package com.xtrh.node.protocol; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.MessageDigest; +import java.security.Security; +import java.util.Arrays; +import java.util.zip.CRC32; + +/** + * 按既有 PXHJ-FND/1 图片协议解码,仅允许消息类型 0x12。 + * 先校验边界、时间、CRC 和 HMAC,再解密 JPG1 分片;协议不是自定义演示格式。 + * 设备密钥由有效私有任务许可提供,本类不读取配置文件或记录明文密钥。 + */ +public final class ImageWireDecoder { + static { + if (Security.getProvider("BC") == null) Security.addProvider(new BouncyCastleProvider()); + } + + private ImageWireDecoder() {} + + // 头部 collectorId 是未认证线索,只可用于缩小候选许可,不能据此接受报文。 + public static int collectorId(byte[] frame) { + return frame.length > 9 ? Byte.toUnsignedInt(frame[9]) : -1; + } + + /** + * 固定头长、密文偏移与 MAC 范围和 PXHJ WireCodec 保持一致。 + * 不扩展原协议的 1024 字节明文限制,超长 UDP 即使被截断也会拒绝。 + */ + public static byte[] decode(byte[] frame, byte[] sm4, byte[] hmac, int collectorId, long now) throws Exception { + require(frame.length >= 109 && frame.length <= 1133); + ByteBuffer input = ByteBuffer.wrap(frame).order(ByteOrder.BIG_ENDIAN); + require(input.getInt() == 0x5AA5FAFB && input.getInt() == frame.length); + require(Byte.toUnsignedInt(input.get()) == 0x12); + require(Byte.toUnsignedInt(input.get()) == collectorId); + input.get(); + long time = input.getLong(); + input.getLong(); + // 使用上下界而不是 abs 差值,异常 Long 值不能通过溢出绕过时间窗口。 + require(time >= now - 60_000 && time <= now + 60_000); + require(input.get() == 1 && input.get() == 1 && input.getInt() == 1); + byte[] iv = new byte[16]; + input.get(iv); + int length = input.getInt(); + require(length > 0 && length % 16 == 0 && length + 93 == frame.length); + int macStart = 53 + length; + // CRC 覆盖 MAC 但不含 CRC 自身和帧尾,保持现有设备发送端的精确字节范围。 + CRC32 crc = new CRC32(); + crc.update(frame, 0, macStart + 32); + require((int) crc.getValue() == ByteBuffer.wrap(frame, macStart + 32, 4).getInt()); + require(ByteBuffer.wrap(frame, frame.length - 4, 4).getInt() == 0xA55AFCFD); + + // 认证覆盖长度后的协议头、IV 和密文;CRC 只检错,不能替代身份认证。 + Mac authentication = Mac.getInstance("HMACSM3", "BC"); + authentication.init(new SecretKeySpec(hmac, "HMACSM3")); + authentication.update(frame, 4, macStart - 4); + require(MessageDigest.isEqual(authentication.doFinal(), Arrays.copyOfRange(frame, macStart, macStart + 32))); + Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS7Padding", "BC"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(sm4, "SM4"), new IvParameterSpec(iv)); + byte[] plain = cipher.doFinal(Arrays.copyOfRange(frame, 53, macStart)); + require(plain.length <= 1024); + return plain; + } + + // 固定错误消息不含输入报文、密钥或失败的认证摘要。 + private static void require(boolean valid) { + if (!valid) throw new IllegalArgumentException("Invalid authenticated image packet"); + } +} diff --git a/src/main/java/com/xtrh/node/service/ImageIngressService.java b/src/main/java/com/xtrh/node/service/ImageIngressService.java new file mode 100644 index 0000000..26c4122 --- /dev/null +++ b/src/main/java/com/xtrh/node/service/ImageIngressService.java @@ -0,0 +1,488 @@ +package com.xtrh.node.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.xtrh.common.config.Settings; +import com.xtrh.node.config.WorkerCapabilityCondition; +import com.xtrh.node.protocol.ImageWireDecoder; +import jakarta.annotation.PreDestroy; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Conditional; +import org.springframework.core.env.Environment; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.io.ByteArrayInputStream; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.BitSet; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; +import java.util.zip.CRC32; + +import static com.xtrh.common.util.Jsons.object; + +/** + * 节点直接接收设备原图,总控和网关只负责控制,不中转图片字节。 + * 私有许可只在内存中保存,按任务版本和单调时钟到期,不写入 H2 或公共快照。 + * 接收队列、分片重组和近期帧都有独立上限;无首帧时不生成替代演示图。 + */ +@Service +@Conditional(WorkerCapabilityCondition.class) +public class ImageIngressService implements AutoCloseable { + // 一个 FND 帧最多承载 1024 字节明文,其中 36 字节属于 JPG1 分片头。 + // 重组最多 32 帧、64MiB;完整帧缓存另有 64MiB 预算,不随设备持续上传而增长。 + // 图片解码串行执行,实际尺寸也受限制,压缩 JPEG 不能扩大到任意像素数。 + public static final int CHUNK_SIZE = 988, MAX_IMAGE_BYTES = 2 * 1024 * 1024; + private static final long ASSEMBLY_TTL = TimeUnit.SECONDS.toNanos(5); + private static final long MAX_BUFFER_BYTES = 64L * 1024 * 1024; + private final Settings settings; + private final Integer configuredPort; + private final LongSupplier clock, nanos; + // 接收线程只投递受限报文,耗时认证、重组和 ImageIO 解码在单个处理线程执行。 + // 队列最多约 2.2MiB 密文;过载时计数丢弃,让设备后续完整帧恢复画面。 + private final ArrayBlockingQueue queue = new ArrayBlockingQueue<>(2048); + private final Map permits = new HashMap<>(); + private final Map accessErrors = new HashMap<>(); + private final Map pending = new HashMap<>(); + // 最近帧按任务版本隔离;同来源的多项任务可以引用同一份不可变 JPEG 字节。 + // 元数据查询后还有两帧容错空间,防止下一次上传立刻淘汰正在取图的帧号。 + private final Map> recent = new HashMap<>(); + private final LinkedHashMap completed = new LinkedHashMap<>(); + private final AtomicLong accepted = new AtomicLong(), rejected = new AtomicLong(), dropped = new AtomicLong(); + private volatile DatagramSocket socket; + private volatile boolean closed; + private volatile String error = ""; + private long retryAfter, pendingBytes; + + // 来源地址由 UDP socket 提供,不能从设备载荷中的自报字段推导。 + // Permit 包含集群私有计划给出的设备密钥,不能直接序列化为对外状态。 + // Assembly 的 group 含来源与密钥摘要,避免密钥轮换后拼接旧许可的在途分片。 + private record Packet(byte[] bytes, InetSocketAddress source) {} + private record Permit(String task, String endpoint, InetAddress source, int collector, + byte[] sm4, byte[] hmac, String group, long deadline) {} + private record Assembly(String group, long frame, long time, int size, int count, int crc, + int width, int height, long created, byte[] data, BitSet seen) {} + + /** 原图不可变视图;公开元数据不包含设备认证材料或网络路由许可。 */ + public record Frame(String frameId, String endpointId, long sourceTime, long receivedTime, + int width, int height, byte[] jpeg, long receivedNanos) { + public ObjectNode metadata() { + return object().put("frameId", frameId).put("endpointId", endpointId) + .put("sourceTime", sourceTime).put("receivedTime", receivedTime) + .put("width", width).put("height", height).put("bytes", jpeg.length); + } + } + + @Autowired + public ImageIngressService(Settings settings, Environment environment) { + this(settings, environment.getProperty("fusion.image-port", Integer.class), System::currentTimeMillis, System::nanoTime); + } + + public ImageIngressService(Settings settings, int imagePort) { + this(settings, imagePort, System::currentTimeMillis, System::nanoTime); + } + + ImageIngressService(Settings settings, Integer port, LongSupplier clock, LongSupplier nanos) { + // 注入两种时钟只为确定性测试;生产授权始终以单调时钟判断到期。 + // 墙上时间负责设备拍摄时间窗,不能用于延长任务或在途分片的寿命。 + this.settings = settings; + this.configuredPort = port; + this.clock = clock; + this.nanos = nanos; + } + + /** + * 默认使用真实 HTTP 端口+1000;等待 HTTP 就绪,不能把配置中的随机端口 0 当实际端口。 + * 显式图片端口 0 由操作系统分配,便于隔离测试,也会登记实际绑定结果。 + */ + public synchronized boolean ensureStarted() { + if (closed || socket != null) return socket != null && !socket.isClosed(); + if (configuredPort == null && !settings.httpReady()) return false; + if (clock.getAsLong() < retryAfter) return false; + int port = configuredPort == null ? settings.port + 1000 : configuredPort; + DatagramSocket opened = null; + try { + if (port < 0 || port > 65535) throw new IllegalArgumentException(); + // 不启用复用地址,部署端口冲突应明确失败,不能让多个节点抢收同一批分片。 + // 绑定地址遵守该角色原有配置,登记时另行选择设备可达的 IPv4 网卡地址。 + opened = new DatagramSocket(null); + opened.setReceiveBufferSize(4 * 1024 * 1024); + opened.bind(new InetSocketAddress(settings.bindHost, port)); + socket = opened; + daemon("xtrh-original-receive", this::receive); + daemon("xtrh-original-assemble", this::process); + error = ""; + return true; + } catch (Exception failure) { + // 失败保持未就绪并有限重试;错误信息不带异常报文、设备密钥或明文图片。 + // 未成功绑定的 socket 及时释放,启动失败不会泄漏后续可重试的系统资源。 + if (opened != null) opened.close(); + error = "原图监听未就绪,请检查图片端口和绑定地址"; + retryAfter = clock.getAsLong() + 1500; + return false; + } + } + + /** + * 接收与组装只在成功绑定后启动,重试监听不会重复建立工作线程。 + * 使用守护线程并配合 closed/socket.close,让服务退出不等待设备关闭连接。 + */ + private static void daemon(String name, Runnable body) { + Thread thread = new Thread(body, name); + thread.setDaemon(true); + thread.start(); + } + + public int inputPort() { + // 登记的是操作系统实际分配端口,显式配置 0 也不能上报为未知目的端口。 + // 服务关闭后返回 0,后续心跳不能继续向总控承诺已失效的接入能力。 + DatagramSocket current = socket; + return current == null || current.isClosed() ? 0 : current.getLocalPort(); + } + + // 多余一字节用于拒绝超长包;队列满时计数丢弃,不能无限缓存密文。 + private void receive() { + while (!closed) { + try { + DatagramPacket packet = new DatagramPacket(new byte[1134], 1134); + socket.receive(packet); + // 复制准确报文长度,不能把复用缓冲区剩余字节带入 CRC 和 HMAC 校验。 + Packet work = new Packet(Arrays.copyOf(packet.getData(), packet.getLength()), + (InetSocketAddress) packet.getSocketAddress()); + if (!queue.offer(work)) dropped.incrementAndGet(); + } catch (Exception failure) { + if (!closed) rejected.incrementAndGet(); + } + } + } + + // 单个组装线程使图片解码并发和临时内存有界;失败不终止其他通道接收。 + private void process() { + // 接收线程不等待 JPEG 解码;独立队列隔离慢图片,避免 UDP socket 长时间无人读取。 + // 停机关闭 socket 后接收线程退出;处理线程最多等待一次受限 poll 即结束。 + while (!closed) { + try { + Packet packet = queue.poll(1, TimeUnit.SECONDS); + if (packet != null) accept(packet); + } catch (InterruptedException stopped) { + Thread.currentThread().interrupt(); + return; + } + } + } + + /** + * 完整计划替换许可;未再列出的版本立即删除帧和在途分片。 + * 同版本状态由准备变为运行时保留原图,避免元数据查询后 JPEG 被状态切换清除。 + */ + public synchronized void applyPlans(JsonNode entries, long deadline) { + // 总控发送完整而非增量计划;任何未列出的许可都应撤销,不能靠旧缓存继续授权。 + // 新表构建完成后一次替换,状态查询、组装和许可回收共享此同步边界。 + Map next = new HashMap<>(); + accessErrors.clear(); + for (JsonNode entry : entries) { + if (!entry.path("mediaKind").asText().equals("ORIGINAL_IMAGE")) continue; + String task = taskKey(entry.path("taskId").asText(), entry.path("assignment").path("version").asLong()); + try { + if (next.size() >= 128) throw new IllegalArgumentException(); + JsonNode access = entry.path("imageAccess"); + String endpoint = access.path("endpointId").asText(); + if (endpoint.isBlank() || endpoint.length() > 200) throw new IllegalArgumentException(); + if (entry.path("taskId").asText().isBlank() || !entry.path("assignment").path("version").isIntegralNumber() + || !entry.path("assignment").path("version").canConvertToLong() || entry.path("assignment").path("version").asLong() < 1) throw new IllegalArgumentException(); + InetAddress source = numericIpv4(access.path("sourceHost").asText()); + // 编号严格要求整数和单字节范围,禁止大整数截断后命中另一个采集器。 + // 同一条私有许可还需与公共来源字段一致,防止内部上下文混配到其他装备。 + JsonNode collectorValue = access.path("collectorId"); + if (!collectorValue.isIntegralNumber() || !collectorValue.canConvertToInt()) throw new IllegalArgumentException(); + int collector = collectorValue.asInt(); + byte[] sm4 = Base64.getDecoder().decode(access.path("sm4Key").asText()); + byte[] hmac = Base64.getDecoder().decode(access.path("hmacKey").asText()); + // HMAC 密钥长度与既有采集端兼容为 32..64 字节,SM4 固定 128 位。 + // Base64 只是私有 JSON 传输编码,不能被当作脱敏或公开发布依据。 + if (sm4.length != 16 || hmac.length < 32 || hmac.length > 64 || collector < 1 || collector > 255) throw new IllegalArgumentException(); + if (entry.hasNonNull("sourceEndpointId") && !endpoint.equals(entry.path("sourceEndpointId").asText())) throw new IllegalArgumentException(); + if (entry.has("collectorId") && (!entry.path("collectorId").isIntegralNumber() + || !entry.path("collectorId").canConvertToInt() || collector != entry.path("collectorId").asInt())) throw new IllegalArgumentException(); + if (entry.hasNonNull("sourceHost") && !source.equals(numericIpv4(entry.path("sourceHost").asText()))) throw new IllegalArgumentException(); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + // 分组摘要只作内部缓存键,不作为网络认证或向外返回的设备身份。 + // 同 endpoint/IP/collector/密钥的多个任务共享重组,仍分别检查自己的租约。 + digest.update(sm4); + String group = endpoint + "/" + source.getHostAddress() + "/" + collector + "/" + + HexFormat.of().formatHex(digest.digest(hmac)); + Permit permit = new Permit(task, endpoint, source, collector, sm4, hmac, group, deadline); + // 同版本的授权来源变化也清除旧帧,不能把前一台设备的缓存交给新的连接。 + Permit old = permits.get(task); + if (old != null && !old.group.equals(group)) recent.remove(task); + next.put(task, permit); + } catch (Exception invalid) { + // 只暴露固定原因,Base64 解码器或异常对象中的密钥不进入日志、状态或快照。 + accessErrors.put(task, "原图接入许可无效"); + } + } + permits.clear(); + permits.putAll(next); + cleanup(); + } + + public static String taskKey(String id, long version) { return id + "/" + version; } + + // 接入许可只接受字面 IPv4,空字符串和域名不能借 DNS 解析改变授权来源。 + private static InetAddress numericIpv4(String host) throws Exception { + // 手工核对四个十进制段,拒绝 DNS、缩写地址、八进制和超出单字节范围的值。 + // 回环地址可用于隔离测试;泛地址、组播和广播不属于可授权的设备来源。 + String[] parts = host.split("\\.", -1); + require(parts.length == 4); + byte[] address = new byte[4]; + for (int index = 0; index < 4; index++) { + require(parts[index].matches("0|[1-9][0-9]{0,2}")); + int value = Integer.parseInt(parts[index]); + require(value <= 255); + address[index] = (byte) value; + } + InetAddress parsed = InetAddress.getByAddress(address); + require(!parsed.isAnyLocalAddress() && !parsed.isMulticastAddress() && !host.equals("255.255.255.255")); + return parsed; + } + + /** 端口与许可先就绪,不等首帧,以免设备等待地址、节点等待首帧形成协商循环。 */ + public synchronized boolean ready(String task) { + cleanup(); + return inputPort() > 0 && permits.containsKey(task); + } + + public synchronized String accessError(String task) { return accessErrors.getOrDefault(task, ""); } + + /** 新鲜度同时检查拍摄时间和单调接收时间,延迟或重放旧图不能完成迁移接管。 */ + public synchronized boolean imageReady(String task) { + // 新图只在来源时间和接收时间都新鲜时可完成迁移,旧图不能靠再次查询恢复活性。 + // 最近帧缓存并不等同于视频在线,业务界面应使用 lastFrameTime 判断拍摄时刻。 + Frame frame = latest(task); + return frame != null && clock.getAsLong() - frame.sourceTime <= 5000 + && clock.getAsLong() >= frame.sourceTime - 5000 + && nanos.getAsLong() - frame.receivedNanos <= ASSEMBLY_TTL; + } + + public synchronized Frame latest(String task) { + // 最新元数据与具体帧内容分开取,但始终在每次读取之前回收失效许可。 + // 许可有效时保留近期原图;imageReady 另行表达是否五秒内有新鲜画面。 + cleanup(); + List frames = recent.get(task); + return frames == null || frames.isEmpty() ? null : frames.get(0); + } + + public synchronized Frame frame(String task, String id) { + // 帧号只在所属任务的近期缓存中查找,不能跨任务或分配版本借用其他装备原图。 + cleanup(); + return recent.getOrDefault(task, List.of()).stream().filter(frame -> frame.frameId.equals(id)).findFirst().orElse(null); + } + + // 许可到期和分片超时用单调时钟;校时不延长执行或组装寿命。 + private void cleanup() { + long now = nanos.getAsLong(); + permits.values().removeIf(permit -> now >= permit.deadline); + recent.keySet().removeIf(task -> !permits.containsKey(task)); + Set groups = new HashSet<>(); + // 仅当同来源分组仍有有效任务时保留在途分片,撤销一路不会误停共享的其他任务。 + // 任务全部撤销或超时后立即归还预算,不需要等待设备再发包才释放内存。 + permits.values().forEach(permit -> groups.add(permit.group)); + var iterator = pending.values().iterator(); + while (iterator.hasNext()) { + Assembly assembly = iterator.next(); + if (!groups.contains(assembly.group) || now - assembly.created > ASSEMBLY_TTL) { + pendingBytes -= assembly.size; + iterator.remove(); + } + } + completed.entrySet().removeIf(entry -> now - entry.getValue() > ASSEMBLY_TTL); + } + + private void accept(Packet packet) { + try { + // 来源 IP 与 collectorId 先匹配许可,再执行密钥认证,未授权来源不尝试解密。 + List matches; + synchronized (this) { + cleanup(); + matches = permits.values().stream().filter(permit -> permit.source.equals(packet.source.getAddress()) + && permit.collector == ImageWireDecoder.collectorId(packet.bytes)).toList(); + } + Map groups = new LinkedHashMap<>(); + // 密钥轮换期间允许尝试多个明确授权的候选;同一分组只认证一次。 + // 只有唯一来源成功认证才组装,不能用未知来源的 packet header 直接发布图片。 + matches.forEach(permit -> groups.putIfAbsent(permit.group, permit)); + Permit authenticated = null; + byte[] plain = null; + for (Permit permit : groups.values()) { + // 逐个候选先验 MAC 再解密;报文 CRC 正确也不足以证明持有设备密钥。 + try { + byte[] candidate = ImageWireDecoder.decode(packet.bytes, permit.sm4, permit.hmac, permit.collector, clock.getAsLong()); + if (authenticated != null) throw new IllegalStateException("Ambiguous image source"); + authenticated = permit; + plain = candidate; + } catch (IllegalStateException ambiguity) { throw ambiguity; } + catch (Exception wrongCredential) { /* 同来源密钥轮换时可尝试另一个已授权候选。 */ } + } + if (authenticated == null) throw new IllegalArgumentException(); + assemble(authenticated, plain); + accepted.incrementAndGet(); + } catch (Exception invalid) { + rejected.incrementAndGet(); + } + } + + /** + * JPG1 头为36字节,988字节分片;严格核对同帧参数和重复片内容。 + * 完整 CRC、JPEG 标记、实际解码尺寸均通过后才发布,缺片永远不会成为可见图片。 + */ + private synchronized void assemble(Permit authenticated, byte[] plain) throws Exception { + cleanup(); + // 包从队列取出到完成认证之间可能已撤销任务,组装前再次确认来源许可仍存在。 + if (permits.values().stream().noneMatch(permit -> permit.group.equals(authenticated.group))) throw new IllegalArgumentException(); + require(plain.length > 36 && plain.length <= 1024); + ByteBuffer input = ByteBuffer.wrap(plain).order(ByteOrder.BIG_ENDIAN); + require(input.getInt() == 0x4A504731); + long wireFrame = input.getLong(), time = input.getLong(); + int size = input.getInt(), index = Short.toUnsignedInt(input.getShort()), count = Short.toUnsignedInt(input.getShort()); + int crc = input.getInt(), width = Short.toUnsignedInt(input.getShort()), height = Short.toUnsignedInt(input.getShort()); + long now = clock.getAsLong(); + require(time >= now - 60_000 && time <= now + 60_000); + require(size > 3 && size <= MAX_IMAGE_BYTES && count == (size + CHUNK_SIZE - 1) / CHUNK_SIZE && index < count); + require(width > 0 && width <= 4096 && height > 0 && height <= 4096); + // 每片实际长度必须与图片大小推导结果一致;缺片、尾片越界和额外字节均拒绝。 + // 不通过扩容补齐异常分片,固定一次分配的 buffer 使每帧预算可准确核算。 + int offset = index * CHUNK_SIZE, bytes = Math.min(CHUNK_SIZE, size - offset); + require(input.remaining() == bytes); + String key = authenticated.group + "/" + Long.toUnsignedString(wireFrame) + "/" + time; + if (completed.containsKey(key)) return; + // 已发布帧的短期重复上传只作幂等确认,不生成新帧号或刷新图片新鲜度。 + Assembly assembly = pending.get(key); + if (assembly == null) { + require(pending.size() < 32 && pendingBytes + size <= MAX_BUFFER_BYTES); + assembly = new Assembly(authenticated.group, wireFrame, time, size, count, crc, width, height, + nanos.getAsLong(), new byte[size], new BitSet(count)); + pending.put(key, assembly); + pendingBytes += size; + } + require(assembly.time == time && assembly.size == size && assembly.count == count + && assembly.crc == crc && assembly.width == width && assembly.height == height); + if (assembly.seen.get(index)) { + // 正常 UDP 重复包允许;同索引携带不同内容属于冲突,不能覆盖已接收的部分图。 + for (int i = 0; i < bytes; i++) require(assembly.data[offset + i] == plain[36 + i]); + return; + } + input.get(assembly.data, offset, bytes); + assembly.seen.set(index); + if (assembly.seen.cardinality() != count) return; + pending.remove(key); + pendingBytes -= size; + // 接收所有片只是必要条件,完整 JPEG 的 CRC 与图片格式仍需独立验证。 + // 丢弃完整但损坏的图片之后释放其重组预算,后续帧继续正常接入。 + CRC32 checksum = new CRC32(); + checksum.update(assembly.data); + require((int) checksum.getValue() == crc); + require(Byte.toUnsignedInt(assembly.data[0]) == 255 && Byte.toUnsignedInt(assembly.data[1]) == 216 + && Byte.toUnsignedInt(assembly.data[size - 2]) == 255 && Byte.toUnsignedInt(assembly.data[size - 1]) == 217); + validateJpeg(assembly.data, width, height); + + // 同来源多任务共享只读帧对象;任务版本仍各有许可,撤销其中一路不影响其他有效任务。 + Frame frame = new Frame(java.util.UUID.randomUUID().toString(), authenticated.endpoint, time, now, + width, height, assembly.data, nanos.getAsLong()); + for (Permit permit : permits.values()) { + if (!permit.group.equals(authenticated.group)) continue; + List frames = new ArrayList<>(recent.getOrDefault(permit.task, List.of())); + frames.add(frame); + frames.sort(Comparator.comparingLong(Frame::sourceTime).thenComparingLong(Frame::receivedTime).reversed()); + if (frames.size() > 3) frames = new ArrayList<>(frames.subList(0, 3)); + recent.put(permit.task, frames); + } + completed.put(key, nanos.getAsLong()); + // 去重索引本身也受数量限制,长期上传不会累积无限历史帧号。 + while (completed.size() > 1024) completed.remove(completed.keySet().iterator().next()); + trimFrames(); + } + + // 先读取实际尺寸再解码,不能相信分片头伪造的小尺寸放任解压大图。 + private static void validateJpeg(byte[] data, int width, int height) throws Exception { + try (var input = ImageIO.createImageInputStream(new ByteArrayInputStream(data))) { + // ImageReader 先读取 JPEG 内真实尺寸,再作完整解码,头部谎报小图不能绕过像素限制。 + // 正确标记和 CRC 不能证明压缩数据可解码,因此仍需要解码确认图片结构完整。 + var readers = ImageIO.getImageReaders(input); + require(readers.hasNext()); + var reader = readers.next(); + try { + reader.setInput(input); + require(reader.getFormatName().equalsIgnoreCase("JPEG") && reader.getWidth(0) == width && reader.getHeight(0) == height); + require(reader.read(0) != null); + } finally { reader.dispose(); } + } + } + + // 每任务保留最近3帧供元数据/取图竞争;总缓存预算不足时淘汰最旧引用并返回明确404。 + private void trimFrames() { + // 预算按缓存引用计数,共享 JPEG 也保守计入;实际保留内存不会大于计算结果。 + // 容量不足允许提前淘汰旧图,HTTP 接口用 404 明确表达帧已不在缓存。 + long bytes = recent.values().stream().flatMap(List::stream).mapToLong(frame -> frame.jpeg.length).sum(); + while (bytes > MAX_BUFFER_BYTES) { + // 淘汰按单调接收顺序选择,系统校时不会颠倒缓存内存的释放顺序。 + String owner = null; + Frame oldest = null; + for (var entry : recent.entrySet()) for (Frame frame : entry.getValue()) { + if (oldest == null || frame.receivedNanos < oldest.receivedNanos) { owner = entry.getKey(); oldest = frame; } + } + if (oldest == null) return; + recent.get(owner).remove(oldest); + bytes -= oldest.jpeg.length; + } + } + + private static void require(boolean valid) { if (!valid) throw new IllegalArgumentException("Invalid original image fragment"); } + + @Scheduled(fixedDelay = 250) + // 独立定时清理保证设备静默时也释放到期任务,不依赖下一次 UDP 包触发回收。 + public synchronized void tick() { ensureStarted(); cleanup(); } + + public synchronized ObjectNode status() { + // 仅输出计数和接入能力,permit、source keys 和私有计划不进入工作节点状态。 + cleanup(); + return object().put("imageInputPort", inputPort()).put("running", inputPort() > 0).put("error", error) + .put("accepted", accepted.get()).put("rejected", rejected.get()).put("queueDrops", dropped.get()) + .put("pending", pending.size()).put("bufferedBytes", pendingBytes).put("authorizedTasks", permits.size()); + } + + @PreDestroy + @Override + public synchronized void close() { + // 停机立即撤销所有接入授权与缓存,不持久化恢复设备密钥或已结束任务的原图。 + // close 可重复调用,测试和 Spring 容器都可以可靠释放动态 UDP 端口。 + closed = true; + if (socket != null) socket.close(); + queue.clear(); + permits.clear(); + accessErrors.clear(); + pending.clear(); + recent.clear(); + completed.clear(); + pendingBytes = 0; + } +} diff --git a/src/main/java/com/xtrh/node/service/WorkerService.java b/src/main/java/com/xtrh/node/service/WorkerService.java new file mode 100644 index 0000000..97a69b3 --- /dev/null +++ b/src/main/java/com/xtrh/node/service/WorkerService.java @@ -0,0 +1,335 @@ +package com.xtrh.node.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.xtrh.common.config.Settings; +import com.xtrh.communication.client.PeerClient; +import com.xtrh.discovery.service.CoordinatorLocator; +import com.xtrh.discovery.util.NodeAddress; +import com.xtrh.node.config.WorkerCapabilityCondition; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Conditional; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Font; +import java.awt.GradientPaint; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.net.InetAddress; +import java.net.Inet4Address; +import java.security.MessageDigest; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; + +import static com.xtrh.common.util.Jsons.object; + +/** + * 融合节点的业务服务:自动注册、领取许可、准备画面并报告就绪。 + * 节点只执行总控分配的任务,不自行决定试验轮次或占用路数。 + * 演示任务生成验证画面;原图任务只提供设备上传的真实 JPEG。 + */ +@Service +@Conditional(WorkerCapabilityCondition.class) +public class WorkerService { + /** + * 一个分配版本的本地运行状态,集中保存在服务内部。 + * 同一任务迁移后使用新版本,旧播放地址不能访问新任务。 + * 画面与许可由定时处理更新,volatile 保证播放线程及时可见。 + */ + public static final class MediaTask { + final String taskId, key, name, trialName, desired, mediaKind; + final long version; + volatile byte[] jpeg; + volatile long deadlineNanos; + + MediaTask(JsonNode plan) { + taskId = plan.path("taskId").asText(); + version = plan.path("assignment").path("version").asLong(); + key = plan.path("assignment").path("mediaKey").asText(); + name = plan.path("name").asText(); + trialName = plan.path("trialName").asText(); + desired = plan.path("desired").asText(); + mediaKind = plan.path("mediaKind").asText("DEMO_VIDEO"); + } + + // 使用单调时钟,电脑调整系统时间不会延长任务执行许可。 + boolean valid() { + return System.nanoTime() < deadlineNanos; + } + } + + private final Settings settings; + private final CoordinatorLocator locator; + private final PeerClient peer; + private final ImageIngressService images; + // 心跳、画面生成和 HTTP 播放会同时访问任务,键包含任务号及分配版本。 + private final ConcurrentMap tasks = new ConcurrentHashMap<>(); + private volatile String session = "", connection = "WAITING"; + // 发现失败后的重试间隔只影响联网操作,不改变已经领取的许可期限。 + private long retryAfter; + private int registeredImagePort = -1; + + public WorkerService(Settings settings, CoordinatorLocator locator, PeerClient peer) { + this(settings, locator, peer, null); + } + + @Autowired + public WorkerService(Settings settings, CoordinatorLocator locator, PeerClient peer, ImageIngressService images) { + // 正式服务注入原图接入端;兼容构造保留现有演示测试,避免额外启动 UDP。 + // 演示和原图分别有画面来源,不能因为接入失败向 PXHJ 返回伪造的替代图片。 + this.settings = settings; + this.locator = locator; + this.peer = peer; + this.images = images; + } + + /** + * 先移除过期任务,再发现总控、注册并领取最新分配。 + * 原图先报告接入就绪供设备协商地址,再单独报告新鲜首帧决定迁移接管。 + * 心跳异常时保留尚未到期的任务,超过许可期限后自动停止播放。 + */ + @Scheduled(fixedDelay = 700) + public void heartbeat() { + tasks.entrySet().removeIf(entry -> !entry.getValue().valid()); + if (System.currentTimeMillis() < retryAfter) { + return; + } + try { + if (images != null) images.ensureStarted(); + String coordinator = locator.locate(); + registerIfNeeded(coordinator); + ObjectNode input = readinessReport(); + // 从发出请求的时刻计算许可,不能把网络等待时间额外算作授权。 + long requestedAt = System.nanoTime(); + JsonNode plan = peer.request(coordinator, + "/control/nodes/" + settings.id + "/heartbeat", "POST", input); + long deadline = requestedAt + TimeUnit.MILLISECONDS.toNanos(plan.path("leaseMs").asLong()); + if (System.nanoTime() >= deadline) { + throw new IllegalStateException("Expired task plan"); + } + applyPlan(plan, deadline); + connection = "CONNECTED"; + } catch (Exception failure) { + // 下次重新发现与注册;旧会话不会跨总控重启或连接故障继续复用。 + session = ""; + locator.disconnect(); + connection = "WAITING"; + retryAfter = System.currentTimeMillis() + 1500; + } + } + + /** + * 持久节点标识区分电脑,本次 bootId 区分同一电脑的不同启动。 + * 会话由总控返回,后续心跳继续证明当前启动的身份。 + * 对外地址按与总控通信的网卡确定,避免上报不可达的回环地址。 + */ + private void registerIfNeeded(String coordinator) throws Exception { + int imagePort = images == null ? 0 : images.inputPort(); + if (!session.isEmpty() && registeredImagePort == imagePort) { + return; + } + ObjectNode registration = object().put("clusterId", settings.cluster) + .put("nodeId", settings.id).put("bootId", settings.bootId) + // 主执行端复用总控身份和同一 HTTP 端口,加入同一调度池而不额外复制节点。 + .put("nodeRole", settings.role.equals("coordinator") ? "COORDINATOR" : "WORKER") + .put("name", settings.name).put("capacity", settings.capacity) + .put("baseUrl", NodeAddress.url(settings, coordinator)); + // 图片直达节点 UDP 端口;控制与浏览器媒体接口仍复用现有 HTTP 端口。 + // 广播和注册中只有地址与能力,设备 SM4/HMAC 密钥只存在私有任务计划。 + if (images != null && imagePort > 0) { + String host = NodeAddress.host(settings, coordinator); + String ipv4 = java.util.Arrays.stream(InetAddress.getAllByName(host)) + .filter(address -> address instanceof Inet4Address).map(InetAddress::getHostAddress) + .findFirst().orElseThrow(() -> new IllegalStateException("Image input requires IPv4")); + registration.put("imageHost", ipv4).put("imageInputPort", imagePort); + } + JsonNode result = peer.request(coordinator, "/control/nodes/register", "POST", registration); + session = result.path("sessionId").asText(); + registeredImagePort = imagePort; + } + + // 没有生成首帧或许可已经过期时,不向总控承诺该任务可播放。 + // 就绪报告带分配版本,旧节点的迟到报告不能确认新版本的迁移。 + private ObjectNode readinessReport() { + // 每个仍有效分配都报告接入和画面状态,包括迁移中的 active 与 candidate。 + ObjectNode input = object().put("sessionId", session).put("bootId", settings.bootId); + ArrayNode reports = input.putArray("reports"); + for (MediaTask task : tasks.values()) { + if (!task.valid()) continue; + ObjectNode report = object().put("taskId", task.taskId).put("version", task.version); + if (task.mediaKind.equals("ORIGINAL_IMAGE")) { + String id = ImageIngressService.taskKey(task.taskId, task.version); + report.put("ready", images != null && images.ready(id)) + .put("imageReady", images != null && images.imageReady(id)); + if (images != null) { + ImageIngressService.Frame frame = images.latest(id); + if (frame != null) report.put("lastFrameId", frame.frameId()).put("lastFrameTime", frame.sourceTime()); + if (!images.accessError(id).isEmpty()) report.put("error", images.accessError(id)); + } + reports.add(report); + } else if (task.jpeg != null) reports.add(report.put("ready", true)); + } + return input; + } + + /** + * 完整计划决定本机应保留哪些分配,未列出的任务及时释放。 + * 首次领取或准备转运行时,先生成首帧再发布新的任务对象。 + * 同版本播放连接按键读取当前对象,状态切换不会绑死在旧对象上。 + */ + private void applyPlan(JsonNode plan, long deadline) throws Exception { + // 完整计划先替换接入许可,撤销任务时连同在途分片和旧图一并释放。 + // 原图接入不等待上传,否则设备将拿不到目标地址而无法发送首帧。 + if (images != null) images.applyPlans(plan.path("tasks"), deadline); + Set present = new HashSet<>(); + for (JsonNode entry : plan.path("tasks")) { + String id = entry.path("taskId").asText() + "/" + + entry.path("assignment").path("version").asLong(); + present.add(id); + MediaTask old = tasks.get(id); + if (old == null || !old.desired.equals(entry.path("desired").asText()) + || !old.mediaKind.equals(entry.path("mediaKind").asText("DEMO_VIDEO"))) { + MediaTask fresh = new MediaTask(entry); + fresh.deadlineNanos = deadline; + if (!fresh.mediaKind.equals("ORIGINAL_IMAGE")) fresh.jpeg = render(fresh); + tasks.put(id, fresh); + } else { + old.deadlineNanos = deadline; + } + } + tasks.keySet().removeIf(id -> !present.contains(id)); + } + + // 一路画面生成失败保留其上次成功帧,其他通道仍继续刷新。 + // 许可检查独立于画面生成;缓存帧不能让已过期任务继续提供视频。 + @Scheduled(fixedDelay = 250) + public void frames() { + for (MediaTask task : tasks.values()) { + if (task.valid() && !task.mediaKind.equals("ORIGINAL_IMAGE")) { + try { + task.jpeg = render(task); + } catch (Exception ignored) { + // 保持最后一帧,等待下一次定时生成重试。 + } + } + } + } + + /** + * 播放必须同时满足任务号、分配版本、访问密钥及有效许可。 + * 密钥采用固定时间比较,避免直接字符串比较泄露匹配前缀。 + * 该校验每帧执行,任务撤销或许可失效可终止已经打开的连接。 + */ + public MediaTask playable(String taskId, long version, String key) { + MediaTask task = tasks.get(taskId + "/" + version); + if (task == null || !task.valid() || key == null + || !MessageDigest.isEqual(task.key.getBytes(StandardCharsets.UTF_8), + key.getBytes(StandardCharsets.UTF_8))) { + throw new IllegalArgumentException("Video task is not active"); + } + return task; + } + + // 控制层只取得当前授权帧,任务对象与其可变状态由服务层管理。 + public byte[] frame(String taskId, long version, String key) { + MediaTask task = playable(taskId, version, key); + if (!task.mediaKind.equals("ORIGINAL_IMAGE")) return task.jpeg; + ImageIngressService.Frame frame = originalFrame(taskId, version, null); + return frame == null ? null : frame.jpeg(); + } + + /** + * 私有原图接口由服务 HMAC 鉴权,另逐次检查分配版本和租约。 + * 元数据和 JPEG 之间允许短暂并发,用不透明帧号锁定近期真实帧。 + * 无图返回 null,不能回退到演示画面或其他装备的图片。 + */ + public ImageIngressService.Frame originalFrame(String taskId, long version, String frameId) { + // 服务认证只证明调用者身份,不能代替本次任务的版本与执行许可检查。 + MediaTask task = tasks.get(ImageIngressService.taskKey(taskId, version)); + if (task == null || !task.valid() || !task.mediaKind.equals("ORIGINAL_IMAGE") || images == null) return null; + String id = ImageIngressService.taskKey(taskId, version); + return frameId == null ? images.latest(id) : images.frame(id, frameId); + } + + // 对外状态不包含会话、播放密钥或采集业务数据。 + public ObjectNode status() { + ObjectNode result = object().put("nodeId", settings.id).put("connection", connection).put("tasks", tasks.size()); + if (images != null) result.set("imageInput", images.status()); + return result; + } + + /** + * 合成带通道名、试验名、节点名及版本号的演示帧。 + * 移动图形用于观察画面是否持续刷新,不代表真实装备运动。 + * 不读取或伪造采集数据,未来可在此服务接入真正的融合算法。 + */ + private byte[] render(MediaTask task) throws Exception { + int width = 640, height = 360; + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + try { + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + drawScene(graphics, width, height); + drawLabels(graphics, task); + } finally { + // 每次生成均释放图形上下文,避免长期运行积累本地图形资源。 + graphics.dispose(); + } + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + ImageIO.write(image, "jpg", buffer); + return buffer.toByteArray(); + } + + // 场景只负责可见运动与背景绘制,业务身份文字单独输出。 + private void drawScene(Graphics2D graphics, int width, int height) { + graphics.setPaint(new GradientPaint(0, 0, new Color(35, 64, 89), + 0, height, new Color(9, 22, 38))); + graphics.fillRect(0, 0, width, height); + graphics.setColor(new Color(74, 102, 121)); + for (int x = 0; x < width; x += 40) { + graphics.drawLine(x, 95, x, height); + } + for (int y = 95; y < height; y += 35) { + graphics.drawLine(0, y, width, y); + } + graphics.setColor(new Color(79, 112, 129)); + graphics.fillRect(0, 220, width, 65); + graphics.setColor(new Color(170, 185, 190)); + for (int x = 0; x < width; x += 65) { + graphics.fillRect(x, 250, 35, 3); + } + int x = (int) ((System.currentTimeMillis() / 30) % 730) - 65; + graphics.setColor(new Color(38, 161, 217)); + graphics.fillRoundRect(x, 221, 72, 33, 7, 7); + graphics.setColor(new Color(163, 216, 238)); + graphics.fillRect(x + 18, 226, 25, 11); + graphics.setColor(new Color(5, 14, 23)); + graphics.fillOval(x + 10, 247, 15, 15); + graphics.fillOval(x + 49, 247, 15, 15); + } + + // 持续标出演示性质,避免界面把节点演示误当成真实视频融合结果。 + private void drawLabels(Graphics2D graphics, MediaTask task) { + graphics.setColor(Color.WHITE); + graphics.setFont(new Font("Microsoft YaHei", Font.BOLD, 18)); + graphics.drawString(task.name, 22, 33); + graphics.setFont(new Font("Microsoft YaHei", Font.PLAIN, 13)); + graphics.drawString(task.trialName, 22, 58); + graphics.setColor(new Color(255, 206, 108)); + graphics.drawString("演示视频 · 尚未接入真实视频融合", 22, 83); + graphics.setColor(new Color(214, 229, 240)); + graphics.drawString(settings.name + " / " + + (task.desired.equals("RUNNING") ? "本轮运行中" : "准备预览"), 22, 326); + graphics.drawString("任务版本 " + task.version, 510, 326); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..ed4da75 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,16 @@ +server.address=0.0.0.0 +server.port=18100 +spring.config.import=optional:file:./config/cluster.local.properties +spring.jackson.default-property-inclusion=non_null +spring.mvc.async.request-timeout=0 +fusion.role=coordinator +fusion.coordinator-worker-enabled=true +fusion.cluster-id=pxhj-fusion +fusion.token=${XTRH_CLUSTER_TOKEN:} +fusion.capacity=2 +fusion.lease-ms=8000 +fusion.discovery-enabled=true +fusion.gateway-port=19002 +fusion.forward-target-port=19001 +logging.level.javax.jmdns=WARN +server.error.include-message=never diff --git a/src/main/resources/static/console/assets/brand.svg b/src/main/resources/static/console/assets/brand.svg new file mode 100644 index 0000000..2019af3 --- /dev/null +++ b/src/main/resources/static/console/assets/brand.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/main/resources/static/console/assets/index-CGh3g9P7.css b/src/main/resources/static/console/assets/index-CGh3g9P7.css new file mode 100644 index 0000000..149b3af --- /dev/null +++ b/src/main/resources/static/console/assets/index-CGh3g9P7.css @@ -0,0 +1 @@ +.secondary-equipment-layout{display:grid;grid-template-columns:296px minmax(0,1fr);gap:18px;align-items:stretch}.secondary-detail-stack{display:flex;flex-direction:column;gap:14px;min-width:0}.secondary-search{display:flex;align-items:center;gap:10px;width:330px;max-width:100%;min-height:40px;padding:0 12px;color:#647e9f;border:1px solid #d6e2f0;border-radius:6px;background:#fff}.secondary-search:focus-within{border-color:#3280d4;box-shadow:0 0 0 3px #2b7bd316}.secondary-search input{width:100%;min-width:0;height:38px;padding:0;color:#203753;font:inherit;font-size:13px;border:0;outline:0;background:transparent}.secondary-search input::placeholder{color:#7d91ab}.secondary-list-search{padding:0 18px 18px}.secondary-list-search .secondary-search{width:auto}.secondary-panel-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0 0 18px}.secondary-muted,.secondary-blank{color:#8294ac}.secondary-blank{white-space:nowrap;font-variant-numeric:tabular-nums}.secondary-blank small{margin-left:8px;color:#8294ac;font-size:12px}.secondary-unavailable{display:inline-flex;align-items:center;gap:7px;color:#8393a9;white-space:nowrap;font-size:13px}.secondary-unavailable i,.node-state i{width:8px;height:8px;flex:0 0 8px;border-radius:50%;background:currentColor}.secondary-table-scroll{width:100%;overflow-x:auto}.secondary-table{width:100%;border-spacing:0;border-collapse:collapse;table-layout:fixed;color:#294360;font-size:13px}.secondary-table th{height:48px;padding:12px 14px;color:#5c7798;background:#f4f8fc;border:1px solid #e1eaf4;font-weight:500;text-align:left}.secondary-table td{height:83px;padding:17px 14px;border:1px solid #e1eaf4;vertical-align:middle}.secondary-table th svg{display:inline-block;margin:0 6px -2px 0}.secondary-table tbody tr:hover{background:#f9fbfe}.secondary-table .secondary-empty-cell{height:230px;padding:30px 16px;text-align:center}.secondary-table .secondary-empty-cell:hover{background:#fff}.secondary-column-unit{white-space:nowrap}.secondary-panel-note{margin:20px 0 0;color:#7f94ae;font-size:12px;line-height:1.6}.virtual-list-panel{min-height:650px}.virtual-list-empty{display:grid;place-items:center;padding:80px 18px 28px}.virtual-unselected-header{display:flex;align-items:center;gap:18px;padding:23px;min-height:100px;border:1px solid #dfe8f3;border-radius:8px;background:#fff}.virtual-equipment-icon{display:grid;place-items:center;width:48px;height:48px;color:#a2b5cc;background:#f2f6fb;border-radius:9px}.virtual-unselected-header h2,.video-unselected-header h2{margin:0 0 8px;font-size:21px;font-weight:600;color:#566d89}.virtual-unselected-header p,.video-unselected-header p{margin:0;color:#8396ae;font-size:13px}.virtual-unselected-header p span{margin-left:12px}.virtual-unselected-header>.secondary-unavailable{margin-left:auto}.virtual-state-body{padding:0}.virtual-health-row{display:flex;align-items:center;gap:20px;padding:20px 22px;background:#f9fbfe;border:1px solid #e5edf6;border-radius:5px}.virtual-health-row>svg{color:#9badc2}.virtual-health-label{color:#66809f;font-size:14px}.virtual-health-row strong{color:#8195ad;font-size:23px;font-weight:500;white-space:nowrap}.virtual-health-track{flex:1;height:10px;margin-left:8px;border-radius:6px;background:#e7edf5}.virtual-position-grid{display:grid;grid-template-columns:1fr 1fr;gap:19px 36px;padding:23px 12px 4px}.virtual-position-item{display:grid;grid-template-columns:84px 1fr;gap:12px;font-size:14px}.virtual-position-item>span:first-child{color:#6783a3}.virtual-overview-table{min-width:620px}.virtual-overview-table th:first-child{width:23%}.virtual-overview-table th:nth-child(3){width:33%}.video-main-panel{min-width:0;overflow:hidden}.video-unselected-header{display:flex;align-items:center;gap:18px;min-height:91px;color:#91a6bf}.video-main-panel>.panel-header:empty{display:none}.video-stage{position:relative;display:flex;flex-direction:column;min-height:480px;aspect-ratio:16 / 10;overflow:hidden;background:#101d2c;border-radius:6px}.video-empty-frame{display:flex;flex:1;flex-direction:column;align-items:center;justify-content:center;padding:180px 24px 36px;color:#5f7893;text-align:center}.video-empty-frame h3{margin:16px 0 9px;color:#c1cede;font-size:19px;font-weight:500}.video-empty-frame p{margin:0;color:#7d92a9;font-size:13px;line-height:1.7}.video-metrics-overlay{position:absolute;top:18px;right:18px;width:222px;padding:15px 17px;color:#dfe7f0;border:1px solid #52667c50;border-radius:7px;background:#223246e8}.video-overlay-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-bottom:11px;border-bottom:1px solid #a4b4c12b}.video-overlay-heading strong{font-size:14px}.video-overlay-heading>span{max-width:102px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#9fb2c7;font-size:11px}.video-metrics-overlay dl{display:flex;flex-direction:column;gap:10px;margin:12px 0 0;font-size:12px}.video-metrics-overlay dl>div{display:flex;justify-content:space-between;align-items:center;gap:15px}.video-metrics-overlay dt{color:#c1cede}.video-metrics-overlay dd{margin:0;color:#a9bbcf;white-space:nowrap}.video-metrics-overlay dd small{margin-left:6px;color:#8098b3;font-size:10px}.video-control-bar{display:flex;align-items:center;gap:16px;min-height:48px;padding:0 16px;color:#8195ad;background:#1c2a3a}.video-control-bar .secondary-unavailable{color:#879bb2;font-size:12px}.video-control-divider{height:21px;width:1px;background:#47596d}.video-control-bar button{display:grid;place-items:center;min-width:25px;padding:5px 0;border:0;color:#768b9f;background:none}.video-control-bar button:disabled{cursor:not-allowed;opacity:.55}.video-volume-track{width:96px;height:3px;border-radius:3px;background:#3f5369}.video-resolution{margin-left:auto;font-size:11px;white-space:nowrap}.fusion-metrics-panel{min-height:550px}.fusion-metrics-table{min-width:1000px}.fusion-metrics-table th:first-child{width:215px}.fusion-metrics-table th:last-child{width:105px}.fusion-metrics-table td:not(:first-child),.fusion-metrics-table th:not(:first-child){text-align:center}.fusion-metrics-table td{height:93px}.fusion-equipment-name{display:flex;align-items:center;gap:14px}.fusion-equipment-name>svg{color:#287dcb;flex-shrink:0}.fusion-equipment-name>div{min-width:0}.fusion-equipment-name strong{display:block;color:#1b395c;font-size:15px;font-weight:600;word-break:break-word}.fusion-equipment-name span{display:block;margin-top:7px;color:#7e94b0;font-size:12px;word-break:break-all}.fusion-metrics-footer{margin:22px 0 0;color:#7b93b0;font-size:13px}.node-status-table{min-width:1020px}.node-status-table th:first-child{width:250px}.node-status-table th:nth-child(2){width:90px}.node-status-table th:nth-child(4){width:180px}.node-status-table th:last-child{width:115px}.node-status-table td{height:96px}.node-identity{display:flex;align-items:center;gap:15px}.node-identity>svg{flex-shrink:0;color:#267bd1}.node-identity>div{min-width:0}.node-identity strong{display:block;color:#193a5c;font-size:15px;font-weight:600;word-break:break-word}.node-identity span{display:block;margin-top:7px;color:#7a91ad;font-size:11px;overflow-wrap:anywhere}.node-state{display:inline-flex;align-items:center;gap:7px;font-size:13px;white-space:nowrap}.node-state-online{color:#0da76a}.node-state-offline,.node-state-unknown{color:#8092aa}.node-network-panel{margin-top:24px;padding:18px;border:1px solid #e0e9f4;border-radius:6px}.node-network-panel h3{display:flex;align-items:center;gap:9px;margin:0 0 18px;padding-bottom:14px;border-bottom:1px solid #e6edf6;font-size:15px;font-weight:600;color:#234363}.node-network-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.node-network-item{display:flex;align-items:center;gap:11px;min-width:0;padding:7px 0}.node-network-item>svg{flex-shrink:0;color:#287dc9}.node-network-item>div{min-width:0}.node-network-item strong{display:block;color:#294664;font-size:13px;font-weight:600;word-break:break-word}.node-network-item>div>span{display:block;margin-top:6px;color:#8294aa;font-size:11px}.node-network-item>.node-state{margin-left:auto}.node-network-empty{margin:28px 0;color:#8b9bb0;text-align:center;font-size:13px}@media(max-width:1220px){.secondary-equipment-layout{grid-template-columns:260px minmax(0,1fr)}.video-stage{min-height:490px}.video-empty-frame{padding-top:230px}.node-network-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.virtual-health-row{gap:13px;padding:18px 14px}.virtual-health-row strong{font-size:19px}}@media(max-width:950px){.secondary-equipment-layout{grid-template-columns:1fr}.virtual-list-panel{min-height:240px}.virtual-list-empty{padding:25px 18px}.video-stage{aspect-ratio:16 / 10;min-height:440px}.video-empty-frame{padding-top:180px}}@media(max-width:650px){.virtual-unselected-header{gap:12px;padding:17px;flex-wrap:wrap}.virtual-unselected-header>.secondary-unavailable{margin-left:60px}.virtual-position-grid{grid-template-columns:1fr}.virtual-health-row{flex-wrap:wrap}.virtual-health-track{min-width:70px}.video-stage{min-height:480px}.video-metrics-overlay{width:200px;right:12px;top:12px}.video-empty-frame{padding-top:250px}.video-control-bar{gap:12px;padding:0 12px}.video-volume-track,.video-resolution{display:none}.video-control-bar button:last-child{margin-left:auto}.node-network-grid{grid-template-columns:1fr}}:root{font-family:Microsoft YaHei,Segoe UI,sans-serif;color:#263e5b;background:#f4f6fa;font-size:14px;font-weight:400;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased}*{box-sizing:border-box}body{margin:0;min-width:360px}button,input,select{font:inherit}button,a,input,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.45}a{color:inherit;text-decoration:none}h1,h2,h3,p{margin:0}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,tr:focus-visible{outline:2px solid #276cc0;outline-offset:3px}button{border:0}input,select{color:#314d70}input::placeholder{color:#8d9bb1}svg{flex-shrink:0}time,.mono{font-variant-numeric:tabular-nums}.muted{color:#8797ac;font-size:12px}.sidebar{width:246px;background:#172c46;color:#becddd;position:fixed;inset:0 auto 0 0;display:flex;flex-direction:column;padding:27px 13px 24px;z-index:10}.brand{display:flex;align-items:center;gap:12px;padding:0 12px 30px;color:#fff;position:relative;white-space:nowrap}.brand img{width:43px;height:43px}.brand strong{font-size:18px;font-weight:600}.collapse-toggle{display:grid;place-items:center;width:23px;height:25px;background:#ffffff06;color:#9eb3cc;border:1px solid #ffffff20;border-radius:4px;position:absolute;right:0;bottom:9px}.sidebar nav{padding-top:16px}.nav-link{display:flex;align-items:center;gap:20px;padding:16px;margin:6px 0 12px;border-radius:7px;font-size:15px;white-space:nowrap;min-height:54px}.nav-link:hover{background:#ffffff0b}.nav-link.active{background:#2c5d97;color:#fff;font-weight:600}.nav-link svg{color:#a5bdd6}.nav-link.active svg{color:#f2f8ff}.sidebar-foot{margin-top:auto;display:flex;gap:19px;align-items:center;border-top:1px solid #ffffff14;padding:25px 17px 3px;font-size:14px;color:#bdcede}.main-shell{margin-left:246px;min-height:100vh;display:flex;flex-direction:column;min-width:0}.topbar{height:64px;min-height:64px;padding:0 29px;display:flex;align-items:center;justify-content:space-between;gap:18px;background:#fff;border-bottom:1px solid #e1e8f0;font-size:13px}.breadcrumb{display:flex;align-items:center;gap:13px;color:#7e90a9;white-space:nowrap}.breadcrumb strong{color:#344c6b;font-weight:600}.service-state{display:flex;gap:22px;align-items:center;white-space:nowrap}.service-state time{border-left:1px solid #d7e1ed;padding-left:22px;color:#6e84a3;font-size:12px}main{padding:28px 29px 0;flex:1;min-width:0;max-width:1900px;width:100%;margin:auto}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:22px;min-height:70px}.page-heading h1{font-size:27px;line-height:1.35;font-weight:650;color:#1f3653;letter-spacing:.2px}.page-heading p{margin-top:8px;color:#7b8fa8;line-height:1.5;font-size:14px}.heading-actions{display:flex;gap:12px;align-items:center;justify-content:flex-end}.refresh-button{display:flex;gap:7px;align-items:center;padding:9px 12px;min-height:38px;background:#fff;border:1px solid #dce5ef;border-radius:5px;color:#6b80a0;font-size:12px}.refresh-button:hover{border-color:#276cc0;color:#276cc0}.context-select{display:flex;align-items:center;gap:10px;color:#7a8ca4;font-size:12px}.context-select select{max-width:260px;border:1px solid #d9e3ef;border-radius:5px;background:#fff;padding:10px 28px 10px 11px;min-width:120px}.panel{background:#fff;border:1px solid #dfe7f1;border-radius:7px;min-width:0;overflow:hidden}.panel-title{padding:19px 20px 15px;display:flex;align-items:center;justify-content:space-between;gap:15px;min-height:56px}.panel-title h2{font-size:17px;color:#253f5f;font-weight:600}.panel-extra{font-size:12px;color:#8193ab}.equipment-layout{display:grid;grid-template-columns:290px minmax(0,1fr);gap:12px;min-height:650px;align-items:stretch}.equipment-list{min-height:650px}.equipment-list .panel-title{padding-bottom:14px}.search-input{display:flex;align-items:center;gap:10px;border:1px solid #cfdbeb;border-radius:5px;min-height:42px;padding:0 12px;margin:4px 20px 20px;color:#708baa;min-width:0}.search-input input{background:transparent;width:100%;min-width:0;border:0;outline:none!important;padding:10px 0;font-size:13px}.search-input:focus-within{border-color:#4384c7;box-shadow:0 0 0 2px #276cc014}.equipment-rows{border-top:1px solid #e5ecf4}.equipment-row{display:flex;align-items:center;gap:14px;min-height:92px;width:100%;padding:18px 18px 18px 23px;background:#fff;color:#2873bf;text-align:left;border-bottom:1px solid #e3ebf4;border-left:3px solid transparent}.equipment-row:hover{background:#f6faff}.equipment-row.selected{background:#eaf3ff;border-left-color:#3284df}.equipment-row-copy{min-width:0;flex:1}.equipment-row-copy strong{display:block;font-weight:600;color:#273f61;font-size:15px;overflow-wrap:anywhere}.equipment-row-copy small{display:block;color:#8198b4;font-size:12px;margin-top:5px;overflow-wrap:anywhere}.equipment-row .status{font-size:11px;gap:5px;max-width:86px;white-space:normal;line-height:1.45}.status{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;line-height:1.45}.status i{width:7px;height:7px;flex:none;background:currentColor;border-radius:50%}.status-good{color:#14965c}.status-warn{color:#bf881c}.status-bad{color:#c25b48}.status-muted{color:#879bb6}.detail-stack{display:flex;flex-direction:column;gap:12px;min-width:0}.equipment-header{display:flex;align-items:center;justify-content:space-between;padding:24px 25px;gap:18px;min-height:106px}.equipment-heading{display:flex;align-items:center;gap:23px;min-width:0;color:#2778c2}.equipment-heading h2{font-size:24px;color:#213b60;font-weight:600;overflow-wrap:anywhere}.equipment-heading p{font-size:12px;color:#8297b4;margin-top:7px;line-height:1.5}.equipment-heading p span{margin:0 11px}.equipment-heading>.status{background:#eaf7f0;padding:5px 9px;border-radius:3px;margin-left:3px}.equipment-updated{font-size:12px;color:#8b9db5;white-space:nowrap}.equipment-updated time{color:#607c9f;margin-left:9px}.table-scroll{overflow:auto;max-width:100%}.data-table{border-collapse:collapse;width:100%;text-align:left;font-size:13px}.data-table th{background:#f6f8fc;color:#7c91ad;font-size:12px;font-weight:500;padding:14px 19px;border-block:1px solid #e5ecf4;white-space:nowrap}.data-table td{border-bottom:1px solid #e5ecf4;padding:19px;vertical-align:middle;color:#426185;font-variant-numeric:tabular-nums}.data-table tr:last-child td{border-bottom:0}.data-table tr.selected{background:#f3f8ff}.data-table td strong{font-weight:500;color:#334f72}.data-table td small{display:block;font-size:11px;color:#8d9eb3;margin-top:5px}.receive-table tbody tr{cursor:pointer}.receive-table tbody tr:hover{background:#f6faff}.receive-table td.parameter-value{overflow-wrap:anywhere;min-width:130px;max-width:450px}.receive-table td:first-child{min-width:145px}.receive-table td time{white-space:nowrap}.value-details{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px 20px;margin:0;padding:14px 24px 25px}.value-details>div{min-width:0;border-left:1px solid #e2ebf6;padding-left:16px}.value-details dt{color:#8095b1;font-size:12px;margin-bottom:11px}.value-details dd{margin:0;font-size:16px;color:#2e4c73;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:12px;min-height:190px;padding:34px 24px;color:#94a9c0}.empty-state strong{font-size:15px;font-weight:500;color:#6d88a7}.empty-state p{max-width:450px;font-size:12px;line-height:1.8;color:#8c9fb6}.equipment-list .empty-state{min-height:240px;padding-inline:18px}.unselected-panel{min-height:650px;display:flex;align-items:center;justify-content:center}.unselected-panel .empty-state{min-height:400px}.sync-values{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px;padding:0 20px 22px}.sync-values>div{border:1px solid #e1eaf5;padding:17px 18px}.sync-values span{font-size:13px;color:#7289a8}.sync-values strong{display:block;font-size:27px;font-weight:600;margin-top:12px;color:#5d7ba0}.sync-values small{font-size:12px;font-weight:400}.sync-values p{margin-top:8px;font-size:11px;color:#91a3ba}.source-notice{display:flex;align-items:center;gap:8px;background:#fff7e8;border:1px solid #f2e3c5;color:#a27f3f;font-size:12px;padding:11px 15px;border-radius:5px;margin-bottom:16px;line-height:1.7}.app-footer{padding:23px 29px;font-size:11px;color:#92a5be;display:flex;align-items:center;justify-content:space-between;gap:12px}.nav-collapsed .sidebar{width:76px;padding-inline:10px}.nav-collapsed .main-shell{margin-left:76px}.nav-collapsed .brand{padding:0 6px 28px}.nav-collapsed .brand img{width:43px;height:43px}.nav-collapsed .brand strong,.nav-collapsed .nav-link span,.nav-collapsed .sidebar-foot span{display:none}.nav-collapsed .nav-link{padding:15px;justify-content:center}.nav-collapsed .sidebar-foot{padding:24px 17px 3px}.nav-collapsed .collapse-toggle{bottom:-4px;right:16px}.brand img{background:#4b92d5;border-radius:10px;padding:5px}.sidebar nav{padding-top:3px}.secondary-equipment-layout{grid-template-columns:290px minmax(0,1fr);gap:12px}.video-unselected-header{padding:23px}.virtual-state-body{padding:0 20px 20px}.virtual-overview-panel>.secondary-table-scroll,.fusion-metrics-panel>.secondary-table-scroll,.node-page>.secondary-table-scroll{width:calc(100% - 40px);margin-inline:20px}.secondary-panel-toolbar{margin-inline:20px}.node-page>.node-network-panel{margin:24px 20px 0}.secondary-panel-note{margin:20px}.fusion-metrics-panel,.node-page{padding-bottom:18px}@media(min-width:1550px){.equipment-layout,.secondary-equipment-layout{grid-template-columns:350px minmax(0,1fr)}.equipment-row{padding-block:22px}.equipment-row .status{font-size:12px}.equipment-header{min-height:111px}.equipment-list,.equipment-layout{min-height:730px}.nav-link{font-size:16px}.brand strong{font-size:19px}.page-heading h1{font-size:29px}.data-table{font-size:14px}}@media(max-width:1280px){.sidebar{width:210px;padding-inline:10px}.main-shell{margin-left:210px}.brand{gap:9px;padding-inline:8px}.brand img{width:36px;height:36px}.brand strong{font-size:16px}.nav-link{font-size:13px;gap:15px;padding-inline:12px}.equipment-layout{grid-template-columns:250px minmax(0,1fr)}.equipment-row{gap:10px;padding-inline:14px}.equipment-row .status{font-size:10px;max-width:65px}.equipment-row-copy strong{font-size:13px}.equipment-row-copy small{font-size:11px}.equipment-heading{gap:15px}.equipment-heading h2{font-size:21px}.equipment-header{padding:20px;flex-wrap:wrap;gap:12px}.equipment-updated{width:100%;padding-left:61px}.topbar{padding-inline:22px}main{padding:24px 22px 0}.data-table td,.data-table th{padding-inline:14px}.service-state{gap:12px}.service-state time{padding-left:12px}.value-details{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:1000px){.service-state time{display:none}.topbar{gap:10px}.equipment-layout{grid-template-columns:220px minmax(0,1fr)}.equipment-row{flex-wrap:wrap;column-gap:9px;padding-block:14px;min-height:86px}.equipment-row .status{margin-left:35px;max-width:none;width:100%;font-size:10px}.equipment-list .panel-title h2{font-size:15px}.search-input{margin-inline:14px;padding-inline:9px}.equipment-heading>.status{margin-left:0}.equipment-heading{flex-wrap:wrap;gap:12px}.equipment-heading>svg{width:34px}.equipment-updated{padding-left:46px}.panel-title h2{font-size:15px}.heading-actions{flex-wrap:wrap}.context-select>span{display:none}.context-select select{max-width:180px}.refresh-button span{display:none}.sync-values{gap:9px;padding-inline:14px}.sync-values>div{padding:13px 10px}.sync-values span{font-size:11px}}@media(max-width:800px){.sidebar{width:68px;padding-inline:8px}.main-shell{margin-left:68px}.brand{padding:0 7px 30px}.brand img{width:37px;height:37px}.brand strong,.nav-link span,.sidebar-foot span,.collapse-toggle{display:none}.nav-link{padding:14px;justify-content:center}.sidebar-foot{justify-content:center;padding-inline:0}.equipment-layout{grid-template-columns:210px minmax(0,1fr)}main{padding:20px 17px 0}.topbar{padding-inline:17px}.page-heading h1{font-size:24px}.page-heading p{font-size:12px}.page-heading{align-items:flex-start;gap:12px}.equipment-heading h2{font-size:19px}.equipment-heading p{font-size:11px}.equipment-header{padding:18px 16px}.value-details{gap:20px 12px;padding-inline:15px}.value-details dd{font-size:14px}}@media(max-width:650px){.equipment-layout{grid-template-columns:1fr;min-height:0}.equipment-list{min-height:0}.equipment-list .equipment-rows{display:flex;overflow:auto}.equipment-row{min-width:220px;width:220px;border-left:0;border-top:3px solid transparent}.equipment-row.selected{border-top-color:#3284df}.equipment-list .empty-state{min-height:140px}.unselected-panel{min-height:320px}.page-heading{flex-direction:column}.heading-actions{width:100%;justify-content:flex-end}.context-select{flex:1}.context-select select{max-width:100%;width:100%}.service-state{display:none}.breadcrumb{font-size:12px}.topbar{height:56px;min-height:56px}.detail-stack{gap:12px}.equipment-updated{padding-left:0}.sync-values{grid-template-columns:1fr}.sync-values>div{display:flex;align-items:center;justify-content:space-between;gap:10px}.sync-values strong,.sync-values p{margin:0}.app-footer{padding:20px 17px}.app-footer span:first-child{display:none}.nav-collapsed .sidebar{width:68px}.nav-collapsed .main-shell{margin-left:68px}} diff --git a/src/main/resources/static/console/assets/index-DHXeX6-t.js b/src/main/resources/static/console/assets/index-DHXeX6-t.js new file mode 100644 index 0000000..027d47b --- /dev/null +++ b/src/main/resources/static/console/assets/index-DHXeX6-t.js @@ -0,0 +1,214 @@ +(function(){const S=document.createElement("link").relList;if(S&&S.supports&&S.supports("modulepreload"))return;for(const _ of document.querySelectorAll('link[rel="modulepreload"]'))o(_);new MutationObserver(_=>{for(const D of _)if(D.type==="childList")for(const w of D.addedNodes)w.tagName==="LINK"&&w.rel==="modulepreload"&&o(w)}).observe(document,{childList:!0,subtree:!0});function N(_){const D={};return _.integrity&&(D.integrity=_.integrity),_.referrerPolicy&&(D.referrerPolicy=_.referrerPolicy),_.crossOrigin==="use-credentials"?D.credentials="include":_.crossOrigin==="anonymous"?D.credentials="omit":D.credentials="same-origin",D}function o(_){if(_.ep)return;_.ep=!0;const D=N(_);fetch(_.href,D)}})();function ky(s){return s&&s.__esModule&&Object.prototype.hasOwnProperty.call(s,"default")?s.default:s}var rf={exports:{}},Au={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Yh;function $y(){if(Yh)return Au;Yh=1;var s=Symbol.for("react.transitional.element"),S=Symbol.for("react.fragment");function N(o,_,D){var w=null;if(D!==void 0&&(w=""+D),_.key!==void 0&&(w=""+_.key),"key"in _){D={};for(var J in _)J!=="key"&&(D[J]=_[J])}else D=_;return _=D.ref,{$$typeof:s,type:o,key:w,ref:_!==void 0?_:null,props:D}}return Au.Fragment=S,Au.jsx=N,Au.jsxs=N,Au}var qh;function Wy(){return qh||(qh=1,rf.exports=$y()),rf.exports}var f=Wy(),vf={exports:{}},X={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Gh;function Fy(){if(Gh)return X;Gh=1;var s=Symbol.for("react.transitional.element"),S=Symbol.for("react.portal"),N=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),_=Symbol.for("react.profiler"),D=Symbol.for("react.consumer"),w=Symbol.for("react.context"),J=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),V=Symbol.for("react.lazy"),B=Symbol.for("react.activity"),ul=Symbol.iterator;function Tl(y){return y===null||typeof y!="object"?null:(y=ul&&y[ul]||y["@@iterator"],typeof y=="function"?y:null)}var nl={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},gl=Object.assign,Rl={};function _l(y,A,O){this.props=y,this.context=A,this.refs=Rl,this.updater=O||nl}_l.prototype.isReactComponent={},_l.prototype.setState=function(y,A){if(typeof y!="object"&&typeof y!="function"&&y!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,y,A,"setState")},_l.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function Cl(){}Cl.prototype=_l.prototype;function bl(y,A,O){this.props=y,this.context=A,this.refs=Rl,this.updater=O||nl}var Nl=bl.prototype=new Cl;Nl.constructor=bl,gl(Nl,_l.prototype),Nl.isPureReactComponent=!0;var Xl=Array.isArray;function Bl(){}var Y={H:null,A:null,T:null,S:null},Yl=Object.prototype.hasOwnProperty;function Il(y,A,O){var C=O.ref;return{$$typeof:s,type:y,key:A,ref:C!==void 0?C:null,props:O}}function Pt(y,A){return Il(y.type,A,y.props)}function ct(y){return typeof y=="object"&&y!==null&&y.$$typeof===s}function Ll(y){var A={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(O){return A[O]})}var Q=/\/+/g;function gt(y,A){return typeof y=="object"&&y!==null&&y.key!=null?Ll(""+y.key):A.toString(36)}function ql(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status=="string"?y.then(Bl,Bl):(y.status="pending",y.then(function(A){y.status==="pending"&&(y.status="fulfilled",y.value=A)},function(A){y.status==="pending"&&(y.status="rejected",y.reason=A)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function E(y,A,O,C,L){var k=typeof y;(k==="undefined"||k==="boolean")&&(y=null);var fl=!1;if(y===null)fl=!0;else switch(k){case"bigint":case"string":case"number":fl=!0;break;case"object":switch(y.$$typeof){case s:case S:fl=!0;break;case V:return fl=y._init,E(fl(y._payload),A,O,C,L)}}if(fl)return L=L(y),fl=C===""?"."+gt(y,0):C,Xl(L)?(O="",fl!=null&&(O=fl.replace(Q,"$&/")+"/"),E(L,A,O,"",function(De){return De})):L!=null&&(ct(L)&&(L=Pt(L,O+(L.key==null||y&&y.key===L.key?"":(""+L.key).replace(Q,"$&/")+"/")+fl)),A.push(L)),1;fl=0;var $l=C===""?".":C+":";if(Xl(y))for(var Al=0;Al>>1,ll=E[cl];if(0<_(ll,M))E[cl]=M,E[q]=ll,q=cl;else break l}}function N(E){return E.length===0?null:E[0]}function o(E){if(E.length===0)return null;var M=E[0],q=E.pop();if(q!==M){E[0]=q;l:for(var cl=0,ll=E.length,y=ll>>>1;cl_(O,q))C_(L,O)?(E[cl]=L,E[C]=q,cl=C):(E[cl]=O,E[A]=q,cl=A);else if(C_(L,q))E[cl]=L,E[C]=q,cl=C;else break l}}return M}function _(E,M){var q=E.sortIndex-M.sortIndex;return q!==0?q:E.id-M.id}if(s.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var D=performance;s.unstable_now=function(){return D.now()}}else{var w=Date,J=w.now();s.unstable_now=function(){return w.now()-J}}var p=[],x=[],V=1,B=null,ul=3,Tl=!1,nl=!1,gl=!1,Rl=!1,_l=typeof setTimeout=="function"?setTimeout:null,Cl=typeof clearTimeout=="function"?clearTimeout:null,bl=typeof setImmediate<"u"?setImmediate:null;function Nl(E){for(var M=N(x);M!==null;){if(M.callback===null)o(x);else if(M.startTime<=E)o(x),M.sortIndex=M.expirationTime,S(p,M);else break;M=N(x)}}function Xl(E){if(gl=!1,Nl(E),!nl)if(N(p)!==null)nl=!0,Bl||(Bl=!0,Ll());else{var M=N(x);M!==null&&ql(Xl,M.startTime-E)}}var Bl=!1,Y=-1,Yl=5,Il=-1;function Pt(){return Rl?!0:!(s.unstable_now()-IlE&&Pt());){var cl=B.callback;if(typeof cl=="function"){B.callback=null,ul=B.priorityLevel;var ll=cl(B.expirationTime<=E);if(E=s.unstable_now(),typeof ll=="function"){B.callback=ll,Nl(E),M=!0;break t}B===N(p)&&o(p),Nl(E)}else o(p);B=N(p)}if(B!==null)M=!0;else{var y=N(x);y!==null&&ql(Xl,y.startTime-E),M=!1}}break l}finally{B=null,ul=q,Tl=!1}M=void 0}}finally{M?Ll():Bl=!1}}}var Ll;if(typeof bl=="function")Ll=function(){bl(ct)};else if(typeof MessageChannel<"u"){var Q=new MessageChannel,gt=Q.port2;Q.port1.onmessage=ct,Ll=function(){gt.postMessage(null)}}else Ll=function(){_l(ct,0)};function ql(E,M){Y=_l(function(){E(s.unstable_now())},M)}s.unstable_IdlePriority=5,s.unstable_ImmediatePriority=1,s.unstable_LowPriority=4,s.unstable_NormalPriority=3,s.unstable_Profiling=null,s.unstable_UserBlockingPriority=2,s.unstable_cancelCallback=function(E){E.callback=null},s.unstable_forceFrameRate=function(E){0>E||125cl?(E.sortIndex=q,S(x,E),N(p)===null&&E===N(x)&&(gl?(Cl(Y),Y=-1):gl=!0,ql(Xl,q-cl))):(E.sortIndex=ll,S(p,E),nl||Tl||(nl=!0,Bl||(Bl=!0,Ll()))),E},s.unstable_shouldYield=Pt,s.unstable_wrapCallback=function(E){var M=ul;return function(){var q=ul;ul=M;try{return E.apply(this,arguments)}finally{ul=q}}}})(Sf)),Sf}var Lh;function lr(){return Lh||(Lh=1,gf.exports=Py()),gf.exports}var bf={exports:{}},kl={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Zh;function tr(){if(Zh)return kl;Zh=1;var s=Nf();function S(p){var x="https://react.dev/errors/"+p;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(s)}catch(S){console.error(S)}}return s(),bf.exports=tr(),bf.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Kh;function er(){if(Kh)return xu;Kh=1;var s=lr(),S=Nf(),N=ar();function o(l){var t="https://react.dev/errors/"+l;if(1ll||(l.current=cl[ll],cl[ll]=null,ll--)}function O(l,t){ll++,cl[ll]=l.current,l.current=t}var C=y(null),L=y(null),k=y(null),fl=y(null);function $l(l,t){switch(O(k,t),O(L,l),O(C,null),t.nodeType){case 9:case 11:l=(l=t.documentElement)&&(l=l.namespaceURI)?ch(l):0;break;default:if(l=t.tagName,t=t.namespaceURI)t=ch(t),l=ih(t,l);else switch(l){case"svg":l=1;break;case"math":l=2;break;default:l=0}}A(C),O(C,l)}function Al(){A(C),A(L),A(k)}function De(l){l.memoizedState!==null&&O(fl,l);var t=C.current,a=ih(t,l.type);t!==a&&(O(L,l),O(C,a))}function Nu(l){L.current===l&&(A(C),A(L)),fl.current===l&&(A(fl),zu._currentValue=q)}var $n,Rf;function pa(l){if($n===void 0)try{throw Error()}catch(a){var t=a.stack.trim().match(/\n( *(at )?)/);$n=t&&t[1]||"",Rf=-1)":-1u||d[e]!==m[u]){var z=` +`+d[e].replace(" at new "," at ");return l.displayName&&z.includes("")&&(z=z.replace("",l.displayName)),z}while(1<=e&&0<=u);break}}}finally{Wn=!1,Error.prepareStackTrace=a}return(a=l?l.displayName||l.name:"")?pa(a):""}function x0(l,t){switch(l.tag){case 26:case 27:case 5:return pa(l.type);case 16:return pa("Lazy");case 13:return l.child!==t&&t!==null?pa("Suspense Fallback"):pa("Suspense");case 19:return pa("SuspenseList");case 0:case 15:return Fn(l.type,!1);case 11:return Fn(l.type.render,!1);case 1:return Fn(l.type,!0);case 31:return pa("Activity");default:return""}}function Bf(l){try{var t="",a=null;do t+=x0(l,a),a=l,l=l.return;while(l);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var In=Object.prototype.hasOwnProperty,Pn=s.unstable_scheduleCallback,lc=s.unstable_cancelCallback,p0=s.unstable_shouldYield,_0=s.unstable_requestPaint,it=s.unstable_now,N0=s.unstable_getCurrentPriorityLevel,Yf=s.unstable_ImmediatePriority,qf=s.unstable_UserBlockingPriority,Mu=s.unstable_NormalPriority,M0=s.unstable_LowPriority,Gf=s.unstable_IdlePriority,O0=s.log,D0=s.unstable_setDisableYieldValue,Ue=null,ft=null;function la(l){if(typeof O0=="function"&&D0(l),ft&&typeof ft.setStrictMode=="function")try{ft.setStrictMode(Ue,l)}catch{}}var st=Math.clz32?Math.clz32:H0,U0=Math.log,C0=Math.LN2;function H0(l){return l>>>=0,l===0?32:31-(U0(l)/C0|0)|0}var Ou=256,Du=262144,Uu=4194304;function _a(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&261888;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function Cu(l,t,a){var e=l.pendingLanes;if(e===0)return 0;var u=0,n=l.suspendedLanes,c=l.pingedLanes;l=l.warmLanes;var i=e&134217727;return i!==0?(e=i&~n,e!==0?u=_a(e):(c&=i,c!==0?u=_a(c):a||(a=i&~l,a!==0&&(u=_a(a))))):(i=e&~n,i!==0?u=_a(i):c!==0?u=_a(c):a||(a=e&~l,a!==0&&(u=_a(a)))),u===0?0:t!==0&&t!==u&&(t&n)===0&&(n=u&-u,a=t&-t,n>=a||n===32&&(a&4194048)!==0)?t:u}function Ce(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function R0(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Qf(){var l=Uu;return Uu<<=1,(Uu&62914560)===0&&(Uu=4194304),l}function tc(l){for(var t=[],a=0;31>a;a++)t.push(l);return t}function He(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function B0(l,t,a,e,u,n){var c=l.pendingLanes;l.pendingLanes=a,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=a,l.entangledLanes&=a,l.errorRecoveryDisabledLanes&=a,l.shellSuspendCounter=0;var i=l.entanglements,d=l.expirationTimes,m=l.hiddenUpdates;for(a=c&~a;0"u")return null;try{return l.activeElement||l.body}catch{return l.body}}var L0=/[\n"\\]/g;function bt(l){return l.replace(L0,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function ic(l,t,a,e,u,n,c,i){l.name="",c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"?l.type=c:l.removeAttribute("type"),t!=null?c==="number"?(t===0&&l.value===""||l.value!=t)&&(l.value=""+St(t)):l.value!==""+St(t)&&(l.value=""+St(t)):c!=="submit"&&c!=="reset"||l.removeAttribute("value"),t!=null?fc(l,c,St(t)):a!=null?fc(l,c,St(a)):e!=null&&l.removeAttribute("value"),u==null&&n!=null&&(l.defaultChecked=!!n),u!=null&&(l.checked=u&&typeof u!="function"&&typeof u!="symbol"),i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?l.name=""+St(i):l.removeAttribute("name")}function Pf(l,t,a,e,u,n,c,i){if(n!=null&&typeof n!="function"&&typeof n!="symbol"&&typeof n!="boolean"&&(l.type=n),t!=null||a!=null){if(!(n!=="submit"&&n!=="reset"||t!=null)){cc(l);return}a=a!=null?""+St(a):"",t=t!=null?""+St(t):a,i||t===l.value||(l.value=t),l.defaultValue=t}e=e??u,e=typeof e!="function"&&typeof e!="symbol"&&!!e,l.checked=i?l.checked:!!e,l.defaultChecked=!!e,c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"&&(l.name=c),cc(l)}function fc(l,t,a){t==="number"&&Bu(l.ownerDocument)===l||l.defaultValue===""+a||(l.defaultValue=""+a)}function Fa(l,t,a,e){if(l=l.options,t){t={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),yc=!1;if(Yt)try{var qe={};Object.defineProperty(qe,"passive",{get:function(){yc=!0}}),window.addEventListener("test",qe,qe),window.removeEventListener("test",qe,qe)}catch{yc=!1}var aa=null,rc=null,qu=null;function cs(){if(qu)return qu;var l,t=rc,a=t.length,e,u="value"in aa?aa.value:aa.textContent,n=u.length;for(l=0;l=Xe),hs=" ",ys=!1;function rs(l,t){switch(l){case"keyup":return m1.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function vs(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var te=!1;function S1(l,t){switch(l){case"compositionend":return vs(t);case"keypress":return t.which!==32?null:(ys=!0,hs);case"textInput":return l=t.data,l===hs&&ys?null:l;default:return null}}function b1(l,t){if(te)return l==="compositionend"||!bc&&rs(l,t)?(l=cs(),qu=rc=aa=null,te=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:a,offset:t-l};l=e}l:{for(;a;){if(a.nextSibling){a=a.nextSibling;break l}a=a.parentNode}a=void 0}a=Ts(a)}}function xs(l,t){return l&&t?l===t?!0:l&&l.nodeType===3?!1:t&&t.nodeType===3?xs(l,t.parentNode):"contains"in l?l.contains(t):l.compareDocumentPosition?!!(l.compareDocumentPosition(t)&16):!1:!1}function ps(l){l=l!=null&&l.ownerDocument!=null&&l.ownerDocument.defaultView!=null?l.ownerDocument.defaultView:window;for(var t=Bu(l.document);t instanceof l.HTMLIFrameElement;){try{var a=typeof t.contentWindow.location.href=="string"}catch{a=!1}if(a)l=t.contentWindow;else break;t=Bu(l.document)}return t}function jc(l){var t=l&&l.nodeName&&l.nodeName.toLowerCase();return t&&(t==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||t==="textarea"||l.contentEditable==="true")}var _1=Yt&&"documentMode"in document&&11>=document.documentMode,ae=null,Tc=null,Ke=null,Ac=!1;function _s(l,t,a){var e=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;Ac||ae==null||ae!==Bu(e)||(e=ae,"selectionStart"in e&&jc(e)?e={start:e.selectionStart,end:e.selectionEnd}:(e=(e.ownerDocument&&e.ownerDocument.defaultView||window).getSelection(),e={anchorNode:e.anchorNode,anchorOffset:e.anchorOffset,focusNode:e.focusNode,focusOffset:e.focusOffset}),Ke&&Ve(Ke,e)||(Ke=e,e=Un(Tc,"onSelect"),0>=c,u-=c,Dt=1<<32-st(t)+u|a<K?(I=H,H=null):I=H.sibling;var al=g(r,H,v[K],j);if(al===null){H===null&&(H=I);break}l&&H&&al.alternate===null&&t(r,H),h=n(al,h,K),tl===null?R=al:tl.sibling=al,tl=al,H=I}if(K===v.length)return a(r,H),P&&Gt(r,K),R;if(H===null){for(;KK?(I=H,H=null):I=H.sibling;var Aa=g(r,H,al.value,j);if(Aa===null){H===null&&(H=I);break}l&&H&&Aa.alternate===null&&t(r,H),h=n(Aa,h,K),tl===null?R=Aa:tl.sibling=Aa,tl=Aa,H=I}if(al.done)return a(r,H),P&&Gt(r,K),R;if(H===null){for(;!al.done;K++,al=v.next())al=T(r,al.value,j),al!==null&&(h=n(al,h,K),tl===null?R=al:tl.sibling=al,tl=al);return P&&Gt(r,K),R}for(H=e(H);!al.done;K++,al=v.next())al=b(H,r,K,al.value,j),al!==null&&(l&&al.alternate!==null&&H.delete(al.key===null?K:al.key),h=n(al,h,K),tl===null?R=al:tl.sibling=al,tl=al);return l&&H.forEach(function(wy){return t(r,wy)}),P&&Gt(r,K),R}function yl(r,h,v,j){if(typeof v=="object"&&v!==null&&v.type===gl&&v.key===null&&(v=v.props.children),typeof v=="object"&&v!==null){switch(v.$$typeof){case Tl:l:{for(var R=v.key;h!==null;){if(h.key===R){if(R=v.type,R===gl){if(h.tag===7){a(r,h.sibling),j=u(h,v.props.children),j.return=r,r=j;break l}}else if(h.elementType===R||typeof R=="object"&&R!==null&&R.$$typeof===Yl&&qa(R)===h.type){a(r,h.sibling),j=u(h,v.props),Fe(j,v),j.return=r,r=j;break l}a(r,h);break}else t(r,h);h=h.sibling}v.type===gl?(j=Ca(v.props.children,r.mode,j,v.key),j.return=r,r=j):(j=ku(v.type,v.key,v.props,null,r.mode,j),Fe(j,v),j.return=r,r=j)}return c(r);case nl:l:{for(R=v.key;h!==null;){if(h.key===R)if(h.tag===4&&h.stateNode.containerInfo===v.containerInfo&&h.stateNode.implementation===v.implementation){a(r,h.sibling),j=u(h,v.children||[]),j.return=r,r=j;break l}else{a(r,h);break}else t(r,h);h=h.sibling}j=Dc(v,r.mode,j),j.return=r,r=j}return c(r);case Yl:return v=qa(v),yl(r,h,v,j)}if(ql(v))return U(r,h,v,j);if(Ll(v)){if(R=Ll(v),typeof R!="function")throw Error(o(150));return v=R.call(v),G(r,h,v,j)}if(typeof v.then=="function")return yl(r,h,tn(v),j);if(v.$$typeof===bl)return yl(r,h,Fu(r,v),j);an(r,v)}return typeof v=="string"&&v!==""||typeof v=="number"||typeof v=="bigint"?(v=""+v,h!==null&&h.tag===6?(a(r,h.sibling),j=u(h,v),j.return=r,r=j):(a(r,h),j=Oc(v,r.mode,j),j.return=r,r=j),c(r)):a(r,h)}return function(r,h,v,j){try{We=0;var R=yl(r,h,v,j);return ye=null,R}catch(H){if(H===he||H===Pu)throw H;var tl=ot(29,H,null,r.mode);return tl.lanes=j,tl.return=r,tl}finally{}}}var Qa=Ws(!0),Fs=Ws(!1),ia=!1;function Zc(l){l.updateQueue={baseState:l.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Vc(l,t){l=l.updateQueue,t.updateQueue===l&&(t.updateQueue={baseState:l.baseState,firstBaseUpdate:l.firstBaseUpdate,lastBaseUpdate:l.lastBaseUpdate,shared:l.shared,callbacks:null})}function fa(l){return{lane:l,tag:0,payload:null,callback:null,next:null}}function sa(l,t,a){var e=l.updateQueue;if(e===null)return null;if(e=e.shared,(il&2)!==0){var u=e.pending;return u===null?t.next=t:(t.next=u.next,u.next=t),e.pending=t,t=wu(l),Hs(l,null,a),t}return Ju(l,e,t,a),wu(l)}function Ie(l,t,a){if(t=t.updateQueue,t!==null&&(t=t.shared,(a&4194048)!==0)){var e=t.lanes;e&=l.pendingLanes,a|=e,t.lanes=a,Lf(l,a)}}function Kc(l,t){var a=l.updateQueue,e=l.alternate;if(e!==null&&(e=e.updateQueue,a===e)){var u=null,n=null;if(a=a.firstBaseUpdate,a!==null){do{var c={lane:a.lane,tag:a.tag,payload:a.payload,callback:null,next:null};n===null?u=n=c:n=n.next=c,a=a.next}while(a!==null);n===null?u=n=t:n=n.next=t}else u=n=t;a={baseState:e.baseState,firstBaseUpdate:u,lastBaseUpdate:n,shared:e.shared,callbacks:e.callbacks},l.updateQueue=a;return}l=a.lastBaseUpdate,l===null?a.firstBaseUpdate=t:l.next=t,a.lastBaseUpdate=t}var Jc=!1;function Pe(){if(Jc){var l=oe;if(l!==null)throw l}}function lu(l,t,a,e){Jc=!1;var u=l.updateQueue;ia=!1;var n=u.firstBaseUpdate,c=u.lastBaseUpdate,i=u.shared.pending;if(i!==null){u.shared.pending=null;var d=i,m=d.next;d.next=null,c===null?n=m:c.next=m,c=d;var z=l.alternate;z!==null&&(z=z.updateQueue,i=z.lastBaseUpdate,i!==c&&(i===null?z.firstBaseUpdate=m:i.next=m,z.lastBaseUpdate=d))}if(n!==null){var T=u.baseState;c=0,z=m=d=null,i=n;do{var g=i.lane&-536870913,b=g!==i.lane;if(b?(F&g)===g:(e&g)===g){g!==0&&g===de&&(Jc=!0),z!==null&&(z=z.next={lane:0,tag:i.tag,payload:i.payload,callback:null,next:null});l:{var U=l,G=i;g=t;var yl=a;switch(G.tag){case 1:if(U=G.payload,typeof U=="function"){T=U.call(yl,T,g);break l}T=U;break l;case 3:U.flags=U.flags&-65537|128;case 0:if(U=G.payload,g=typeof U=="function"?U.call(yl,T,g):U,g==null)break l;T=B({},T,g);break l;case 2:ia=!0}}g=i.callback,g!==null&&(l.flags|=64,b&&(l.flags|=8192),b=u.callbacks,b===null?u.callbacks=[g]:b.push(g))}else b={lane:g,tag:i.tag,payload:i.payload,callback:i.callback,next:null},z===null?(m=z=b,d=T):z=z.next=b,c|=g;if(i=i.next,i===null){if(i=u.shared.pending,i===null)break;b=i,i=b.next,b.next=null,u.lastBaseUpdate=b,u.shared.pending=null}}while(!0);z===null&&(d=T),u.baseState=d,u.firstBaseUpdate=m,u.lastBaseUpdate=z,n===null&&(u.shared.lanes=0),ra|=c,l.lanes=c,l.memoizedState=T}}function Is(l,t){if(typeof l!="function")throw Error(o(191,l));l.call(t)}function Ps(l,t){var a=l.callbacks;if(a!==null)for(l.callbacks=null,l=0;ln?n:8;var c=E.T,i={};E.T=i,oi(l,!1,t,a);try{var d=u(),m=E.S;if(m!==null&&m(i,d),d!==null&&typeof d=="object"&&typeof d.then=="function"){var z=B1(d,e);eu(l,t,z,mt(l))}else eu(l,t,e,mt(l))}catch(T){eu(l,t,{then:function(){},status:"rejected",reason:T},mt())}finally{M.p=n,c!==null&&i.types!==null&&(c.types=i.types),E.T=c}}function L1(){}function si(l,t,a,e){if(l.tag!==5)throw Error(o(476));var u=Dd(l).queue;Od(l,u,t,q,a===null?L1:function(){return Ud(l),a(e)})}function Dd(l){var t=l.memoizedState;if(t!==null)return t;t={memoizedState:q,baseState:q,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Zt,lastRenderedState:q},next:null};var a={};return t.next={memoizedState:a,baseState:a,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Zt,lastRenderedState:a},next:null},l.memoizedState=t,l=l.alternate,l!==null&&(l.memoizedState=t),t}function Ud(l){var t=Dd(l);t.next===null&&(t=l.alternate.memoizedState),eu(l,t.next.queue,{},mt())}function di(){return Kl(zu)}function Cd(){return pl().memoizedState}function Hd(){return pl().memoizedState}function Z1(l){for(var t=l.return;t!==null;){switch(t.tag){case 24:case 3:var a=mt();l=fa(a);var e=sa(t,l,a);e!==null&&(nt(e,t,a),Ie(e,t,a)),t={cache:Gc()},l.payload=t;return}t=t.return}}function V1(l,t,a){var e=mt();a={lane:e,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},yn(l)?Bd(t,a):(a=Nc(l,t,a,e),a!==null&&(nt(a,l,e),Yd(a,t,e)))}function Rd(l,t,a){var e=mt();eu(l,t,a,e)}function eu(l,t,a,e){var u={lane:e,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null};if(yn(l))Bd(t,u);else{var n=l.alternate;if(l.lanes===0&&(n===null||n.lanes===0)&&(n=t.lastRenderedReducer,n!==null))try{var c=t.lastRenderedState,i=n(c,a);if(u.hasEagerState=!0,u.eagerState=i,dt(i,c))return Ju(l,t,u,0),rl===null&&Ku(),!1}catch{}finally{}if(a=Nc(l,t,u,e),a!==null)return nt(a,l,e),Yd(a,t,e),!0}return!1}function oi(l,t,a,e){if(e={lane:2,revertLane:Zi(),gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},yn(l)){if(t)throw Error(o(479))}else t=Nc(l,a,e,2),t!==null&&nt(t,l,2)}function yn(l){var t=l.alternate;return l===Z||t!==null&&t===Z}function Bd(l,t){ve=nn=!0;var a=l.pending;a===null?t.next=t:(t.next=a.next,a.next=t),l.pending=t}function Yd(l,t,a){if((a&4194048)!==0){var e=t.lanes;e&=l.pendingLanes,a|=e,t.lanes=a,Lf(l,a)}}var uu={readContext:Kl,use:sn,useCallback:El,useContext:El,useEffect:El,useImperativeHandle:El,useLayoutEffect:El,useInsertionEffect:El,useMemo:El,useReducer:El,useRef:El,useState:El,useDebugValue:El,useDeferredValue:El,useTransition:El,useSyncExternalStore:El,useId:El,useHostTransitionStatus:El,useFormState:El,useActionState:El,useOptimistic:El,useMemoCache:El,useCacheRefresh:El};uu.useEffectEvent=El;var qd={readContext:Kl,use:sn,useCallback:function(l,t){return Wl().memoizedState=[l,t===void 0?null:t],l},useContext:Kl,useEffect:Ed,useImperativeHandle:function(l,t,a){a=a!=null?a.concat([l]):null,on(4194308,4,xd.bind(null,t,l),a)},useLayoutEffect:function(l,t){return on(4194308,4,l,t)},useInsertionEffect:function(l,t){on(4,2,l,t)},useMemo:function(l,t){var a=Wl();t=t===void 0?null:t;var e=l();if(Xa){la(!0);try{l()}finally{la(!1)}}return a.memoizedState=[e,t],e},useReducer:function(l,t,a){var e=Wl();if(a!==void 0){var u=a(t);if(Xa){la(!0);try{a(t)}finally{la(!1)}}}else u=t;return e.memoizedState=e.baseState=u,l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:u},e.queue=l,l=l.dispatch=V1.bind(null,Z,l),[e.memoizedState,l]},useRef:function(l){var t=Wl();return l={current:l},t.memoizedState=l},useState:function(l){l=ui(l);var t=l.queue,a=Rd.bind(null,Z,t);return t.dispatch=a,[l.memoizedState,a]},useDebugValue:ii,useDeferredValue:function(l,t){var a=Wl();return fi(a,l,t)},useTransition:function(){var l=ui(!1);return l=Od.bind(null,Z,l.queue,!0,!1),Wl().memoizedState=l,[!1,l]},useSyncExternalStore:function(l,t,a){var e=Z,u=Wl();if(P){if(a===void 0)throw Error(o(407));a=a()}else{if(a=t(),rl===null)throw Error(o(349));(F&127)!==0||nd(e,t,a)}u.memoizedState=a;var n={value:a,getSnapshot:t};return u.queue=n,Ed(id.bind(null,e,n,l),[l]),e.flags|=2048,ge(9,{destroy:void 0},cd.bind(null,e,n,a,t),null),a},useId:function(){var l=Wl(),t=rl.identifierPrefix;if(P){var a=Ut,e=Dt;a=(e&~(1<<32-st(e)-1)).toString(32)+a,t="_"+t+"R_"+a,a=cn++,0<\/script>",n=n.removeChild(n.firstChild);break;case"select":n=typeof e.is=="string"?c.createElement("select",{is:e.is}):c.createElement("select"),e.multiple?n.multiple=!0:e.size&&(n.size=e.size);break;default:n=typeof e.is=="string"?c.createElement(u,{is:e.is}):c.createElement(u)}}n[Zl]=t,n[Pl]=e;l:for(c=t.child;c!==null;){if(c.tag===5||c.tag===6)n.appendChild(c.stateNode);else if(c.tag!==4&&c.tag!==27&&c.child!==null){c.child.return=c,c=c.child;continue}if(c===t)break l;for(;c.sibling===null;){if(c.return===null||c.return===t)break l;c=c.return}c.sibling.return=c.return,c=c.sibling}t.stateNode=n;l:switch(wl(n,u,e),u){case"button":case"input":case"select":case"textarea":e=!!e.autoFocus;break l;case"img":e=!0;break l;default:e=!1}e&&Kt(t)}}return ml(t),xi(t,t.type,l===null?null:l.memoizedProps,t.pendingProps,a),null;case 6:if(l&&t.stateNode!=null)l.memoizedProps!==e&&Kt(t);else{if(typeof e!="string"&&t.stateNode===null)throw Error(o(166));if(l=k.current,fe(t)){if(l=t.stateNode,a=t.memoizedProps,e=null,u=Vl,u!==null)switch(u.tag){case 27:case 5:e=u.memoizedProps}l[Zl]=t,l=!!(l.nodeValue===a||e!==null&&e.suppressHydrationWarning===!0||uh(l.nodeValue,a)),l||na(t,!0)}else l=Cn(l).createTextNode(e),l[Zl]=t,t.stateNode=l}return ml(t),null;case 31:if(a=t.memoizedState,l===null||l.memoizedState!==null){if(e=fe(t),a!==null){if(l===null){if(!e)throw Error(o(318));if(l=t.memoizedState,l=l!==null?l.dehydrated:null,!l)throw Error(o(557));l[Zl]=t}else Ha(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;ml(t),l=!1}else a=Rc(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=a),l=!0;if(!l)return t.flags&256?(yt(t),t):(yt(t),null);if((t.flags&128)!==0)throw Error(o(558))}return ml(t),null;case 13:if(e=t.memoizedState,l===null||l.memoizedState!==null&&l.memoizedState.dehydrated!==null){if(u=fe(t),e!==null&&e.dehydrated!==null){if(l===null){if(!u)throw Error(o(318));if(u=t.memoizedState,u=u!==null?u.dehydrated:null,!u)throw Error(o(317));u[Zl]=t}else Ha(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;ml(t),u=!1}else u=Rc(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=u),u=!0;if(!u)return t.flags&256?(yt(t),t):(yt(t),null)}return yt(t),(t.flags&128)!==0?(t.lanes=a,t):(a=e!==null,l=l!==null&&l.memoizedState!==null,a&&(e=t.child,u=null,e.alternate!==null&&e.alternate.memoizedState!==null&&e.alternate.memoizedState.cachePool!==null&&(u=e.alternate.memoizedState.cachePool.pool),n=null,e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),n!==u&&(e.flags|=2048)),a!==l&&a&&(t.child.flags|=8192),Sn(t,t.updateQueue),ml(t),null);case 4:return Al(),l===null&&wi(t.stateNode.containerInfo),ml(t),null;case 10:return Xt(t.type),ml(t),null;case 19:if(A(xl),e=t.memoizedState,e===null)return ml(t),null;if(u=(t.flags&128)!==0,n=e.rendering,n===null)if(u)cu(e,!1);else{if(jl!==0||l!==null&&(l.flags&128)!==0)for(l=t.child;l!==null;){if(n=un(l),n!==null){for(t.flags|=128,cu(e,!1),l=n.updateQueue,t.updateQueue=l,Sn(t,l),t.subtreeFlags=0,l=a,a=t.child;a!==null;)Rs(a,l),a=a.sibling;return O(xl,xl.current&1|2),P&&Gt(t,e.treeForkCount),t.child}l=l.sibling}e.tail!==null&&it()>Tn&&(t.flags|=128,u=!0,cu(e,!1),t.lanes=4194304)}else{if(!u)if(l=un(n),l!==null){if(t.flags|=128,u=!0,l=l.updateQueue,t.updateQueue=l,Sn(t,l),cu(e,!0),e.tail===null&&e.tailMode==="hidden"&&!n.alternate&&!P)return ml(t),null}else 2*it()-e.renderingStartTime>Tn&&a!==536870912&&(t.flags|=128,u=!0,cu(e,!1),t.lanes=4194304);e.isBackwards?(n.sibling=t.child,t.child=n):(l=e.last,l!==null?l.sibling=n:t.child=n,e.last=n)}return e.tail!==null?(l=e.tail,e.rendering=l,e.tail=l.sibling,e.renderingStartTime=it(),l.sibling=null,a=xl.current,O(xl,u?a&1|2:a&1),P&&Gt(t,e.treeForkCount),l):(ml(t),null);case 22:case 23:return yt(t),kc(),e=t.memoizedState!==null,l!==null?l.memoizedState!==null!==e&&(t.flags|=8192):e&&(t.flags|=8192),e?(a&536870912)!==0&&(t.flags&128)===0&&(ml(t),t.subtreeFlags&6&&(t.flags|=8192)):ml(t),a=t.updateQueue,a!==null&&Sn(t,a.retryQueue),a=null,l!==null&&l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(a=l.memoizedState.cachePool.pool),e=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),e!==a&&(t.flags|=2048),l!==null&&A(Ya),null;case 24:return a=null,l!==null&&(a=l.memoizedState.cache),t.memoizedState.cache!==a&&(t.flags|=2048),Xt(Ml),ml(t),null;case 25:return null;case 30:return null}throw Error(o(156,t.tag))}function $1(l,t){switch(Cc(t),t.tag){case 1:return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 3:return Xt(Ml),Al(),l=t.flags,(l&65536)!==0&&(l&128)===0?(t.flags=l&-65537|128,t):null;case 26:case 27:case 5:return Nu(t),null;case 31:if(t.memoizedState!==null){if(yt(t),t.alternate===null)throw Error(o(340));Ha()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 13:if(yt(t),l=t.memoizedState,l!==null&&l.dehydrated!==null){if(t.alternate===null)throw Error(o(340));Ha()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 19:return A(xl),null;case 4:return Al(),null;case 10:return Xt(t.type),null;case 22:case 23:return yt(t),kc(),l!==null&&A(Ya),l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 24:return Xt(Ml),null;case 25:return null;default:return null}}function fo(l,t){switch(Cc(t),t.tag){case 3:Xt(Ml),Al();break;case 26:case 27:case 5:Nu(t);break;case 4:Al();break;case 31:t.memoizedState!==null&&yt(t);break;case 13:yt(t);break;case 19:A(xl);break;case 10:Xt(t.type);break;case 22:case 23:yt(t),kc(),l!==null&&A(Ya);break;case 24:Xt(Ml)}}function iu(l,t){try{var a=t.updateQueue,e=a!==null?a.lastEffect:null;if(e!==null){var u=e.next;a=u;do{if((a.tag&l)===l){e=void 0;var n=a.create,c=a.inst;e=n(),c.destroy=e}a=a.next}while(a!==u)}}catch(i){dl(t,t.return,i)}}function ha(l,t,a){try{var e=t.updateQueue,u=e!==null?e.lastEffect:null;if(u!==null){var n=u.next;e=n;do{if((e.tag&l)===l){var c=e.inst,i=c.destroy;if(i!==void 0){c.destroy=void 0,u=t;var d=a,m=i;try{m()}catch(z){dl(u,d,z)}}}e=e.next}while(e!==n)}}catch(z){dl(t,t.return,z)}}function so(l){var t=l.updateQueue;if(t!==null){var a=l.stateNode;try{Ps(t,a)}catch(e){dl(l,l.return,e)}}}function oo(l,t,a){a.props=La(l.type,l.memoizedProps),a.state=l.memoizedState;try{a.componentWillUnmount()}catch(e){dl(l,t,e)}}function fu(l,t){try{var a=l.ref;if(a!==null){switch(l.tag){case 26:case 27:case 5:var e=l.stateNode;break;case 30:e=l.stateNode;break;default:e=l.stateNode}typeof a=="function"?l.refCleanup=a(e):a.current=e}}catch(u){dl(l,t,u)}}function Ct(l,t){var a=l.ref,e=l.refCleanup;if(a!==null)if(typeof e=="function")try{e()}catch(u){dl(l,t,u)}finally{l.refCleanup=null,l=l.alternate,l!=null&&(l.refCleanup=null)}else if(typeof a=="function")try{a(null)}catch(u){dl(l,t,u)}else a.current=null}function ho(l){var t=l.type,a=l.memoizedProps,e=l.stateNode;try{l:switch(t){case"button":case"input":case"select":case"textarea":a.autoFocus&&e.focus();break l;case"img":a.src?e.src=a.src:a.srcSet&&(e.srcset=a.srcSet)}}catch(u){dl(l,l.return,u)}}function pi(l,t,a){try{var e=l.stateNode;gy(e,l.type,a,t),e[Pl]=t}catch(u){dl(l,l.return,u)}}function yo(l){return l.tag===5||l.tag===3||l.tag===26||l.tag===27&&ba(l.type)||l.tag===4}function _i(l){l:for(;;){for(;l.sibling===null;){if(l.return===null||yo(l.return))return null;l=l.return}for(l.sibling.return=l.return,l=l.sibling;l.tag!==5&&l.tag!==6&&l.tag!==18;){if(l.tag===27&&ba(l.type)||l.flags&2||l.child===null||l.tag===4)continue l;l.child.return=l,l=l.child}if(!(l.flags&2))return l.stateNode}}function Ni(l,t,a){var e=l.tag;if(e===5||e===6)l=l.stateNode,t?(a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a).insertBefore(l,t):(t=a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a,t.appendChild(l),a=a._reactRootContainer,a!=null||t.onclick!==null||(t.onclick=Bt));else if(e!==4&&(e===27&&ba(l.type)&&(a=l.stateNode,t=null),l=l.child,l!==null))for(Ni(l,t,a),l=l.sibling;l!==null;)Ni(l,t,a),l=l.sibling}function bn(l,t,a){var e=l.tag;if(e===5||e===6)l=l.stateNode,t?a.insertBefore(l,t):a.appendChild(l);else if(e!==4&&(e===27&&ba(l.type)&&(a=l.stateNode),l=l.child,l!==null))for(bn(l,t,a),l=l.sibling;l!==null;)bn(l,t,a),l=l.sibling}function ro(l){var t=l.stateNode,a=l.memoizedProps;try{for(var e=l.type,u=t.attributes;u.length;)t.removeAttributeNode(u[0]);wl(t,e,a),t[Zl]=l,t[Pl]=a}catch(n){dl(l,l.return,n)}}var Jt=!1,Ul=!1,Mi=!1,vo=typeof WeakSet=="function"?WeakSet:Set,Ql=null;function W1(l,t){if(l=l.containerInfo,Wi=Qn,l=ps(l),jc(l)){if("selectionStart"in l)var a={start:l.selectionStart,end:l.selectionEnd};else l:{a=(a=l.ownerDocument)&&a.defaultView||window;var e=a.getSelection&&a.getSelection();if(e&&e.rangeCount!==0){a=e.anchorNode;var u=e.anchorOffset,n=e.focusNode;e=e.focusOffset;try{a.nodeType,n.nodeType}catch{a=null;break l}var c=0,i=-1,d=-1,m=0,z=0,T=l,g=null;t:for(;;){for(var b;T!==a||u!==0&&T.nodeType!==3||(i=c+u),T!==n||e!==0&&T.nodeType!==3||(d=c+e),T.nodeType===3&&(c+=T.nodeValue.length),(b=T.firstChild)!==null;)g=T,T=b;for(;;){if(T===l)break t;if(g===a&&++m===u&&(i=c),g===n&&++z===e&&(d=c),(b=T.nextSibling)!==null)break;T=g,g=T.parentNode}T=b}a=i===-1||d===-1?null:{start:i,end:d}}else a=null}a=a||{start:0,end:0}}else a=null;for(Fi={focusedElem:l,selectionRange:a},Qn=!1,Ql=t;Ql!==null;)if(t=Ql,l=t.child,(t.subtreeFlags&1028)!==0&&l!==null)l.return=t,Ql=l;else for(;Ql!==null;){switch(t=Ql,n=t.alternate,l=t.flags,t.tag){case 0:if((l&4)!==0&&(l=t.updateQueue,l=l!==null?l.events:null,l!==null))for(a=0;a title"))),wl(n,e,a),n[Zl]=l,Gl(n),e=n;break l;case"link":var c=Eh("link","href",u).get(e+(a.href||""));if(c){for(var i=0;iyl&&(c=yl,yl=G,G=c);var r=As(i,G),h=As(i,yl);if(r&&h&&(b.rangeCount!==1||b.anchorNode!==r.node||b.anchorOffset!==r.offset||b.focusNode!==h.node||b.focusOffset!==h.offset)){var v=T.createRange();v.setStart(r.node,r.offset),b.removeAllRanges(),G>yl?(b.addRange(v),b.extend(h.node,h.offset)):(v.setEnd(h.node,h.offset),b.addRange(v))}}}}for(T=[],b=i;b=b.parentNode;)b.nodeType===1&&T.push({element:b,left:b.scrollLeft,top:b.scrollTop});for(typeof i.focus=="function"&&i.focus(),i=0;ia?32:a,E.T=null,a=Bi,Bi=null;var n=ma,c=Ft;if(Hl=0,je=ma=null,Ft=0,(il&6)!==0)throw Error(o(331));var i=il;if(il|=4,po(n.current),To(n,n.current,c,a),il=i,ru(0,!1),ft&&typeof ft.onPostCommitFiberRoot=="function")try{ft.onPostCommitFiberRoot(Ue,n)}catch{}return!0}finally{M.p=u,E.T=e,Vo(l,t)}}function Jo(l,t,a){t=Et(a,t),t=vi(l.stateNode,t,2),l=sa(l,t,2),l!==null&&(He(l,2),Ht(l))}function dl(l,t,a){if(l.tag===3)Jo(l,l,a);else for(;t!==null;){if(t.tag===3){Jo(t,l,a);break}else if(t.tag===1){var e=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof e.componentDidCatch=="function"&&(va===null||!va.has(e))){l=Et(a,l),a=Jd(2),e=sa(t,a,2),e!==null&&(wd(a,e,t,l),He(e,2),Ht(e));break}}t=t.return}}function Qi(l,t,a){var e=l.pingCache;if(e===null){e=l.pingCache=new P1;var u=new Set;e.set(t,u)}else u=e.get(t),u===void 0&&(u=new Set,e.set(t,u));u.has(a)||(Ui=!0,u.add(a),l=uy.bind(null,l,t,a),t.then(l,l))}function uy(l,t,a){var e=l.pingCache;e!==null&&e.delete(t),l.pingedLanes|=l.suspendedLanes&a,l.warmLanes&=~a,rl===l&&(F&a)===a&&(jl===4||jl===3&&(F&62914560)===F&&300>it()-jn?(il&2)===0&&Te(l,0):Ci|=a,Ee===F&&(Ee=0)),Ht(l)}function wo(l,t){t===0&&(t=Qf()),l=Ua(l,t),l!==null&&(He(l,t),Ht(l))}function ny(l){var t=l.memoizedState,a=0;t!==null&&(a=t.retryLane),wo(l,a)}function cy(l,t){var a=0;switch(l.tag){case 31:case 13:var e=l.stateNode,u=l.memoizedState;u!==null&&(a=u.retryLane);break;case 19:e=l.stateNode;break;case 22:e=l.stateNode._retryCache;break;default:throw Error(o(314))}e!==null&&e.delete(t),wo(l,a)}function iy(l,t){return Pn(l,t)}var Mn=null,xe=null,Xi=!1,On=!1,Li=!1,Sa=0;function Ht(l){l!==xe&&l.next===null&&(xe===null?Mn=xe=l:xe=xe.next=l),On=!0,Xi||(Xi=!0,sy())}function ru(l,t){if(!Li&&On){Li=!0;do for(var a=!1,e=Mn;e!==null;){if(l!==0){var u=e.pendingLanes;if(u===0)var n=0;else{var c=e.suspendedLanes,i=e.pingedLanes;n=(1<<31-st(42|l)+1)-1,n&=u&~(c&~i),n=n&201326741?n&201326741|1:n?n|2:0}n!==0&&(a=!0,Fo(e,n))}else n=F,n=Cu(e,e===rl?n:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),(n&3)===0||Ce(e,n)||(a=!0,Fo(e,n));e=e.next}while(a);Li=!1}}function fy(){ko()}function ko(){On=Xi=!1;var l=0;Sa!==0&&by()&&(l=Sa);for(var t=it(),a=null,e=Mn;e!==null;){var u=e.next,n=$o(e,t);n===0?(e.next=null,a===null?Mn=u:a.next=u,u===null&&(xe=a)):(a=e,(l!==0||(n&3)!==0)&&(On=!0)),e=u}Hl!==0&&Hl!==5||ru(l),Sa!==0&&(Sa=0)}function $o(l,t){for(var a=l.suspendedLanes,e=l.pingedLanes,u=l.expirationTimes,n=l.pendingLanes&-62914561;0i)break;var z=d.transferSize,T=d.initiatorType;z&&nh(T)&&(d=d.responseEnd,c+=z*(d"u"?null:document;function gh(l,t,a){var e=pe;if(e&&typeof t=="string"&&t){var u=bt(t);u='link[rel="'+l+'"][href="'+u+'"]',typeof a=="string"&&(u+='[crossorigin="'+a+'"]'),mh.has(u)||(mh.add(u),l={rel:l,crossOrigin:a,href:t},e.querySelector(u)===null&&(t=e.createElement("link"),wl(t,"link",l),Gl(t),e.head.appendChild(t)))}}function Ny(l){It.D(l),gh("dns-prefetch",l,null)}function My(l,t){It.C(l,t),gh("preconnect",l,t)}function Oy(l,t,a){It.L(l,t,a);var e=pe;if(e&&l&&t){var u='link[rel="preload"][as="'+bt(t)+'"]';t==="image"&&a&&a.imageSrcSet?(u+='[imagesrcset="'+bt(a.imageSrcSet)+'"]',typeof a.imageSizes=="string"&&(u+='[imagesizes="'+bt(a.imageSizes)+'"]')):u+='[href="'+bt(l)+'"]';var n=u;switch(t){case"style":n=_e(l);break;case"script":n=Ne(l)}_t.has(n)||(l=B({rel:"preload",href:t==="image"&&a&&a.imageSrcSet?void 0:l,as:t},a),_t.set(n,l),e.querySelector(u)!==null||t==="style"&&e.querySelector(Su(n))||t==="script"&&e.querySelector(bu(n))||(t=e.createElement("link"),wl(t,"link",l),Gl(t),e.head.appendChild(t)))}}function Dy(l,t){It.m(l,t);var a=pe;if(a&&l){var e=t&&typeof t.as=="string"?t.as:"script",u='link[rel="modulepreload"][as="'+bt(e)+'"][href="'+bt(l)+'"]',n=u;switch(e){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":n=Ne(l)}if(!_t.has(n)&&(l=B({rel:"modulepreload",href:l},t),_t.set(n,l),a.querySelector(u)===null)){switch(e){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(a.querySelector(bu(n)))return}e=a.createElement("link"),wl(e,"link",l),Gl(e),a.head.appendChild(e)}}}function Uy(l,t,a){It.S(l,t,a);var e=pe;if(e&&l){var u=$a(e).hoistableStyles,n=_e(l);t=t||"default";var c=u.get(n);if(!c){var i={loading:0,preload:null};if(c=e.querySelector(Su(n)))i.loading=5;else{l=B({rel:"stylesheet",href:l,"data-precedence":t},a),(a=_t.get(n))&&uf(l,a);var d=c=e.createElement("link");Gl(d),wl(d,"link",l),d._p=new Promise(function(m,z){d.onload=m,d.onerror=z}),d.addEventListener("load",function(){i.loading|=1}),d.addEventListener("error",function(){i.loading|=2}),i.loading|=4,Rn(c,t,e)}c={type:"stylesheet",instance:c,count:1,state:i},u.set(n,c)}}}function Cy(l,t){It.X(l,t);var a=pe;if(a&&l){var e=$a(a).hoistableScripts,u=Ne(l),n=e.get(u);n||(n=a.querySelector(bu(u)),n||(l=B({src:l,async:!0},t),(t=_t.get(u))&&nf(l,t),n=a.createElement("script"),Gl(n),wl(n,"link",l),a.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},e.set(u,n))}}function Hy(l,t){It.M(l,t);var a=pe;if(a&&l){var e=$a(a).hoistableScripts,u=Ne(l),n=e.get(u);n||(n=a.querySelector(bu(u)),n||(l=B({src:l,async:!0,type:"module"},t),(t=_t.get(u))&&nf(l,t),n=a.createElement("script"),Gl(n),wl(n,"link",l),a.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},e.set(u,n))}}function Sh(l,t,a,e){var u=(u=k.current)?Hn(u):null;if(!u)throw Error(o(446));switch(l){case"meta":case"title":return null;case"style":return typeof a.precedence=="string"&&typeof a.href=="string"?(t=_e(a.href),a=$a(u).hoistableStyles,e=a.get(t),e||(e={type:"style",instance:null,count:0,state:null},a.set(t,e)),e):{type:"void",instance:null,count:0,state:null};case"link":if(a.rel==="stylesheet"&&typeof a.href=="string"&&typeof a.precedence=="string"){l=_e(a.href);var n=$a(u).hoistableStyles,c=n.get(l);if(c||(u=u.ownerDocument||u,c={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},n.set(l,c),(n=u.querySelector(Su(l)))&&!n._p&&(c.instance=n,c.state.loading=5),_t.has(l)||(a={rel:"preload",as:"style",href:a.href,crossOrigin:a.crossOrigin,integrity:a.integrity,media:a.media,hrefLang:a.hrefLang,referrerPolicy:a.referrerPolicy},_t.set(l,a),n||Ry(u,l,a,c.state))),t&&e===null)throw Error(o(528,""));return c}if(t&&e!==null)throw Error(o(529,""));return null;case"script":return t=a.async,a=a.src,typeof a=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=Ne(a),a=$a(u).hoistableScripts,e=a.get(t),e||(e={type:"script",instance:null,count:0,state:null},a.set(t,e)),e):{type:"void",instance:null,count:0,state:null};default:throw Error(o(444,l))}}function _e(l){return'href="'+bt(l)+'"'}function Su(l){return'link[rel="stylesheet"]['+l+"]"}function bh(l){return B({},l,{"data-precedence":l.precedence,precedence:null})}function Ry(l,t,a,e){l.querySelector('link[rel="preload"][as="style"]['+t+"]")?e.loading=1:(t=l.createElement("link"),e.preload=t,t.addEventListener("load",function(){return e.loading|=1}),t.addEventListener("error",function(){return e.loading|=2}),wl(t,"link",a),Gl(t),l.head.appendChild(t))}function Ne(l){return'[src="'+bt(l)+'"]'}function bu(l){return"script[async]"+l}function zh(l,t,a){if(t.count++,t.instance===null)switch(t.type){case"style":var e=l.querySelector('style[data-href~="'+bt(a.href)+'"]');if(e)return t.instance=e,Gl(e),e;var u=B({},a,{"data-href":a.href,"data-precedence":a.precedence,href:null,precedence:null});return e=(l.ownerDocument||l).createElement("style"),Gl(e),wl(e,"style",u),Rn(e,a.precedence,l),t.instance=e;case"stylesheet":u=_e(a.href);var n=l.querySelector(Su(u));if(n)return t.state.loading|=4,t.instance=n,Gl(n),n;e=bh(a),(u=_t.get(u))&&uf(e,u),n=(l.ownerDocument||l).createElement("link"),Gl(n);var c=n;return c._p=new Promise(function(i,d){c.onload=i,c.onerror=d}),wl(n,"link",e),t.state.loading|=4,Rn(n,a.precedence,l),t.instance=n;case"script":return n=Ne(a.src),(u=l.querySelector(bu(n)))?(t.instance=u,Gl(u),u):(e=a,(u=_t.get(n))&&(e=B({},a),nf(e,u)),l=l.ownerDocument||l,u=l.createElement("script"),Gl(u),wl(u,"link",e),l.head.appendChild(u),t.instance=u);case"void":return null;default:throw Error(o(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(e=t.instance,t.state.loading|=4,Rn(e,a.precedence,l));return t.instance}function Rn(l,t,a){for(var e=a.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),u=e.length?e[e.length-1]:null,n=u,c=0;c title"):null)}function By(l,t,a){if(a===1||t.itemProp!=null)return!1;switch(l){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return l=t.disabled,typeof t.precedence=="string"&&l==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function Th(l){return!(l.type==="stylesheet"&&(l.state.loading&3)===0)}function Yy(l,t,a,e){if(a.type==="stylesheet"&&(typeof e.media!="string"||matchMedia(e.media).matches!==!1)&&(a.state.loading&4)===0){if(a.instance===null){var u=_e(e.href),n=t.querySelector(Su(u));if(n){t=n._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(l.count++,l=Yn.bind(l),t.then(l,l)),a.state.loading|=4,a.instance=n,Gl(n);return}n=t.ownerDocument||t,e=bh(e),(u=_t.get(u))&&uf(e,u),n=n.createElement("link"),Gl(n);var c=n;c._p=new Promise(function(i,d){c.onload=i,c.onerror=d}),wl(n,"link",e),a.instance=n}l.stylesheets===null&&(l.stylesheets=new Map),l.stylesheets.set(a,t),(t=a.state.preload)&&(a.state.loading&3)===0&&(l.count++,a=Yn.bind(l),t.addEventListener("load",a),t.addEventListener("error",a))}}var cf=0;function qy(l,t){return l.stylesheets&&l.count===0&&Gn(l,l.stylesheets),0cf?50:800)+t);return l.unsuspend=a,function(){l.unsuspend=null,clearTimeout(e),clearTimeout(u)}}:null}function Yn(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Gn(this,this.stylesheets);else if(this.unsuspend){var l=this.unsuspend;this.unsuspend=null,l()}}}var qn=null;function Gn(l,t){l.stylesheets=null,l.unsuspend!==null&&(l.count++,qn=new Map,t.forEach(Gy,l),qn=null,Yn.call(l))}function Gy(l,t){if(!(t.state.loading&4)){var a=qn.get(l);if(a)var e=a.get(null);else{a=new Map,qn.set(l,a);for(var u=l.querySelectorAll("link[data-precedence],style[data-precedence]"),n=0;n"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(s)}catch(S){console.error(S)}}return s(),mf.exports=er(),mf.exports}var nr=ur();/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const cr=s=>s==null?void 0:s.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase();/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */function ir(s,S,N=[]){if(S==null)throw new Error("[lucide]: iconNode is required when icon name is used");return{name:cr(s),size:24,node:S,...N.length>0?{aliases:N}:{}}}/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const fr=s=>{let S="",N=!1;for(const o of s){if(o==="-"||o==="_"||o<=" "){N=S.length>0;continue}S.length===0?S+=o.toLowerCase():S+=N?o.toUpperCase():o,N=!1}return S};/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const sr=s=>{const S=fr(s);return S.charAt(0).toUpperCase()+S.slice(1)};/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const jf=(...s)=>s.filter((S,N,o)=>!!S&&S.trim()!==""&&o.indexOf(S)===N).join(" ").trim();/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ka={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */function zf(s){return s!=null}function dr(s,S={}){var ul,Tl;const N=S.attributeNames??{},o=nl=>N[nl]??nl,_=s.size??s.width??Ka.width,D=s.size??s.height??Ka.height,w=((ul=s.aliases)==null?void 0:ul.filter(nl=>typeof nl=="string"&&nl.trim()!=="").map(nl=>`lucide-${nl}`))??[],J=[...s.name?[`lucide-${s.name}`]:[],...w],p=((Tl=S.className)==null?void 0:Tl.split(" ").filter(Boolean))??[],x=S.includeDefaultClasses===!1?jf(...p):jf("lucide",...J,...p),V=S.absoluteStrokeWidth?Number(S.strokeWidth??Ka["stroke-width"])*Number(s.size??s.width??Ka.width)/Number(S.size??S.width??Ka.width):S.strokeWidth??Ka["stroke-width"];return["svg",{...Object.entries(Ka).reduce((nl,[gl,Rl])=>(nl[o(gl)]=Rl,nl),{}),..."color"in S&&S.color&&{[o("stroke")]:S.color},..."size"in S&&zf(S.size)&&{[o("width")]:S.size,[o("height")]:S.size},..."width"in S&&zf(S.width)&&{[o("width")]:S.width},..."height"in S&&zf(S.height)&&{[o("height")]:S.height},[o("stroke-width")]:V,...x&&{[o("class")]:x},[o("viewBox")]:`0 0 ${_} ${D}`,...S.hasA11yProp===!1?{[o("aria-hidden")]:"true"}:{},..."attributes"in S&&S.attributes},s.node.map(nl=>{const[gl,Rl,_l]=nl,Cl=S.nonScalingStroke?{[o("vector-effect")]:"non-scaling-stroke",...Rl}:Rl;return _l?[gl,Cl,_l]:[gl,Cl]})]}/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */function or(s,S={}){return dr(s,{...S,attributeNames:{...S.attributeNames,class:"className","stroke-width":"strokeWidth","stroke-linecap":"strokeLinecap","stroke-linejoin":"strokeLinejoin","vector-effect":"vectorEffect"}})}/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const hr=s=>{for(const S in s)if(S.startsWith("aria-")||S==="role"||S==="title")return!0;return!1},yr=el.createContext({}),rr=()=>el.useContext(yr),vr=el.forwardRef(({color:s,size:S,width:N,height:o,strokeWidth:_,absoluteStrokeWidth:D,nonScalingStroke:w,className:J="",children:p,iconNode:x=[],icon:V={node:x,aliases:[],size:24},...B},ul)=>{const{size:Tl=24,strokeWidth:nl=2,absoluteStrokeWidth:gl=!1,nonScalingStroke:Rl=!1,color:_l="currentColor",className:Cl=""}=rr()??{},bl=!!p||hr(B),[Nl,Xl,Bl=[]]=or(V,{color:s??_l,width:N??S??Tl,height:o??S??Tl,strokeWidth:_??nl,absoluteStrokeWidth:D??gl,nonScalingStroke:w??Rl,className:jf(Cl,J),hasA11yProp:bl,attributes:B});return el.createElement(Nl,{ref:ul,...Xl},[...Bl.map(([Y,Yl])=>el.createElement(Y,Yl)),...Array.isArray(p)?p:[p]])});/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */function zl(s,S=[],N=[]){const o=typeof s=="string"?ir(s,S,N):s,_=el.forwardRef(({className:D,...w},J)=>el.createElement(vr,{ref:J,icon:o,className:D,...w}));return o.name&&(_.displayName=sr(o.name)),_}/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const l0={name:"activity",size:24,node:[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]]};l0.node;const pu=zl(l0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const t0={name:"arrow-down-to-line",size:24,node:[["path",{d:"M12 17V3",key:"1cwfxf"}],["path",{d:"m6 11 6 6 6-6",key:"12ii2o"}],["path",{d:"M19 21H5",key:"150jfl"}]]};t0.node;const mr=zl(t0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const a0={name:"arrow-up-from-line",size:24,node:[["path",{d:"m18 9-6-6-6 6",key:"kcunyi"}],["path",{d:"M12 3v14",key:"7cf3v8"}],["path",{d:"M5 21h14",key:"11awu3"}]]};a0.node;const gr=zl(a0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const e0={name:"chart-no-axes-column",size:24,node:[["path",{d:"M5 21v-6",key:"1hz6c0"}],["path",{d:"M12 21V3",key:"1lcnhd"}],["path",{d:"M19 21V9",key:"unv183"}]],aliases:["bar-chart-2"]};e0.node;const Sr=zl(e0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const u0={name:"chevron-right",size:24,node:[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]]};u0.node;const br=zl(u0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const n0={name:"circle-alert",size:24,node:[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]],aliases:["alert-circle"]};n0.node;const zr=zl(n0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const c0={name:"cpu",size:24,node:[["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M17 20v2",key:"1rnc9c"}],["path",{d:"M17 2v2",key:"11trls"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M2 17h2",key:"7oei6x"}],["path",{d:"M2 7h2",key:"asdhe0"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"M20 17h2",key:"1fpfkl"}],["path",{d:"M20 7h2",key:"1o8tra"}],["path",{d:"M7 20v2",key:"4gnj0m"}],["path",{d:"M7 2v2",key:"1i4yhu"}],["rect",{x:"4",y:"4",width:"16",height:"16",rx:"2",key:"1vbyd7"}],["rect",{x:"8",y:"8",width:"8",height:"8",rx:"1",key:"z9xiuo"}]]};c0.node;const Er=zl(c0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const i0={name:"gauge",size:24,node:[["path",{d:"m12 14 4-4",key:"9kzdfg"}],["path",{d:"M3.34 19a10 10 0 1 1 17.32 0",key:"19p75a"}]]};i0.node;const jr=zl(i0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const f0={name:"heart-pulse",size:24,node:[["path",{d:"M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5",key:"mvr1a0"}],["path",{d:"M3.22 13H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27",key:"auskq0"}]]};f0.node;const wh=zl(f0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const s0={name:"inbox",size:24,node:[["polyline",{points:"22 12 16 12 14 15 10 15 8 12 2 12",key:"o97t9d"}],["path",{d:"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",key:"oot6mr"}]]};s0.node;const Tr=zl(s0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const d0={name:"maximize",size:24,node:[["path",{d:"M8 3H5a2 2 0 0 0-2 2v3",key:"1dcmit"}],["path",{d:"M21 8V5a2 2 0 0 0-2-2h-3",key:"1e4gt3"}],["path",{d:"M3 16v3a2 2 0 0 0 2 2h3",key:"wsl5sc"}],["path",{d:"M16 21h3a2 2 0 0 0 2-2v-3",key:"18trek"}]]};d0.node;const Ar=zl(d0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const o0={name:"monitor-play",size:24,node:[["path",{d:"M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z",key:"vbtd3f"}],["path",{d:"M12 17v4",key:"1riwvh"}],["path",{d:"M8 21h8",key:"1ev6f3"}],["rect",{x:"2",y:"3",width:"20",height:"14",rx:"2",key:"x3v2xh"}]]};o0.node;const Tf=zl(o0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const h0={name:"network",size:24,node:[["rect",{x:"16",y:"16",width:"6",height:"6",rx:"1",key:"4q2zg0"}],["rect",{x:"2",y:"16",width:"6",height:"6",rx:"1",key:"8cvhb9"}],["rect",{x:"9",y:"2",width:"6",height:"6",rx:"1",key:"1egb70"}],["path",{d:"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3",key:"1jsf9p"}],["path",{d:"M12 12V8",key:"2874zd"}]]};h0.node;const y0=zl(h0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const r0={name:"panel-left-close",size:24,node:[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}],["path",{d:"M9 3v18",key:"fh3hqa"}],["path",{d:"m16 15-3-3 3-3",key:"14y99z"}]],aliases:["sidebar-close"]};r0.node;const xr=zl(r0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const v0={name:"pause",size:24,node:[["rect",{x:"14",y:"3",width:"5",height:"18",rx:"1",key:"kaeet6"}],["rect",{x:"5",y:"3",width:"5",height:"18",rx:"1",key:"1wsw3u"}]]};v0.node;const pr=zl(v0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const m0={name:"plane",size:24,node:[["path",{d:"M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z",key:"1v9wt8"}]]};m0.node;const _r=zl(m0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const g0={name:"radio",size:24,node:[["path",{d:"M16.247 7.761a6 6 0 0 1 0 8.478",key:"1fwjs5"}],["path",{d:"M19.075 4.933a10 10 0 0 1 0 14.134",key:"ehdyv1"}],["path",{d:"M4.925 19.067a10 10 0 0 1 0-14.134",key:"1q22gi"}],["path",{d:"M7.753 16.239a6 6 0 0 1 0-8.478",key:"r2q7qm"}],["circle",{cx:"12",cy:"12",r:"2",key:"1c9p78"}]]};g0.node;const Af=zl(g0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const S0={name:"refresh-cw",size:24,node:[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]]};S0.node;const Nr=zl(S0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const b0={name:"search",size:24,node:[["path",{d:"m21 21-4.34-4.34",key:"14j7rj"}],["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}]]};b0.node;const z0=zl(b0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const E0={name:"send",size:24,node:[["path",{d:"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z",key:"1ffxy3"}],["path",{d:"m21.854 2.147-10.94 10.939",key:"12cjpa"}]]};E0.node;const Mr=zl(E0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const j0={name:"server",size:24,node:[["rect",{width:"20",height:"8",x:"2",y:"2",rx:"2",ry:"2",key:"ngkwjq"}],["rect",{width:"20",height:"8",x:"2",y:"14",rx:"2",ry:"2",key:"iecqi9"}],["line",{x1:"6",x2:"6.01",y1:"6",y2:"6",key:"16zg32"}],["line",{x1:"6",x2:"6.01",y1:"18",y2:"18",key:"nzw8ys"}]]};j0.node;const Ef=zl(j0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const T0={name:"truck",size:24,node:[["path",{d:"M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2",key:"wrbu53"}],["path",{d:"M15 18H9",key:"1lyqi6"}],["path",{d:"M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14",key:"lysw3i"}],["circle",{cx:"17",cy:"18",r:"2",key:"332jqn"}],["circle",{cx:"7",cy:"18",r:"2",key:"19iecd"}]]};T0.node;const Or=zl(T0);/** + * @license lucide-react v1.57.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const A0={name:"volume-2",size:24,node:[["path",{d:"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z",key:"uqj9uw"}],["path",{d:"M16 9a5 5 0 0 1 0 6",key:"1q6k2b"}],["path",{d:"M19.364 18.364a9 9 0 0 0 0-12.728",key:"ijwkga"}]]};A0.node;const Dr=zl(A0),kh={RECEIVING:"接收正常",GOOD:"正常",CONNECTED:"服务连接正常",STALE:"数据已过期",NOT_RECEIVED:"未接收",MISSING:"未采到",ERROR:"数据异常",WAITING:"等待数据同步",INVALID_TIME:"时间异常",INVALID_VALUE:"数值异常",AMBIGUOUS:"来源不明确",online:"在线",offline:"离线",UNKNOWN:"未接入"};function _u({state:s="UNKNOWN",label:S}){const N=["RECEIVING","GOOD","CONNECTED","online"].includes(s)?"good":["STALE","INVALID_TIME","AMBIGUOUS"].includes(s)?"warn":["ERROR","INVALID_VALUE"].includes(s)?"bad":"muted";return f.jsxs("span",{className:`status status-${N}`,children:[f.jsx("i",{}),S||kh[s]||kh.UNKNOWN]})}function Fl({title:s,children:S,extra:N,className:o=""}){return f.jsxs("section",{className:`panel ${o}`,children:[(s||N)&&f.jsxs("div",{className:"panel-title",children:[f.jsx("h2",{children:s}),N&&f.jsx("div",{className:"panel-extra",children:N})]}),S]})}function xa({title:s="暂无数据",description:S,icon:N=Tr}){return f.jsxs("div",{className:"empty-state",children:[f.jsx(N,{size:35,strokeWidth:1.4}),f.jsx("strong",{children:s}),S&&f.jsx("p",{children:S})]})}function Mf({equipment:s,size:S=25}){const o=/无人机|航空|飞行|UAV/i.test(`${(s==null?void 0:s.name)||""} ${(s==null?void 0:s.code)||""} ${(s==null?void 0:s.type)||""}`)?_r:Or;return f.jsx(o,{size:S,strokeWidth:1.7})}function Of({equipments:s=[],selectedId:S,onSelect:N,title:o="实装列表",emptyText:_="暂无已绑定装备"}){const[D,w]=el.useState(""),J=el.useMemo(()=>s.filter(p=>`${p.name||""} ${p.code||""}`.toLowerCase().includes(D.trim().toLowerCase())),[s,D]);return f.jsxs(Fl,{title:o,className:"equipment-list",children:[f.jsxs("label",{className:"search-input",children:[f.jsx(z0,{size:17}),f.jsx("input",{type:"search","aria-label":"搜索装备名称或编号",placeholder:"搜索装备名称或编号",value:D,onChange:p=>w(p.target.value)})]}),f.jsx("div",{className:"equipment-rows",children:J.map(p=>f.jsxs("button",{type:"button",className:`equipment-row ${S===p.id?"selected":""}`,onClick:()=>N==null?void 0:N(p.id),"aria-pressed":S===p.id,children:[f.jsx(Mf,{equipment:p}),f.jsxs("span",{className:"equipment-row-copy",children:[f.jsx("strong",{children:p.name||p.code||"未命名装备"}),f.jsx("small",{children:p.code||p.participantCode||"—"})]}),f.jsx(_u,{state:p.state})]},p.id))}),!J.length&&f.jsx(xa,{title:D?"无匹配装备":_,description:D?"请调整名称或编号。":"绑定装备后将在此显示。"})]})}function Df({equipment:s,statusLabel:S}){return s?f.jsxs(Fl,{className:"equipment-header",children:[f.jsxs("div",{className:"equipment-heading",children:[f.jsx(Mf,{equipment:s,size:46}),f.jsxs("div",{children:[f.jsx("h2",{children:s.name||s.code||"未命名装备"}),f.jsxs("p",{children:[s.code||"—",f.jsx("span",{children:"·"}),s.type||"实装装备"]})]}),f.jsx(_u,{state:s.state,label:S})]}),f.jsxs("div",{className:"equipment-updated",children:["最近接收 ",f.jsx("time",{children:wn(s.lastReceivedAt)})]})]}):null}function wn(s){if(s==null||s==="")return"—";const S=new Date(s);return Number.isNaN(S.getTime())?"—":new Intl.DateTimeFormat("zh-CN",{timeZone:"Asia/Shanghai",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}).format(S)}const $h=s=>(s==null?void 0:s.simulated)===!0||/SIMULATED|模拟|演示/i.test([s==null?void 0:s.quality,s==null?void 0:s.sourceType,s==null?void 0:s.sourceName].join(" "));function Ur(s){return(Array.isArray(s==null?void 0:s.equipment)?s.equipment:[]).filter(S=>!$h(S)).map(S=>({...S,id:String(S.id||S.equipmentId||""),items:(Array.isArray(S.items)?S.items:[]).filter(N=>!$h(N))})).filter(S=>S.id)}function Wh(s,S=""){if(s==null||s===""||typeof s=="object")return"—";const N=typeof s=="boolean"?s?"是":"否":String(s);return S?`${N} ${S}`:N}function Cr(s,S){return S?"ERROR":["CONNECTED","STALE","WAITING"].includes(s==null?void 0:s.state)?s.state:"WAITING"}function Hr(s,S){if(s)return S==="CONNECTED"?s:{...s,state:s.lastReceivedAt?"STALE":"NOT_RECEIVED",items:(s.items||[]).map(N=>["GOOD","VALID"].includes(N.quality)?{...N,quality:"STALE"}:N)}}const Rr={NUMBER:"数值数据",STATE:"状态数据",IMAGE:"图像数据"};function Br({equipments:s,selectedId:S,onSelect:N}){const o=s.find(p=>p.id===S),_=(o==null?void 0:o.items)||[],[D,w]=el.useState("");el.useEffect(()=>w(""),[S]);const J=_.find(p=>p.key===D)||_[0];return f.jsxs("div",{className:"equipment-layout",children:[f.jsx(Of,{equipments:s,selectedId:S,onSelect:N}),f.jsx("div",{className:"detail-stack",children:o?f.jsxs(f.Fragment,{children:[f.jsx(Df,{equipment:o}),f.jsxs(Fl,{title:"数据接收情况",extra:f.jsx("span",{className:"muted",children:o.participantCode||""}),children:[f.jsx("div",{className:"table-scroll",children:f.jsxs("table",{className:"data-table receive-table",children:[f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{children:"数据类型 / 参数"}),f.jsx("th",{children:"最新数据"}),f.jsx("th",{children:"接收时间"}),f.jsx("th",{children:"接收状态"})]})}),f.jsx("tbody",{children:_.map((p,x)=>f.jsxs("tr",{className:J===p?"selected":"",onClick:()=>w(p.key),tabIndex:0,"aria-selected":J===p,onKeyDown:V=>{(V.key==="Enter"||V.key===" ")&&(V.preventDefault(),w(p.key))},children:[f.jsxs("td",{children:[f.jsx("strong",{children:p.label||p.key}),f.jsx("small",{children:Rr[p.type]||p.type||"采集数据"})]}),f.jsx("td",{className:"parameter-value",children:Wh(p.value,p.unit)}),f.jsx("td",{children:f.jsx("time",{children:wn(p.receivedAt)})}),f.jsx("td",{children:f.jsx(_u,{state:p.quality})})]},`${p.key}-${x}`))})]})}),!_.length&&f.jsx(xa,{title:"尚未收到该装备的数据",description:"采集数据到达后将在此显示。",icon:Af})]}),f.jsx(Fl,{title:J?`${J.label||J.key}详情`:"数据详情",children:J?f.jsxs("dl",{className:"value-details",children:[f.jsxs("div",{children:[f.jsx("dt",{children:"当前值"}),f.jsx("dd",{children:Wh(J.value,J.unit)})]}),f.jsxs("div",{children:[f.jsx("dt",{children:"数据来源"}),f.jsx("dd",{children:J.sourceName||"—"})]}),f.jsxs("div",{children:[f.jsx("dt",{children:"采样时间"}),f.jsx("dd",{children:wn(J.sampleTime)})]}),f.jsxs("div",{children:[f.jsx("dt",{children:"接收时间"}),f.jsx("dd",{children:wn(J.receivedAt)})]}),f.jsxs("div",{children:[f.jsx("dt",{children:"数据质量"}),f.jsx("dd",{children:f.jsx(_u,{state:J.quality})})]})]}):f.jsx(xa,{title:"暂无参数详情",description:"选择已接收参数查看其来源和时间。"})})]}):f.jsx(Fl,{className:"unselected-panel",children:f.jsx(xa,{title:"等待装备采集数据",description:"已绑定的实装及其采集数据同步后将在此显示。",icon:Af})})})]})}function Yr({equipments:s,selectedId:S,onSelect:N}){const o=s.find(_=>_.id===S);return f.jsxs("div",{className:"equipment-layout",children:[f.jsx(Of,{equipments:s,selectedId:S,onSelect:N}),f.jsxs("div",{className:"detail-stack",children:[o&&f.jsx(Df,{equipment:{...o,state:"NOT_RECEIVED"},statusLabel:"同步指标未接入"}),f.jsx(Fl,{title:"当前同步指标",children:f.jsx("div",{className:"sync-values",children:[["同步延迟","ms"],["数据丢包率","%"],["同步偏差","ms"]].map(([_,D])=>f.jsxs("div",{children:[f.jsx("span",{children:_}),f.jsxs("strong",{children:["— ",f.jsx("small",{children:D})]}),f.jsx("p",{children:"未接入"})]},_))})}),f.jsx(Fl,{title:"同步质量趋势",children:f.jsx(xa,{title:"暂无同步质量数据",description:"同步质量数据接入后,可查看所选装备的指标与趋势。",icon:pu})}),f.jsxs(Fl,{title:"数据同步状态",children:[f.jsx("div",{className:"table-scroll",children:f.jsx("table",{className:"data-table",children:f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{children:"数据类型"}),f.jsx("th",{children:"数据时间"}),f.jsx("th",{children:"同步偏差"}),f.jsx("th",{children:"同步状态"})]})})})}),f.jsx(xa,{title:"暂无同步状态"})]})]})]})}const xf=[["帧率","fps"],["码率","Mbps"],["丢帧率","%"],["同步偏差","ms"],["网络带宽","Mbps"],["运行时长","min"]];function kn(s){return String((s==null?void 0:s.equipmentId)??(s==null?void 0:s.id)??(s==null?void 0:s.code)??"")}function Fh(s){return(s==null?void 0:s.equipmentName)||(s==null?void 0:s.name)||(s==null?void 0:s.code)||kn(s)||"未命名装备"}function pf(s){return(s==null?void 0:s.equipmentCode)||(s==null?void 0:s.code)||kn(s)||"—"}function Uf({value:s,onChange:S,placeholder:N,label:o}){return f.jsxs("label",{className:"secondary-search",children:[f.jsx(z0,{size:17,"aria-hidden":"true"}),f.jsx("input",{"aria-label":o||N,value:s,onChange:_=>S(_.target.value),placeholder:N,type:"search"})]})}function Oe({unit:s}){return f.jsxs("span",{className:"secondary-blank",children:["—",s&&f.jsx("small",{children:s})]})}function Cf({children:s}){return f.jsxs("span",{className:"secondary-unavailable",children:[f.jsx("i",{}),s]})}function Hf({colSpan:s,title:S,description:N,icon:o}){return f.jsx("tr",{children:f.jsx("td",{colSpan:s,className:"secondary-empty-cell",children:f.jsx(xa,{title:S,description:N,icon:o})})})}function qr(){const[s,S]=el.useState("");return f.jsxs("div",{className:"secondary-equipment-layout virtual-page",children:[f.jsxs(Fl,{title:"虚拟装备列表",className:"virtual-list-panel",children:[f.jsx("div",{className:"secondary-list-search",children:f.jsx(Uf,{value:s,onChange:S,placeholder:"搜索虚拟装备名称或编号"})}),f.jsx("div",{className:"virtual-list-empty",children:f.jsx(xa,{title:s.trim()?"未找到匹配的虚拟装备":"暂无虚拟装备",description:"接入虚拟装备后,可在此选择查看。",icon:pu})})]}),f.jsxs("div",{className:"secondary-detail-stack",children:[f.jsxs("div",{className:"virtual-unselected-header",children:[f.jsx("span",{className:"virtual-equipment-icon",children:f.jsx(pu,{size:32,"aria-hidden":"true"})}),f.jsxs("div",{children:[f.jsx("h2",{children:"未选择虚拟装备"}),f.jsxs("p",{children:["装备编号 ",f.jsx("span",{children:"—"})]})]}),f.jsx(Cf,{children:"暂无状态"})]}),f.jsx(Fl,{title:"当前装备状态",className:"virtual-state-panel",children:f.jsxs("div",{className:"virtual-state-body",children:[f.jsxs("div",{className:"virtual-health-row",children:[f.jsx(wh,{size:31,"aria-hidden":"true"}),f.jsx("span",{className:"virtual-health-label",children:"血量"}),f.jsx("strong",{children:"— / —"}),f.jsx("div",{className:"virtual-health-track","aria-label":"暂无血量数据"}),f.jsx("span",{className:"secondary-muted",children:"—"})]}),f.jsx("div",{className:"virtual-position-grid",children:[["经度","°"],["速度","m/s"],["纬度","°"],["航向角","°"],["高程","m"],["动作状态",""]].map(([N,o])=>f.jsxs("div",{className:"virtual-position-item",children:[f.jsx("span",{children:N}),f.jsx(Oe,{unit:o})]},N))})]})}),f.jsx(Fl,{title:"虚拟装备状态一览",className:"virtual-overview-panel",children:f.jsx("div",{className:"secondary-table-scroll",children:f.jsxs("table",{className:"secondary-table virtual-overview-table",children:[f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{scope:"col",children:"虚拟装备"}),f.jsx("th",{scope:"col",children:"血量"}),f.jsx("th",{scope:"col",children:"位置(经度 / 纬度)"}),f.jsx("th",{scope:"col",children:"高程"}),f.jsx("th",{scope:"col",children:"状态"})]})}),f.jsx("tbody",{children:f.jsx(Hf,{colSpan:5,title:"暂无虚拟装备状态",description:"装备接入后显示血量、位置与运行状态。",icon:wh})})]})})})]})]})}function Gr({equipments:s=[],selectedId:S,onSelect:N}){const o=s.find(_=>kn(_)===String(S??""));return f.jsxs("div",{className:"secondary-equipment-layout video-page",children:[f.jsx(Of,{equipments:s,selectedId:S,onSelect:N,title:"实装列表",emptyText:"暂无已接入的实装"}),f.jsxs("div",{className:"secondary-detail-stack video-main-column",children:[o?f.jsx(Df,{equipment:{...o,state:"NOT_RECEIVED"},statusLabel:"暂无融合视频"}):f.jsx(Fl,{children:f.jsxs("div",{className:"video-unselected-header",children:[f.jsx(Tf,{size:30,"aria-hidden":"true"}),f.jsxs("div",{children:[f.jsx("h2",{children:"未选择实装"}),f.jsx("p",{children:"从实装列表选择装备查看融合视频。"})]})]})}),f.jsx(Fl,{className:"video-main-panel",children:f.jsxs("div",{className:"video-stage",children:[f.jsxs("div",{className:"video-empty-frame",children:[f.jsx(Tf,{size:54,strokeWidth:1.5,"aria-hidden":"true"}),f.jsx("h3",{children:o?"暂无融合视频":"请选择实装"}),f.jsx("p",{children:o?"当前装备尚无可用的融合视频源。":"选择装备后查看对应的融合画面。"})]}),f.jsxs("aside",{className:"video-metrics-overlay","aria-label":"当前装备融合指标",children:[f.jsxs("div",{className:"video-overlay-heading",children:[f.jsx("strong",{children:"融合指标"}),f.jsx("span",{children:o?pf(o):"—"})]}),f.jsx("dl",{children:xf.map(([_,D])=>f.jsxs("div",{children:[f.jsx("dt",{children:_}),f.jsxs("dd",{children:["—",f.jsx("small",{children:D})]})]},_))})]}),f.jsxs("div",{className:"video-control-bar","aria-label":"视频播放控制",children:[f.jsx(Cf,{children:o?"暂无画面":"未选择装备"}),f.jsx("span",{className:"video-control-divider"}),f.jsx("button",{type:"button",disabled:!0,"aria-label":"暂停视频",title:"暂无可播放的融合视频",children:f.jsx(pr,{size:19})}),f.jsx("button",{type:"button",disabled:!0,"aria-label":"视频音量",title:"暂无可播放的融合视频",children:f.jsx(Dr,{size:20})}),f.jsx("div",{className:"video-volume-track","aria-hidden":"true"}),f.jsx("span",{className:"video-resolution",children:"分辨率 —"}),f.jsx("button",{type:"button",disabled:!0,"aria-label":"视频全屏",title:"暂无可播放的融合视频",children:f.jsx(Ar,{size:20})})]})]})})]})]})}function Qr({equipments:s=[]}){const[S,N]=el.useState(""),o=el.useMemo(()=>{const _=S.trim().toLocaleLowerCase();return _?s.filter(D=>`${Fh(D)} ${pf(D)}`.toLocaleLowerCase().includes(_)):s},[s,S]);return f.jsxs("div",{className:"fusion-metrics-page",children:[f.jsxs(Fl,{title:"装备融合指标",className:"fusion-metrics-panel",children:[f.jsx("div",{className:"secondary-panel-toolbar",children:f.jsx(Uf,{value:S,onChange:N,placeholder:"搜索装备名称或编号"})}),f.jsx("div",{className:"secondary-table-scroll",children:f.jsxs("table",{className:"secondary-table fusion-metrics-table",children:[f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{scope:"col",children:"装备名称"}),xf.map(([_,D])=>f.jsxs("th",{scope:"col",children:[_,f.jsxs("span",{className:"secondary-column-unit",children:["(",D,")"]})]},_)),f.jsx("th",{scope:"col",children:"状态"})]})}),f.jsx("tbody",{children:o.length?o.map((_,D)=>f.jsxs("tr",{children:[f.jsx("td",{children:f.jsxs("div",{className:"fusion-equipment-name",children:[f.jsx(Mf,{equipment:_,size:26}),f.jsxs("div",{children:[f.jsx("strong",{children:Fh(_)}),f.jsx("span",{children:pf(_)})]})]})}),xf.map(([w])=>f.jsx("td",{children:f.jsx(Oe,{})},w)),f.jsx("td",{children:f.jsx(Cf,{children:"暂无指标"})})]},`${kn(_)}-${D}`)):f.jsx(Hf,{colSpan:8,title:S.trim()?"未找到匹配的装备":"暂无实装",description:S.trim()?"请调整名称或编号后重试。":"装备接入后,将在此显示对应的融合指标。",icon:pu})})]})}),f.jsx("p",{className:"secondary-panel-note",children:"未接入融合指标的装备显示为 —。"})]}),f.jsx("p",{className:"fusion-metrics-footer",children:"可在融合视频展示页查看对应装备的画面与融合指标。"})]})}function Ih({node:s}){const S=s.online===!0?"online":s.online===!1?"offline":"unknown";return f.jsxs("span",{className:`node-state node-state-${S}`,children:[f.jsx("i",{}),S==="online"?"在线":S==="offline"?"离线":"状态未知"]})}function Xr(s){return s.nodeRole==="COORDINATOR"?"主节点":s.nodeRole==="WORKER"?"从节点":"—"}function Lr({nodes:s=[]}){const[S,N]=el.useState(""),o=el.useMemo(()=>{const _=S.trim().toLocaleLowerCase();return _?s.filter(D=>`${D.name||""} ${D.nodeId||""}`.toLocaleLowerCase().includes(_)):s},[s,S]);return f.jsxs(Fl,{title:"节点运行状态",className:"node-page",children:[f.jsx("div",{className:"secondary-panel-toolbar",children:f.jsx(Uf,{value:S,onChange:N,placeholder:"搜索节点名称或编号"})}),f.jsx("div",{className:"secondary-table-scroll",children:f.jsxs("table",{className:"secondary-table node-status-table",children:[f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{scope:"col",children:"节点名称"}),f.jsx("th",{scope:"col",children:"角色"}),f.jsxs("th",{scope:"col",children:[f.jsx(Er,{size:15,"aria-hidden":"true"}),"CPU 使用率"]}),f.jsx("th",{scope:"col",children:"内存使用情况"}),f.jsxs("th",{scope:"col",children:[f.jsx(mr,{size:15,"aria-hidden":"true"}),"网络接收"]}),f.jsxs("th",{scope:"col",children:[f.jsx(gr,{size:15,"aria-hidden":"true"}),"网络发送"]}),f.jsx("th",{scope:"col",children:"运行状态"})]})}),f.jsx("tbody",{children:o.length?o.map((_,D)=>f.jsxs("tr",{children:[f.jsx("td",{children:f.jsxs("div",{className:"node-identity",children:[f.jsx(Ef,{size:28,"aria-hidden":"true"}),f.jsxs("div",{children:[f.jsx("strong",{children:_.name||_.nodeId||"未命名节点"}),f.jsx("span",{title:_.nodeId||"",children:_.nodeId||"—"})]})]})}),f.jsx("td",{children:Xr(_)}),f.jsx("td",{children:f.jsx(Oe,{})}),f.jsx("td",{children:f.jsx(Oe,{})}),f.jsx("td",{children:f.jsx(Oe,{})}),f.jsx("td",{children:f.jsx(Oe,{})}),f.jsx("td",{children:f.jsx(Ih,{node:_})})]},_.nodeId||D)):f.jsx(Hf,{colSpan:7,title:S.trim()?"未找到匹配的节点":"暂无已注册节点",description:S.trim()?"请调整名称或编号后重试。":"节点注册后,可在此查看运行状态。",icon:Ef})})]})}),f.jsxs("section",{className:"node-network-panel","aria-label":"节点心跳连接状态",children:[f.jsxs("h3",{children:[f.jsx(y0,{size:18,"aria-hidden":"true"}),"节点心跳连接状态"]}),o.length?f.jsx("div",{className:"node-network-grid",children:o.map((_,D)=>f.jsxs("div",{className:"node-network-item",children:[f.jsx(Ef,{size:25,"aria-hidden":"true"}),f.jsxs("div",{children:[f.jsx("strong",{children:_.name||_.nodeId||"未命名节点"}),f.jsx("span",{children:_.online===!0?"心跳在线":_.online===!1?"心跳中断":"暂无心跳状态"})]}),f.jsx(Ih,{node:_})]},_.nodeId||D))}):f.jsx("p",{className:"node-network-empty",children:"暂无节点连接信息"})]}),f.jsx("p",{className:"secondary-panel-note",children:"资源使用情况与网络速率未上报时显示为 —。"})]})}const _f=[{id:"receive",name:"数据接收",description:"按装备查看接收数据与当前状态",icon:Af},{id:"sync",name:"同步质量监控",description:"按装备查看同步状态与质量指标",icon:pu},{id:"virtual",name:"虚拟数据下发",description:"查看虚拟装备的血量、位置与运行状态",icon:Mr},{id:"video",name:"融合视频展示",description:"按实装查看融合视频及当前融合指标",icon:Tf},{id:"metrics",name:"融合指标展示",description:"以装备列表查看各实装的融合指标",icon:Sr},{id:"nodes",name:"主从控制",description:"查看各节点的 CPU、内存和网络运行状态",icon:y0}],Zr={collection:{state:"WAITING",contexts:[]},nodes:[]},Ph=()=>{var s;return((s=_f.find(S=>S.id===location.hash.slice(1)))==null?void 0:s.id)||"receive"};function Vr(){var ct,Ll;const[s,S]=el.useState(Ph),[N,o]=el.useState(Zr),[_,D]=el.useState(""),[w,J]=el.useState(""),[p,x]=el.useState(!1),[V,B]=el.useState(!0),[ul,Tl]=el.useState(0),[nl,gl]=el.useState(!1),[Rl,_l]=el.useState(Date.now());el.useEffect(()=>{const Q=()=>{S(Ph()),window.scrollTo(0,0)};return window.addEventListener("hashchange",Q),()=>window.removeEventListener("hashchange",Q)},[]),el.useEffect(()=>{const Q=setInterval(()=>_l(Date.now()),1e3);return()=>clearInterval(Q)},[]),el.useEffect(()=>{let Q=!1,gt,ql;const E=async()=>{var q;ql=new AbortController;const M=setTimeout(()=>ql.abort(),6e3);try{const cl=await fetch("/console/api/snapshot",{signal:ql.signal,credentials:"same-origin",cache:"no-store",headers:{Accept:"application/json"}});if(!cl.ok)throw new Error("snapshot");const ll=await cl.json();if(!ll||!Array.isArray((q=ll.collection)==null?void 0:q.contexts)||!Array.isArray(ll.nodes))throw new Error("shape");Q||(o(ll),x(!1))}catch{Q||x(!0)}finally{clearTimeout(M),Q||(B(!1),gt=setTimeout(E,2e3))}};return E(),()=>{Q=!0,clearTimeout(gt),ql==null||ql.abort()}},[ul]);const Cl=Cr(N.collection,p),bl=N.collection.contexts||[],Nl=bl.find(Q=>Q.id===_)||bl[0],Xl=el.useMemo(()=>Ur(Nl).map(Q=>Hr(Q,Cl)),[Nl,Cl]),Bl=((ct=Xl.find(Q=>Q.id===w))==null?void 0:ct.id)||((Ll=Xl[0])==null?void 0:Ll.id)||"",Y=_f.find(Q=>Q.id===s);el.useEffect(()=>{document.title=`${Y.name} · 系统融合软件`},[Y.name]);const Yl=el.useCallback(Q=>J(Q),[]),Il={equipments:Xl,selectedId:Bl,onSelect:Yl},Pt=new Intl.DateTimeFormat("zh-CN",{timeZone:"Asia/Shanghai",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}).format(new Date(Rl));return f.jsxs("div",{className:`app ${nl?"nav-collapsed":""}`,children:[f.jsxs("aside",{className:"sidebar",children:[f.jsxs("div",{className:"brand",children:[f.jsx("img",{src:"/console/assets/brand.svg",alt:""}),f.jsx("strong",{children:"系统融合软件"}),f.jsx("button",{className:"collapse-toggle",type:"button","aria-label":"切换侧栏",onClick:()=>gl(Q=>!Q),children:f.jsx(xr,{size:16})})]}),f.jsx("nav",{"aria-label":"功能导航",children:_f.map(Q=>f.jsxs("a",{href:`#${Q.id}`,className:`nav-link ${Q.id===Y.id?"active":""}`,title:Q.name,"aria-current":Q.id===Y.id?"page":void 0,children:[f.jsx(Q.icon,{size:23,strokeWidth:1.6}),f.jsx("span",{children:Q.name})]},Q.id))}),f.jsxs("div",{className:"sidebar-foot",children:[f.jsx(jr,{size:22}),f.jsx("span",{children:"运行监测"})]})]}),f.jsxs("div",{className:"main-shell",children:[f.jsxs("header",{className:"topbar",children:[f.jsxs("div",{className:"breadcrumb",children:[f.jsx("span",{children:"系统融合软件"}),f.jsx(br,{size:14}),f.jsx("strong",{children:Y.name})]}),f.jsxs("div",{className:"service-state",children:[f.jsx(_u,{state:p?"ERROR":V?"WAITING":"CONNECTED",label:p?"服务连接中断":V?"正在连接":"服务连接正常"}),f.jsx("time",{children:Pt})]})]}),f.jsxs("main",{children:[f.jsxs("div",{className:"page-heading",children:[f.jsxs("div",{children:[f.jsx("h1",{children:Y.name}),f.jsx("p",{children:Y.description})]}),f.jsxs("div",{className:"heading-actions",children:[bl.length>0&&f.jsxs("label",{className:"context-select",children:[f.jsx("span",{children:"试验"}),f.jsx("select",{"aria-label":"选择试验上下文",value:(Nl==null?void 0:Nl.id)||"",onChange:Q=>{D(Q.target.value),J("")},children:bl.map(Q=>f.jsx("option",{value:Q.id,children:Q.name||Q.id},Q.id))})]}),f.jsxs("button",{className:"refresh-button",type:"button",onClick:()=>Tl(Q=>Q+1),"aria-label":"刷新数据",title:"刷新数据",children:[f.jsx(Nr,{size:17}),f.jsx("span",{children:"刷新"})]})]})]}),(p||Cl==="STALE")&&f.jsxs("div",{className:"source-notice",role:"status",children:[f.jsx(zr,{size:17}),p?"服务暂时不可用,当前显示最后一次收到的数据。":"采集数据同步已超时,当前显示最后一次收到的数据。"]}),Y.id==="receive"&&f.jsx(Br,{...Il}),Y.id==="sync"&&f.jsx(Yr,{...Il}),Y.id==="virtual"&&f.jsx(qr,{}),Y.id==="video"&&f.jsx(Gr,{...Il}),Y.id==="metrics"&&f.jsx(Qr,{equipments:Xl}),Y.id==="nodes"&&f.jsx(Lr,{nodes:N.nodes||[]})]}),f.jsxs("footer",{className:"app-footer",children:[f.jsx("span",{children:"系统融合软件"}),f.jsx("span",{children:Cl==="WAITING"?"等待采集数据接入":Cl==="CONNECTED"?"采集数据自动更新":"采集数据同步中断"})]})]})]})}nr.createRoot(document.getElementById("root")).render(f.jsx(Iy.StrictMode,{children:f.jsx(Vr,{})})); diff --git a/src/main/resources/static/console/index.html b/src/main/resources/static/console/index.html new file mode 100644 index 0000000..c4ad3f2 --- /dev/null +++ b/src/main/resources/static/console/index.html @@ -0,0 +1,13 @@ + + + + + + + + 系统融合软件 + + + +

+ diff --git a/src/test/java/com/xtrh/communication/client/PeerClientTest.java b/src/test/java/com/xtrh/communication/client/PeerClientTest.java new file mode 100644 index 0000000..43b8d82 --- /dev/null +++ b/src/test/java/com/xtrh/communication/client/PeerClientTest.java @@ -0,0 +1,69 @@ +package com.xtrh.communication.client; + +import com.xtrh.discovery.service.Discovery; +import com.xtrh.common.util.Jsons; +import com.xtrh.common.config.Settings; +import com.xtrh.communication.security.Signatures; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.env.MockEnvironment; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static com.xtrh.common.util.Jsons.object; +import static org.junit.jupiter.api.Assertions.*; + +class PeerClientTest { + private static final String TOKEN = "peer-unit-test-credential-not-deployed"; + @TempDir Path directory; + + @Test void signsRequestsWithoutDisclosingCredentialAndRejectsForgedResponses() throws Exception { + Settings settings = new Settings(new MockEnvironment().withProperty("fusion.token", TOKEN) + .withProperty("fusion.data-dir", directory.toString()).withProperty("fusion.discovery-enabled", "false")); + PeerClient peer = new PeerClient(settings, new ObjectMapper()); + AtomicReference mode = new AtomicReference<>("valid"); + AtomicBoolean verifiedRequest = new AtomicBoolean(); + AtomicBoolean leakedCredential = new AtomicBoolean(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/control/info", exchange -> { + byte[] requestBody = exchange.getRequestBody().readAllBytes(); + String nonce = exchange.getRequestHeaders().getFirst("X-Xtrh-Nonce"); + String timestamp = exchange.getRequestHeaders().getFirst("X-Xtrh-Time"); + verifiedRequest.set(Signatures.equal(Signatures.request(TOKEN, exchange.getRequestMethod(), exchange.getRequestURI().toString(), + timestamp, nonce, requestBody), exchange.getRequestHeaders().getFirst("X-Xtrh-Signature"))); + leakedCredential.set(exchange.getRequestHeaders().values().stream().flatMap(v -> v.stream()).anyMatch(v -> v.contains(TOKEN))); + byte[] body = "{\"clusterId\":\"test-cluster\",\"storageHealthy\":true}".getBytes(StandardCharsets.UTF_8); + String signature = switch (mode.get()) { + case "wrong-token" -> Signatures.response("forged-server-credential", nonce, 200, body); + case "wrong-nonce" -> Signatures.response(TOKEN, "nonce-from-previous-response", 200, body); + case "wrong-body" -> Signatures.response(TOKEN, nonce, 200, new byte[0]); + case "wrong-status" -> Signatures.response(TOKEN, nonce, 500, body); + default -> Signatures.response(TOKEN, nonce, 200, body); + }; + if (!mode.get().equals("unsigned")) exchange.getResponseHeaders().set("X-Xtrh-Response-Signature", signature); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + assertEquals("test-cluster", peer.request(baseUrl, "/control/info", "POST", object().put("value", "中文正文")).path("clusterId").asText()); + assertTrue(verifiedRequest.get()); + assertFalse(leakedCredential.get()); + for (String forgery : new String[]{"unsigned", "wrong-token", "wrong-nonce", "wrong-body", "wrong-status"}) { + mode.set(forgery); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> peer.request(baseUrl, "/control/info", "GET", null), forgery); + assertEquals("Peer response authentication failed", failure.getMessage()); + } + } finally { server.stop(0); } + } +} diff --git a/src/test/java/com/xtrh/communication/security/ServiceSecurityTest.java b/src/test/java/com/xtrh/communication/security/ServiceSecurityTest.java new file mode 100644 index 0000000..7c79da6 --- /dev/null +++ b/src/test/java/com/xtrh/communication/security/ServiceSecurityTest.java @@ -0,0 +1,97 @@ +package com.xtrh.communication.security; + +import com.xtrh.discovery.service.Discovery; +import com.xtrh.common.config.Settings; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class ServiceSecurityTest { + private static final String TOKEN = "security-unit-test-credential-only"; + @TempDir Path directory; + + @Test void authenticatedRequestPreservesBodyAndSignsExactResponseBytes() throws Exception { + ServiceSecurity security = security(); + byte[] input = "{\"name\":\"融合电脑A\",\"capacity\":4}".getBytes(StandardCharsets.UTF_8); + MockHttpServletRequest request = signed("POST", "/control/nodes/register", input, UUID.randomUUID().toString()); + MockHttpServletResponse response = new MockHttpServletResponse(); + byte[] output = "{\"result\":\"已登记\"}".getBytes(StandardCharsets.UTF_8); + security.doFilter(request, response, (req, res) -> { + assertArrayEquals(input, req.getInputStream().readAllBytes()); + ((HttpServletResponse) res).setStatus(201); + res.getOutputStream().write(output); + }); + assertEquals(201, response.getStatus()); + assertArrayEquals(output, response.getContentAsByteArray()); + assertTrue(Signatures.equal(Signatures.response(TOKEN, request.getHeader("X-Xtrh-Nonce"), 201, output), + response.getHeader("X-Xtrh-Response-Signature"))); + assertNull(request.getHeader("X-Xtrh-Token")); + } + + @Test void replayOfAnAcceptedNonceIsRejectedBeforeExecutingOperation() throws Exception { + ServiceSecurity security = security(); + String nonce = UUID.randomUUID().toString(); + AtomicInteger operations = new AtomicInteger(); + FilterChain chain = (req, res) -> operations.incrementAndGet(); + MockHttpServletResponse first = new MockHttpServletResponse(); + security.doFilter(signed("PATCH", "/control/nodes/node-aaaa/capacity", new byte[]{1, 2}, nonce), first, chain); + assertEquals(200, first.getStatus()); + MockHttpServletResponse replay = new MockHttpServletResponse(); + security.doFilter(signed("PATCH", "/control/nodes/node-aaaa/capacity", new byte[]{1, 2}, nonce), replay, chain); + assertEquals(401, replay.getStatus()); + assertEquals(1, operations.get()); + } + + @Test void alteredBodyPathQueryMethodAndExpiredTimeAreRejected() throws Exception { + ServiceSecurity security = security(); + byte[] input = "{\"capacity\":2}".getBytes(StandardCharsets.UTF_8); + for (String alteration : new String[]{"body", "path", "query", "method", "expired"}) { + MockHttpServletRequest request = signed("PATCH", "/control/nodes/node-aaaa/capacity", input, UUID.randomUUID().toString()); + switch (alteration) { + case "body" -> request.setContent("{\"capacity\":64}".getBytes(StandardCharsets.UTF_8)); + case "path" -> request.setRequestURI("/control/nodes/node-bbbb/capacity"); + case "query" -> request.setQueryString("override=true"); + case "method" -> request.setMethod("PUT"); + case "expired" -> { + String oldTime = Long.toString(System.currentTimeMillis() - 120_000); + request.removeHeader("X-Xtrh-Time"); + request.removeHeader("X-Xtrh-Signature"); + request.addHeader("X-Xtrh-Time", oldTime); + request.addHeader("X-Xtrh-Signature", Signatures.request(TOKEN, request.getMethod(), request.getRequestURI(), + oldTime, request.getHeader("X-Xtrh-Nonce"), input)); + } + } + MockHttpServletResponse response = new MockHttpServletResponse(); + security.doFilter(request, response, (req, res) -> fail("Tampered request executed: " + alteration)); + assertEquals(401, response.getStatus(), alteration); + } + } + + private ServiceSecurity security() throws Exception { + return new ServiceSecurity(new Settings(new MockEnvironment().withProperty("fusion.token", TOKEN) + .withProperty("fusion.data-dir", directory.toString()).withProperty("fusion.discovery-enabled", "false"))); + } + + private MockHttpServletRequest signed(String method, String path, byte[] bytes, String nonce) { + String timestamp = Long.toString(System.currentTimeMillis()); + MockHttpServletRequest request = new MockHttpServletRequest(method, path); + request.setContent(bytes); + request.addHeader("X-Xtrh-Time", timestamp); + request.addHeader("X-Xtrh-Nonce", nonce); + request.addHeader("X-Xtrh-Signature", Signatures.request(TOKEN, method, path, timestamp, nonce, bytes)); + return request; + } +} diff --git a/src/test/java/com/xtrh/console/controller/ConsoleControllerTest.java b/src/test/java/com/xtrh/console/controller/ConsoleControllerTest.java new file mode 100644 index 0000000..f4d261f --- /dev/null +++ b/src/test/java/com/xtrh/console/controller/ConsoleControllerTest.java @@ -0,0 +1,70 @@ +package com.xtrh.console.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.xtrh.console.service.ConsoleCollectionStore; +import com.xtrh.common.config.Settings; +import com.xtrh.control.service.CoordinatorService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static com.xtrh.common.util.Jsons.*; +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Path; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class ConsoleControllerTest { + @TempDir Path directory; + private CoordinatorService coordinator; + + @AfterEach void close() throws Exception { if (coordinator != null) coordinator.close(); } + + @Test void exposesOnlyWhitelistedNodeFieldsAndKeepsUnavailableResourceTelemetryNull() throws Exception { + ObjectNode node = object().put("nodeId", "node-a").put("name", "节点 A").put("nodeRole", "WORKER") + .put("online", true).put("lastHeartbeat", 1000).put("sessionId", "must-not-leak") + .put("baseUrl", "http://private-node:18120").put("capacity", 3).put("mediaKey", "must-not-leak"); + ObjectNode internalSnapshot = object().set("nodes", array().add(node)); + coordinator = new CoordinatorService(settings(), new ObjectMapper()) { + @Override public ObjectNode snapshot() { return internalSnapshot; } + }; + ConsoleController controller = new ConsoleController(new ConsoleCollectionStore(new ObjectMapper()), coordinator); + ObjectNode result = controller.snapshot(); + assertEquals("WAITING", result.path("collection").path("state").asText()); + assertEquals(0, result.path("collection").path("contexts").size()); + assertEquals("node-a", result.path("nodes").get(0).path("nodeId").asText()); + assertTrue(result.path("nodes").get(0).path("cpu").isNull()); + assertTrue(result.path("nodes").get(0).path("memory").isNull()); + assertTrue(result.path("nodes").get(0).path("network").isNull()); + assertFalse(result.toString().contains("must-not-leak")); + assertFalse(result.toString().contains("private-node")); + assertFalse(result.toString().contains("capacity")); + } + + @Test void collectionPushDoesNotCallCoordinatorBusinessOperationsAndRootRoutesToNativeResources() throws Exception { + coordinator = new CoordinatorService(settings(), new ObjectMapper()); + ObjectNode before = coordinator.snapshot(); + ConsoleCollectionStore collection = new ConsoleCollectionStore(new ObjectMapper()); + MockMvc mvc = MockMvcBuilders.standaloneSetup(new ConsoleController(collection, coordinator), + new ConsoleCollectionController(collection)).build(); + mvc.perform(post("/control/collection/sync").contentType("application/json") + .content(object().put("source", "PXHJ").put("observedAt", System.currentTimeMillis()).set("contexts", array()).toString())) + .andExpect(status().isOk()).andExpect(jsonPath("$.accepted").value(true)); + assertEquals("CONNECTED", collection.snapshot().path("state").asText()); + ObjectNode after = coordinator.snapshot(); + before.remove("serverTime"); + after.remove("serverTime"); + assertEquals(before, after, "Collection display must not alter persisted scheduling state"); + mvc.perform(get("/console")).andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/console/")); + mvc.perform(get("/console/")).andExpect(status().isOk()).andExpect(forwardedUrl("/console/index.html")); + } + + private Settings settings() throws Exception { + return new Settings(new MockEnvironment().withProperty("fusion.token", "console-controller-unit-test-credential") + .withProperty("fusion.data-dir", directory.toString()).withProperty("fusion.discovery-enabled", "false")); + } +} diff --git a/src/test/java/com/xtrh/console/security/ConsoleSecurityTest.java b/src/test/java/com/xtrh/console/security/ConsoleSecurityTest.java new file mode 100644 index 0000000..9afbf69 --- /dev/null +++ b/src/test/java/com/xtrh/console/security/ConsoleSecurityTest.java @@ -0,0 +1,86 @@ +package com.xtrh.console.security; + +import com.xtrh.common.config.Settings; +import com.xtrh.communication.security.ServiceSecurity; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class ConsoleSecurityTest { + @TempDir Path directory; + + @Test void localSameOriginGetAndHeadAreReadOnlyAndDoNotExposeServiceCredential() throws Exception { + ServiceSecurity security = security("coordinator", true); + for (String method : new String[]{"GET", "HEAD"}) { + MockHttpServletRequest request = request(method, "/console/api/snapshot"); + request.addHeader("Origin", "http://127.0.0.1:18100"); + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicInteger calls = new AtomicInteger(); + security.doFilter(request, response, (req, res) -> calls.incrementAndGet()); + assertEquals(1, calls.get()); + assertEquals(200, response.getStatus()); + assertEquals("no-store", response.getHeader("Cache-Control")); + assertNull(response.getHeader("X-Xtrh-Response-Signature")); + } + } + + @Test void remoteHostSpoofCrossOriginAndWriteRequestsAreRejectedBeforeController() throws Exception { + ServiceSecurity security = security("coordinator", true); + for (String attack : new String[]{"remote", "host", "port", "origin", "null-origin", "fetch-site", "write", "encoded", "traversal"}) { + MockHttpServletRequest request = request("GET", "/console/api/snapshot"); + switch (attack) { + case "remote" -> request.setRemoteAddr("192.168.1.25"); + case "host" -> { request.removeHeader("Host"); request.addHeader("Host", "attacker.example:18100"); } + case "port" -> { request.removeHeader("Host"); request.addHeader("Host", "127.0.0.1:9999"); } + case "origin" -> request.addHeader("Origin", "http://localhost:18100"); + case "null-origin" -> request.addHeader("Origin", "null"); + case "fetch-site" -> request.addHeader("Sec-Fetch-Site", "cross-site"); + case "write" -> request.setMethod("POST"); + case "encoded" -> request.setRequestURI("/console/%2e%2e/control/snapshot"); + case "traversal" -> request.setRequestURI("/console/../control/snapshot"); + } + MockHttpServletResponse response = new MockHttpServletResponse(); + security.doFilter(request, response, (req, res) -> fail("Attack reached controller: " + attack)); + assertEquals(403, response.getStatus(), attack); + } + } + + @Test void staticConsoleReadsDoNotOpenInternalControlOrDisabledRoles() throws Exception { + ServiceSecurity security = security("coordinator", true); + MockHttpServletResponse page = new MockHttpServletResponse(); + security.doFilter(request("GET", "/console/index.html"), page, (req, res) -> res.getWriter().write("page")); + assertEquals("page", page.getContentAsString()); + for (String path : new String[]{"/control/snapshot", "/control/collection/sync", "/consolex/api/snapshot"}) { + MockHttpServletResponse response = new MockHttpServletResponse(); + security.doFilter(request("GET", path), response, (req, res) -> fail("Internal API bypassed HMAC")); + assertEquals(401, response.getStatus()); + } + for (ServiceSecurity closed : new ServiceSecurity[]{security("worker", true), security("coordinator", false)}) { + MockHttpServletResponse response = new MockHttpServletResponse(); + closed.doFilter(request("GET", "/console/api/snapshot"), response, (req, res) -> fail("Console unexpectedly enabled")); + assertEquals(401, response.getStatus()); + } + } + + private ServiceSecurity security(String role, boolean enabled) throws Exception { + return new ServiceSecurity(new Settings(new MockEnvironment() + .withProperty("fusion.token", "console-security-unit-test-credential") + .withProperty("fusion.role", role).withProperty("fusion.discovery-enabled", "false") + .withProperty("fusion.data-dir", directory.resolve(role).toString())), enabled); + } + + private MockHttpServletRequest request(String method, String path) { + MockHttpServletRequest request = new MockHttpServletRequest(method, path); + request.setRemoteAddr("127.0.0.1"); + request.setLocalPort(18100); + request.addHeader("Host", "127.0.0.1:18100"); + return request; + } +} diff --git a/src/test/java/com/xtrh/console/service/ConsoleCollectionStoreTest.java b/src/test/java/com/xtrh/console/service/ConsoleCollectionStoreTest.java new file mode 100644 index 0000000..6eff5c1 --- /dev/null +++ b/src/test/java/com/xtrh/console/service/ConsoleCollectionStoreTest.java @@ -0,0 +1,112 @@ +package com.xtrh.console.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicLong; + +import static com.xtrh.common.util.Jsons.*; +import static org.junit.jupiter.api.Assertions.*; + +class ConsoleCollectionStoreTest { + private final AtomicLong time = new AtomicLong(1_000_000); + private final ConsoleCollectionStore store = new ConsoleCollectionStore(new ObjectMapper(), time::get); + + @Test void startsEmptyAndBecomesStaleWithoutReplacingLastRealValues() { + assertEquals("WAITING", store.snapshot().path("state").asText()); + assertTrue(store.snapshot().path("lastSyncedAt").isNull()); + store.sync(input()); + assertEquals("CONNECTED", store.snapshot().path("state").asText()); + assertEquals(0, firstItem().path("value").asInt()); + time.addAndGet(15_001); + assertEquals("STALE", store.snapshot().path("state").asText()); + assertEquals("speed", firstItem().path("key").asText()); + assertEquals(1_000_000, store.snapshot().path("lastSyncedAt").asLong()); + ObjectNode fresh = input(); + fresh.put("observedAt", time.get()); + store.sync(fresh); + assertEquals("CONNECTED", store.snapshot().path("state").asText()); + } + + @Test void stripsPrivateFieldsAndFiltersSimulatedAndCredentialItems() { + ObjectNode input = input().put("token", "must-not-leak"); + ObjectNode equipment = (ObjectNode) input.path("contexts").get(0).path("equipment").get(0); + equipment.put("mediaKey", "must-not-leak"); + ArrayNode items = (ArrayNode) equipment.path("items"); + ((ObjectNode) items.get(0)).put("keyAlias", "must-not-leak"); + items.add(item("fake", 50).put("quality", "SIMULATED")); + items.add(item("fake2", 50).put("sourceName", "本地模拟")); + items.add(item("fake3", 50).put("sourceType", "DEMO")); + items.add(item("fake4", 50).put("simulated", true)); + items.add(item("mediaKey", 123)); + store.sync(input); + assertEquals(1, store.snapshot().path("contexts").get(0).path("equipment").get(0).path("items").size()); + assertFalse(store.snapshot().toString().contains("must-not-leak")); + assertFalse(store.snapshot().toString().contains("mediaKey")); + ObjectNode copy = store.snapshot(); + ((ArrayNode) copy.path("contexts")).removeAll(); + assertEquals(1, store.snapshot().path("contexts").size()); + } + + @Test void invalidOrOutOfOrderPayloadDoesNotEraseAcceptedSnapshot() { + store.sync(input()); + ObjectNode old = input().put("observedAt", time.get() - 1); + assertThrows(IllegalArgumentException.class, () -> store.sync(old)); + ObjectNode future = input().put("observedAt", time.get() + 60_001); + assertThrows(IllegalArgumentException.class, () -> store.sync(future)); + ObjectNode nested = input(); + ((ObjectNode) nested.path("contexts").get(0).path("equipment").get(0).path("items").get(0)) + .set("value", object().put("secret", "hidden")); + assertThrows(IllegalArgumentException.class, () -> store.sync(nested)); + assertThrows(IllegalArgumentException.class, () -> store.sync(input().put("source", "OTHER"))); + assertThrows(IllegalArgumentException.class, () -> store.sync(input().put("simulated", true))); + assertEquals("speed", firstItem().path("key").asText()); + } + + @Test void rejectsExcessiveFieldsCountsAndSizeBeforeReplacingState() { + store.sync(input()); + ObjectNode longName = input(); + ((ObjectNode) longName.path("contexts").get(0)).put("name", "x".repeat(201)); + assertThrows(IllegalArgumentException.class, () -> store.sync(longName)); + ObjectNode excessive = input(); + ArrayNode contexts = (ArrayNode) excessive.path("contexts"); + for (int i = 0; i < 32; i++) contexts.add(object().put("id", "context-" + i).set("equipment", array())); + assertThrows(IllegalArgumentException.class, () -> store.sync(excessive)); + ObjectNode large = input().put("privateExtra", "x".repeat(1_500_000)); + assertThrows(IllegalArgumentException.class, () -> store.sync(large)); + assertEquals(1, store.snapshot().path("contexts").size()); + } + + @Test void nullUnknownQualityAndMissingSignalRemainUnavailableRatherThanBecomingZero() { + ObjectNode input = input(); + ObjectNode item = (ObjectNode) input.path("contexts").get(0).path("equipment").get(0).path("items").get(0); + item.putNull("value").putNull("receivedAt").put("quality", "UNRECOGNIZED"); + store.sync(input); + assertTrue(firstItem().path("value").isNull()); + assertTrue(firstItem().path("receivedAt").isNull()); + assertEquals("UNKNOWN", firstItem().path("quality").asText()); + } + + private ObjectNode input() { + ObjectNode result = object().put("source", "PXHJ").put("observedAt", time.get()); + ObjectNode equipment = object().put("id", "equipment-a").put("participantCode", "p-1") + .put("name", "真实装备").put("code", "E-01").put("state", "ONLINE").put("lastReceivedAt", time.get()); + equipment.set("items", array().add(item("speed", 0))); + ObjectNode context = object().put("id", "trial-1").put("name", "试验").put("roundId", "round-1"); + context.set("equipment", array().add(equipment)); + result.set("contexts", array().add(context)); + return result; + } + + private ObjectNode item(String key, int value) { + return object().put("key", key).put("label", "速度").put("type", "NUMBER").put("value", value) + .put("unit", "m/s").put("receivedAt", time.get()).put("sampleTime", time.get()) + .put("quality", "GOOD").put("sourceName", "采集模块").put("sourceType", "FIXED_MODULE"); + } + + private com.fasterxml.jackson.databind.JsonNode firstItem() { + return store.snapshot().path("contexts").get(0).path("equipment").get(0).path("items").get(0); + } +} diff --git a/src/test/java/com/xtrh/control/service/CoordinatorTest.java b/src/test/java/com/xtrh/control/service/CoordinatorTest.java new file mode 100644 index 0000000..7258462 --- /dev/null +++ b/src/test/java/com/xtrh/control/service/CoordinatorTest.java @@ -0,0 +1,343 @@ +package com.xtrh.control.service; + +import com.xtrh.common.util.Jsons; +import com.xtrh.common.dao.StateStore; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +import static com.xtrh.common.util.Jsons.*; +import static org.junit.jupiter.api.Assertions.*; + +class CoordinatorTest { + private static final String CLUSTER = "test-cluster"; + private static final String NODE_A = "node-aaaa"; + private static final String NODE_B = "node-bbbb"; + private final AtomicLong time = new AtomicLong(100_000); + private final Map sessions = new HashMap<>(); + private final Map boots = new HashMap<>(); + private CoordinatorService coordinator; + + @TempDir Path directory; + + @BeforeEach void setup() throws Exception { + coordinator = new CoordinatorService(new StateStore("jdbc:h2:mem:" + UUID.randomUUID(), new ObjectMapper()), time::get, 8_000, CLUSTER); + } + + @AfterEach void close() throws Exception { if (coordinator != null) coordinator.close(); } + + @Test void reservesCapacityBeforeAWorkerReportsReadyAndQueuesExcessTasks() { + register(NODE_A, 2); + coordinator.context(context(5, "RUNNING")); + JsonNode snapshot = coordinator.snapshot(); + assertEquals(2, snapshot.path("nodes").get(0).path("reserved").asInt()); + assertEquals(3, snapshot.path("summary").path("queued").asInt()); + assertEquals(0, snapshot.path("summary").path("active").asInt()); + + activate(NODE_A); + snapshot = coordinator.snapshot(); + assertEquals(2, snapshot.path("summary").path("active").asInt()); + assertEquals(3, snapshot.path("summary").path("queued").asInt()); + assertEquals(2, heartbeat(NODE_A, array()).path("tasks").size()); + + // 同一启动实例重复注册不能产生新节点,也不能覆盖 PXHJ 设置的容量。 + register(NODE_A, 64); + assertEquals(1, coordinator.snapshot().path("nodes").size()); + assertEquals(2, coordinator.snapshot().path("nodes").get(0).path("capacity").asInt()); + } + + @Test void migrationKeepsOldPlaybackUntilDestinationIsActuallyReady() { + register(NODE_A, 2); + register(NODE_B, 0); + coordinator.context(context(2, "RUNNING")); + activate(NODE_A); + Map initialUrls = playbackUrls(); + + coordinator.capacity(NODE_B, object().put("capacity", 2)); + coordinator.capacity(NODE_A, object().put("capacity", 0)); + JsonNode migrating = coordinator.snapshot(); + assertEquals(2, migrating.path("summary").path("active").asInt()); + for (JsonNode task : migrating.path("tasks")) { + assertEquals("MIGRATING", task.path("status").asText()); + assertEquals(NODE_A, task.path("nodeId").asText()); + assertEquals(initialUrls.get(task.path("taskId").asText()), task.path("playUrl").asText()); + } + assertEquals(2, heartbeat(NODE_A, array()).path("tasks").size()); + assertEquals(2, heartbeat(NODE_B, array()).path("tasks").size()); + + activate(NODE_B); + assertEquals(0, heartbeat(NODE_A, array()).path("tasks").size()); + for (JsonNode task : coordinator.snapshot().path("tasks")) { + assertEquals("RUNNING", task.path("status").asText()); + assertEquals(NODE_B, task.path("nodeId").asText()); + assertNotEquals(initialUrls.get(task.path("taskId").asText()), task.path("playUrl").asText()); + } + } + + @Test void loweringCapacityWithoutSpareCapacityPreservesExistingOutput() { + register(NODE_A, 2); + coordinator.context(context(2, "RUNNING")); + activate(NODE_A); + Map urls = playbackUrls(); + + coordinator.capacity(NODE_A, object().put("capacity", 0)); + assertEquals(urls, playbackUrls()); + assertTrue(coordinator.snapshot().path("nodes").get(0).path("adjusting").asBoolean()); + for (JsonNode task : coordinator.snapshot().path("tasks")) + assertEquals("CAPACITY_WAIT_KEEP_RUNNING", task.path("reason").asText()); + } + + @Test void destinationFailureDuringMigrationKeepsSourceUntilAnotherDestinationIsReady() { + register(NODE_A, 1); + register(NODE_B, 0); + coordinator.context(context(1, "RUNNING")); + activate(NODE_A); + Map original = playbackUrls(); + coordinator.capacity(NODE_B, object().put("capacity", 1)); + coordinator.capacity(NODE_A, object().put("capacity", 0)); + assertEquals("MIGRATING", coordinator.snapshot().path("tasks").get(0).path("status").asText()); + + time.addAndGet(8_001); + heartbeat(NODE_A, array()); + assertEquals(original, playbackUrls()); + assertEquals("CAPACITY_WAIT_KEEP_RUNNING", coordinator.snapshot().path("tasks").get(0).path("reason").asText()); + assertEquals("RUNNING", coordinator.snapshot().path("tasks").get(0).path("status").asText()); + + register(NODE_B, 1); + assertEquals(original, playbackUrls()); + activate(NODE_B); + assertEquals(NODE_B, coordinator.snapshot().path("tasks").get(0).path("nodeId").asText()); + } + + @Test void staleSessionsAndTaskVersionsCannotPublishOldPlayback() { + register(NODE_A, 1); + coordinator.context(context(1, "RUNNING")); + JsonNode originalPlan = heartbeat(NODE_A, array()).path("tasks").get(0); + String oldSession = sessions.get(NODE_A), oldBoot = boots.get(NODE_A); + + boots.put(NODE_A, "new-boot"); + register(NODE_A, 1); + assertThrows(IllegalStateException.class, () -> coordinator.heartbeat(NODE_A, + object().put("sessionId", oldSession).put("bootId", oldBoot).set("reports", array()))); + + // 新会话中迟到的旧任务回执也不能把旧版本变成可播放任务。 + heartbeat(NODE_A, reports(originalPlan, true)); + JsonNode task = coordinator.snapshot().path("tasks").get(0); + assertEquals("PREPARING", task.path("status").asText()); + assertFalse(task.has("playUrl")); + activate(NODE_A); + assertTrue(coordinator.snapshot().path("tasks").get(0).path("version").asLong() + > originalPlan.path("assignment").path("version").asLong()); + } + + @Test void endingTheAuthoritativeRoundRevokesTasksButKeepsUdpRoutes() { + register(NODE_A, 2); + ObjectNode configuredRoutes = object(); + configuredRoutes.set("routes", array().add(object().put("endpointId", "collector-a") + .put("sourceHost", "127.0.0.1").put("sourcePort", 35000).put("relayPort", 35001) + .put("targetHost", "127.0.0.1").put("targetPort", 19001))); + coordinator.routes(configuredRoutes); + coordinator.context(context(2, "RUNNING")); + activate(NODE_A); + JsonNode before = coordinator.snapshot(); + + coordinator.context(context(2, "SAVED")); + JsonNode ended = coordinator.snapshot(); + assertEquals(before.path("routes"), ended.path("routes")); + assertEquals(0, heartbeat(NODE_A, array()).path("tasks").size()); + assertEquals(0, ended.path("summary").path("active").asInt()); + for (JsonNode task : ended.path("tasks")) { + assertEquals("ENDED", task.path("status").asText()); + assertFalse(task.has("playUrl")); + } + } + + @Test void nodeLeaseExpiryStopsAdvertisingStaleMedia() { + register(NODE_A, 1); + coordinator.context(context(1, "RUNNING")); + activate(NODE_A); + time.addAndGet(8_001); + coordinator.tick(); + JsonNode snapshot = coordinator.snapshot(); + assertFalse(snapshot.path("nodes").get(0).path("online").asBoolean()); + assertEquals(0, snapshot.path("summary").path("active").asInt()); + assertFalse(snapshot.path("tasks").get(0).has("playUrl")); + } + + @Test void wallClockMovingBackwardsDoesNotExtendDisconnectedNodeLease() throws Exception { + coordinator.close(); + AtomicLong monotonic = new AtomicLong(10_000); + coordinator = new CoordinatorService(new StateStore("jdbc:h2:mem:" + UUID.randomUUID(), new ObjectMapper()), + time::get, monotonic::get, 8_000, CLUSTER); + register(NODE_A, 1); + coordinator.context(context(1, "RUNNING")); + activate(NODE_A); + time.addAndGet(-600_000); + monotonic.addAndGet(8_001); + coordinator.tick(); + assertFalse(coordinator.snapshot().path("nodes").get(0).path("online").asBoolean()); + assertTrue(playbackUrls().isEmpty()); + } + + @Test void freshCoordinatorDoesNotTreatEmptyRoutesAsAnAuthoritativeClearCommand() { + ObjectNode heartbeat = object().put("clusterId", CLUSTER).put("gatewayId", "gateway-test"); + assertFalse(coordinator.gateway(heartbeat).path("routesConfirmed").asBoolean()); + ObjectNode clear = object(); + clear.putArray("routes"); + coordinator.routes(clear); + assertTrue(coordinator.gateway(heartbeat).path("routesConfirmed").asBoolean()); + } + + @Test void restartPersistsCapacityButFencesOldSessionsAndRequiresPxhjReconfirmation() throws Exception { + coordinator.close(); + String url = "jdbc:h2:file:" + directory.resolve("restart").toString().replace('\\', '/') + ";DB_CLOSE_ON_EXIT=FALSE"; + coordinator = new CoordinatorService(new StateStore(url, new ObjectMapper()), time::get, 8_000, CLUSTER); + register(NODE_A, 3); + coordinator.capacity(NODE_A, object().put("capacity", 2)); + coordinator.context(context(2, "RUNNING")); + activate(NODE_A); + JsonNode oldPlan = heartbeat(NODE_A, array()).path("tasks").get(0); + String oldSession = sessions.get(NODE_A), boot = boots.get(NODE_A); + long oldEpoch = coordinator.info().path("epoch").asLong(); + coordinator.close(); + + coordinator = new CoordinatorService(new StateStore(url, new ObjectMapper()), time::get, 8_000, CLUSTER); + assertEquals(oldEpoch + 1, coordinator.info().path("epoch").asLong()); + assertEquals(2, coordinator.snapshot().path("nodes").get(0).path("capacity").asInt()); + assertFalse(coordinator.snapshot().path("contexts").get(0).path("confirmed").asBoolean()); + assertThrows(IllegalStateException.class, () -> coordinator.heartbeat(NODE_A, + object().put("sessionId", oldSession).put("bootId", boot).set("reports", reports(oldPlan, true)))); + + register(NODE_A, 10); + assertEquals(0, heartbeat(NODE_A, reports(oldPlan, true)).path("tasks").size()); + assertTrue(playbackUrls().isEmpty()); + coordinator.context(context(2, "RUNNING")); + activate(NODE_A); + assertEquals(2, playbackUrls().size()); + assertEquals(2, coordinator.snapshot().path("nodes").get(0).path("capacity").asInt()); + } + + @Test void preparationImagesUseConfiguredCapacityAndCredentialsNeverEnterPublicOrStoredState() throws Exception { + coordinator.close(); + StateStore store=new StateStore("jdbc:h2:mem:"+UUID.randomUUID(),new ObjectMapper()); + coordinator=new CoordinatorService(store,time::get,8_000,CLUSTER); + registerImageNode(NODE_A,1,19101); + ObjectNode source=imageSource(UUID.randomUUID().toString()); + assertEquals("WAITING",coordinator.imageSource(source).path("status").asText()); + JsonNode plan=heartbeat(NODE_A,array()).path("tasks").get(0); + assertEquals("ORIGINAL_IMAGE",plan.path("mediaKind").asText()); + assertEquals(source.path("sm4Key"),plan.path("imageAccess").path("sm4Key")); + // 没有任何正式试验上下文,也能先准备原图入口;未收到首帧不会伪造图像就绪。 + heartbeat(NODE_A,reports(plan,true)); + JsonNode route=coordinator.imageSource(source); + assertEquals("READY",route.path("status").asText()); + assertEquals(19101,route.path("targets").get(0).path("port").asInt()); + assertFalse(coordinator.snapshot().path("tasks").get(0).path("imageReady").asBoolean()); + assertEquals("MJPEG_ORIGINAL",coordinator.snapshot().path("tasks").get(0).path("playProtocol").asText()); + assertFalse(coordinator.snapshot().toString().contains("sm4Key")); + assertFalse(store.load().toString().contains("hmacKey")); + assertEquals("WAITING",coordinator.imageSource(imageSource(UUID.randomUUID().toString())).path("status").asText()); + assertEquals(1,coordinator.snapshot().path("summary").path("queued").asInt()); + } + + @Test void imageMigrationBrieflyPublishesTwoTargetsAndKeepsPreparationReadingOldNodeUntilFirstFrame() { + registerImageNode(NODE_A,1,19101);registerImageNode(NODE_B,0,19102); + ObjectNode source=imageSource(UUID.randomUUID().toString()); + coordinator.imageSource(source);activate(NODE_A); + coordinator.capacity(NODE_B,object().put("capacity",1)); + coordinator.capacity(NODE_A,object().put("capacity",0)); + JsonNode target=heartbeat(NODE_B,array()).path("tasks").get(0); + heartbeat(NODE_B,reports(target,true)); + JsonNode route=coordinator.imageSource(source); + assertEquals(2,route.path("targets").size()); + ObjectNode query=object();query.putArray("endpointIds").add(source.path("endpointId").asText()); + assertEquals("http://127.0.0.1:18101",coordinator.imageRoutes(query).path("routes").get(0).path("nodeBaseUrl").asText()); + // 接收端口就绪不代表原图已经送达;新节点拿到完整帧后才成为准备页面的来源。 + ArrayNode firstFrame=reports(target,true);((ObjectNode)firstFrame.get(0)).put("imageReady",true).put("lastFrameTime",time.get()); + heartbeat(NODE_B,firstFrame); + route=coordinator.imageSource(source); + assertEquals(1,route.path("targets").size());assertEquals(19102,route.path("targets").get(0).path("port").asInt()); + assertEquals("http://127.0.0.1:18102",coordinator.imageRoutes(query).path("routes").get(0).path("nodeBaseUrl").asText()); + assertEquals(0,heartbeat(NODE_A,array()).path("tasks").size()); + } + + @Test void disconnectedSourceReleasesCapacityAndResumingCannotReuseAnOldRouteVersion() { + registerImageNode(NODE_A,1,19101); + ObjectNode source=imageSource(UUID.randomUUID().toString()); + coordinator.imageSource(source);activate(NODE_A); + long old=coordinator.imageSource(source).path("version").asLong(); + time.addAndGet(30_001);registerImageNode(NODE_A,1,19101);coordinator.tick(); + assertTrue(coordinator.snapshot().path("tasks").isEmpty()); + assertEquals(0,heartbeat(NODE_A,array()).path("tasks").size()); + JsonNode resumed=coordinator.imageSource(source);assertTrue(resumed.path("version").asLong()>old); + assertEquals("WAITING",resumed.path("status").asText()); + } + + private ObjectNode imageSource(String endpoint){ + return object().put("endpointId",endpoint).put("name","采集模块") + .put("sourceHost","127.0.0.1").put("collectorId",3) + .put("sm4Key",java.util.Base64.getEncoder().encodeToString(new byte[16])) + .put("hmacKey",java.util.Base64.getEncoder().encodeToString(new byte[32])); + } + + private void registerImageNode(String id,int capacity,int imagePort){ + String boot=boots.computeIfAbsent(id,k->UUID.randomUUID().toString()); + JsonNode result=coordinator.register(object().put("clusterId",CLUSTER).put("nodeId",id) + .put("bootId",boot).put("name",id).put("baseUrl","http://127.0.0.1:"+(id.equals(NODE_A)?18101:18102)) + .put("capacity",capacity).put("imageHost","127.0.0.1").put("imageInputPort",imagePort)); + sessions.put(id,result.path("sessionId").asText()); + } + + private void register(String id, int capacity) { + String boot = boots.computeIfAbsent(id, k -> UUID.randomUUID().toString()); + JsonNode result = coordinator.register(object().put("clusterId", CLUSTER).put("nodeId", id) + .put("bootId", boot).put("name", id).put("baseUrl", "http://127.0.0.1:" + (id.equals(NODE_A) ? 18101 : 18102)) + .put("capacity", capacity)); + sessions.put(id, result.path("sessionId").asText()); + } + + private ObjectNode context(int channels, String phase) { + ObjectNode result = object().put("trialId", "trial-real-a").put("roundId", "round-real-a") + .put("name", "真实试验上下文").put("roundStatus", phase).put("configurationVersion", "revision-a"); + ArrayNode list = result.putArray("channels"); + for (int i = 0; i < channels; i++) list.add(object().put("channelId", "channel-" + i) + .put("equipmentId", "equipment-" + i).put("name", "装备视频" + i)); + result.putArray("bindings"); + return result; + } + + private JsonNode heartbeat(String id, ArrayNode reports) { + return coordinator.heartbeat(id, object().put("sessionId", sessions.get(id)).put("bootId", boots.get(id)).set("reports", reports)); + } + + private void activate(String id) { + ArrayNode ready = array(); + for (JsonNode plan : heartbeat(id, array()).path("tasks")) + ready.add(object().put("taskId", plan.path("taskId").asText()).put("version", plan.path("assignment").path("version").asLong()).put("ready", true)); + heartbeat(id, ready); + } + + private ArrayNode reports(JsonNode plan, boolean ready) { + return array().add(object().put("taskId", plan.path("taskId").asText()) + .put("version", plan.path("assignment").path("version").asLong()).put("ready", ready)); + } + + private Map playbackUrls() { + Map result = new HashMap<>(); + for (JsonNode task : coordinator.snapshot().path("tasks")) if (task.has("playUrl")) + result.put(task.path("taskId").asText(), task.path("playUrl").asText()); + return result; + } +} diff --git a/src/test/java/com/xtrh/gateway/service/GatewayTest.java b/src/test/java/com/xtrh/gateway/service/GatewayTest.java new file mode 100644 index 0000000..187a9cb --- /dev/null +++ b/src/test/java/com/xtrh/gateway/service/GatewayTest.java @@ -0,0 +1,230 @@ +package com.xtrh.gateway.service; + +import com.xtrh.discovery.service.CoordinatorLocator; +import com.xtrh.discovery.service.Discovery; +import com.xtrh.common.util.Jsons; +import com.xtrh.communication.client.PeerClient; +import com.xtrh.common.config.Settings; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.env.MockEnvironment; + +import java.net.*; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Random; + +import static com.xtrh.common.util.Jsons.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class GatewayTest { + @TempDir Path directory; + + @Test void confirmedEmptyRoutePlanRevokesOldRouteEvenWhenVersionNumberIsUnchanged() throws Exception { + Settings settings = settings(directory.resolve("same-version-gateway")); + CoordinatorLocator locator = mock(CoordinatorLocator.class); + PeerClient peer = mock(PeerClient.class); + when(locator.locate()).thenReturn("http://127.0.0.1:18100"); + try (DatagramSocket collector = loopbackSocket(); DatagramSocket pxhj = loopbackSocket()) { + when(peer.request(anyString(), anyString(), anyString(), any())) + .thenReturn(routes(collector.getLocalPort(), freePort(), pxhj.getLocalPort())); + GatewayService gateway = new GatewayService(settings, locator, peer, new ObjectMapper()); + try { + gateway.heartbeat(); + int ingressPort = gateway.status().path("inputPort").asInt(); + byte[] bytes = opaqueBytes(64); + collector.send(new DatagramPacket(bytes, bytes.length, InetAddress.getLoopbackAddress(), ingressPort)); + assertArrayEquals(bytes, payload(receive(pxhj))); + + // 总控数据库重新初始化后可能复用旧版本号;明确确认的路由内容仍然必须生效。 + ObjectNode confirmedClear = object().put("routeVersion", 1).put("routesConfirmed", true); + confirmedClear.putArray("routes"); + when(peer.request(anyString(), anyString(), anyString(), any())).thenReturn(confirmedClear); + gateway.heartbeat(); + assertEquals(1, gateway.status().path("routeVersion").asLong()); + assertEquals(0, gateway.status().path("routeStatus").size()); + collector.send(new DatagramPacket(bytes, bytes.length, InetAddress.getLoopbackAddress(), ingressPort)); + pxhj.setSoTimeout(250); + assertThrows(SocketTimeoutException.class, () -> receive(pxhj)); + assertEquals(1, gateway.status().path("unknownSource").asLong()); + } finally { gateway.close(); } + } + } + + @Test void forwardsOpaqueDatagramBytesAndReverseRepliesWithoutChangingSourceIdentity() throws Exception { + Settings settings = settings(directory.resolve("gateway")); + CoordinatorLocator locator = mock(CoordinatorLocator.class); + PeerClient peer = mock(PeerClient.class); + when(locator.locate()).thenReturn("http://127.0.0.1:18100"); + try (DatagramSocket collector = loopbackSocket(); DatagramSocket pxhj = loopbackSocket()) { + int relayPort = freePort(); + ObjectNode plan = routes(collector.getLocalPort(), relayPort, pxhj.getLocalPort()); + when(peer.request(anyString(), eq("/control/gateways/heartbeat"), eq("POST"), any())).thenReturn(plan); + try (AutoCloseableGateway managed = new AutoCloseableGateway(settings, locator, peer, new ObjectMapper())) { + GatewayService gateway = managed.gateway; + gateway.heartbeat(); + assertEquals("APPLIED", gateway.status().path("routeState").asText()); + int ingressPort = gateway.status().path("inputPort").asInt(); + + // 网关不能依赖报文文本或专有标记;使用包含零字节和高位字节的任意密文验证透明转发。 + for (int length : new int[]{1, 32, 512, 4096, 60000}) { + byte[] encryptedBytes = opaqueBytes(length); + collector.send(new DatagramPacket(encryptedBytes, encryptedBytes.length, InetAddress.getLoopbackAddress(), ingressPort)); + DatagramPacket received = receive(pxhj); + assertArrayEquals(encryptedBytes, payload(received)); + assertEquals(relayPort, received.getPort()); + + byte[] reply = opaqueBytes(47); + pxhj.send(new DatagramPacket(reply, reply.length, received.getSocketAddress())); + DatagramPacket returned = receive(collector); + assertArrayEquals(reply, payload(returned)); + assertEquals(ingressPort, returned.getPort()); + } + assertEquals(5, gateway.status().path("forwarded").asLong()); + + // 总控失联时,已确认路由仍然继续转发,不影响 PXHJ 采集数据显示。 + when(locator.locate()).thenThrow(new IllegalStateException("coordinator offline")); + gateway.heartbeat(); + byte[] afterDisconnect = opaqueBytes(77); + collector.send(new DatagramPacket(afterDisconnect, afterDisconnect.length, InetAddress.getLoopbackAddress(), ingressPort)); + assertArrayEquals(afterDisconnect, payload(receive(pxhj))); + } + } + } + + @Test void unknownSourcesAreDroppedAndLastConfirmedRoutesSurviveRestart() throws Exception { + Settings settings = settings(directory.resolve("persisted-gateway")); + CoordinatorLocator locator = mock(CoordinatorLocator.class); + PeerClient peer = mock(PeerClient.class); + when(locator.locate()).thenReturn("http://127.0.0.1:18100"); + try (DatagramSocket collector = loopbackSocket(); DatagramSocket unknown = loopbackSocket(); DatagramSocket pxhj = loopbackSocket()) { + when(peer.request(anyString(), anyString(), anyString(), any())).thenReturn(routes(collector.getLocalPort(), freePort(), pxhj.getLocalPort())); + GatewayService gateway = new GatewayService(settings, locator, peer, new ObjectMapper()); + try { + gateway.heartbeat(); + byte[] bytes = opaqueBytes(96); + unknown.send(new DatagramPacket(bytes, bytes.length, InetAddress.getLoopbackAddress(), gateway.status().path("inputPort").asInt())); + pxhj.setSoTimeout(250); + assertThrows(SocketTimeoutException.class, () -> receive(pxhj)); + assertEquals(1, gateway.status().path("unknownSource").asLong()); + } finally { gateway.close(); } + + gateway = new GatewayService(settings, locator, peer, new ObjectMapper()); + try { + assertEquals("APPLIED", gateway.status().path("routeState").asText()); + ObjectNode unconfirmed = object().put("routeVersion", 0).put("routesConfirmed", false); + unconfirmed.putArray("routes"); + when(peer.request(anyString(), anyString(), anyString(), any())).thenReturn(unconfirmed); + gateway.heartbeat(); + assertEquals(1, gateway.status().path("routeVersion").asLong()); + pxhj.setSoTimeout(3000); + byte[] bytes = opaqueBytes(160); + collector.send(new DatagramPacket(bytes, bytes.length, InetAddress.getLoopbackAddress(), gateway.status().path("inputPort").asInt())); + assertArrayEquals(bytes, payload(receive(pxhj))); + } finally { gateway.close(); } + } + } + + @Test void imageNegotiationRepliesUseTheirOwnSocketWithoutChangingLegacyReplyDestination() throws Exception { + Settings settings = settings(directory.resolve("image-negotiation-gateway")); + CoordinatorLocator locator = mock(CoordinatorLocator.class); + PeerClient peer = mock(PeerClient.class); + when(locator.locate()).thenReturn("http://127.0.0.1:18100"); + try (DatagramSocket negotiation = loopbackSocket(); DatagramSocket data = loopbackSocket(); + DatagramSocket heartbeat = loopbackSocket(); DatagramSocket pxhj = loopbackSocket()) { + // 同一采集器允许动态源端口,协商与采集上传必须各自记住回程地址。 + when(peer.request(anyString(), anyString(), anyString(), any())) + .thenReturn(routes(0, freePort(), pxhj.getLocalPort())); + GatewayService gateway = new GatewayService(settings, locator, peer, new ObjectMapper()); + try { + gateway.heartbeat(); + InetSocketAddress ingress = new InetSocketAddress(InetAddress.getLoopbackAddress(), + gateway.status().path("inputPort").asInt()); + byte[] hello = typedBytes(0x14), sample = typedBytes(0x11); + negotiation.send(new DatagramPacket(hello, hello.length, ingress)); + DatagramPacket upstream = receive(pxhj); + assertArrayEquals(hello, payload(upstream)); + data.send(new DatagramPacket(sample, sample.length, ingress)); + assertArrayEquals(sample, payload(receive(pxhj))); + + byte[] answer = typedBytes(0x15); + pxhj.send(new DatagramPacket(answer, answer.length, upstream.getSocketAddress())); + DatagramPacket returned = receive(negotiation); + assertArrayEquals(answer, payload(returned)); + assertEquals(ingress.getPort(), returned.getPort()); + data.setSoTimeout(100); + assertThrows(SocketTimeoutException.class, () -> receive(data)); + + byte[] legacyReply = typedBytes(0x81); + pxhj.send(new DatagramPacket(legacyReply, legacyReply.length, upstream.getSocketAddress())); + data.setSoTimeout(3000); + assertArrayEquals(legacyReply, payload(receive(data))); + + // 非 FND 的普通二进制回执即使第8字节为0x15,也不能误回协商端口。 + byte[] rawReply = opaqueBytes(128); + rawReply[0] = 0; rawReply[8] = (byte) 0x15; + pxhj.send(new DatagramPacket(rawReply, rawReply.length, upstream.getSocketAddress())); + assertArrayEquals(rawReply, payload(receive(data))); + negotiation.setSoTimeout(100); + assertThrows(SocketTimeoutException.class, () -> receive(negotiation)); + negotiation.setSoTimeout(3000); + + // 心跳更换临时源端口后,图片回复仍回最初的协商 socket。 + byte[] pulse = typedBytes(0x13); + heartbeat.send(new DatagramPacket(pulse, pulse.length, ingress)); + assertArrayEquals(pulse, payload(receive(pxhj))); + pxhj.send(new DatagramPacket(answer, answer.length, upstream.getSocketAddress())); + assertArrayEquals(answer, payload(receive(negotiation))); + pxhj.send(new DatagramPacket(legacyReply, legacyReply.length, upstream.getSocketAddress())); + assertArrayEquals(legacyReply, payload(receive(heartbeat))); + } finally { gateway.close(); } + } + } + + private Settings settings(Path data) throws Exception { + return new Settings(new MockEnvironment().withProperty("fusion.role", "gateway") + .withProperty("fusion.token", "unit-test-credential-not-deployed") + .withProperty("fusion.data-dir", data.toString()).withProperty("fusion.gateway-port", "0") + .withProperty("fusion.advertised-host", "127.0.0.1").withProperty("fusion.discovery-enabled", "false")); + } + + private ObjectNode routes(int source, int relay, int target) { + ObjectNode plan = object().put("routeVersion", 1).put("routesConfirmed", true); + plan.set("routes", array().add(object().put("endpointId", "collector-a").put("sourceHost", "127.0.0.1") + .put("sourcePort", source).put("relayPort", relay).put("targetHost", "127.0.0.1").put("targetPort", target))); + return plan; + } + + private DatagramSocket loopbackSocket() throws Exception { + DatagramSocket socket = new DatagramSocket(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); + socket.setSoTimeout(3000); + return socket; + } + + private int freePort() throws Exception { try (DatagramSocket socket = loopbackSocket()) { return socket.getLocalPort(); } } + private DatagramPacket receive(DatagramSocket socket) throws Exception { + DatagramPacket packet = new DatagramPacket(new byte[65507], 65507); socket.receive(packet); return packet; + } + private byte[] payload(DatagramPacket packet) { return Arrays.copyOfRange(packet.getData(), packet.getOffset(), packet.getOffset() + packet.getLength()); } + private byte[] opaqueBytes(int length) { byte[] bytes = new byte[length]; new Random(length).nextBytes(bytes); return bytes; } + private byte[] typedBytes(int type) { + byte[] bytes = opaqueBytes(109); bytes[8] = (byte) type; + ByteBuffer.wrap(bytes).putInt(0, 0x5AA5FAFB).putInt(4, bytes.length) + .putInt(bytes.length - 4, 0xA55AFCFD); + return bytes; + } + + private static final class AutoCloseableGateway implements AutoCloseable { + final GatewayService gateway; + AutoCloseableGateway(Settings settings, CoordinatorLocator locator, PeerClient peer, ObjectMapper mapper) throws Exception { + gateway = new GatewayService(settings, locator, peer, mapper); + } + @Override public void close() throws Exception { gateway.close(); } + } +} diff --git a/src/test/java/com/xtrh/node/config/WorkerCapabilityTest.java b/src/test/java/com/xtrh/node/config/WorkerCapabilityTest.java new file mode 100644 index 0000000..efbd3ae --- /dev/null +++ b/src/test/java/com/xtrh/node/config/WorkerCapabilityTest.java @@ -0,0 +1,86 @@ +package com.xtrh.node.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xtrh.common.config.Settings; +import com.xtrh.communication.client.PeerClient; +import com.xtrh.control.controller.CoordinatorController; +import com.xtrh.control.service.CoordinatorService; +import com.xtrh.discovery.service.CoordinatorLocator; +import com.xtrh.node.controller.WorkerController; +import com.xtrh.node.service.WorkerService; +import com.xtrh.node.service.ImageIngressService; +import com.xtrh.node.controller.NodeImageController; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class WorkerCapabilityTest { + @TempDir Path directory; + + @Test void coordinatorEnablesExecutionByDefaultInTheSameContext() { + runner("coordinator").run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(CoordinatorService.class) + .hasSingleBean(CoordinatorController.class).hasSingleBean(WorkerService.class) + .hasSingleBean(WorkerController.class).hasSingleBean(AsyncMediaConfiguration.class); + assertThat(context.getBean(Settings.class).supportsWorker).isTrue(); + }); + } + + @Test void coordinatorOnlyRetainsControlAndDoesNotExposeWorkerController() { + runner("coordinator").withPropertyValues("fusion.coordinator-worker-enabled=false") + .run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(CoordinatorService.class) + .hasSingleBean(CoordinatorController.class).doesNotHaveBean(WorkerService.class) + .doesNotHaveBean(WorkerController.class).doesNotHaveBean(AsyncMediaConfiguration.class); + assertThat(context.getBean(Settings.class).supportsWorker).isFalse(); + assertThat(context.getBean(CoordinatorService.class).info().path("role").asText()) + .isEqualTo("coordinator"); + }); + } + + @Test void standaloneWorkerIsIndependentOfTheCoordinatorExecutionSwitch() { + runner("worker").withPropertyValues("fusion.coordinator-worker-enabled=false") + .run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(WorkerService.class) + .hasSingleBean(WorkerController.class).doesNotHaveBean(CoordinatorService.class) + .doesNotHaveBean(CoordinatorController.class).hasSingleBean(AsyncMediaConfiguration.class); + assertThat(context.getBean(Settings.class).supportsWorker).isTrue(); + }); + } + + @Test void gatewayNeverStartsAnExecutionService() { + runner("gateway").withPropertyValues("fusion.coordinator-worker-enabled=true") + .run(context -> { + assertThat(context).hasNotFailed().doesNotHaveBean(WorkerService.class) + .doesNotHaveBean(WorkerController.class).doesNotHaveBean(CoordinatorService.class) + .doesNotHaveBean(AsyncMediaConfiguration.class); + assertThat(context.getBean(Settings.class).supportsWorker).isFalse(); + }); + } + + private ApplicationContextRunner runner(String role) { + // 条件测试不启用调度和网络;真实自注册与播放由随机端口集成测试覆盖。 + return new ApplicationContextRunner().withUserConfiguration(Infrastructure.class, + CoordinatorService.class, CoordinatorController.class, WorkerService.class, + WorkerController.class, NodeImageController.class, ImageIngressService.class, AsyncMediaConfiguration.class) + .withPropertyValues("fusion.role=" + role, "fusion.discovery-enabled=false", + "fusion.token=capability-test-credential-not-deployed", "server.port=0", "fusion.image-port=0", + "fusion.data-dir=" + directory.resolve(role)); + } + + @TestConfiguration(proxyBeanMethods = false) + static class Infrastructure { + @Bean Settings settings(Environment environment) throws Exception { return new Settings(environment); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + @Bean CoordinatorLocator coordinatorLocator() { return mock(CoordinatorLocator.class); } + @Bean PeerClient peerClient() { return mock(PeerClient.class); } + } +} diff --git a/src/test/java/com/xtrh/node/service/CoordinatorWorkerIntegrationTest.java b/src/test/java/com/xtrh/node/service/CoordinatorWorkerIntegrationTest.java new file mode 100644 index 0000000..c8463e8 --- /dev/null +++ b/src/test/java/com/xtrh/node/service/CoordinatorWorkerIntegrationTest.java @@ -0,0 +1,147 @@ +package com.xtrh.node.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.xtrh.common.config.Settings; +import com.xtrh.communication.client.PeerClient; +import com.xtrh.node.controller.WorkerController; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.ApplicationContext; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import javax.imageio.ImageIO; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +import static com.xtrh.common.util.Jsons.object; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "fusion.role=coordinator", "fusion.capacity=1", "fusion.discovery-enabled=false", + "fusion.advertised-host=127.0.0.1", "fusion.token=combined-master-test-credential-only", "fusion.image-port=0" +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class CoordinatorWorkerIntegrationTest { + private static final Path DATA_DIRECTORY = temporaryDirectory(); + @LocalServerPort int port; + @Autowired Settings settings; + @Autowired PeerClient peer; + @Autowired ApplicationContext application; + + @DynamicPropertySource static void storage(DynamicPropertyRegistry registry) { + registry.add("fusion.data-dir", DATA_DIRECTORY::toString); + } + + @Test + @Timeout(45) + void masterRegistersOneExecutionIdentityAndServesItsOwnAssignedJpegOnTheMasterPort() throws Exception { + assertTrue(settings.supportsWorker); + assertTrue(application.containsBean("workerService")); + assertNotNull(application.getBean(WorkerController.class)); + assertEquals(port, settings.port, "媒体登记必须使用随机分配的实际 HTTP 端口"); + String base = "http://127.0.0.1:" + port; + + JsonNode initial = waitFor(base, snapshot -> snapshot.path("nodes").size() == 1 + && snapshot.path("nodes").get(0).path("online").asBoolean()); + JsonNode node = initial.path("nodes").get(0); + assertEquals(settings.id, node.path("nodeId").asText()); + assertEquals("COORDINATOR", node.path("nodeRole").asText()); + assertEquals(port, URI.create(node.path("baseUrl").asText()).getPort()); + assertEquals(settings.id, peer.request(base, "/control/info", "GET", null).path("serviceId").asText()); + + // 主身份不能伪装成普通节点,也不能由另一个身份额外登记第二个主执行节点。 + ObjectNode registration = object().put("clusterId", settings.cluster).put("bootId", settings.bootId) + .put("name", "非法主身份验证").put("baseUrl", base).put("capacity", 1); + registration.put("nodeId", settings.id).put("nodeRole", "WORKER"); + assertThrows(IllegalStateException.class, () -> peer.request(base, "/control/nodes/register", "POST", registration)); + registration.put("nodeId", "another-main-identity").put("nodeRole", "COORDINATOR"); + assertThrows(IllegalStateException.class, () -> peer.request(base, "/control/nodes/register", "POST", registration)); + assertEquals(1, peer.request(base, "/control/snapshot", "GET", null).path("nodes").size()); + + // 使用隔离总控的正式上下文接口;不访问正在运行的 PXHJ、采集器或其他节点。 + ObjectNode context = object().put("trialId", "combined-test-trial").put("roundId", "combined-test-round") + .put("name", "主进程执行验证").put("roundStatus", "PENDING").put("configurationVersion", "1"); + context.putArray("channels").add(object().put("channelId", "combined-test-channel") + .put("equipmentId", "combined-test-equipment").put("name", "主节点演示画面")); + context.putArray("bindings"); + peer.request(base, "/control/contexts/sync", "POST", context); + JsonNode ready = waitFor(base, snapshot -> snapshot.path("tasks").size() == 1 + && snapshot.path("tasks").get(0).path("status").asText().equals("READY") + && snapshot.path("tasks").get(0).hasNonNull("playUrl")); + JsonNode task = ready.path("tasks").get(0); + assertEquals(1, ready.path("nodes").size(), "主控制与执行必须共用一个节点身份"); + assertEquals(settings.id, task.path("nodeId").asText()); + assertEquals(1, ready.path("nodes").get(0).path("running").asInt()); + assertEquals(port, URI.create(task.path("playUrl").asText()).getPort()); + assertActualJpeg(task.path("playUrl").asText()); + + context.put("roundStatus", "SAVED"); + peer.request(base, "/control/contexts/sync", "POST", context); + JsonNode ended = waitFor(base, snapshot -> snapshot.path("tasks").get(0).path("status").asText().equals("ENDED")); + assertFalse(ended.path("tasks").get(0).has("playUrl")); + } + + private JsonNode waitFor(String base, Predicate expected) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while (System.nanoTime() < deadline) { + JsonNode snapshot = peer.request(base, "/control/snapshot", "GET", null); + if (expected.test(snapshot)) return snapshot; + Thread.sleep(100); + } + throw new AssertionError("隔离主节点自动注册或任务准备超时"); + } + + private void assertActualJpeg(String url) throws Exception { + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + HttpResponse response = client.send(HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(3)).GET().build(), HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream input = response.body()) { + assertEquals(200, response.statusCode()); + assertTrue(response.headers().firstValue("Content-Type").orElse("").startsWith("multipart/x-mixed-replace")); + assertEquals("--frame", line(input)); + int length = -1; + for (String header = line(input); !header.isEmpty(); header = line(input)) { + if (header.toLowerCase(java.util.Locale.ROOT).startsWith("content-length:")) + length = Integer.parseInt(header.substring(header.indexOf(':') + 1).trim()); + } + assertTrue(length > 4 && length < 500_000); + byte[] jpeg = input.readNBytes(length); + assertEquals(length, jpeg.length); + assertNotNull(ImageIO.read(new ByteArrayInputStream(jpeg)), "实际 HTTP 帧必须能解码为图片"); + } + } + + private static String line(InputStream input) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + for (int count = 0; count < 1024; count++) { + int value = input.read(); + if (value < 0) throw new IOException("媒体流提前结束"); + if (value == '\n') return bytes.toString(StandardCharsets.US_ASCII).trim(); + bytes.write(value); + } + throw new IOException("媒体分段头超过限定长度"); + } + + private static Path temporaryDirectory() { + try { return Files.createTempDirectory("xtrh-combined-master-test-"); } + catch (IOException e) { throw new IllegalStateException("无法创建隔离测试目录", e); } + } +} diff --git a/src/test/java/com/xtrh/node/service/ImageIngressTest.java b/src/test/java/com/xtrh/node/service/ImageIngressTest.java new file mode 100644 index 0000000..9a1127a --- /dev/null +++ b/src/test/java/com/xtrh/node/service/ImageIngressTest.java @@ -0,0 +1,193 @@ +package com.xtrh.node.service; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.xtrh.common.config.Settings; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.env.MockEnvironment; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BooleanSupplier; +import java.util.zip.CRC32; +import static com.xtrh.common.util.Jsons.*; +import static org.junit.jupiter.api.Assertions.*; + +class ImageIngressTest { + @TempDir Path directory; + private static final String TASK = "image-task/1"; + + @Test void existingPxhjEncryptedFragmentsReassembleOutOfOrderAndPublishTheExactJpeg() throws Exception { + try (ImageIngressService ingress = ingress(); DatagramSocket sender = sender("127.0.0.1")) { + permit(ingress); + assertTrue(ingress.ready(TASK), "地址协商不能等待首帧"); + assertFalse(ingress.imageReady(TASK)); + byte[] jpeg = PxhjWireOracle.jpeg(1); + List packets = new ArrayList<>(PxhjWireOracle.chunks(jpeg, 1, System.currentTimeMillis(), 80, 60)); + assertTrue(packets.size() > 1); + Collections.reverse(packets); + packets.add(packets.get(0)); + PxhjWireOracle.send(sender, ingress.inputPort(), packets); + await(() -> ingress.latest(TASK) != null); + ImageIngressService.Frame frame = ingress.latest(TASK); + assertArrayEquals(jpeg, frame.jpeg()); + assertEquals(80, frame.metadata().path("width").asInt()); + assertEquals(jpeg.length, frame.metadata().path("bytes").asInt()); + assertTrue(ingress.imageReady(TASK)); + assertFalse(ingress.status().toString().contains("sm4Key")); + } + } + + @Test void wrongSourceCollectorKeyAndTamperedAuthenticationNeverPublishAFrame() throws Exception { + try (ImageIngressService ingress = ingress(); DatagramSocket sender = sender("127.0.0.1"); DatagramSocket other = sender("127.0.0.2")) { + permit(ingress); + List good = PxhjWireOracle.chunks(PxhjWireOracle.jpeg(2), 2, System.currentTimeMillis(), 80, 60); + PxhjWireOracle.send(other, ingress.inputPort(), good); + await(() -> ingress.status().path("rejected").asLong() >= good.size()); + List collector = mutate(good, 9), cipher = mutate(good, 53); + PxhjWireOracle.send(sender, ingress.inputPort(), collector); + PxhjWireOracle.send(sender, ingress.inputPort(), cipher); + ObjectNode wrong = entry().deepCopy(); + wrong.withObject("imageAccess").put("hmacKey", Base64.getEncoder().encodeToString(new byte[32])); + ingress.applyPlans(array().add(wrong), System.nanoTime() + TimeUnit.SECONDS.toNanos(30)); + PxhjWireOracle.send(sender, ingress.inputPort(), good); + await(() -> ingress.status().path("rejected").asLong() >= good.size() * 4L); + assertNull(ingress.latest(TASK)); + } + } + + @Test void incompleteImageExpiresWithoutBecomingAFrameAndReleasesItsBudget() throws Exception { + AtomicLong nanos = new AtomicLong(System.nanoTime()); + try (ImageIngressService ingress = new ImageIngressService(settings(), 0, System::currentTimeMillis, nanos::get); DatagramSocket sender = sender("127.0.0.1")) { + assertTrue(ingress.ensureStarted()); + ingress.applyPlans(array().add(entry()), nanos.get() + TimeUnit.SECONDS.toNanos(30)); + List chunks = PxhjWireOracle.chunks(PxhjWireOracle.jpeg(3), 3, System.currentTimeMillis(), 80, 60); + PxhjWireOracle.send(sender, ingress.inputPort(), chunks.subList(0, 1)); + await(() -> ingress.status().path("pending").asInt() == 1); + assertNull(ingress.latest(TASK)); + nanos.addAndGet(TimeUnit.SECONDS.toNanos(6)); + assertEquals(0, ingress.status().path("pending").asInt()); + assertEquals(0, ingress.status().path("bufferedBytes").asLong()); + } + } + + @Test void metadataFrameReferenceSurvivesNewFramesUntilTheBoundedThreeFrameCacheEvictsIt() throws Exception { + try (ImageIngressService ingress = ingress(); DatagramSocket sender = sender("127.0.0.1")) { + permit(ingress); + long now = System.currentTimeMillis(); + PxhjWireOracle.send(sender, ingress.inputPort(), PxhjWireOracle.chunks(PxhjWireOracle.jpeg(4), 4, now, 80, 60)); + await(() -> ingress.latest(TASK) != null); + String oldest = ingress.latest(TASK).frameId(); + for (int count = 1; count <= 2; count++) { + String previous = ingress.latest(TASK).frameId(); + PxhjWireOracle.send(sender, ingress.inputPort(), PxhjWireOracle.chunks(PxhjWireOracle.jpeg(4 + count), 4 + count, now + count, 80, 60)); + await(() -> !ingress.latest(TASK).frameId().equals(previous)); + } + assertNotNull(ingress.frame(TASK, oldest)); + PxhjWireOracle.send(sender, ingress.inputPort(), PxhjWireOracle.chunks(PxhjWireOracle.jpeg(7), 7, now + 3, 80, 60)); + await(() -> ingress.frame(TASK, oldest) == null); + } + } + + @Test void RevocationAndMonotonicLeaseExpiryRemoveCachedImagesAndPreventMoreUploads() throws Exception { + AtomicLong nanos = new AtomicLong(System.nanoTime()); + try (ImageIngressService ingress = new ImageIngressService(settings(), 0, System::currentTimeMillis, nanos::get); DatagramSocket sender = sender("127.0.0.1")) { + assertTrue(ingress.ensureStarted()); + ingress.applyPlans(array().add(entry()), nanos.get() + TimeUnit.SECONDS.toNanos(30)); + List chunks = PxhjWireOracle.chunks(PxhjWireOracle.jpeg(8), 8, System.currentTimeMillis(), 80, 60); + PxhjWireOracle.send(sender, ingress.inputPort(), chunks); + await(() -> ingress.latest(TASK) != null); + assertTrue(ingress.imageReady(TASK)); + nanos.addAndGet(TimeUnit.SECONDS.toNanos(6)); + assertFalse(ingress.imageReady(TASK), "缓存存在不表示新鲜原图仍可迁移接管"); + assertNotNull(ingress.latest(TASK), "有效许可内仍可明确取得最后一张真实原图"); + ingress.applyPlans(array(), nanos.get() + TimeUnit.SECONDS.toNanos(30)); + assertNull(ingress.latest(TASK)); + assertFalse(ingress.ready(TASK)); + PxhjWireOracle.send(sender, ingress.inputPort(), chunks); + await(() -> ingress.status().path("rejected").asInt() >= chunks.size()); + ingress.applyPlans(array().add(entry()), nanos.get() + TimeUnit.SECONDS.toNanos(1)); + nanos.addAndGet(TimeUnit.SECONDS.toNanos(2)); + assertFalse(ingress.ready(TASK)); + } + } + + @Test void ImageCrcDimensionsAndCaptureTimeAreValidatedAfterSuccessfulWireAuthentication() throws Exception { + try (ImageIngressService ingress = ingress(); DatagramSocket sender = sender("127.0.0.1")) { + permit(ingress); + byte[] jpeg = PxhjWireOracle.jpeg(9), corrupted = jpeg.clone(); + corrupted[0] = 0; + List badMarker = PxhjWireOracle.chunks(corrupted, 9, System.currentTimeMillis(), 80, 60); + List badDimensions = PxhjWireOracle.chunks(jpeg, 10, System.currentTimeMillis(), 1, 1); + List stale = PxhjWireOracle.chunks(jpeg, 11, System.currentTimeMillis() - 120_000, 80, 60); + List badCrc = PxhjWireOracle.chunks(jpeg, 12, System.currentTimeMillis(), 80, 60, true); + PxhjWireOracle.send(sender, ingress.inputPort(), badMarker); + PxhjWireOracle.send(sender, ingress.inputPort(), badDimensions); + PxhjWireOracle.send(sender, ingress.inputPort(), stale); + PxhjWireOracle.send(sender, ingress.inputPort(), badCrc); + await(() -> ingress.status().path("accepted").asInt() + ingress.status().path("rejected").asInt() + >= badMarker.size() + badDimensions.size() + stale.size() + badCrc.size()); + assertNull(ingress.latest(TASK)); + assertFalse(ingress.imageReady(TASK)); + } + } + + @Test void InvalidSourceAndTruncatedCollectorPermitFailClosedWithoutExposingKeys() throws Exception { + try (ImageIngressService ingress = ingress()) { + for (String source : List.of("", "localhost", "0.0.0.0", "127.0.0.999")) { + ObjectNode entry = entry(); entry.withObject("imageAccess").put("sourceHost", source); + ingress.applyPlans(array().add(entry), System.nanoTime() + TimeUnit.SECONDS.toNanos(30)); + assertFalse(ingress.ready(TASK)); + } + ObjectNode entry = entry(); entry.withObject("imageAccess").put("collectorId", 4294967299L); + ingress.applyPlans(array().add(entry), System.nanoTime() + TimeUnit.SECONDS.toNanos(30)); + assertFalse(ingress.ready(TASK)); + assertEquals("原图接入许可无效", ingress.accessError(TASK)); + assertFalse(ingress.status().toString().contains(Base64.getEncoder().encodeToString(PxhjWireOracle.HMAC))); + } + } + + private ImageIngressService ingress() throws Exception { + ImageIngressService ingress = new ImageIngressService(settings(), 0); + assertTrue(ingress.ensureStarted()); return ingress; + } + private Settings settings() throws Exception { + return new Settings(new MockEnvironment().withProperty("fusion.role", "worker") + .withProperty("fusion.token", "image-test-credential-only").withProperty("server.address", "127.0.0.1") + .withProperty("fusion.data-dir", directory.toString()).withProperty("fusion.discovery-enabled", "false")); + } + private void permit(ImageIngressService ingress) { ingress.applyPlans(array().add(entry()), System.nanoTime() + TimeUnit.SECONDS.toNanos(30)); } + static ObjectNode entry() { + ObjectNode entry = object().put("taskId", "image-task").put("name", "原图").put("trialName", "试验准备") + .put("desired", "READY").put("mediaKind", "ORIGINAL_IMAGE").put("sourceEndpointId", "endpoint-test") + .put("sourceHost", "127.0.0.1").put("collectorId", 3); + entry.set("assignment", object().put("version", 1).put("mediaKey", "image-play-key")); + entry.set("imageAccess", object().put("endpointId", "endpoint-test").put("sourceHost", "127.0.0.1").put("collectorId", 3) + .put("sm4Key", Base64.getEncoder().encodeToString(PxhjWireOracle.SM4)) + .put("hmacKey", Base64.getEncoder().encodeToString(PxhjWireOracle.HMAC))); + return entry; + } + static void await(BooleanSupplier predicate) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { if (predicate.getAsBoolean()) return; Thread.sleep(10); } + fail("原图 UDP 接收超时"); + } + private static DatagramSocket sender(String address) throws Exception { return new DatagramSocket(0, InetAddress.getByName(address)); } + private List mutate(List packets, int offset) { + List result = new ArrayList<>(); + for (byte[] packet : packets) { + byte[] changed = packet.clone(); changed[offset] ^= 1; + CRC32 crc = new CRC32(); crc.update(changed, 0, changed.length - 8); + ByteBuffer.wrap(changed, changed.length - 8, 4).putInt((int) crc.getValue()); + result.add(changed); + } + return result; + } +} diff --git a/src/test/java/com/xtrh/node/service/OriginalImageIntegrationTest.java b/src/test/java/com/xtrh/node/service/OriginalImageIntegrationTest.java new file mode 100644 index 0000000..f36b4b8 --- /dev/null +++ b/src/test/java/com/xtrh/node/service/OriginalImageIntegrationTest.java @@ -0,0 +1,130 @@ +package com.xtrh.node.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.xtrh.common.config.Settings; +import com.xtrh.communication.client.PeerClient; +import com.xtrh.communication.security.Signatures; +import com.xtrh.control.service.CoordinatorService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.ApplicationContext; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import javax.imageio.ImageIO; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.Base64; +import java.util.UUID; + +import static com.xtrh.common.util.Jsons.object; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "fusion.role=coordinator", "fusion.capacity=1", "fusion.discovery-enabled=false", + "fusion.advertised-host=127.0.0.1", "fusion.token=original-image-integration-test-only", "fusion.image-port=0" +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class OriginalImageIntegrationTest { + private static final Path DATA_DIRECTORY = temporaryDirectory(); + @LocalServerPort int port; + @Autowired Settings settings; + @Autowired PeerClient peer; + @Autowired ApplicationContext application; + + @DynamicPropertySource static void storage(DynamicPropertyRegistry registry) { + registry.add("fusion.data-dir", DATA_DIRECTORY::toString); + } + + @Test + @Timeout(45) + void realEncryptedOriginalImageIsAvailableDuringPreparationWithAuthenticatedBinaryHttp() throws Exception { + String base = "http://127.0.0.1:" + port; + waitFor(base, snapshot -> snapshot.path("nodes").size() == 1 && snapshot.path("nodes").get(0).path("online").asBoolean()); + CoordinatorService control = application.getBean(CoordinatorService.class); + String endpoint = UUID.randomUUID().toString(); + ObjectNode source = object().put("endpointId", endpoint).put("name", "准备原图设备") + .put("sourceHost", "127.0.0.1").put("collectorId", 3) + .put("sm4Key", Base64.getEncoder().encodeToString(PxhjWireOracle.SM4)) + .put("hmacKey", Base64.getEncoder().encodeToString(PxhjWireOracle.HMAC)); + control.imageSource(source); + JsonNode ready = waitFor(base, snapshot -> { + for (JsonNode task : snapshot.path("tasks")) if (task.path("sourceEndpointId").asText().equals(endpoint)) + return task.path("status").asText().equals("READY"); + return false; + }); + JsonNode task = null; + for (JsonNode candidate : ready.path("tasks")) if (candidate.path("sourceEndpointId").asText().equals(endpoint)) task = candidate; + assertNotNull(task); + assertEquals("READY", task.path("desired").asText(), "原图接入不得启动正式轮次"); + String taskId = task.path("taskId").asText(); + JsonNode route = control.imageRoutes(object().set("endpointIds", com.xtrh.common.util.Jsons.array().add(endpoint))).path("routes").get(0); + long version = route.path("version").asLong(); + String path = "/node/images/" + taskId + "/" + version; + HttpResponse noFrame = signedGet(base, path + "/latest"); + assertEquals(404, noFrame.statusCode()); + JsonNode imageRoute = control.imageSource(source); + assertEquals("READY", imageRoute.path("status").asText()); + int udpPort = imageRoute.path("targets").get(0).path("port").asInt(); + assertTrue(udpPort > 0); + byte[] jpeg = PxhjWireOracle.jpeg(99); + try (DatagramSocket sender = new DatagramSocket(0, InetAddress.getByName("127.0.0.1"))) { + PxhjWireOracle.send(sender, udpPort, PxhjWireOracle.chunks(jpeg, 99, System.currentTimeMillis(), 80, 60)); + } + ImageIngressTest.await(() -> application.getBean(WorkerService.class).originalFrame(taskId, version, null) != null); + JsonNode metadata = new com.fasterxml.jackson.databind.ObjectMapper().readTree(signedGet(base, path + "/latest").body()); + assertEquals(endpoint, metadata.path("endpointId").asText()); + assertEquals(jpeg.length, metadata.path("bytes").asInt()); + HttpResponse frame = signedGet(base, path + "/frames/" + metadata.path("frameId").asText()); + assertEquals(200, frame.statusCode()); + assertEquals("image/jpeg", frame.headers().firstValue("Content-Type").orElseThrow()); + assertArrayEquals(jpeg, frame.body(), "节点输出必须保留设备 JPEG 原字节"); + assertNotNull(ImageIO.read(new ByteArrayInputStream(frame.body()))); + assertEquals(404, signedGet(base, "/node/images/" + taskId + "/" + (version + 1) + "/latest").statusCode()); + HttpResponse anonymous = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(base + path + "/latest")).GET().build(), HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(401, anonymous.statusCode()); + assertFalse(peer.request(base, "/control/snapshot", "GET", null).toString().contains("sm4Key")); + } + + private HttpResponse signedGet(String base, String path) throws Exception { + String time = Long.toString(System.currentTimeMillis()), nonce = UUID.randomUUID().toString(); + HttpRequest request = HttpRequest.newBuilder(URI.create(base + path)).timeout(Duration.ofSeconds(3)) + .header("X-Xtrh-Time", time).header("X-Xtrh-Nonce", nonce) + .header("X-Xtrh-Signature", Signatures.request(settings.token, "GET", path, time, nonce, new byte[0])).GET().build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(Signatures.response(settings.token, nonce, response.statusCode(), response.body()), + response.headers().firstValue("X-Xtrh-Response-Signature").orElse(null)); + return response; + } + + private JsonNode waitFor(String base, Predicate expected) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while (System.nanoTime() < deadline) { + JsonNode snapshot = peer.request(base, "/control/snapshot", "GET", null); + if (expected.test(snapshot)) return snapshot; + Thread.sleep(100); + } + throw new AssertionError("隔离主节点自动注册或任务准备超时"); + } + + private static Path temporaryDirectory() { + try { return Files.createTempDirectory("xtrh-combined-master-test-"); } + catch (IOException e) { throw new IllegalStateException("无法创建隔离测试目录", e); } + } +} diff --git a/src/test/java/com/xtrh/node/service/PxhjWireOracle.java b/src/test/java/com/xtrh/node/service/PxhjWireOracle.java new file mode 100644 index 0000000..17c51ed --- /dev/null +++ b/src/test/java/com/xtrh/node/service/PxhjWireOracle.java @@ -0,0 +1,87 @@ +package com.xtrh.node.service; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.SecureRandom; +import java.security.Security; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.zip.CRC32; + +/** + * 测试发送方以现有 E:/pxhj/backend/.../telemetry/codec/WireCodec.encode 为独立协议基准。 + * 这里保留该发送端的字段顺序、MAC/CRC 范围和 SM4 参数,不调用 XTRH 解码器或重组服务。 + * JPG1 头对应现有 ImageFrameStore 与嵌入式 PlatformImageSender 的 36 字节规范。 + */ +final class PxhjWireOracle { + static final byte[] SM4 = new byte[16], HMAC = new byte[48]; + private static final SecureRandom RANDOM = new SecureRandom(); + static { + if (Security.getProvider("BC") == null) Security.addProvider(new BouncyCastleProvider()); + new Random(441).nextBytes(SM4); + new Random(442).nextBytes(HMAC); + } + + static byte[] encode(int type, int module, byte[] payload, byte[] sm4, byte[] hmac, long now) throws Exception { + if (payload.length > 1024) throw new IllegalArgumentException(); + byte[] iv = new byte[16]; RANDOM.nextBytes(iv); + Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS7Padding", "BC"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(sm4, "SM4"), new IvParameterSpec(iv)); + byte[] encrypted = cipher.doFinal(payload); + var buffer = ByteBuffer.allocate(93 + encrypted.length).order(ByteOrder.BIG_ENDIAN); + buffer.putInt(0x5AA5FAFB).putInt(buffer.capacity()).put((byte) type).put((byte) module).put((byte) RANDOM.nextInt(256)); + buffer.putLong(now).putLong(RANDOM.nextLong()); + buffer.put((byte) 1).put((byte) 1).putInt(1).put(iv).putInt(encrypted.length).put(encrypted); + Mac mac = Mac.getInstance("HMACSM3", "BC"); + mac.init(new SecretKeySpec(hmac, "HMACSM3")); + mac.update(buffer.array(), 4, buffer.position() - 4); + buffer.put(mac.doFinal()); + CRC32 crc = new CRC32(); crc.update(buffer.array(), 0, buffer.position()); + buffer.putInt((int) crc.getValue()).putInt(0xA55AFCFD); + return buffer.array(); + } + + static List chunks(byte[] jpeg, long frame, long sourceTime, int width, int height) throws Exception { + return chunks(jpeg, frame, sourceTime, width, height, false); + } + + static List chunks(byte[] jpeg, long frame, long sourceTime, int width, int height, boolean wrongImageCrc) throws Exception { + int count = (jpeg.length + 987) / 988; + CRC32 crc = new CRC32(); crc.update(jpeg); + List packets = new ArrayList<>(); + for (int index = 0; index < count; index++) { + int size = Math.min(988, jpeg.length - index * 988); + ByteBuffer payload = ByteBuffer.allocate(36 + size).order(ByteOrder.BIG_ENDIAN); + payload.putInt(0x4A504731).putLong(frame).putLong(sourceTime).putInt(jpeg.length) + .putShort((short) index).putShort((short) count).putInt((int) crc.getValue() ^ (wrongImageCrc ? 1 : 0)) + .putShort((short) width).putShort((short) height).put(jpeg, index * 988, size); + packets.add(encode(0x12, 3, payload.array(), SM4, HMAC, System.currentTimeMillis())); + } + return packets; + } + + static byte[] jpeg(int seed) throws Exception { + BufferedImage image = new BufferedImage(80, 60, BufferedImage.TYPE_INT_RGB); + Random random = new Random(seed); + for (int y = 0; y < image.getHeight(); y++) for (int x = 0; x < image.getWidth(); x++) image.setRGB(x, y, random.nextInt()); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ImageIO.write(image, "JPEG", bytes); + return bytes.toByteArray(); + } + + static void send(DatagramSocket socket, int port, List packets) throws Exception { + for (byte[] bytes : packets) socket.send(new DatagramPacket(bytes, bytes.length, InetAddress.getByName("127.0.0.1"), port)); + } +} diff --git a/src/test/java/com/xtrh/node/service/WorkerTest.java b/src/test/java/com/xtrh/node/service/WorkerTest.java new file mode 100644 index 0000000..8acd49e --- /dev/null +++ b/src/test/java/com/xtrh/node/service/WorkerTest.java @@ -0,0 +1,152 @@ +package com.xtrh.node.service; + +import com.xtrh.discovery.service.CoordinatorLocator; +import com.xtrh.discovery.service.Discovery; +import com.xtrh.common.util.Jsons; +import com.xtrh.communication.client.PeerClient; +import com.xtrh.common.config.Settings; +import com.xtrh.node.controller.WorkerController; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.env.MockEnvironment; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Path; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.util.concurrent.atomic.AtomicReference; + +import static com.xtrh.common.util.Jsons.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WorkerTest { + @TempDir Path directory; + + @Test void runningTransitionKeepsExistingStreamConnectedToCurrentTaskObject() throws Exception { + Settings settings = settings(); + CoordinatorLocator locator = mock(CoordinatorLocator.class); + PeerClient peer = mock(PeerClient.class); + when(locator.locate()).thenReturn("http://127.0.0.1:18100"); + when(peer.request(anyString(), eq("/control/nodes/register"), eq("POST"), any())) + .thenReturn(object().put("sessionId", "test-session")); + when(peer.request(anyString(), contains("/heartbeat"), eq("POST"), any())) + .thenReturn(plan("READY", 8000), plan("RUNNING", 8000)); + WorkerService worker = new WorkerService(settings, locator, peer); + worker.heartbeat(); + WorkerService.MediaTask original = worker.playable("task-a", 1, "media-key-a"); + var stream = new WorkerController(worker).stream("task-a", 1, "media-key-a"); + worker.heartbeat(); + WorkerService.MediaTask replacement = worker.playable("task-a", 1, "media-key-a"); + assertNotSame(original, replacement); + assertEquals("RUNNING", replacement.desired); + original.deadlineNanos = 0; + + // 播放连接必须每帧查询当前任务;准备状态对象到期不能断掉已经开始的同版本视频。 + ByteArrayOutputStream written = new ByteArrayOutputStream(); + OutputStream firstFrameOnly = new OutputStream() { + @Override public void write(int value) throws IOException { written.write(value); throw new IOException("test stream closed"); } + @Override public void write(byte[] bytes, int offset, int length) throws IOException { + written.write(bytes, offset, length); throw new IOException("test stream closed"); + } + }; + assertThrows(IOException.class, () -> stream.getBody().writeTo(firstFrameOnly)); + assertTrue(written.toString(java.nio.charset.StandardCharsets.US_ASCII).startsWith("--frame")); + } + + @Test void delayedPlanCannotExtendExpiredExecutionLeaseFromResponseArrivalTime() throws Exception { + Settings settings = settings(); + CoordinatorLocator locator = mock(CoordinatorLocator.class); + PeerClient peer = mock(PeerClient.class); + when(locator.locate()).thenReturn("http://127.0.0.1:18100"); + when(peer.request(anyString(), eq("/control/nodes/register"), eq("POST"), any())) + .thenReturn(object().put("sessionId", "test-session")); + when(peer.request(anyString(), contains("/heartbeat"), eq("POST"), any())).thenAnswer(invocation -> { + Thread.sleep(20); + return plan("RUNNING", 1); + }); + WorkerService worker = new WorkerService(settings, locator, peer); + worker.heartbeat(); + assertEquals("WAITING", worker.status().path("connection").asText()); + assertEquals(0, worker.status().path("tasks").asInt()); + assertThrows(IllegalArgumentException.class, () -> worker.playable("task-a", 1, "media-key-a")); + } + + @Test void originalImageIsIntakeReadyBeforeFirstFrameAndNeverFallsBackToDemoJpeg() throws Exception { + Settings settings = settings(); + CoordinatorLocator locator = mock(CoordinatorLocator.class); + PeerClient peer = mock(PeerClient.class); + AtomicReference report = new AtomicReference<>(), registration = new AtomicReference<>(); + when(locator.locate()).thenReturn("http://127.0.0.1:18100"); + when(peer.request(anyString(), eq("/control/nodes/register"), eq("POST"), any())).thenAnswer(call -> { + registration.set(((ObjectNode) call.getArgument(3)).deepCopy()); + return object().put("sessionId", "image-session"); + }); + ObjectNode plan = object().put("leaseMs", 8000); + plan.set("tasks", array().add(ImageIngressTest.entry())); + when(peer.request(anyString(), contains("/heartbeat"), eq("POST"), any())).thenAnswer(call -> { + report.set(((ObjectNode) call.getArgument(3)).deepCopy()); return plan; + }); + try (ImageIngressService images = new ImageIngressService(settings, 0); + DatagramSocket sender = new DatagramSocket(0, InetAddress.getByName("127.0.0.1"))) { + WorkerService worker = new WorkerService(settings, locator, peer, images); + worker.heartbeat(); worker.frames(); worker.heartbeat(); + assertEquals(images.inputPort(), registration.get().path("imageInputPort").asInt()); + assertEquals("127.0.0.1", registration.get().path("imageHost").asText()); + assertTrue(report.get().path("reports").get(0).path("ready").asBoolean()); + assertFalse(report.get().path("reports").get(0).path("imageReady").asBoolean()); + assertNull(worker.frame("image-task", 1, "image-play-key")); + byte[] jpeg = PxhjWireOracle.jpeg(30); + PxhjWireOracle.send(sender, images.inputPort(), PxhjWireOracle.chunks(jpeg, 30, System.currentTimeMillis(), 80, 60)); + ImageIngressTest.await(() -> worker.originalFrame("image-task", 1, null) != null); + worker.frames(); worker.heartbeat(); + assertArrayEquals(jpeg, worker.frame("image-task", 1, "image-play-key")); + assertTrue(report.get().path("reports").get(0).path("imageReady").asBoolean()); + assertNotNull(report.get().path("reports").get(0).get("lastFrameId")); + WorkerService.MediaTask active = worker.playable("image-task", 1, "image-play-key"); + active.deadlineNanos = 0; + assertNull(worker.originalFrame("image-task", 1, null), "私有接口不能在任务租约结束后借用近期缓存"); + assertThrows(IllegalArgumentException.class, () -> worker.frame("image-task", 1, "image-play-key")); + assertNull(worker.originalFrame("image-task", 2, null)); + assertFalse(worker.status().toString().contains("imageAccess")); + } + } + + @Test void revokedOriginalAssignmentStopsExistingMediaAndPrivateFrameLookups() throws Exception { + Settings settings = settings(); + CoordinatorLocator locator = mock(CoordinatorLocator.class); + PeerClient peer = mock(PeerClient.class); + when(locator.locate()).thenReturn("http://127.0.0.1:18100"); + when(peer.request(anyString(), eq("/control/nodes/register"), eq("POST"), any())) + .thenReturn(object().put("sessionId", "image-session")); + ObjectNode assigned = object().put("leaseMs", 8000), revoked = object().put("leaseMs", 8000); + assigned.set("tasks", array().add(ImageIngressTest.entry())); revoked.set("tasks", array()); + when(peer.request(anyString(), contains("/heartbeat"), eq("POST"), any())).thenReturn(assigned, revoked); + try (ImageIngressService images = new ImageIngressService(settings, 0)) { + WorkerService worker = new WorkerService(settings, locator, peer, images); + worker.heartbeat(); assertTrue(images.ready("image-task/1")); + worker.heartbeat(); assertFalse(images.ready("image-task/1")); + assertNull(worker.originalFrame("image-task", 1, null)); + assertThrows(IllegalArgumentException.class, () -> worker.frame("image-task", 1, "image-play-key")); + } + } + + private Settings settings() throws Exception { + return new Settings(new MockEnvironment().withProperty("fusion.role", "worker") + .withProperty("fusion.token", "worker-unit-test-credential-only").withProperty("fusion.data-dir", directory.toString()) + .withProperty("fusion.advertised-host", "127.0.0.1").withProperty("fusion.discovery-enabled", "false")); + } + + private ObjectNode plan(String phase, long leaseMs) { + ObjectNode task = object().put("taskId", "task-a").put("name", "演示画面").put("trialName", "测试试验").put("desired", phase); + task.set("assignment", object().put("version", 1).put("mediaKey", "media-key-a")); + ObjectNode result = object().put("leaseMs", leaseMs); + result.set("tasks", array().add(task)); + return result; + } +} diff --git a/启动系统融合软件.cmd b/启动系统融合软件.cmd new file mode 100644 index 0000000..1eb6479 --- /dev/null +++ b/启动系统融合软件.cmd @@ -0,0 +1,11 @@ +@echo off +setlocal +chcp 65001 >nul +cd /d "%~dp0" +where pwsh.exe >nul 2>nul +if %errorlevel%==0 ( + pwsh.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Open-Console.ps1" -OpenBrowser %* +) else ( + powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Open-Console.ps1" -OpenBrowser %* +) +if errorlevel 1 pause diff --git a/总控与多机融合服务开发方案.md b/总控与多机融合服务开发方案.md new file mode 100644 index 0000000..1dea88b --- /dev/null +++ b/总控与多机融合服务开发方案.md @@ -0,0 +1,644 @@ +# 总控与多机融合服务开发方案 + +日期:2026-10-15 +项目工作空间:`E:\Codex\xtrh` +文档状态:本阶段实现说明与后续设计。第 0 节列出当前已实现范围和验证证据,其余涉及真实媒体、仿真和录像的内容属于后续设计,不能视为已实现或通过设备验收。 + +## 0. 最新开发范围与验证状态 + +2026-10-15 已加入上传地址协商与节点原图接收:嵌入式通过网关请求接收地址,PXHJ 按原设备密钥认证,再由总控按节点容量分配;图片原分片直接上传节点,准备页面沿已有图片 API 向当前节点取原图,不以正式试验开始为前提。迁移期间最多向两个节点上传,候选收到完整首帧后切换。许可到期或有效等待回复使设备回到已配置的 PXHJ 原图片地址。 + +Java 服务已在本机加载新版。XTRH 38 项测试全部通过;加入 PXHJ 原图快照缓存、完整正文超时和批量缓存淘汰修复后,PXHJ 411 项回归无失败、错误,跳过 30 项。Windows 独立嵌入式组件与实际 C++ / PXHJ Java 加密报文双向互操作通过;RK3588 已完成完整 ARM 构建、部署并开启协商,现有 PXHJ 准备页面已取得原图。板上日志测试 9 项通过,图片协商 8 项通过、1 项可选夹具跳过。当前视频输入为已有文件经 RTP 输入真实板卡,尚未验收物理摄像机、多台物理电脑或真实融合。流程见 [图片直传协商与准备取图](E:/Codex/xtrh/docs/图片直传协商与准备取图.md),实际配置和迁移证据见 [开发板联调记录](E:/Codex/xtrh/docs/开发板图片协商联调记录.md)。 + +### 0.1 上一阶段范围与验证(2026-10-14) + +按用户最新要求,本阶段使用 Java 实现总控、节点自动发现注册、动态路数任务分发和原始 UDP 转发,并接入 PXHJ 的正式试验页面统一操作。真实视频融合暂不开发,只提供明确标记的演示 MJPEG 画面。 + +| 功能 | 当前状态 | +|---|---| +| 总控、网关、多个 Worker | 同一个 Java JAR 的三种部署角色;总控默认在同一进程兼任执行节点,网关独立转发 | +| 主节点执行能力 | 默认开启、默认容量 2 路,与控制接口共用 18100 端口和稳定身份;可配置只调度模式 | +| 自动发现与注册 | mDNS / DNS-SD,发现后验证签名与总控身份;节点不需要预填总控地址 | +| 动态路数分配 | 每节点 0~64 路,准备任务计入容量;先准备新节点,再切换正式输出;容量不足保留原输出 | +| UDP 转发 | 按登记来源识别采集器,不解析、不改写原始字节,使用独立转发源端口;支持反向原字节回执 | +| 重启与失联保护 | H2 持久化、启动会话、任务版本和限时许可;总控重启后要求 PXHJ 重确认试验上下文 | +| PXHJ 统一操作 | 已通过真实业务服务、会话/CSRF、隔离 H2 与实际总控/节点串联验证;主达梦实例已加载新版并实际同步总控状态 | +| 视频地址同步 | 节点注册对外地址并回报就绪,总控登记有效播放地址;PXHJ 后台签名轮询快照并保存播放信息;没有主动推送回调 | +| 真实视频与仿真 | 尚未实现真实输入、仿真数据协议、目标图层、标定、时间匹配、视频融合和录制 | +| 性能感知调度及高可用 | 尚未实现 CPU/GPU 负载测量自动调整容量、总控/网关主备切换 | + +2026-10-14 分层后的 XTRH 原有 19 项测试及新增主节点执行能力 5 项测试全部通过,共 **24 项**。新增随机端口测试验证同一主进程自动注册唯一主身份、执行一条 READY 任务,并从该主端口实际读取、解码 JPEG。总控只调度模式、普通 worker 与 gateway 的组件条件也已验证。主执行能力版本已经打包并升级正式本机总控。 + +PXHJ 上一阶段新增融合后端 17 项针对性测试和 1 项真实业务串联测试通过。业务串联在隔离 H2 中通过正式业务服务创建试验,经登录会话/CSRF 和正式接口层连接实际总控、子节点,验证了保存装备绑定的五路任务、2+3 分配、1+4 动态迁移、正式轮次开始/结束,以及各路 MJPEG 的 JPEG 首帧。 + +PXHJ 全后端回归和打包已完成,报告共 392 项测试,失败 0、错误 0、跳过 30 项。独立构建目录为 `runtime/pxhj-build`,产物为 [pxhj-server-0.1.0.jar](E:/Codex/xtrh/runtime/pxhj-build/pxhj-server-0.1.0.jar)。跳过的实时外部服务和达梦 opt-in 项不代表现场验证已通过;真实业务串联已另行显式运行。 + +实际在一台 Windows 电脑上启动总控、网关和两个节点,未配置节点总控 URL,验证了自动发现注册和原字节双向 UDP;结束测试轮次后 UDP 仍正常转发。PXHJ 另有两项中转 UDP 测试,覆盖实际加密数据与心跳接收、来源及密钥校验和动态配置回程地址。 + +这些结果是本机多进程、隔离 H2 和正式业务实现的软件验证,不能替代多台物理电脑、真实采集器或达梦主实例的现场验收。详细证据见 [本机验证记录](E:/Codex/xtrh/docs/本机验证记录.md),构建和部署方式见 [README](E:/Codex/xtrh/README.md)。 + +目前新版默认端口角色已经运行:主总控 18100 同时控制和执行、主容量 2,网关 HTTP 18110 / UDP 19002,子节点 A 18120 / 容量 2、B 18121 / 容量 3。只读签名快照确认 **三个在线执行节点、一个在线网关,总容量 7 路**。主节点只有一条 `COORDINATOR` 记录,主身份与总控一致,子节点为 `WORKER`;子角色配置均无总控 URL。隔离验证的四个 182xx 实例已按身份停止。 + +PXHJ 达梦主后端已接通总控。本次升级只重启 18100 主进程,原身份、数据和配置保留,PXHJ 后端、前端、网关及两个子节点未再次重启。实际缓存表自动同步三个在线执行节点和一个在线网关,检查时年龄 64 毫秒;UDP 19001、图片 UDP 6020 与网关 UDP 19002 均在运行。原有一轮 `RUNNING` 保持不变,未新增或修改正式试验视频配置,未调整子节点容量,真实设备采集路径也未切换。当前生产视频任务为 0,主端口实际 JPEG 已由隔离随机端口测试验证。 + +## 1. 要做成什么 + +做一套可部署在多台电脑上的后台软件,由 **PXHJ 统一操作**。PXHJ 负责设计、试验组织和数据查看,新软件负责采集接入、UDP 转发、任务分发、仿真交互和视频融合。 + +用户日常只使用 PXHJ。新软件部署好后自动运行,不再设置一套独立的试验操作界面。 + +核心要求如下: + +| 要求 | 设计结果 | +|---|---| +| PXHJ 统一操作 | 配置、准备、开始、结束、节点路数调整及视频查看均在 PXHJ 中完成 | +| 多台电脑参与融合 | 每台电脑运行一个融合节点,一个节点可以同时处理多路视频 | +| 部署后自动加入 | 节点启动后自动发现本项目总控、完成认证及注册,PXHJ 自动出现节点记录 | +| 路数动态配置 | PXHJ 修改各节点的最大并行路数,总控据此分配和调整任务 | +| 采集数据接入迁移 | 采集器向新软件的统一入口发送数据 | +| PXHJ 继续查看采集数据 | 新软件保持原始加密报文内容,通过 UDP 转发给 PXHJ | +| 仿真交互 | 将当前试验所需的装备状态和相机信息发送给仿真系统 | +| 准确融合 | 仿真目标与真实视频的坐标、相机视角和拍摄时间一致 | +| 视频自动同步 | 节点将服务地址和就绪结果报给总控,PXHJ 后台签名轮询总控并登记播放信息,页面自动加载 | + +## 2. 总体架构 + +新软件分成三个后台角色,它们属于同一个项目,可以分别启动和部署。 + +```mermaid +flowchart TB + P["PXHJ 后端与页面
试验组织、统一操作、数据和视频查看"] + M["主节点:同一总控进程
配置管理、任务分配、状态汇总
默认也执行本机视频任务,端口18100"] + G["采集接入网关
已实现原始报文双向转发
协议解析与数据分发后续实现"] + D["已登记采集器与采集模块"] + C["真实摄像头或视频源"] + S["仿真推演系统
后续接入:统一场景状态、多相机视角输出"] + W["多台融合节点
接收采集器原图、处理多路演示视频"] + + P -->|试验配置与操作指令| M + M -->|已确认的来源与转发路由| G + M -->|视频任务与路数限制| W + D -->|采集报文、心跳与协商| G + G -->|原始报文 UDP 转发| P + D -->|协商后原 JPG 分片直传| W + W -->|准备阶段原图、内部签名 HTTP| P + P -->|原字节配置与回执| G + G -.->|后续:位置、姿态等带时间的数据| W + G -.->|后续:当前试验装备状态| S + C -.->|后续:真实视频输入| W + W -.->|后续:相机视角与渲染请求| S + S -.->|后续:目标图层、遮罩及帧标识| W + W -->|对外地址、心跳与任务就绪结果| M + P -->|签名轮询总控快照并登记播放信息| M + W -->|当前演示 MJPEG 直接供页面播放| P +``` + +图中实线表示当前链路,虚线表示后续真实视频与仿真链路。“直接供页面播放”是 PXHJ 浏览器连接融合节点的播放服务。总控和 PXHJ 业务后端主要处理控制与地址信息,避免所有视频都经它们转码或转运。 + +### 三个后台角色的职责 + +| 角色 | 主要职责 | 部署建议 | +|---|---|---| +| 总控服务 Coordinator | 当前接收 PXHJ 上下文、登记节点、分配任务、调整路数、管理版本、汇总状态与播放地址,供 PXHJ 轮询 | 第一版一个有效总控实例 | +| 采集接入网关 Gateway | 当前接收已登记来源的原始 UDP 字节并双向转发;解析装备状态、时间缓存和节点数据分发为后续项 | 第一版一个网关实例,与总控可以同机,进程独立 | +| 融合节点 Worker | 当前自动登记、领取多个任务、发布演示画面;真实视频、仿真、融合及录像为后续项 | 每台融合电脑部署一个节点,可运行多个媒体任务 | + +主节点在同一个总控进程中加载 `WorkerService` 和媒体控制器,自动向自身总控进行签名身份校验与注册,控制和播放共用默认端口 18100;不需要另外启动主电脑的 worker 进程。主节点使用总控已有的稳定 ID,快照标记 `nodeRole=COORDINATOR`;普通子节点标记 `WORKER`。PXHJ 统一配置主、子节点路数,主身份不会重复登记为第二个普通节点。 + +**总控或某个融合任务发生异常时,采集接入网关的接收与转发仍独立运行。** 网关使用最近一次确认的来源及转发配置,总控恢复后再同步变化。 + +## 3. 部署方式 + +### 单机起步,多机扩展 + +单机调试时,三个角色可以部署在一台电脑上,使用不同监听端口。正式扩展时,把 Worker 部署到其他电脑,网关和总控可以继续在原来的接入电脑上运行。 + +| 电脑 | 部署内容 | 作用 | +|---|---|---| +| PXHJ 服务器 | 现有 PXHJ 后端、前端 | 业务数据及统一操作入口 | +| 接入与总控电脑 | Coordinator、Gateway | 收数据、转发、管理节点和任务 | +| 融合电脑 A | Worker、媒体处理程序、播放服务 | 按配置处理多路视频 | +| 融合电脑 B | Worker、媒体处理程序、播放服务 | 按配置处理多路视频 | +| 后续新增电脑 | 同样的 Worker 部署包 | 启动后自动发现总控并登记,满足容量和能力要求后接收任务 | + +统一部署配置预置项目集群标识、注册凭证或受信任证书、默认容量策略及媒体程序位置。同一局域网内,新增节点不要求逐台填写总控 IP,也不要求在 PXHJ 中手动添加节点。 + +节点首次启动自动生成并保存独立节点 ID,自动选择可达网卡并登记服务地址。多网卡或特殊网络需要指定网卡、对外播放地址时,可通过统一部署配置覆盖。后续任务和路数配置均由 PXHJ 管理。 + +### 自动发现与自动注册 + +本方案明确包含两个功能:**先自动找到总控,再自动向总控注册**。 + +这里的局域网发现适用于子节点和网关。主进程的执行组件等待自身 HTTP 服务就绪后,使用本机实际端口访问并验证自家总控,不依赖监听自己的 mDNS 广播。对外播放地址独立选择可访问网卡,测试或单机部署可显式指定地址。 + +```mermaid +flowchart TD + A["安装节点服务并启动"] --> B["寻找本项目总控服务"] + B --> C["验证集群身份及服务认证"] + C --> D["自动注册节点身份、能力和地址"] + D --> E["PXHJ 自动出现该节点"] + E --> F["持续心跳并领取任务"] + F -->|总控失联| B +``` + +局域网发现已采用 mDNS / DNS-SD。总控启动时发布项目专用服务类型 `_xtrh-control._tcp.local.`,携带集群标识、逻辑总控身份、接口版本、服务端口等发现信息。节点浏览该服务类型,解析可达地址后发起正式注册。mDNS 用于本地链路发现,DNS-SD 描述服务类型、地址和端口。[mDNS 规范](https://www.rfc-editor.org/rfc/rfc6762.html)、[DNS-SD 规范](https://www.rfc-editor.org/rfc/rfc6763.html) + +发现地址的顺序如下: + +1. 如统一部署包预置了固定入口或域名,先尝试该入口。 +2. 验证上次成功连接的总控地址,成功则直接恢复连接。 +3. 前两项不可用时,自动发现同一局域网内本项目的总控。 +4. 暂未找到时保持等待,当前采用约 1.5 秒固定退避,按各角色的心跳周期继续重试;总控稍后启动也能自动加入。指数退避和随机抖动属于后续优化。 + +发现信息只用于寻找地址,正式注册仍校验项目身份和服务凭证。同网内其他项目的总控不会被误选;本项目出现多个相互冲突的有效总控时,上报总控冲突状态,避免随机切换任务归属。 + +注册与恢复规则如下: + +- `nodeId` 首次启动生成并持久化,部署模板不包含所有电脑共用的节点 ID。 +- `bootId` 每次节点进程启动重新生成,用于区分旧进程和本次启动。 +- 重复注册更新同一节点记录,电脑 IP 改变后仍保持原节点身份。 +- 重启后自动注册和核对任务,旧启动会话的回报及授权失效。 +- 总控不可用时自动重连及重新发现,节点仍遵守当前任务的授权有效期。 +- 首次注册使用部署配置中的容量,后续以 PXHJ 保存的容量为准;重复注册不覆盖已调整的路数。当前尚未进行显卡能力检测或自动容量测量。 + +Gateway 可使用同一套发现机制,通过网关登记接口加入总控;其角色与 Worker 区分,不领取视频融合任务。 + +**发现范围:** 同一局域网、允许发现报文通过时,可以自动找到总控。跨路由或 VLAN、网络隔离或禁用组播时,统一部署配置预置可达的总控域名或入口;节点仍自动注册,不需要在 PXHJ 中逐台添加。基础网络与服务凭证由项目部署时统一配置。 + +### 端口规划 + +下面是当前安装脚本使用的默认值和后续接入预留值,部署时检查实际占用情况。直接运行 JAR 时应显式配置角色和端口。 + +| 用途 | 端口 | 说明 | +|---|---|---| +| 总控 HTTP 接口 | 18100 | PXHJ 后端和节点访问 | +| 局域网服务发现 | UDP 5353 | mDNS 组件按标准共享使用,与采集数据及心跳入口独立 | +| 网关 HTTP 接口 | 18110 | 当前接入状态查询;节点数据订阅后续实现 | +| 主节点演示播放接口 | 18100 | 同一个总控进程、身份和端口,无需额外主执行进程 | +| 子节点管理与演示播放接口 | 18120 | 每台不同电脑可以使用相同端口 | +| 新网关采集 UDP 入口 | 19002 | 采集器上传目标;与 PXHJ 原入口区分 | +| PXHJ 采集 UDP 入口 | 现有配置,默认 19001 | 接收网关转发的原始报文 | +| 新网关图片 UDP 入口 | 6021,后续预留 | 当前未实现独立图片分片组装入口;原字节路由如需转送图片须单独配置和验证 | +| PXHJ 原图片 UDP 入口 | 现有配置,默认 6020 | 是否保留原始图片转发由项目配置决定 | +| 原采集器 RTP 输入 | 原有 9200 配置 | 属于采集器视频输入链路,不能当成平台采集入口 | +| 视频播放端口 | 随播放服务配置 | 地址必须从 PXHJ 浏览器所在电脑可访问 | + +采集器上传地址应指向稳定的网关入口。调整某台电脑的视频处理路数,不修改采集器上传地址。 + +## 4. 功能清单 + +本节保留最终建设目标,包含后续功能。当前可用范围以第 0 节为准:标定资料管理、仿真接口、装备状态解析与订阅、真实媒体、资源感知调度及录像未实现。 + +### 4.1 PXHJ 需要增加或接入的功能 + +- 在正式试验中启用采集与融合,使用该试验已保存的装备、实体模块及安装关系。 +- 配置视频通道、对应装备与模块、相机标定资料、仿真接口信息。 +- 查看节点在线状态、配置路数、当前运行路数、待分配路数和任务异常。 +- 修改节点最大并行路数,启用或停用节点;停用采用迁出任务后退出的方式。 +- 复用正式试验的准备、轮次开始和轮次结束操作,驱动后台服务。 +- 继续显示采集数据,增加融合视频观看及录像索引。 + +普通试验页面显示业务信息;服务地址、节点部署、协议和相机标定放在有权限的配置入口。 + +### 4.2 总控服务需要具备的功能 + +- 接收并校验试验上下文,保存配置版本、装备与模块关系、视频通道及轮次信息。 +- 发布本项目的发现信息,支持节点自动找到总控并完成认证登记。 +- 管理节点自动登记、启动会话、心跳、能力、并行路数和任务运行状态。 +- 为每路视频创建任务,并自动分配到具备空余容量的节点。 +- 在路数变化、节点加入、持续过载、节点退出或失联时调整分配。 +- 管理任务版本和限时执行授权,过滤旧节点、旧轮次的迟到状态和播放地址。 +- 当前汇总网关与演示视频状态,供 PXHJ 后台签名轮询;仿真状态及主动推送为后续项。 +- 保存必要状态,重启后核对 PXHJ 当前执行状态,再恢复有效任务。 +- 对重复操作去重;失败的指令和上报可重试,PXHJ 能看到实际执行结果。 + +### 4.3 采集接入网关需要具备的功能 + +- 接收全部已登记采集器的原始数据和心跳,识别来源及模块身份。 +- 保持原始加密报文内容,通过独立转发通道发送给 PXHJ。 +- 复用现有协议规则进行校验和解析,为仿真与融合生成标准化数据。 +- 根据试验绑定关系,将模块数据对应到装备和安装部位。 +- 缓存带采样时间的有效数据,向节点分发所需装备数据。 +- 分别统计采集器心跳、模块有效数据、转发数量、接收异常和队列丢弃情况。 +- 需要使用图片上传时,独立接收、校验、组装图片分片,按通道分发。 +- 当接入的设备使用配置下发与回执时,提供对应的双向转送能力。 + +### 4.4 融合节点需要具备的功能 + +- 自动发现总控、注册及重连,接收任务配置,同一台电脑同时执行多路媒体任务。 +- 根据分配获取视频源、装备数据、相机参数和仿真目标图层。 +- 按帧时间匹配位置与姿态,完成图像校正、目标合成、编码及发布。 +- 上报真实运行状态、播放入口、帧率、处理延迟及错误。 +- 任务迁移、撤销或执行授权到期时,停止旧任务的有效输出。 +- 按试验、轮次和通道保存分段录像,结束后返回录像索引。 +- 管理媒体进程异常、日志轮转和磁盘保留上限。 + +## 5. PXHJ 操作到后台执行的流程 + +以下含真实媒体的步骤是后续目标。本阶段由 PXHJ 同步保存的试验、绑定和轮次,节点准备演示画面;尚不检查真实视频输入、标定或仿真接口,也不启动录像。 + +### 5.1 试验准备 + +1. 用户在 PXHJ 中选择正式试验并进入准备。 +2. PXHJ 后端读取该试验保存的装备与模块绑定,组织融合上下文并下发给总控。 +3. 总控配置网关的数据对应关系,检查节点容量、视频输入、标定资料和仿真接口。 +4. 网关持续接收与转发数据,PXHJ 可以检查模块状态。 +5. 节点建立媒体输入和预览链路,确认能取得画面及所需数据后,上报准备结果。 +6. PXHJ 显示准备情况以及缺少的条件。 + +仅打开查看其他试验不应重新绑定正在运行的后台任务。任务切换由明确的准备或执行操作触发。 + +### 5.2 开始与结束轮次 + +```mermaid +sequenceDiagram + participant P as PXHJ 后端 + participant M as 总控服务 + participant G as 接入网关 + participant W as 融合节点 + P->>M: 下发试验配置和准备指令 + M->>G: 来源配置与数据分发关系 + M->>W: 分配任务并准备媒体链路 + W-->>M: 实际准备结果 + M-->>P: 准备状态 + P->>P: 确认正式轮次开始,保存开始时间 + P->>M: 开始指令:试验、轮次、时间、操作ID + M->>W: 启用该轮融合与录像 + W-->>M: 有效任务状态与播放入口 + P->>M: 签名轮询快照 + M-->>P: 返回视频地址和执行状态 + P->>P: 保存正式轮次结束时间 + P->>M: 结束该轮指令 + M->>W: 撤销任务授权,结束该轮输出与录像 + W-->>M: 结束结果与录像索引 + M-->>P: 汇总结束结果 + Note over G: 轮次结束后,采集接收与原始转发继续运行 +``` + +试验和轮次状态以 PXHJ 为准。PXHJ 正式轮次状态与“融合启动中、运行中、异常、停止中”等后台执行状态分别记录。 + +建议 PXHJ 在保存正式开始或结束操作时,同时保存待发送指令,由后台可靠重试发送。总控按操作 ID 去重。正式轮次已经开始而融合启动失败时,页面明确显示融合异常,按同一轮次重试,不能显示为已成功融合或另建一轮。 + +## 6. 每台电脑的路数配置与任务分发 + +### 6.1 配置含义 + +主节点同样参与路数配置,默认最大 2 路,与子节点使用相同的容量预留、任务许可和迁移规则。部署参数 `fusion.coordinator-worker-enabled=false` 或安装脚本 `-CoordinatorOnly` 可关闭主执行能力,仅保留总控;关闭后不加载执行服务、媒体控制器和媒体线程池。此开关不关闭独立 worker 的执行能力,gateway 始终只承担转发。 + +每个节点配置一个“最大同时处理路数”。实际运行路数由总控分配,不能超过已认可的运行容量;任务准备和迁移预热也需要计入资源占用。 + +| 节点 | 最大并行路数 | 当前运行路数 | 待准备任务 | 状态 | +|---|---:|---:|---:|---| +| A | 4 | 4 | 0 | 正常 | +| B | 6 | 6 | 0 | 正常 | +| C | 2 | 2 | 0 | 正常 | + +这只是 12 路任务的分配示例,不表示实际电脑一定能达到这些容量。容量应以指定分辨率、帧率和实际融合处理的测试结果配置;采用 NVIDIA 硬件编码时,还需计入编码资源和并发限制。[NVENC 官方说明](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvenc-application-note/index.html) + +### 6.2 分配规则 + +一个任务对应“一个试验、一个轮次、一条视频通道”,每条通道的连续帧由同一个当前有效节点处理。 + +总控分配时依次检查: + +1. 节点在线且允许接收新任务。 +2. 节点支持该输入格式、输出格式和融合处理方式。 +3. 配置路数内存在空余位置,实际处理资源也可用。 +4. 优先使用已有有效分配,减少运行中的迁移。 +5. 新任务优先分配给空余容量充足、运行表现稳定的节点。 + +总容量不足时,任务进入“等待分配”,PXHJ 显示待分配数量和原因。持续过载时,可以在配置上限内迁移任务;瞬时负载波动不触发频繁搬迁。 + +### 6.3 动态调整示例 + +原来 A / B / C 分别跑 4 / 6 / 2 路,修改为 2 / 6 / 4 路: + +1. 总控先确认 C 的新增容量可用,将变更显示为“调整中”。 +2. 为 A 上需迁出的两路任务建立 C 上的候选执行版本;切换成功前,A 的原版本仍是当前有效版本。 +3. 当前 C 生成演示画面并确认可播放;后续真实融合时再获取视频源、标定参数、采集数据缓存和仿真图层。 +4. 总控切换该通道的有效版本及播放入口,PXHJ 下一次签名轮询获取更新。 +5. 撤销 A 上的旧版本,释放其资源,完成路数调整。 + +配置上限下降后,迁出期间实际运行路数可能暂时高于新目标,必须显示调整进度。没有可接管容量时,不宣称变更已完成;由 PXHJ 选择增加容量、保留原任务或明确停止部分任务。 + +迁移需要源节点和目标节点的资源配合。候选版本获得准备许可,完成切换后才获得正式输出许可;准备失败时取消候选版本,不误撤销仍有效的原版本。没有预热余量时采用先停后启,允许短暂断画;是否需要接近连续的切换,应在实际链路上单独验证。 + +## 7. 所有采集模块的数据接收与 UDP 转发 + +当前网关只执行来源匹配、原始字节双向转发和统计。后面的协议解析、采样时间缓存、装备数据分发及独立图片组装是后续设计;当前协议认证与业务样本解析仍在 PXHJ 的正式接收入口执行。 + +### 7.1 接收一次,按用途分发 + +```text +已登记采集器 + → 网关识别来源并校验报文 + → 保留原始加密报文字节,转发给 PXHJ + → 解析模块数据,按试验绑定对应到装备 + → 仿真系统所需的装备状态 + → 各融合节点所需的带时间的位置和姿态 +``` + +已登记来源的原始数据完整转发给 PXHJ。当前试验名单决定哪些解析数据参与仿真和融合,不用于删改一个原始包内的模块数据。 + +网关保存采样时间、启动会话及原有序号,不为了中转重新生成采集时间或修改原始加密内容。同一装备的数据可以分发给多个视频任务。 + +UDP 转发、数据解析、节点分发分别使用有界队列和独立处理流程。慢节点与仿真异常不阻塞原始转发;队列满时按通道记录丢弃数量和原因。 + +UDP 本身不保证送达、有序或自动补发,因此必须区分“网关已发送”和“PXHJ 实际接受”。需要无损记录时,应另行设计确认、缓存及补发规则,并兼容现有的去重和时间窗口。[UDP 使用指南](https://www.rfc-editor.org/rfc/rfc8085.html) + +### 7.2 保持 PXHJ 的采集器身份对应 + +现有 PXHJ 会检查来源 IP、来源端口及协议身份,再按登记密钥校验解码。中转后,网络来源变为网关,必须同步维护身份映射。 + +建议每个采集器分配独立、固定的转发源端口: + +| 原采集器 | 网关转发源端口,示例 | PXHJ 中对应的连接 | +|---|---:|---| +| 采集器 A | 31001 | 保留采集器 A 原连接 ID,登记网关 IP 和 31001 | +| 采集器 B | 31002 | 保留采集器 B 原连接 ID,登记网关 IP 和 31002 | + +保持原连接 ID、协议身份与密钥关联,避免已有试验绑定失效。多个采集器经同一网关转发时,PXHJ 来源端口必须明确,避免多个端口通配配置造成来源歧义。 + +切换前保存原始来源配置,逐个采集器验证:原始字节一致、PXHJ 来源校验通过、数据归属正确、心跳正常。失败时恢复该采集器的上传目标及 PXHJ 来源配置。 + +### 7.3 协议接入范围 + +- 优先覆盖当前固定模块数据及采集器心跳,沿用现有加密协议。 +- 图片分片采用独立接入链路,按采集器和帧标识组装及分发。 +- 当试验使用动态配置模块时,配置下发、回执及数据上报要作为一组链路适配并验证,再切换该来源。 +- 当前网关不需要设备解密密钥,协议认证和解析仍由 PXHJ 完成。后续如在网关内启用解析,再增加受控密钥配置或引用,密钥不放入页面、源码、任务日志或公开接口返回。 + +网关持续接收全部已登记来源,模块数据以正式实体身份识别,并兼容已有采集器来源及模块编号组合。采集器有心跳、模块有有效样本、视频有新帧分别报告。 + +### 7.4 数据接收的后续扩容 + +第一版使用一个采集接入网关。若实测接收量超出单机能力,再按采集器分组增加网关实例,每个来源归一个当前有效接入节点。 + +网关按采集器分组扩容,融合节点按视频路数扩容。两种分配关系独立,调整视频路数不改变来源接入归属。 + +## 8. 仿真交互与真实视频融合 + +本节全部为后续设计,本阶段尚未实现。演示 MJPEG 不包含真实相机或仿真目标,不用于评估融合精度、编码性能或正式录像能力。 + +### 8.1 正确融合需要的信息 + +| 信息 | 作用 | +|---|---| +| 统一空间基准、单位和目标尺寸 | 真实装备、相机和仿真目标的位置及大小能够对应 | +| 相机镜头参数和畸变参数 | 生成与真实镜头一致的投影,并校正图像 | +| 相机安装位置、朝向和云台角度 | 从装备姿态计算实际相机视角 | +| 真实拍摄时间、视频帧标识 | 对应同一时刻的装备数据和仿真图层 | +| 仿真目标透明图层或目标遮罩 | 在真实画面上合成目标 | +| 可选真实场景深度或遮挡区域 | 实现真实树木、建筑等前景对目标的遮挡 | + +相机标定资料按具体相机、镜头和使用分辨率管理。变焦、安装改变或图像裁剪影响投影时,更新相应参数。[OpenCV 相机标定说明](https://docs.opencv.org/4.13.0/dc/dbb/tutorial_py_calibration.html) + +仅有车体角度和车辆经纬度,还需要校准相机安装关系并核对测量精度。数据精度不足时,应增加定位或视觉校正措施,再验证融合误差。 + +### 8.2 媒体处理流程 + +```text +读取真实视频帧及拍摄时间 + → 查找或插值该时刻的有效装备位置与姿态 + → 得到实际相机位置、朝向及投影参数 + → 请求仿真系统输出对应帧的目标图层 + → 匹配帧标识、校正画面、合成目标 + → 编码、发布实时视频、按需分段录像 +``` + +网关和节点保留可配置时长的数据短缓存,用于按拍摄时间匹配。采集器、视频源和仿真系统应使用统一时间基准;只有接收时间的输入需要测量链路延迟并补齐拍摄时间关系。 + +仿真图层延迟、所需姿态过期或定位无效时,节点上报融合状态异常,停止使用失效图层;可配置继续显示真实视频。不能将冻结的目标持续展示为有效实时融合。 + +### 8.3 多节点与仿真系统的关系 + +同一试验的仿真场景状态统一维护,各节点按各自的视频通道获取相机视角图层。不能因为有多个节点,就各自独立推进一个互不一致的试验世界。 + +仿真侧需要支持多相机输出;渲染本身成为瓶颈时,需要支持多实例或多机渲染,并共享一致的场景状态和时间。单纯增加融合节点不会自动增加仿真端的渲染能力。 + +仿真产品的具体协议、透明图层能力、坐标定义和渲染并行方式,在接入资料和联调中确定,通过适配器封装。 + +## 9. 融合视频地址如何上报给 PXHJ + +```text +Worker 注册对外服务地址,并在心跳中回报当前任务版本已就绪 + → 总控验证节点会话、任务归属和当前有效版本 + → 总控生成并登记对应任务的完整 MJPEG 播放地址 + → PXHJ 后台约每 1.5 秒用签名 HTTP 请求轮询总控快照 + → PXHJ 将快照持久保存到 biz_fusion_cache,并按试验、轮次筛选视频 + → 页面查询 PXHJ 后端,直接连接对应节点播放 +``` + +**当前采用签名轮询同步,尚未实现总控主动调用 PXHJ 的推送回调。** 节点当前上报的是对外基础地址与任务就绪结果,完整播放路径由总控依据任务 ID、版本和媒体许可生成。 + +当前总控快照中的视频任务包含: + +| 字段 | 含义 | +|---|---| +| `trialId`、`roundId` | 试验与轮次 | +| `equipmentId`、`channelId` | 装备与固定视频通道 | +| `taskId`、`version` | 媒体任务及当前有效执行版本 | +| `nodeId` | 当前执行节点 | +| `playProtocol`、`playUrl` | 播放方式及对应入口 | +| `status`、快照 `serverTime` | 任务准备、运行、迁移或结束状态,以及该快照的时间 | + +演示任务使用 `MJPEG_DEMO`,由 Java Worker 产生演示画面;原图任务使用 `MJPEG_ORIGINAL`,展示采集器实际上传图片,准备页面经 PXHJ 原图片 API 读取。后续正式融合视频可以增加 WebRTC 或 HLS 输出,这些流媒体输出尚未实现。WebRTC 入口由对应播放器完成连接协商,不能按普通 MP4 文件地址直接使用。[MediaMTX 浏览器播放说明](https://mediamtx.org/docs/read/web-browsers) + +地址使用观看电脑可访问的 IP 或域名。注册时核验节点对外地址,不能用节点自己的回环地址或本地文件路径代替网络播放入口。 + +任务迁移后,`channelId` 保持不变,`version` 递增,目标就绪后更新播放入口。PXHJ 根据总控身份、调度代次和修订号过滤旧快照,页面随查询结果更新连接。旧节点迟到的回执不能重新登记旧地址。 + +录像和录像索引尚未实现。后续若增加录制,需要单独管理实时播放入口与分段录像索引,迁移前后的片段归属同一试验、轮次和视频通道。 + +## 10. PXHJ 集成与当前接口 + +### 10.1 可以利用的现有基础 + +已核对现有 PXHJ: + +- `GET /api/trials` 提供试验概要列表。 +- `GET /api/trials/{id}` 提供正式试验详情、保存的绑定关系和轮次。 +- 现有轮次开始、结束接口作为业务执行的统一入口。 +- 现有 UDP 接入包含来源校验、加密校验和固定模块解析。 +- 现有视频展示包含图片帧查询,融合流需要增加对应播放器及登记能力。 + +集成时由 PXHJ 后端组织权威上下文,通过受控接口下发。现有用户会话接口不能直接当成无鉴权的后台集成接口。 + +本阶段接入正式试验页面、后端接口和权限体系,不建设独立演示操作界面。正式业务服务与实际后台节点的 H2 串联测试已通过,主达梦后端也已升级并实际同步节点状态;真实设备采集切换、正式试验现场融合和物理多机验收仍需要另外完成。 + +### 10.2 PXHJ 当前新增接口 + +以下名称与当前 `FusionController` 一致。 + +| 接口 | 用途 | +|---|---| +| `GET /api/fusion/status` | 查询节点、容量、任务与网关汇总状态 | +| `GET /api/trials/{id}/fusion?roundId=...` | 查询该正式试验当前配置及指定轮次视频任务 | +| `PUT /api/trials/{id}/fusion` | 保存该试验的通道配置并同步权威上下文 | +| `PATCH /api/fusion/nodes/{id}/capacity` | 修改最大并行路数与启用状态 | +| `GET /api/fusion/relay-sources` | 查询正式采集器来源及中转配置 | +| `PUT /api/fusion/relay-sources` | 保存所选正式采集器的中转来源和目标 | + +开始和结束融合由现有正式轮次操作触发,后端向总控发送对应指令,页面不增加另一套独立的“开始试验”流程。 + +本阶段没有 `video-reports` 或 `status-reports` 推送回调接口;PXHJ 后台签名读取 `/control/snapshot` 后完成状态和播放信息同步。 + +### 10.3 新软件当前内部接口 + +| 方向 | 接口或通道 | 用途 | +|---|---|---| +| PXHJ 后端 → 总控 | `GET /control/info`、`GET /control/snapshot` | 校验总控身份并同步当前状态、任务及播放地址 | +| PXHJ 后端 → 总控 | `POST /control/contexts/sync` | 统一下发权威试验上下文及 PENDING、RUNNING 或 SAVED 轮次状态 | +| PXHJ 后端 → 总控 | `PATCH /control/nodes/{id}/capacity` | 下发容量变更 | +| Worker → 总控 | `POST /control/nodes/register` | 自动登记节点 ID、启动会话、能力和对外地址;重复登记更新原记录 | +| Worker → 总控 | `POST /control/nodes/{id}/heartbeat` | 汇报运行情况,获取更新后的任务计划和执行授权 | +| PXHJ 后端 → 总控 | `PUT /control/routes` | 保存完整来源和转发映射 | +| Gateway → 总控 | `POST /control/gateways/heartbeat` | 心跳即自动登记,汇报网关统计并获取已确认的版本化路由 | + +节点就绪结果包含在心跳中,当前没有独立任务回报接口。网关到 Worker 的采集数据订阅尚未实现。 + +当前上下文携带配置版本,任务计划携带任务 ID、执行版本、节点会话和授权有效期。PXHJ 持久保存期望配置并定时重新核对正式轮次;尚未实现单独的操作 ID 事件流水。用户操作由 PXHJ 权限控制,后台服务间使用凭证生成请求和响应签名,不在请求头发送明文凭证。 + +## 11. 状态、重启与异常处理 + +### 11.1 需要保存的状态 + +当前总控用 H2 保存节点配置、试验上下文版本、任务目标状态、分配、会话和任务版本。播放状态通过快照供 PXHJ 查询,没有待推送回调队列,也没有独立操作 ID 去重流水。 + +网关保存来源及转发映射、已确认配置版本。实时数据采用有界缓存;需要留存原始报文时,单独配置容量和保留策略。 + +当前节点持久保存自身身份和成功连接的总控地址,签名响应中的总控逻辑身份用于当次认证和地址去重,任务计划保存在内存中,录像索引未实现。总控重启后先核对 PXHJ 的轮次状态,对旧任务重新确认并发放新版本授权,再恢复有效输出。不能仅加载磁盘记录就宣称旧视频仍在正常运行。 + +### 11.2 异常处理规则 + +| 情况 | 处理方式 | +|---|---| +| 节点容量不足 | 任务等待,PXHJ 显示原因 | +| 节点失联或媒体进程崩溃 | 作废旧任务有效版本;有容量时重建任务,记录中断 | +| 节点因网络分区收不到控制信息 | 原授权到期后停止有效发布,等待重新确认 | +| 旧节点迟到上报 | 按任务版本和执行授权拒绝,不覆盖新地址 | +| 路数调整过程中目标节点准备失败 | 保留仍有效的原任务,变更返回失败或待处理状态 | +| 总控暂时不可用 | 网关继续按已确认配置转发;节点按授权规则处理当前任务 | +| PXHJ 暂时不可用 | 总控保留当前状态;PXHJ 恢复后后台重新轮询并核对正式上下文;当前没有推送回调重试队列 | +| 视频或姿态数据过期 | 单独报告数据异常,停止使用过期图层 | +| 原始转发队列满 | 记录数量和原因,PXHJ 显示接入异常 | +| 录像磁盘不足 | 后续实现录像时报告录像失败;本阶段未实现录制 | + +执行授权是一段有限时间内有效的任务许可,节点心跳正常时续期。任务迁移或失联重建后,旧许可失效;业务上同一通道只有一个当前有效输出版本。预热过程可以临时存在两个媒体任务,但不能把两个版本同时登记为当前有效视频。 + +第一版采用单有效总控和单接入网关,支持持久化重启恢复。总控与网关的主备切换需要另行实现、测试,不能将多个 Worker 等同于接入服务已经具备高可用。 + +## 12. 技术实现思路 + +| 层 | 当前实现与后续方向 | 原因 | +|---|---|---| +| 总控及网关 | Java 17、Spring Boot;总控默认在同一进程兼任执行节点 | 控制和执行共享主身份,网关保持独立转发 | +| 总控状态保存 | 第一版 H2 文件数据库及事务记录 | 避免仅靠内存或临时 JSON 保存任务状态 | +| Worker 控制与演示视频 | Java 后台服务,Java2D / ImageIO 产生 MJPEG | 验证任务生命周期、迁移和实际播放 | +| 真实媒体处理 | 后续在 Java 项目内增加真实输入、仿真和合成适配;具体编解码/GPU 库待接入资料确定 | 本阶段尚未实现校正、合成或正式编码 | +| 正式流媒体发布 | 后续提供 WebRTC 或等效播放服务 | 当前只提供演示 MJPEG,正式低延迟输出待实现 | +| PXHJ 页面 | 在现有 Vue 试验页面接入管理及播放器 | 统一用户操作和权限 | + +协议公共模块只抽取编解码、校验和标准数据结构,不把 PXHJ 的数据库访问和试验组织逻辑整体搬入新软件。 + +媒体执行层建议封装视频输入、仿真接入、时间匹配、图像合成、流发布和录像六个部件。缺少真实仿真或标定条件时,明确记录该环节未验证,不能以透传普通视频代替准确融合验收。 + +当前代码结构如下。现阶段保持一个 Maven 项目,通过角色配置运行三个服务,不提前拆出尚未实现的媒体和协议子模块: + +```text +xtrh/ +├─ pom.xml +├─ src/main/java/com/xtrh/ +│ ├─ Application.java Spring Boot 入口 +│ ├─ control/ +│ │ ├─ controller/ CoordinatorController:总控 HTTP 转接 +│ │ └─ service/ CoordinatorService:节点、上下文、容量、任务与版本调度 +│ ├─ gateway/ +│ │ ├─ controller/ GatewayController:网关状态接口 +│ │ └─ service/ GatewayService:双向 UDP、路由应用与统计 +│ ├─ node/ +│ │ ├─ controller/ WorkerController:媒体播放和节点状态接口 +│ │ ├─ service/ WorkerService:领取任务、授权及演示视频 +│ │ └─ config/ WorkerCapabilityCondition:主/子执行条件;AsyncMediaConfiguration:媒体线程 +│ ├─ discovery/ +│ │ ├─ service/ Discovery、CoordinatorLocator:发现与身份核验 +│ │ └─ util/ NodeAddress:对外地址选择 +│ ├─ communication/ +│ │ ├─ client/ PeerClient:签名 HTTP 通信 +│ │ └─ security/ Signatures、ServiceSecurity:签名及防重放 +│ └─ common/ +│ ├─ config/ Settings:角色与部署配置;SchedulerConfiguration:并发调度线程 +│ ├─ controller/ HealthController、ApiErrors:健康与错误接口 +│ ├─ dao/ StateStore:H2 状态;NodeIdentityDao:部署身份文件 +│ └─ util/ Jsons:公共 JSON 辅助 +├─ src/main/resources/ Spring Boot 默认配置 +├─ src/test/java/com/xtrh/ 调度、恢复、真实 UDP 和签名测试 +├─ scripts/ 构建、私有凭证、安装、启停、自启及 PXHJ 连接配置 +├─ config/ 无密钥的配置示例 +├─ docs/ 本机验证记录 +├─ README.md 构建、部署、范围与接口说明 +└─ runtime/ 本机部署产物、身份、数据库和私有配置;不提交仓库 +``` + +控制层只完成 HTTP 参数和服务转接,不承载调度或数据库业务;服务层处理当前业务规则;DAO 层专门保存和读取状态。关键中文注释解释节点会话、旧版本隔离、限时许可、迁移准备、路由缓存及签名校验的原因。测试按对应业务包放置,保留既有测试名称。 + +`StateStore.open(Path, ObjectMapper)` 将 H2 文件 JDBC 地址组装下沉到 DAO,保留 String URL 构造器供测试使用。`NodeIdentityDao` 只维护节点身份文件,不承担网络注册或调度。PXHJ 新增业务采用 `fusion.controller/service/dao/config/security` 分层,`FusionDao` 承接 SQL。 + +注释按 `scripts/Measure-Comments.ps1` 统计完整注释行占非空源码行的比例,包含 Javadoc 与块注释边界,不计空行和代码末尾的行内注释。当前全部 XTRH 主 Java 为 **21.53%(594/2759)**,PXHJ 新增 fusion 业务为 **28.83%(501/1738)**。 + +## 13. 开发顺序与各阶段交付 + +第一、二阶段的服务骨架与 PXHJ 集成代码已完成本机和 H2 正式业务链路验证,主达梦实例已实际接通总控;实际 RK3588 原图准备取图和接收节点迁移也已完成联调,当前证据见第 0 节。下面列的是完整阶段验收目标,真实摄像机和跨物理电脑验收仍未完成。第三阶段真实融合与录制暂不实施。 + +| 阶段 | 开发内容 | 可检查的交付结果 | +|---|---|---| +| 第一阶段:接入及服务骨架 | 总控、网关、Worker 部署;自动发现及注册;容量配置;原始 UDP 转发及身份映射 | 三个可部署后台角色,新增节点自动出现,真实协议报文被 PXHJ 接受,节点能领取和汇报任务 | +| 第二阶段:任务分发及正式集成 | PXHJ 正式试验上下文、轮次操作、持久化指令、动态路数、任务版本、视频信息上报 | 从真实 PXHJ 试验发起,多节点任务分配与状态展示可用 | +| 第三阶段:真实融合 | 相机标定、时间匹配、仿真目标图层接入、合成编码、实时播放与录像 | 真实视频中的目标位置、大小和视角按实际设备验证 | +| 第四阶段:多机与稳定性 | 跨机部署、容量测试、任务迁移、失联重建、重启恢复、资源和日志管理 | 至少两台实际融合电脑协同,形成容量及故障测试记录 | + +开发中使用的隔离协议样本和测试视频仅用于相应功能验证。正式交付需要真实 PXHJ 试验、实际采集器和跨机部署证据;准确融合另需真实相机及仿真接口的验证。 + +优先完成第一、二阶段的基础链路,同时确定仿真和相机资料,使第三阶段有可实现、可验收的输入条件。 + +## 14. 验收重点 + +1. 同一局域网新部署的节点能自动发现、认证及注册,PXHJ 自动出现记录;总控晚启动、地址变化和节点重启后能恢复,重复注册不新增重复节点。 +2. 用户在 PXHJ 内完成配置及试验操作,后台节点自动执行,任务与正式试验、轮次一致。 +3. 多个采集器经网关转发后,原始加密报文字节一致,PXHJ 正确识别来源与模块,实际样本接收正常。 +4. 一个节点可同时执行多个视频任务,最大并行路数可在 PXHJ 修改。 +5. 配置不足时明确等待;容量降低时能显示迁移过程,任务版本和实际占用一致。 +6. 任务跨电脑迁移后,相应数据订阅和视频播放入口同步更新,旧版本回报不能覆盖新版本。 +7. 轮次结束、节点调整及仿真异常均不会停止独立的采集接收与原始转发。 +8. 总控、网关和节点重启后按正式状态恢复;网络分区及授权过期有可观察结果。 +9. 仿真与真实相机使用一致的坐标、投影和时间,按静止、转动和移动场景验证融合位置误差。 +10. 在指定分辨率、帧率、算法和网络条件下测试每节点实际容量、端到端延迟、掉帧、带宽和编码资源。 +11. 形成真实跨机测试记录;同机多进程测试只证明对应的软件行为,不替代跨机验收。 + +融合误差、允许中断时长、目标帧率及端到端延迟的数值,需要结合相机、定位精度、仿真能力和电脑配置确定。此设计稿不预先承诺未经测量的性能指标。 + +## 15. 接入实施前需要明确的资料 + +| 资料 | 用于确定什么 | +|---|---| +| 真实视频通道数、分辨率、帧率、输入格式 | 节点处理容量及媒体输入适配 | +| 各电脑配置、显卡、网络及操作系统 | 编解码、合成能力和部署包 | +| 采集器身份、模块编号、协议和时间字段 | 来源映射、数据解析和按帧同步 | +| 相机型号、安装关系、云台及变焦方式 | 标定与实际相机位姿 | +| 仿真接口、坐标定义、目标图层和多视角能力 | 正确渲染及多节点融合 | +| 实时观看和录像要求 | 发布格式、存储策略及容量 | + +这些资料逐项落实到配置和适配器。服务骨架、UDP 转发和调度部分可以先开发,资料缺失的媒体环节保留明确的待接入状态。 + +## 16. 已核对的 PXHJ 代码依据 + +- [TrialController.java](E:/pxhj/backend/src/main/java/com/pxhj/trial/controller/TrialController.java):试验列表、详情及正式创建接口。 +- [TrialService.java](E:/pxhj/backend/src/main/java/com/pxhj/trial/service/TrialService.java):试验详情包含保存的绑定信息及轮次,列表为概要。 +- [UdpFoundation.java](E:/pxhj/backend/src/main/java/com/pxhj/telemetry/service/UdpFoundation.java):来源、消息类型和加密校验,固定数据及心跳处理。 +- [FixedModuleService.java](E:/pxhj/backend/src/main/java/com/pxhj/telemetry/fixed/FixedModuleService.java):来源与认证身份一致性、模块样本识别及去重保存。 +- [ModuleVideoDialog.vue](E:/pxhj/frontend/src/equipment/ModuleVideoDialog.vue):当前模块视频展示采用图片帧方式,融合流需要对应的播放接入。 +- [FusionController.java](E:/pxhj/backend/src/main/java/com/pxhj/fusion/controller/FusionController.java):新增融合配置、节点容量和中转来源的正式接口。 +- [FusionService.java](E:/pxhj/backend/src/main/java/com/pxhj/fusion/service/FusionService.java):权威试验上下文、签名轮询及播放信息同步。 +- [FusionDao.java](E:/pxhj/backend/src/main/java/com/pxhj/fusion/dao/FusionDao.java):融合配置、状态缓存与中转登记的数据库访问。 + +实施时以当前代码和正式接入资料再次核对,不直接用旧演示中的虚构试验或模拟接口替代以上业务能力。