Compare commits
9 Commits
2a890d894d
...
8c0f55bbf9
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c0f55bbf9 | |||
| 7aacdbff4d | |||
| 2fdbbc210f | |||
| 674d238f6f | |||
| 6b8b76b6ff | |||
| b9acee5af0 | |||
| a41a945656 | |||
| ef7131c15b | |||
| bb76476213 |
35
.env.example
35
.env.example
@ -4,6 +4,7 @@ DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
DB_DATABASE=shy_template
|
||||
DB_SYNC=true
|
||||
DB_TIMEZONE=+08:00
|
||||
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
@ -114,6 +115,40 @@ MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
MQTT_CLIENT_ID=kt-template-online-api-qqbot
|
||||
|
||||
# Admin environment dashboard: optional readonly observation wiring.
|
||||
ENV_DASHBOARD_CACHE_TTL_MS=15000
|
||||
ENV_DASHBOARD_SIGNAL_TIMEOUT_MS=5000
|
||||
ENV_DASHBOARD_EVENT_BUS=local
|
||||
ENV_DASHBOARD_MQTT_URL=
|
||||
ENV_DASHBOARD_MQTT_CLIENT_ID=kt-template-online-api-environment
|
||||
ENV_DASHBOARD_MQTT_USERNAME=
|
||||
ENV_DASHBOARD_MQTT_PASSWORD=
|
||||
ENV_DASHBOARD_MQTT_TOPIC_PREFIX=kt/env
|
||||
ENV_DASHBOARD_SSE_REPLAY_LIMIT=200
|
||||
ENV_DASHBOARD_SSE_HEARTBEAT_MS=25000
|
||||
ENV_DASHBOARD_ADMIN_LOCAL_URL=
|
||||
ENV_DASHBOARD_ADMIN_PUBLIC_URL=
|
||||
ENV_DASHBOARD_JENKINS_URL=
|
||||
ENV_DASHBOARD_JENKINS_JOB=KT-Template/KT-Template-API/main
|
||||
ENV_DASHBOARD_JENKINS_USERNAME=
|
||||
ENV_DASHBOARD_JENKINS_TOKEN=
|
||||
ENV_DASHBOARD_K8S_API_SERVER=
|
||||
ENV_DASHBOARD_K8S_NAMESPACE=kt-prod
|
||||
ENV_DASHBOARD_K8S_DEPLOYMENT=kt-template-online-api
|
||||
ENV_DASHBOARD_K8S_LABEL_SELECTOR=app=kt-template-online-api
|
||||
ENV_DASHBOARD_K8S_BEARER_TOKEN=
|
||||
ENV_DASHBOARD_TENCENT_CLOUD_ENABLED=false
|
||||
ENV_DASHBOARD_TENCENT_SECRET_ID=
|
||||
ENV_DASHBOARD_TENCENT_SECRET_KEY=
|
||||
ENV_DASHBOARD_TENCENT_REGION=
|
||||
ENV_DASHBOARD_TENCENT_INSTANCE_ID=
|
||||
ENV_DASHBOARD_CADDY_ADMIN_URL=
|
||||
ENV_DASHBOARD_CADDY_PUBLIC_URL=
|
||||
ENV_DASHBOARD_R4SE_MIHOMO_URL=
|
||||
ENV_DASHBOARD_R4SE_MIHOMO_SECRET=
|
||||
ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL=
|
||||
ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL=
|
||||
|
||||
FF14_XIVAPI_BASE_URL=https://v2.xivapi.com/api
|
||||
FF14_XIVAPI_CHS_BASE_URL=https://xivapi-v2.xivcdn.com/api
|
||||
FF14_UNIVERSALIS_BASE_URL=https://universalis.app/api/v2
|
||||
|
||||
23
API.md
23
API.md
@ -76,10 +76,22 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
||||
- `degraded`:可选运行时配置缺失,核心 API 可继续工作。
|
||||
- `blocked`:关键运行时配置缺失,不能声明部署或运行态成功。
|
||||
|
||||
## Admin Environment Dashboard
|
||||
|
||||
| 方法 | 路径 | 认证 | 说明 |
|
||||
| ------ | ----------------------------------- | ---- | ------------------------------------------------- |
|
||||
| `GET` | `/system/environment/dashboard` | 是 | 返回 local-dev、NAS 线上、腾讯云、r4se 环境快照 |
|
||||
| `POST` | `/system/environment/self-check` | 是 | 触发只读自检并返回最新环境快照 |
|
||||
| `GET` | `/system/environment/events/stream` | 是 | SSE 推送后端环境事件,支持 `lastEventId` 查询参数 |
|
||||
|
||||
环境总览接口使用 `Site -> Node -> Service -> Signal` 模型聚合状态,`unwired` 表示只读观测尚未配置,`unknown` 表示已知入口但缺少新鲜证据。Admin 首次加载通过 HTTP 获取快照,后续通过 API SSE 接收 local/MQTT 事件;前端不直接连接 MQTT,也不使用定时轮询。
|
||||
|
||||
当前版本只提供观测和只读自检。重启 Pod、触发 Jenkins 部署、执行迁移、重建 NapCat 容器、启停插件、立即执行插件任务、修改 Caddy/OpenClash/WireGuard/Tencent Cloud 等高风险能力只会以禁用动作展示,后端不提供通用写动作入口。
|
||||
|
||||
## 环境变量分组
|
||||
|
||||
| 分组 | 关键变量 |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
|
||||
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
|
||||
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
||||
@ -88,12 +100,15 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
||||
| QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` |
|
||||
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
|
||||
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
|
||||
| Env Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` |
|
||||
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
||||
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` |
|
||||
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
|
||||
|
||||
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。
|
||||
|
||||
Env Dashboard 的 `ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只作为 Admin 入口展示证据;Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 仍通过对应 `ENV_DASHBOARD_*` 只读配置接入,缺失配置必须返回 `unwired` 证据。
|
||||
|
||||
QQBot 插件 worker 队列依赖 Redis。K8s 生产清单提供内部 Redis Service `kt-qqbot-plugin-redis:6379`,用于 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` / `QQBOT_PLUGIN_QUEUE_REDIS_PORT`。`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS` 控制串行队列的排队等待窗口,避免排队时间挤占插件操作本身的 `timeoutMs` 执行预算。插件定时任务可通过 `QQBOT_PLUGIN_TASK_QUEUE_REDIS_*` 使用独立 BullMQ prefix;未配置 host 时复用插件 worker Redis 连接。
|
||||
|
||||
## Admin 与基础后台
|
||||
@ -352,7 +367,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
|
||||
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。
|
||||
|
||||
托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用这些设备字段,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载,降低每次重建都被 QQ 判定为新设备的概率。
|
||||
托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、`02:42:*` MAC 和 machine-id,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`。
|
||||
|
||||
### NapCat Runtime Profile
|
||||
|
||||
@ -427,7 +442,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
插件平台使用统一 `plugin.json` manifest 描述插件 key、版本、入口、操作、事件、定时任务、权限和运行预算;后端会校验路径必须留在插件包内,权限必须命中白名单,安装包 content hash 必须与 manifest 匹配。`tasks` 字段声明平台托管的定时任务:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| -------------- | ---------------------------------------------- |
|
||||
| ------------- | -------------------------------------------------------------------- |
|
||||
| `key` | 全局唯一任务 key,例如 `bangdream.bestdori.sync-main-data` |
|
||||
| `name` | Admin 展示名称 |
|
||||
| `handlerName` | 插件入口暴露的任务处理器名称 |
|
||||
@ -466,7 +481,7 @@ pnpm qqbot-plugin install-local <packageFile>
|
||||
定时任务管理接口:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ----------------------------------------- | -------------------------------------- |
|
||||
| ------ | ------------------------------------------ | ------------------------------------ |
|
||||
| `GET` | `/qqbot/plugin-platform/tasks/page` | 插件定时任务分页,支持插件、状态过滤 |
|
||||
| `GET` | `/qqbot/plugin-platform/tasks/:id` | 任务详情 |
|
||||
| `POST` | `/qqbot/plugin-platform/tasks/:id/enable` | 启用任务并注册 BullMQ Job Scheduler |
|
||||
|
||||
13
README.md
13
README.md
@ -17,8 +17,8 @@
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 说明 |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板 |
|
||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||
@ -56,13 +56,14 @@ ci/ Jenkins Agent/Docker 辅助文件
|
||||
主要配置分组:
|
||||
|
||||
| 分组 | 变量 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
|
||||
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
|
||||
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
||||
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` |
|
||||
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
|
||||
| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` |
|
||||
| Environment Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` |
|
||||
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
||||
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
|
||||
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
|
||||
@ -73,6 +74,8 @@ QQBot 插件 worker 使用 BullMQ 队列串行执行同一插件安装实例的
|
||||
|
||||
QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qqbot_plugin_task` / `qqbot_plugin_task_run`,通过 BullMQ Job Scheduler 调度并经插件 worker 的 `executeTask` 边界执行。`sql/qqbot-init.sql` 可为既有环境增量创建任务表和 Admin 菜单。Admin 页面路径为 `/qqbot/plugin-task`。定时任务队列可用 `QQBOT_PLUGIN_TASK_QUEUE_REDIS_*` 单独配置;留空时复用插件 worker 队列的 Redis 连接。BangDream Bestdori 主数据缓存使用 `BANGDREAM_TSUGU_CACHE_ROOT`,生产清单挂载到容器内 `/data/qqbot/plugins/bangdream/cache`,对应 k3d 节点可写 hostPath `/var/lib/rancher/k3s/kt-template-online-api/qqbot-plugins`。
|
||||
|
||||
Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、NAS 线上、腾讯云和 r4se 状态。`ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只用于展示 Admin 本机与线上入口证据。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin;前端不直连 MQTT,也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。
|
||||
|
||||
NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/登录事件/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog quick -> password 熔断,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。
|
||||
|
||||
## 启动
|
||||
@ -163,7 +166,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
|
||||
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
||||
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
||||
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;状态轮询遇到验证码文案但缺少 URL 时,会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。
|
||||
- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、hostname、machine-id 和 MAC,并在 Docker run 中注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,避免频繁重建被 QQ 判定为全新设备。
|
||||
- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和 `02:42:*` MAC;Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`。
|
||||
- NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。
|
||||
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。
|
||||
- BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangdream/src`;按第三期插件结构放置真实职责代码:业务在 `domain/*`,编排在 `application`,操作在 `operations`,外部 API 在 `infrastructure/integration`,缓存/静态修正在 `infrastructure/storage`,字典和静态配置在 `config`,视觉渲染公共件在 `theme`;不要恢复旧 `tsugu` 层级、旧大桶目录、纯 re-export 转接文件或空 `.gitkeep` 目录壳。
|
||||
@ -202,5 +205,5 @@ BangDream 图片能力改动:
|
||||
## 来源与许可证
|
||||
|
||||
| 一级来源 | 使用方式 | License |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------- |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------- |
|
||||
| [Tsugu BangDream Bot](https://github.com/Yamamoto-2/tsugu-bangdream-bot) | BangDream QQBot 后端能力已重构合入 `src/modules/qqbot/plugins/bangdream/src`,保留本地 `TSUGU-LICENSE` | MIT |
|
||||
|
||||
@ -0,0 +1,807 @@
|
||||
# Admin Environment Dashboard Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将 Admin `/dashboard/analytics` 改造成多站点环境状态总览总控面板,覆盖本机开发、NAS 线上、腾讯云、r4se 远程环境,并明确展示未接入证据,第一版只做观测与只读自检,不提供高风险写操作。
|
||||
|
||||
**Architecture:** 后端在 API `admin/platform-config` 下新增环境面板聚合模块,以 `Site -> Node -> Service -> Signal` 为统一模型;HTTP dashboard/self-check 负责状态快照和只读兜底,平台级 EnvironmentEventBus 通过 local/mqtt 模式收口 topic 事件并驱动 recent events 与 cache invalidation,API SSE stream 把脱敏后的增量事件推送给 Admin;内部服务信号从现有 Nest 服务读取,远程信号通过只读适配器读取 Jenkins、Kubernetes、Tencent Cloud、Caddy、WireGuard、OpenClash/Mihomo;前端保留 Vben Dashboard 路由,替换为 A 方案布局:顶部状态条、左侧站点栏、中间拓扑、右侧证据/动作抽屉、底部事件流。Admin 不跑轮询或定时刷新。
|
||||
|
||||
**Tech Stack:** NestJS、TypeScript、TypeORM、axios、mqtt、tencentcloud-sdk-nodejs、Vben Admin、Vue 3、antdv-next、Vitest/Jest、Playwright 轻量页面烟测。
|
||||
|
||||
---
|
||||
|
||||
## Source Spec
|
||||
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\docs\superpowers\specs\2026-06-18-admin-environment-dashboard-design.md`
|
||||
- 当前确认的页面方向:方案 A `Site Command Center`
|
||||
- 第一版能力边界:观测与只读自检;重启、部署、迁移、重建容器、插件启停、任务立即执行、Caddy/OpenClash 改配置等动作必须展示为禁用。
|
||||
|
||||
## File Structure Map
|
||||
|
||||
### API Repo
|
||||
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\domain\environment-dashboard.types.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-dashboard-status.mapper.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-dashboard-action.catalog.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-event.materializer.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-dashboard.service.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-dashboard-self-check.service.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-event-stream.service.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\environment-dashboard-config.service.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\environment-dashboard-cache.service.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\environment-dashboard-evidence.mapper.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\event\environment-event-bus.service.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\event\environment-mqtt-topic.catalog.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\event\qqbot-environment-event.bridge.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\environment-readonly-http.client.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\jenkins-readonly.adapter.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\kubernetes-readonly.adapter.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\tencent-cloud-readonly.adapter.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\caddy-readonly.adapter.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\wireguard-readonly.adapter.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\mihomo-readonly.adapter.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\collectors\local-dev-signal.collector.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\collectors\nas-prod-signal.collector.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\presentation\environment-dashboard.controller.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\presentation\dto\environment-dashboard.dto.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\admin-platform-config.module.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\core\qqbot-core.module.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugin-platform\qqbot-plugin-platform.module.ts`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\.env.example`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\README.md`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\API.md`
|
||||
- `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\admin\environment-dashboard\*.spec.ts`
|
||||
|
||||
### Admin Repo
|
||||
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\api\system\environment.ts`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\index.vue`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\components\EnvironmentStatusBar.vue`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\components\EnvironmentSiteRail.vue`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\components\EnvironmentTopology.vue`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\components\EnvironmentEvidencePanel.vue`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\components\EnvironmentEventStream.vue`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\composables\useEnvironmentDashboardStream.ts`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\types.ts`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\environment-dashboard.spec.tsx`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\api\system\environment.spec.ts`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\locales\langs\zh-CN\page.json`
|
||||
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\locales\langs\en-US\page.json`
|
||||
|
||||
## Data Contract
|
||||
|
||||
The API response uses stable IDs so Admin can preserve selection across refreshes.
|
||||
|
||||
```ts
|
||||
export type EnvironmentHealthStatus =
|
||||
| 'ok'
|
||||
| 'degraded'
|
||||
| 'down'
|
||||
| 'blocked'
|
||||
| 'isolated'
|
||||
| 'unknown'
|
||||
| 'unwired';
|
||||
|
||||
export type EnvironmentSiteStatus = 'online' | 'degraded' | 'isolated' | 'unknown';
|
||||
|
||||
export type EnvironmentSignalSourceKind =
|
||||
| 'live'
|
||||
| 'cached'
|
||||
| 'derived'
|
||||
| 'configured'
|
||||
| 'external-link'
|
||||
| 'unwired';
|
||||
|
||||
export interface EnvironmentDashboardResponse {
|
||||
generatedAt: string;
|
||||
refreshedAt: string;
|
||||
summary: EnvironmentDashboardSummary;
|
||||
sites: EnvironmentSite[];
|
||||
topology: EnvironmentTopology;
|
||||
actions: EnvironmentAction[];
|
||||
events: EnvironmentEvent[];
|
||||
}
|
||||
|
||||
export interface EnvironmentEventEnvelope {
|
||||
eventId: string;
|
||||
topic: string;
|
||||
siteId: string;
|
||||
nodeId?: string;
|
||||
serviceId?: string;
|
||||
signalId?: string;
|
||||
severity: EnvironmentHealthStatus;
|
||||
sourceKind: 'local' | 'mqtt' | EnvironmentSignalSourceKind;
|
||||
observedAt: string;
|
||||
expiresAt?: string;
|
||||
retained?: boolean;
|
||||
summary: string;
|
||||
evidence?: EnvironmentEvidence[];
|
||||
}
|
||||
|
||||
export type EnvironmentStreamEventType =
|
||||
| 'environment-event'
|
||||
| 'environment-signal'
|
||||
| 'snapshot-required'
|
||||
| 'heartbeat'
|
||||
| 'error';
|
||||
```
|
||||
|
||||
Severity aggregation order is fixed:
|
||||
|
||||
```ts
|
||||
const severityWeight: Record<EnvironmentHealthStatus, number> = {
|
||||
ok: 0,
|
||||
unwired: 1,
|
||||
unknown: 1,
|
||||
degraded: 2,
|
||||
isolated: 3,
|
||||
down: 4,
|
||||
blocked: 5,
|
||||
};
|
||||
```
|
||||
|
||||
## Execution Rules
|
||||
|
||||
- Work on development branches in each touched repo; do not create `.worktree` directories.
|
||||
- No backend `.env.development`, `.env.production`, real token, SSH key, cloud secret, Jenkins token, kube token, or database password may be committed.
|
||||
- Every new or touched function/method/hook/event handler/exported arrow function must include JSDoc explaining purpose, parameter origin, return semantics, and side effects when present.
|
||||
- High-risk actions are represented by disabled action records with `enabled: false` and `disabledReason`; controller must reject execution paths because this plan does not add a generic write-action endpoint.
|
||||
- `unwired` and `unknown` are valid product states. A missing integration must be visible evidence, not a green health badge.
|
||||
- MQTT is an event layer, not the only source of truth. Retained messages require `observedAt` and `expiresAt`; expired retained messages must become `unknown` or `unwired`.
|
||||
- Admin must not connect to MQTT directly. Broker credentials and internal topics stay inside API runtime.
|
||||
- Admin must not poll or use timer-based dashboard refresh. After the first snapshot, fresh state arrives through SSE; only user actions and `snapshot-required` may trigger another dashboard request.
|
||||
- For interface changes, verify through a real local HTTP request against the Nest app or an already running local service.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Baseline Branches And Dependency Check
|
||||
|
||||
- [ ] In API repo, confirm clean state:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
git status --short
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
Expected output: no unstaged lines; branch is `main` or an existing development branch chosen by the user.
|
||||
|
||||
- [ ] Create or switch API development branch:
|
||||
```powershell
|
||||
git switch -c codex/admin-environment-dashboard
|
||||
```
|
||||
Expected output: `Switched to a new branch 'codex/admin-environment-dashboard'`.
|
||||
|
||||
- [ ] In Admin repo, confirm clean state and create branch:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Vue\kt-template-admin
|
||||
git status --short
|
||||
git switch -c codex/admin-environment-dashboard
|
||||
```
|
||||
Expected output: no unstaged lines, then new branch message.
|
||||
|
||||
- [ ] Confirm package manager and engine gates:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm pkg get packageManager
|
||||
pnpm pkg get engines
|
||||
```
|
||||
Expected output: current Node and pnpm satisfy `engines`; if not, run `nvm ls` before changing version.
|
||||
|
||||
- [ ] Install Tencent Cloud official SDK in API repo only when it is not already present:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm add tencentcloud-sdk-nodejs
|
||||
```
|
||||
Expected output: dependency added to `package.json` and lockfile. No credential values are written.
|
||||
|
||||
- [ ] Confirm the existing MQTT client dependency before adding any event-bus code:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
rg -n '"mqtt"' package.json pnpm-lock.yaml
|
||||
```
|
||||
Expected output: `mqtt` is already present in `package.json`; do not add a second broker/client package.
|
||||
|
||||
## Task 1: API Contract, Status Mapper, And RED Tests
|
||||
|
||||
- [ ] Create `environment-dashboard.types.ts` with domain interfaces for site/node/service/signal/action/event/topology. Include status unions from the spec and source metadata:
|
||||
```ts
|
||||
export interface EnvironmentSignal {
|
||||
id: string;
|
||||
label: string;
|
||||
status: EnvironmentHealthStatus;
|
||||
sourceKind: EnvironmentSignalSourceKind;
|
||||
summary: string;
|
||||
evidence: EnvironmentEvidence[];
|
||||
observedAt?: string;
|
||||
staleAfterSeconds?: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Create `environment-dashboard-status.mapper.ts` with documented helpers:
|
||||
- `pickWorstHealthStatus(statuses)`
|
||||
- `mapSiteStatus(statuses)`
|
||||
- `countSignals(sites)`
|
||||
- `normalizeObservedAt(dateLike)`
|
||||
|
||||
- [ ] Add RED test `test/modules/admin/environment-dashboard/environment-dashboard-status.spec.ts`:
|
||||
```ts
|
||||
import {
|
||||
mapSiteStatus,
|
||||
pickWorstHealthStatus,
|
||||
} from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard-status.mapper';
|
||||
|
||||
describe('environment dashboard status mapper', () => {
|
||||
it('uses blocked as the strongest signal', () => {
|
||||
expect(pickWorstHealthStatus(['ok', 'down', 'blocked'])).toBe('blocked');
|
||||
});
|
||||
|
||||
it('keeps unwired integrations visible without marking a site healthy', () => {
|
||||
expect(mapSiteStatus(['ok', 'unwired'])).toBe('unknown');
|
||||
});
|
||||
|
||||
it('marks isolated remote sites separately from ordinary degradation', () => {
|
||||
expect(mapSiteStatus(['ok', 'isolated'])).toBe('isolated');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Run RED:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard-status.spec.ts --runInBand
|
||||
```
|
||||
Expected output: fails before mapper implementation is complete because module or exported functions are missing.
|
||||
|
||||
- [ ] Implement mapper and rerun the same command.
|
||||
Expected output: test suite passes.
|
||||
|
||||
## Task 2: API Action Catalog And Disabled Write Boundaries
|
||||
|
||||
- [ ] Create `environment-dashboard-action.catalog.ts`. It must return all supported visible actions:
|
||||
- enabled readonly: `refresh-dashboard`, `run-self-check`, `open-runtime-logs`, `open-service-route`
|
||||
- disabled high-risk: `restart-api-pod`, `trigger-jenkins-deploy`, `run-db-migration`, `recreate-napcat-container`, `toggle-plugin`, `run-plugin-task-now`, `create-minio-bucket`, `wordpress-import`, `reload-caddy`, `switch-openclash`, `restart-tencent-cvm`, `modify-wireguard-peer`
|
||||
|
||||
- [ ] Add `test/modules/admin/environment-dashboard/environment-dashboard-action.catalog.spec.ts` asserting disabled actions are present and all write actions have `enabled: false`.
|
||||
|
||||
- [ ] Run RED, implement, rerun:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard-action.catalog.spec.ts --runInBand
|
||||
```
|
||||
Expected output after implementation: all action catalog tests pass.
|
||||
|
||||
## Task 3: API Evidence Helpers And Readonly HTTP Client
|
||||
|
||||
- [ ] Create `environment-dashboard-evidence.mapper.ts` with functions:
|
||||
- `liveEvidence(source, summary, observedAt, metadata)`
|
||||
- `unwiredEvidence(source, missingConfigKeys, documentationPath)`
|
||||
- `errorEvidence(source, error, observedAt)`
|
||||
- `cachedEvidence(source, summary, observedAt, expiresAt)`
|
||||
|
||||
- [ ] Create `environment-readonly-http.client.ts` wrapping axios with:
|
||||
- GET and HEAD only
|
||||
- timeout from environment dashboard config
|
||||
- response body truncation before evidence storage
|
||||
- secret-safe header handling
|
||||
|
||||
- [ ] Add tests:
|
||||
- `environment-dashboard-evidence.mapper.spec.ts`
|
||||
- `environment-readonly-http.client.spec.ts`
|
||||
|
||||
- [ ] Run targeted tests:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard-evidence.mapper.spec.ts test/modules/admin/environment-dashboard/environment-readonly-http.client.spec.ts --runInBand
|
||||
```
|
||||
Expected output: tests pass; no real network access is required because axios is mocked.
|
||||
|
||||
## Task 3A: API Platform Event Bus And MQTT Topic Contract
|
||||
|
||||
- [ ] Create `infrastructure/event/environment-mqtt-topic.catalog.ts` with documented topic builders:
|
||||
- `signal(siteId, nodeId, serviceId)`
|
||||
- `event(siteId, nodeId, serviceId)`
|
||||
- `selfCheckResult(siteId)`
|
||||
- `qqbotRuntime(selfId)`
|
||||
- `qqbotNapcatLogin(selfId)`
|
||||
- `pluginTaskRun(pluginKey, taskKey)`
|
||||
|
||||
- [ ] Create `infrastructure/event/environment-event-bus.service.ts`:
|
||||
- supports `ENV_DASHBOARD_EVENT_BUS=local|mqtt`
|
||||
- uses `ENV_DASHBOARD_MQTT_URL`, `ENV_DASHBOARD_MQTT_CLIENT_ID`, `ENV_DASHBOARD_MQTT_USERNAME`, `ENV_DASHBOARD_MQTT_PASSWORD`, and `ENV_DASHBOARD_MQTT_TOPIC_PREFIX`
|
||||
- subscribes only to the environment dashboard topic prefix
|
||||
- publishes local events through in-process subscribers for tests and dev
|
||||
- marks broker disconnect as an environment event
|
||||
- never logs broker credentials or full payloads
|
||||
|
||||
- [ ] Create `application/environment-event.materializer.ts`:
|
||||
- accepts `EnvironmentEventEnvelope`
|
||||
- rejects stale retained messages when `expiresAt` is earlier than current time
|
||||
- appends safe recent events for dashboard response
|
||||
- invalidates `environment-dashboard-cache.service.ts` when a non-stale signal event arrives
|
||||
- never turns MQTT-only data into green status without fresh `observedAt` evidence
|
||||
|
||||
- [ ] Create `infrastructure/event/qqbot-environment-event.bridge.ts`:
|
||||
- maps QQBot/NapCat runtime events into environment event envelopes
|
||||
- depends on a narrow bus interface, not QQBot topic constants inside dashboard application code
|
||||
- does not make environment dashboard depend on `QqbotBusService` implementation details
|
||||
|
||||
- [ ] Add RED/GREEN tests:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-mqtt-topic.catalog.spec.ts test/modules/admin/environment-dashboard/environment-event-bus.service.spec.ts test/modules/admin/environment-dashboard/environment-event.materializer.spec.ts test/modules/admin/environment-dashboard/qqbot-environment-event.bridge.spec.ts --runInBand
|
||||
```
|
||||
Expected output after implementation: topic mapping is deterministic, expired retained signal becomes non-green, broker disconnect creates an event without marking every service down, and QQBot bridge tests pass without importing dashboard code from QQBot-specific topic constants.
|
||||
|
||||
## Task 3B: API SSE Stream For Admin Realtime Updates
|
||||
|
||||
- [ ] Create `application/environment-event-stream.service.ts`:
|
||||
- exposes an RxJS Observable stream compatible with Nest `@Sse`
|
||||
- subscribes to `EnvironmentEventBus` / `environment-event.materializer.ts`
|
||||
- emits `environment-event`, `environment-signal`, `snapshot-required`, `heartbeat`, and `error`
|
||||
- supports browser `Last-Event-ID` or `lastEventId` query fallback for replay
|
||||
- maintains a bounded in-memory replay buffer controlled by `ENV_DASHBOARD_SSE_REPLAY_LIMIT`
|
||||
- emits `snapshot-required` when the requested event id is older than the replay buffer
|
||||
- uses `ENV_DASHBOARD_SSE_HEARTBEAT_MS` only for connection keepalive, not for dashboard refresh
|
||||
- never opens MQTT or remote HTTP connections from the SSE method itself
|
||||
|
||||
- [ ] Add RED/GREEN tests:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-event-stream.service.spec.ts --runInBand
|
||||
```
|
||||
Expected output after implementation: replay by last event id works, stale replay id emits `snapshot-required`, heartbeat does not trigger dashboard service calls, and stream payloads contain no broker credentials.
|
||||
|
||||
## Task 4: API Remote Adapters For Jenkins And Kubernetes
|
||||
|
||||
- [ ] Create `environment-dashboard-config.service.ts` that reads optional dashboard integration env vars. It returns explicit missing-key lists for each integration instead of throwing.
|
||||
|
||||
- [ ] Create `jenkins-readonly.adapter.ts`:
|
||||
- uses Jenkins Remote Access API endpoint from env
|
||||
- reads latest build/job metadata through GET
|
||||
- maps configured-but-failing response to `down` or `degraded`
|
||||
- maps missing URL/token/job to `unwired`
|
||||
- never triggers builds
|
||||
|
||||
- [ ] Create `kubernetes-readonly.adapter.ts`:
|
||||
- uses Kubernetes API server, namespace, label selector, deployment name, and bearer token from env
|
||||
- reads Deployment and Pod metadata through GET
|
||||
- maps generation mismatch or not-ready pods to `degraded`
|
||||
- maps API unreachable to `isolated`
|
||||
- maps missing config to `unwired`
|
||||
- never sends PATCH/POST/DELETE
|
||||
|
||||
- [ ] Add adapter tests:
|
||||
- missing config produces `unwired` evidence with exact missing keys
|
||||
- success response produces `live`
|
||||
- HTTP failure produces non-green status
|
||||
|
||||
- [ ] Run:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/jenkins-readonly.adapter.spec.ts test/modules/admin/environment-dashboard/kubernetes-readonly.adapter.spec.ts --runInBand
|
||||
```
|
||||
Expected output: tests pass with mocked HTTP client.
|
||||
|
||||
## Task 5: API Remote Adapters For Tencent Cloud, Caddy, WireGuard, And Mihomo
|
||||
|
||||
- [ ] Create `tencent-cloud-readonly.adapter.ts`:
|
||||
- uses `tencentcloud-sdk-nodejs`
|
||||
- reads CVM instance status and Cloud Monitor summaries only when credentials and instance IDs are configured
|
||||
- maps missing credentials to `unwired`
|
||||
- maps SDK errors to `isolated` or `unknown` with error evidence
|
||||
- stores no secret in evidence or logs
|
||||
|
||||
- [ ] Create `caddy-readonly.adapter.ts`:
|
||||
- reads configured public URL with HEAD/GET
|
||||
- optionally reads Caddy Admin API config only when admin URL is configured
|
||||
- maps missing admin API to an `unwired` signal and public URL success to `live`
|
||||
- never reloads or writes Caddy config
|
||||
|
||||
- [ ] Create `wireguard-readonly.adapter.ts`:
|
||||
- reads only configured health endpoints or public route checks for Tencent Cloud and r4se WireGuard
|
||||
- if no safe endpoint is configured, returns `unwired` with evidence explaining the absent read source
|
||||
- does not SSH into hosts in the dashboard request path
|
||||
|
||||
- [ ] Create `mihomo-readonly.adapter.ts`:
|
||||
- reads Mihomo/OpenClash REST API `GET /version`, `GET /configs`, and `GET /proxies` when URL and secret are configured
|
||||
- maps missing URL/secret to `unwired`
|
||||
- never changes selector, proxy, rule, or config
|
||||
|
||||
- [ ] Add tests for each adapter using mocked clients:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/tencent-cloud-readonly.adapter.spec.ts test/modules/admin/environment-dashboard/caddy-readonly.adapter.spec.ts test/modules/admin/environment-dashboard/wireguard-readonly.adapter.spec.ts test/modules/admin/environment-dashboard/mihomo-readonly.adapter.spec.ts --runInBand
|
||||
```
|
||||
Expected output: tests pass; Tencent SDK is mocked.
|
||||
|
||||
## Task 6: API Internal Signal Collectors
|
||||
|
||||
- [ ] Create `local-dev-signal.collector.ts` for local-dev site:
|
||||
- API process/runtime health from `RuntimeHealthService.getRuntimeHealth()`
|
||||
- local Admin proxy/config as configured source
|
||||
- local dependency gaps shown as `unknown` or `unwired`
|
||||
|
||||
- [ ] Create `nas-prod-signal.collector.ts` for NAS production site:
|
||||
- Runtime health from `RuntimeHealthService`
|
||||
- QQBot summary from `QqbotDashboardService.summary()`
|
||||
- NapCat runtime/login visibility through exported NapCat runtime port or explicitly exported service
|
||||
- plugin platform/task visibility through `QqbotPluginPlatformService` and exported task service
|
||||
- MinIO connection through existing `MinioClientService.checkConnection`
|
||||
- WordPress integration through `WordpressService.tryLoginWithConfiguredAdmin()`
|
||||
- Jenkins/K8s through adapters from Task 4
|
||||
- Loki/MySQL/Redis as configured or derived signals when no direct safe adapter exists
|
||||
|
||||
- [ ] Update module exports only where the collector needs existing services:
|
||||
- `src/modules/qqbot/core/qqbot-core.module.ts`: export `QqbotDashboardService`
|
||||
- `src/modules/qqbot/plugin-platform/qqbot-plugin-platform.module.ts`: export `QqbotPluginTaskService` if task status counts are required
|
||||
- Prefer existing exported NapCat runtime port before exporting concrete NapCat services
|
||||
|
||||
- [ ] Add `nas-prod-signal.collector.spec.ts` with mocked dependencies proving:
|
||||
- QQBot offline does not mark API down
|
||||
- one disabled plugin task appears as degraded service evidence
|
||||
- MinIO failure is contained to MinIO service
|
||||
- missing Jenkins/K8s config remains `unwired`
|
||||
|
||||
- [ ] Run:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/nas-prod-signal.collector.spec.ts --runInBand
|
||||
```
|
||||
Expected output: tests pass.
|
||||
|
||||
## Task 7: API Dashboard Aggregator, Cache, And Self-Check
|
||||
|
||||
- [ ] Create `environment-dashboard-cache.service.ts`:
|
||||
- caches successful aggregate response for `ENV_DASHBOARD_CACHE_TTL_MS`
|
||||
- marks cached signals with `sourceKind: 'cached'`
|
||||
- does not cache thrown exceptions as green results
|
||||
|
||||
- [ ] Create `environment-dashboard.service.ts`:
|
||||
- assembles four sites: `local-dev`, `nas-prod`, `tencent-cloud`, `r4se`
|
||||
- aggregates summary counters
|
||||
- merges safe recent events from `environment-event.materializer.ts`
|
||||
- builds topology edges:
|
||||
- local-dev -> API/Admin local services
|
||||
- nas-prod -> Jenkins/K8s/API/Admin/MySQL/Redis/Loki/MinIO/WordPress/QQBot/NapCat/Plugin Platform/Plugin Tasks
|
||||
- tencent-cloud -> WireGuard/Caddy
|
||||
- r4se -> WireGuard/OpenClash
|
||||
- attaches action catalog
|
||||
- returns deterministic IDs for UI selection
|
||||
|
||||
- [ ] Create `environment-dashboard-self-check.service.ts`:
|
||||
- runs the same collectors with `forceRefresh: true`
|
||||
- returns `EnvironmentDashboardResponse`
|
||||
- records event entries for failed adapters through `EnvironmentEventBus`
|
||||
- performs no write operation
|
||||
|
||||
- [ ] Add service tests:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard.service.spec.ts test/modules/admin/environment-dashboard/environment-dashboard-self-check.service.spec.ts --runInBand
|
||||
```
|
||||
Expected output: tests pass and generated topology includes all four sites.
|
||||
|
||||
## Task 8: API Controller, Module Wiring, Env Docs, And Local HTTP Smoke
|
||||
|
||||
- [ ] Create `presentation/dto/environment-dashboard.dto.ts` with Swagger DTO classes. Keep DTO shape aligned to the domain response.
|
||||
|
||||
- [ ] Create `presentation/environment-dashboard.controller.ts`:
|
||||
- `GET /system/environment/dashboard`
|
||||
- `POST /system/environment/self-check`
|
||||
- `GET /system/environment/events/stream`
|
||||
- guards: `JwtAuthGuard`
|
||||
- response wrapper: existing `vbenSuccess`
|
||||
- `events/stream` uses Nest `@Sse` and returns `EnvironmentStreamEvent`
|
||||
- JSDoc on controller methods states Admin route origin and no side effects beyond read-only probes/cache refresh/SSE subscription
|
||||
|
||||
- [ ] Update `admin-platform-config.module.ts` imports/providers/controllers. Include only required modules.
|
||||
|
||||
- [ ] Update `.env.example`, `README.md`, and `API.md` with optional env variables and read-only semantics:
|
||||
```env
|
||||
ENV_DASHBOARD_CACHE_TTL_MS=15000
|
||||
ENV_DASHBOARD_SIGNAL_TIMEOUT_MS=5000
|
||||
ENV_DASHBOARD_EVENT_BUS=local
|
||||
ENV_DASHBOARD_MQTT_URL=
|
||||
ENV_DASHBOARD_MQTT_CLIENT_ID=kt-template-online-api-environment
|
||||
ENV_DASHBOARD_MQTT_USERNAME=
|
||||
ENV_DASHBOARD_MQTT_PASSWORD=
|
||||
ENV_DASHBOARD_MQTT_TOPIC_PREFIX=kt/env
|
||||
ENV_DASHBOARD_SSE_REPLAY_LIMIT=200
|
||||
ENV_DASHBOARD_SSE_HEARTBEAT_MS=25000
|
||||
ENV_DASHBOARD_JENKINS_URL=
|
||||
ENV_DASHBOARD_JENKINS_JOB=KT-Template/KT-Template-API/main
|
||||
ENV_DASHBOARD_JENKINS_USERNAME=
|
||||
ENV_DASHBOARD_JENKINS_TOKEN=
|
||||
ENV_DASHBOARD_K8S_API_SERVER=
|
||||
ENV_DASHBOARD_K8S_NAMESPACE=kt-prod
|
||||
ENV_DASHBOARD_K8S_DEPLOYMENT=kt-template-online-api
|
||||
ENV_DASHBOARD_K8S_LABEL_SELECTOR=app=kt-template-online-api
|
||||
ENV_DASHBOARD_K8S_BEARER_TOKEN=
|
||||
ENV_DASHBOARD_TENCENT_CLOUD_ENABLED=false
|
||||
ENV_DASHBOARD_TENCENT_SECRET_ID=
|
||||
ENV_DASHBOARD_TENCENT_SECRET_KEY=
|
||||
ENV_DASHBOARD_TENCENT_REGION=
|
||||
ENV_DASHBOARD_TENCENT_INSTANCE_ID=
|
||||
ENV_DASHBOARD_CADDY_ADMIN_URL=
|
||||
ENV_DASHBOARD_CADDY_PUBLIC_URL=
|
||||
ENV_DASHBOARD_R4SE_MIHOMO_URL=
|
||||
ENV_DASHBOARD_R4SE_MIHOMO_SECRET=
|
||||
ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL=
|
||||
ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL=
|
||||
```
|
||||
|
||||
- [ ] Run API targeted tests:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard.controller.spec.ts --runInBand
|
||||
```
|
||||
Expected output: controller spec passes.
|
||||
|
||||
- [ ] Add controller spec coverage for SSE:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard.events.controller.spec.ts --runInBand
|
||||
```
|
||||
Expected output: authenticated request can subscribe to SSE, `Last-Event-ID` is passed to stream service, and no dashboard polling timer exists in API code.
|
||||
|
||||
- [ ] Run API typecheck:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
pnpm run typecheck
|
||||
```
|
||||
Expected output: no TypeScript errors.
|
||||
|
||||
- [ ] Start or reuse local API service, then make real local requests:
|
||||
```powershell
|
||||
curl.exe -H "Authorization: Bearer <local-admin-token>" http://127.0.0.1:<api-port>/system/environment/dashboard
|
||||
curl.exe -X POST -H "Authorization: Bearer <local-admin-token>" http://127.0.0.1:<api-port>/system/environment/self-check
|
||||
curl.exe -N -H "Authorization: Bearer <local-admin-token>" http://127.0.0.1:<api-port>/system/environment/events/stream
|
||||
```
|
||||
Expected output: dashboard and self-check return `code: 0` or existing success wrapper equivalent, four site records, and at least one `unwired` evidence item when remote env vars are absent; SSE stream returns `text/event-stream` and emits a heartbeat or synthetic environment event without closing immediately.
|
||||
|
||||
## Task 9: Admin API Wrapper And RED Tests
|
||||
|
||||
- [ ] Create `apps/web-antdv-next/src/api/system/environment.ts` with exported interfaces matching the API response and functions:
|
||||
- `getEnvironmentDashboard()`
|
||||
- `runEnvironmentSelfCheck()`
|
||||
- `getEnvironmentDashboardEventsUrl(lastEventId?)`
|
||||
|
||||
- [ ] Add `apps/web-antdv-next/src/api/system/environment.spec.ts`:
|
||||
```ts
|
||||
import { requestClient } from '#/api/request';
|
||||
import {
|
||||
getEnvironmentDashboard,
|
||||
getEnvironmentDashboardEventsUrl,
|
||||
runEnvironmentSelfCheck,
|
||||
} from './environment';
|
||||
|
||||
vi.mock('#/api/request', () => ({
|
||||
requestClient: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('environment dashboard api', () => {
|
||||
it('loads the aggregate dashboard', async () => {
|
||||
await getEnvironmentDashboard();
|
||||
expect(requestClient.get).toHaveBeenCalledWith('/system/environment/dashboard');
|
||||
});
|
||||
|
||||
it('runs readonly self check', async () => {
|
||||
await runEnvironmentSelfCheck();
|
||||
expect(requestClient.post).toHaveBeenCalledWith('/system/environment/self-check');
|
||||
});
|
||||
|
||||
it('builds the SSE stream url without exposing MQTT config', () => {
|
||||
expect(getEnvironmentDashboardEventsUrl()).toContain('/system/environment/events/stream');
|
||||
expect(getEnvironmentDashboardEventsUrl('evt-1')).toContain('lastEventId=evt-1');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] Run RED, implement, rerun:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Vue\kt-template-admin
|
||||
pnpm exec vitest run apps/web-antdv-next/src/api/system/environment.spec.ts
|
||||
```
|
||||
Expected output after implementation: tests pass.
|
||||
|
||||
## Task 10: Admin Component Shell And Visual States
|
||||
|
||||
- [ ] Replace the old sample content in `apps/web-antdv-next/src/views/dashboard/analytics/index.vue` with a single route root element. Do not keep old Vben analysis sample cards/charts.
|
||||
|
||||
- [ ] Create `types.ts` for view-only types. Keep it aligned with API wrapper types by importing from the API wrapper where practical.
|
||||
|
||||
- [ ] Create `EnvironmentStatusBar.vue`:
|
||||
- global status, generated/refreshed time, signal counters
|
||||
- refresh and self-check buttons using antdv-next buttons/icons
|
||||
- loading and error state
|
||||
|
||||
- [ ] Create `EnvironmentSiteRail.vue`:
|
||||
- site cards/list items for local-dev, nas-prod, tencent-cloud, r4se
|
||||
- status badge per site
|
||||
- evidence count and unwired count
|
||||
|
||||
- [ ] Create `EnvironmentTopology.vue`:
|
||||
- responsive service topology using semantic HTML/CSS grid, not canvas-only rendering
|
||||
- service nodes show status, source kind, and selected state
|
||||
- links/edges represented accessibly and remain readable on 1366px desktop
|
||||
|
||||
- [ ] Create `EnvironmentEvidencePanel.vue`:
|
||||
- selected site/service/signal details
|
||||
- evidence list grouped by source
|
||||
- action list with disabled high-risk actions visibly disabled and reason text
|
||||
- no write endpoint calls
|
||||
|
||||
- [ ] Create `EnvironmentEventStream.vue`:
|
||||
- latest events sorted newest first
|
||||
- status/source kind tags
|
||||
- compact display with stable row height
|
||||
|
||||
- [ ] Styling constraints:
|
||||
- use `antdv-next` components already present in the app
|
||||
- avoid marketing hero, gradient blobs, decorative cards inside cards
|
||||
- ensure mobile layout stacks as top status -> site rail -> topology -> evidence -> events
|
||||
- text must not overlap at 390px, 768px, 1366px, and 1920px widths
|
||||
|
||||
## Task 11: Admin Page Integration, Locales, And Tests
|
||||
|
||||
- [ ] Wire `index.vue`:
|
||||
- load dashboard once on mount
|
||||
- start `useEnvironmentDashboardStream.ts` EventSource after the first snapshot succeeds
|
||||
- refresh with `getEnvironmentDashboard`
|
||||
- self-check with `runEnvironmentSelfCheck`
|
||||
- process `environment-event` and `environment-signal` without fetching dashboard again
|
||||
- process `snapshot-required` by running exactly one dashboard snapshot request
|
||||
- ignore `heartbeat` for state updates
|
||||
- preserve selected site/service when IDs still exist after refresh
|
||||
- select first degraded/down/blocked service by default, else first site
|
||||
- display explicit empty/error state when API fails
|
||||
- close EventSource on route leave/unmount
|
||||
|
||||
- [ ] Create `composables/useEnvironmentDashboardStream.ts`:
|
||||
- wraps browser `EventSource`
|
||||
- uses `getEnvironmentDashboardEventsUrl(lastEventId?)`
|
||||
- stores last accepted event id in component state only
|
||||
- exposes connection state for the status bar
|
||||
- does not use `setInterval`, `setTimeout` refresh loops, or background polling
|
||||
- lets native EventSource reconnect; after server sends `snapshot-required`, caller performs one snapshot fetch
|
||||
|
||||
- [ ] Update locale title from analytics sample to environment dashboard:
|
||||
- `apps/web-antdv-next/src/locales/langs/zh-CN/page.json`: `环境总览`
|
||||
- `apps/web-antdv-next/src/locales/langs/en-US/page.json`: `Environment`
|
||||
|
||||
- [ ] Add page spec `environment-dashboard.spec.tsx`:
|
||||
- mocked API returns all four sites
|
||||
- disabled write actions render disabled
|
||||
- unwired Jenkins/K8s evidence renders as evidence, not healthy state
|
||||
- MQTT-origin recent event renders only after API returns it; page does not instantiate an MQTT client
|
||||
- SSE `environment-signal` updates one node without calling `getEnvironmentDashboard`
|
||||
- SSE `snapshot-required` calls `getEnvironmentDashboard` once
|
||||
- no dashboard polling or timer-based refresh is registered
|
||||
- self-check button calls the POST wrapper
|
||||
|
||||
- [ ] Run:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Vue\kt-template-admin
|
||||
pnpm exec vitest run apps/web-antdv-next/src/views/dashboard/analytics/environment-dashboard.spec.tsx
|
||||
```
|
||||
Expected output: tests pass.
|
||||
|
||||
- [ ] Run Admin typecheck:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Vue\kt-template-admin
|
||||
pnpm -F @vben/web-antdv-next run typecheck
|
||||
```
|
||||
Expected output: no TypeScript errors.
|
||||
|
||||
## Task 12: Local Page Smoke And Interface Smoke
|
||||
|
||||
- [ ] Start or reuse local API and Admin dev servers. Record ports and process IDs under `.kt-workspace/test-artifacts/environment-dashboard/`.
|
||||
|
||||
- [ ] In the in-app browser or Playwright, open `/dashboard/analytics` and verify:
|
||||
- top global status bar is visible
|
||||
- left site rail contains local-dev, nas-prod, tencent-cloud, r4se
|
||||
- topology contains Jenkins, K8s, QQBot/NapCat, Plugin Tasks, Tencent Caddy/WireGuard, r4se OpenClash/WireGuard
|
||||
- right panel shows evidence for selected item
|
||||
- bottom event stream appears
|
||||
- event stream can display an API-provided MQTT-origin event without exposing broker configuration in frontend state
|
||||
- Network tab shows one dashboard snapshot and one `events/stream` long connection after page load, with no repeated dashboard polling
|
||||
- closing/reopening the route closes the old EventSource before creating a new one
|
||||
- Jenkins/K8s missing config appears as `unwired` or `unknown`, never green
|
||||
- disabled write actions cannot be clicked
|
||||
|
||||
- [ ] Capture screenshots:
|
||||
- desktop 1366x768
|
||||
- wide desktop 1920x1080
|
||||
- mobile 390x844
|
||||
Store under `.kt-workspace/test-artifacts/environment-dashboard/`.
|
||||
|
||||
- [ ] Make one real local HTTP call from Admin session to both endpoints through the browser network panel or request logs. Evidence must include endpoint, HTTP status, and presence of four site IDs.
|
||||
- [ ] Make one real local SSE connection from Admin session. Evidence must include endpoint, HTTP status, `text/event-stream`, and no repeated dashboard GET requests after the initial snapshot.
|
||||
|
||||
- [ ] Stop Node/Vite processes started by this task if they were not already running before the task.
|
||||
|
||||
## Task 13: KT Loops, Review, Commit, And Push Gate
|
||||
|
||||
- [ ] Run documentation sync check for changed files:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT
|
||||
pnpm --dir mcp/ktWorkflow run change-doc-sync -- --project Node/kt-template-online-api --changed "src/modules/admin/platform-config/environment-dashboard,.env.example,README.md,API.md"
|
||||
pnpm --dir mcp/ktWorkflow run change-doc-sync -- --project Vue/kt-template-admin --changed "apps/web-antdv-next/src/views/dashboard/analytics,apps/web-antdv-next/src/api/system/environment.ts"
|
||||
```
|
||||
Expected output: either required docs are updated or the script records that no additional docs are required.
|
||||
|
||||
- [ ] Run cleanup dry-run and execute only stale `.kt-workspace` cleanup if reported:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT
|
||||
pnpm --dir mcp/ktWorkflow run cleanup-history -- --dry-run
|
||||
```
|
||||
Expected output: if `deleted=[]`, no execute run is needed; if stale files are listed, run with `--execute` and rerun dry-run.
|
||||
|
||||
- [ ] Run global review for API and Admin:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT
|
||||
pnpm --dir mcp/ktWorkflow run global-review -- --project Node/kt-template-online-api
|
||||
pnpm --dir mcp/ktWorkflow run global-review -- --project Vue/kt-template-admin
|
||||
```
|
||||
Expected output: no Important findings remain. Any finding that is a false positive must be documented with evidence.
|
||||
|
||||
- [ ] Update `D:\MyFiles\KT\TASKS.md` with concise scope, changed files, validation evidence, and remaining online env wiring gaps.
|
||||
|
||||
- [ ] Commit API repo:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||
git status --short
|
||||
git add package.json pnpm-lock.yaml .env.example README.md API.md src/modules/admin/platform-config src/modules/qqbot/core/qqbot-core.module.ts src/modules/qqbot/plugin-platform/qqbot-plugin-platform.module.ts test/modules/admin/environment-dashboard docs/superpowers/plans/2026-06-18-admin-environment-dashboard-implementation-plan.md
|
||||
git commit -m "feat: 新增环境总览总控面板接口"
|
||||
```
|
||||
Expected output: one API commit. Do not stage secrets.
|
||||
|
||||
- [ ] Commit Admin repo:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT\Vue\kt-template-admin
|
||||
git status --short
|
||||
git add apps/web-antdv-next/src/api/system/environment.ts apps/web-antdv-next/src/api/system/environment.spec.ts apps/web-antdv-next/src/views/dashboard/analytics apps/web-antdv-next/src/locales/langs/zh-CN/page.json apps/web-antdv-next/src/locales/langs/en-US/page.json
|
||||
git commit -m "feat: 重构分析页为环境总览总控面板"
|
||||
```
|
||||
Expected output: one Admin commit.
|
||||
|
||||
- [ ] Commit KT root if `TASKS.md` changed:
|
||||
```powershell
|
||||
cd D:\MyFiles\KT
|
||||
git status --short
|
||||
git add TASKS.md
|
||||
git commit -m "docs: 同步环境总览面板任务状态"
|
||||
```
|
||||
Expected output: one root coordination commit.
|
||||
|
||||
- [ ] Push only after the user explicitly asks for push or deployment:
|
||||
```powershell
|
||||
git push origin codex/admin-environment-dashboard
|
||||
```
|
||||
Expected output: remote branch pushed. If push triggers deployment, Jenkins/K8s observation and online self-test are required before claiming online completion.
|
||||
|
||||
## Online Wiring Checklist After Implementation
|
||||
|
||||
When the user asks to deploy or test online, wire environment variables in the online secret/config system without committing values:
|
||||
|
||||
- Jenkins read-only URL/job/user/token.
|
||||
- K8s API server/namespace/deployment/selector/bearer token with read-only RBAC.
|
||||
- Tencent Cloud official SDK credentials scoped to CVM/Cloud Monitor read-only APIs.
|
||||
- Tencent Cloud Caddy public URL and optional Admin API URL if safely reachable.
|
||||
- r4se Mihomo/OpenClash read-only API URL and secret.
|
||||
- WireGuard health endpoints for Tencent Cloud and r4se, or explicit unwired status if no safe read endpoint exists.
|
||||
|
||||
Online completion evidence must include:
|
||||
|
||||
- `GET /system/environment/dashboard` returns four sites.
|
||||
- `POST /system/environment/self-check` returns fresh evidence.
|
||||
- `GET /system/environment/events/stream` establishes an SSE stream; a backend environment event reaches Admin without polling.
|
||||
- MQTT event bus is either `local` with explicit configured evidence, or `mqtt` with broker connection status shown as evidence.
|
||||
- A synthetic non-secret MQTT signal event invalidates dashboard cache and appears in recent events; an expired retained signal does not create a green status.
|
||||
- Jenkins/K8s nodes show live evidence when configured, or explicit missing-key evidence when not configured.
|
||||
- Tencent Cloud and r4se nodes do not show green health without a live/configured source.
|
||||
- Admin page displays the same state without console errors.
|
||||
@ -0,0 +1,306 @@
|
||||
# QQBot NapCat Device Name And Timezone Hardening Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Restore a visible QQ security-page device name while keeping NapCat device identity persistent, fix mixed UTC/Asia-Shanghai timestamps, and make runtime/profile evidence persist online.
|
||||
|
||||
**Architecture:** Use upstream NapCat evidence as the source of truth: QQNT Linux reads `os.hostname()` for `hostName`/`devName`, new-device OIDB sends `str_dev_name: os.hostname()`, and Linux GUID is based on `/etc/machine-id + machine-info MAC`. The online regression evidence shows the new container exposes `ubuntu-pc-*`, but Docker MAC was changed to a physical OUI while QQNT `machine-info` still stored the old `02:42:*` MAC, creating inconsistent device identity. Generate a short stable QQNT-safe hostname that the upstream Docker entrypoint will not rewrite, return MAC strategy to upstream-compatible stable Docker bridge `02:42:*`, write matching `machine-info`, persist profile rows after container creation, and set MySQL connection timezone explicitly.
|
||||
|
||||
**Tech Stack:** NestJS, TypeORM, MySQL, NapCat-Docker, Jest, PowerShell/NAS SSH smoke.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify `src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts`
|
||||
- Generate short stable hostname `pc-<8 hex>` to avoid NapCat-Docker entrypoint reset and avoid long desktop names that regress to `未知设备`.
|
||||
- Generate stable upstream-compatible Docker bridge MAC `02:42:*`.
|
||||
- Migrate existing `desktop-hostname-v1` / `physical-oui-v1` identities back to QQNT-safe defaults with evidence.
|
||||
- Modify `src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts`
|
||||
- Carry `machineInfoPath` and hyphen MAC for QQNT Linux `machine-info`.
|
||||
- Modify `src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts`
|
||||
- Write `device.env`, `/etc/machine-id`, and QQNT `machine-info` consistently.
|
||||
- Create and mount a persistent `XDG_RUNTIME_DIR`.
|
||||
- Update `napcat_account_binding.device_identity_id` after create/rebuild.
|
||||
- Persist runtime/protocol profile after successful container creation.
|
||||
- Modify `src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts`
|
||||
- Persist planned runtime and protocol profile rows when repositories are injected.
|
||||
- Modify `src/app.module.ts`
|
||||
- Set TypeORM MySQL timezone from `DB_TIMEZONE`, default `+08:00`.
|
||||
- Modify `src/runtime/config/runtime-config.types.ts` and `src/runtime/config/runtime-config.service.ts`
|
||||
- Expose DB timezone in runtime health evidence.
|
||||
- Modify `ci/napcat-desktop-cn/Dockerfile`
|
||||
- Ensure the derived image keeps `zh_CN.UTF-8` available and does not get shadowed by upstream `C.UTF-8`.
|
||||
- Modify `.env.example`, `README.md`, `API.md`, `sql/qqbot-init.sql`, `sql/refactor-v3/00-full-schema.sql`, `sql/refactor-v3/99-verify.sql`
|
||||
- Document/align defaults and schema verification.
|
||||
- Modify tests:
|
||||
- `test/modules/qqbot/napcat/device-identity.spec.ts`
|
||||
- `test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts`
|
||||
- `test/runtime/runtime-config.service.spec.ts`
|
||||
- Add `test/app/typeorm-timezone-config.spec.ts`
|
||||
|
||||
## Task 1: Device Identity Regression RED/GREEN
|
||||
|
||||
**Files:**
|
||||
- Modify: `test/modules/qqbot/napcat/device-identity.spec.ts`
|
||||
- Modify: `src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts`
|
||||
- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Add expectations that new identities use a QQNT-safe visible hostname and upstream-compatible MAC:
|
||||
|
||||
```ts
|
||||
expect(identity.hostname).toMatch(/^pc-[a-f0-9]{8}$/);
|
||||
expect(identity.hostname).not.toMatch(/^[a-f0-9]{12,}$/);
|
||||
expect(identity.macAddress).toMatch(/^02:42:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}$/);
|
||||
expect(identity.hostnameStrategy).toBe('qqnt-visible-hostname-v1');
|
||||
expect(identity.macStrategy).toBe('docker-bridge-mac-v1');
|
||||
```
|
||||
|
||||
Also update the migration test so a legacy `ubuntu-pc-*` / physical OUI identity migrates to the new strategies and records `trigger: 'qqnt-device-name-regression-repair'`.
|
||||
|
||||
- [x] **Step 2: Run RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: FAIL because current code emits `ubuntu-pc-*` and physical OUI MAC.
|
||||
|
||||
- [x] **Step 3: Implement identity generation**
|
||||
|
||||
Change the service to:
|
||||
|
||||
```ts
|
||||
private buildQqntVisibleHostname(seed: string) {
|
||||
const hash = createHash('sha256').update(seed).digest('hex');
|
||||
return `pc-${hash.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
private buildDockerBridgeMacAddress(accountId: string, containerName: string) {
|
||||
const hash = createHash('sha256')
|
||||
.update(`${accountId}:${containerName}:docker-bridge-mac-v1`)
|
||||
.digest('hex');
|
||||
return `02:42:${hash.slice(0, 2)}:${hash.slice(2, 4)}:${hash.slice(4, 6)}:${hash.slice(6, 8)}`.toLowerCase();
|
||||
}
|
||||
```
|
||||
|
||||
Update migration predicates to migrate too-long hostnames, pure 12+ hex hostnames, `desktop-hostname-v1`, `physical-oui-v1`, rejected virtual prefixes outside `02:42`, and values containing `qq|bot|napcat|docker|container|lxc`.
|
||||
|
||||
- [x] **Step 4: Run GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 2: Docker Script Machine-Info And Binding RED/GREEN
|
||||
|
||||
**Files:**
|
||||
- Modify: `test/modules/qqbot/napcat/device-identity.spec.ts`
|
||||
- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts`
|
||||
- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Assert the generated script writes QQNT machine-info with a backup, mounts persistent runtime dir, and updates binding `deviceIdentityId`:
|
||||
|
||||
```ts
|
||||
expect(createScript).toContain('MACHINE_INFO_PATH=');
|
||||
expect(createScript).toContain('MAC_HYPHEN=');
|
||||
expect(createScript).toContain("printf '\\\\000\\\\000\\\\000\\\\021'");
|
||||
expect(createScript).toContain("tr 'A-Za-z' 'N-ZA-Mn-za-m'");
|
||||
expect(createScript).toContain('-v "$DATA_DIR/runtime:/tmp/runtime-napcat"');
|
||||
expect(bindingRepository.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ accountId: 'account-10001', containerId: 'container-created' }),
|
||||
expect.objectContaining({ deviceIdentityId: expect.any(String) }),
|
||||
);
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: FAIL because `machine-info` and binding updates are not implemented.
|
||||
|
||||
- [x] **Step 3: Implement script and binding update**
|
||||
|
||||
Add `machineInfoPath` and `macAddressHyphen` to `NapcatDockerDeviceOptions`. In `buildRemoteCreateScript`, write:
|
||||
|
||||
```sh
|
||||
MACHINE_INFO_PATH="$DATA_DIR/QQ/nt_qq/global/nt_data/msf/machine-info"
|
||||
NAPCAT_MAC_HYPHEN="$(printf '%s' "$NAPCAT_MAC_ADDRESS" | tr ':' '-')"
|
||||
mkdir -p "$(dirname "$MACHINE_INFO_PATH")"
|
||||
if [ -s "$MACHINE_INFO_PATH" ]; then
|
||||
CURRENT_MACHINE_INFO="$(mktemp)"
|
||||
cp "$MACHINE_INFO_PATH" "$CURRENT_MACHINE_INFO"
|
||||
fi
|
||||
{
|
||||
printf '\000\000\000\021'
|
||||
printf '%s' "$MAC_HYPHEN" | tr 'A-Za-z' 'N-ZA-Mn-za-m'
|
||||
} > "$MACHINE_INFO_PATH"
|
||||
```
|
||||
|
||||
After successful create/rebuild, update `napcat_account_binding.device_identity_id` for the account/container pair.
|
||||
|
||||
- [x] **Step 4: Run GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 3: Runtime/Profile Persistence RED/GREEN
|
||||
|
||||
**Files:**
|
||||
- Modify: `test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts`
|
||||
- Modify: `src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts`
|
||||
- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Add a test that calls `recordPlannedProfiles()` and expects both repositories to save rows:
|
||||
|
||||
```ts
|
||||
expect(runtimeProfileRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: 'account-1',
|
||||
deviceIdentityId: 'identity-1',
|
||||
hostnameStrategy: 'qqnt-visible-hostname-v1',
|
||||
macStrategy: 'docker-bridge-mac-v1',
|
||||
profileStatus: 'pending',
|
||||
}),
|
||||
);
|
||||
expect(protocolProfileRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: 'account-1',
|
||||
o3HookMode: 1,
|
||||
packetBackend: 'auto',
|
||||
profileStatus: 'pending',
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: FAIL because the service currently does not persist generated profiles.
|
||||
|
||||
- [x] **Step 3: Implement persistence**
|
||||
|
||||
Add optional injected repositories to `NapcatRuntimeProfileService`, implement `recordPlannedProfiles(input)`, and call it after successful Docker create/rebuild with the runtime snapshot and config bundle hashes.
|
||||
|
||||
- [x] **Step 4: Run GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 4: MySQL Timezone RED/GREEN
|
||||
|
||||
**Files:**
|
||||
- Add: `test/app/typeorm-timezone-config.spec.ts`
|
||||
- Modify: `src/app.module.ts`
|
||||
- Modify: `src/runtime/config/runtime-config.types.ts`
|
||||
- Modify: `src/runtime/config/runtime-config.service.ts`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
Extract a small exported helper from `app.module.ts` or test the factory through metadata so the returned TypeORM options include:
|
||||
|
||||
```ts
|
||||
expect(options.timezone).toBe('+08:00');
|
||||
```
|
||||
|
||||
Also assert `RuntimeConfigService.readDatabaseProfile()` reports `timezone: '+08:00'`.
|
||||
|
||||
- [x] **Step 2: Run RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec jest --runTestsByPath test/app/typeorm-timezone-config.spec.ts test/runtime/runtime-config.service.spec.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: FAIL because no DB timezone is configured or exposed.
|
||||
|
||||
- [x] **Step 3: Implement timezone config**
|
||||
|
||||
Set TypeORM MySQL `timezone` to `configService.get('DB_TIMEZONE') || '+08:00'`, and expose the same field from runtime health config.
|
||||
|
||||
- [x] **Step 4: Run GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec jest --runTestsByPath test/app/typeorm-timezone-config.spec.ts test/runtime/runtime-config.service.spec.ts --runInBand
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 5: Image, SQL, Docs, And Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `ci/napcat-desktop-cn/Dockerfile`
|
||||
- Modify: `.env.example`
|
||||
- Modify: `README.md`
|
||||
- Modify: `API.md`
|
||||
- Modify: `sql/qqbot-init.sql`
|
||||
- Modify: `sql/refactor-v3/00-full-schema.sql`
|
||||
- Modify: `sql/refactor-v3/99-verify.sql`
|
||||
- Modify: `TASKS.md`
|
||||
|
||||
- [x] **Step 1: Keep derived image actually Chinese-locale capable**
|
||||
|
||||
Ensure the Dockerfile installs `locales`, generates `zh_CN.UTF-8`, and sets `LC_ALL=zh_CN.UTF-8`. The verification script must fail if `locale -a` lacks `zh_CN.utf8`.
|
||||
|
||||
- [x] **Step 2: Align SQL defaults and docs**
|
||||
|
||||
Document `DB_TIMEZONE=+08:00`, `QQBOT_NAPCAT_IMAGE` derived image usage, and device identity strategy names. Update SQL verify checks for `device_identity_id`, strategy columns, and profile tables.
|
||||
|
||||
- [x] **Step 3: Run focused validation**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts test/app/typeorm-timezone-config.spec.ts test/runtime/runtime-config.service.spec.ts --runInBand
|
||||
pnpm run typecheck
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 4: Online controlled repair after deploy**
|
||||
|
||||
After commit/push/deploy, run a scoped online repair:
|
||||
|
||||
1. Backup `napcat_device_identity`, `napcat_account_binding`, `napcat_runtime_profile`, `napcat_protocol_profile`, `napcat_login_session`.
|
||||
2. Restart/recreate NapCat containers through the managed API path so code writes DB binding/profile rows and Docker machine-info.
|
||||
3. Verify:
|
||||
- `napcat_account_binding.device_identity_id` is not null for enabled accounts.
|
||||
- profile tables have rows for enabled accounts.
|
||||
- each running container has hostname `pc-<8 hex>`, Docker MAC `02:42:*`, `/etc/machine-id`, decoded `machine-info` MAC equal to Docker MAC, `TZ=Asia/Shanghai`, writable XDG runtime dir, and `locale -a` includes `zh_CN.utf8`.
|
||||
- MySQL `NOW()` offset or API session rows no longer mix UTC and CST fields.
|
||||
4. Ask user to scan one login QR if QQ requires new-device verification; then confirm QQ mobile page shows a non-unknown device name.
|
||||
File diff suppressed because it is too large
Load Diff
@ -72,6 +72,7 @@
|
||||
"skia-canvas": "^1.0.2",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"svg2img": "1.0.0-beta.2",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.252",
|
||||
"typeorm": "^0.3.28",
|
||||
"unified": "^11.0.5",
|
||||
"ws": "^8.21.0",
|
||||
|
||||
@ -125,6 +125,9 @@ importers:
|
||||
svg2img:
|
||||
specifier: 1.0.0-beta.2
|
||||
version: 1.0.0-beta.2
|
||||
tencentcloud-sdk-nodejs:
|
||||
specifier: ^4.1.252
|
||||
version: 4.1.252
|
||||
typeorm:
|
||||
specifier: ^0.3.28
|
||||
version: 0.3.30(ioredis@5.10.1)(mysql2@3.22.4(@types/node@22.19.19))(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))
|
||||
@ -1638,6 +1641,9 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
@ -2383,6 +2389,10 @@ packages:
|
||||
typescript: '>3.6.0'
|
||||
webpack: ^5.11.0
|
||||
|
||||
form-data@3.0.5:
|
||||
resolution: {integrity: sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
@ -2647,6 +2657,10 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ini@2.0.0:
|
||||
resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ioredis@5.10.1:
|
||||
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
@ -2942,6 +2956,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-bigint@1.0.0:
|
||||
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
|
||||
|
||||
json-buffer@3.0.1:
|
||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||
|
||||
@ -4131,6 +4148,10 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
tencentcloud-sdk-nodejs@4.1.252:
|
||||
resolution: {integrity: sha512-wt7Av7YmYqif2sxWzlqSmUpx9zfGx6yvvGyDuGZPhyLkorKAZjH9poOrDDpIhVI1h/yYjfFr7/mjUgcyXmhGIg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
terser-webpack-plugin@5.6.1:
|
||||
resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
@ -4311,6 +4332,9 @@ packages:
|
||||
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tslib@1.13.0:
|
||||
resolution: {integrity: sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@ -4476,6 +4500,11 @@ packages:
|
||||
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
v8-compile-cache-lib@3.0.1:
|
||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||
|
||||
@ -6384,6 +6413,8 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.33: {}
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
bl@4.1.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
@ -7129,6 +7160,14 @@ snapshots:
|
||||
typescript: 5.9.3
|
||||
webpack: 5.106.0
|
||||
|
||||
form-data@3.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
@ -7495,6 +7534,8 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ini@2.0.0: {}
|
||||
|
||||
ioredis@5.10.1:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.5.1
|
||||
@ -7976,6 +8017,10 @@ snapshots:
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-bigint@1.0.0:
|
||||
dependencies:
|
||||
bignumber.js: 9.3.1
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
@ -9398,6 +9443,21 @@ snapshots:
|
||||
mkdirp: 1.0.4
|
||||
yallist: 4.0.0
|
||||
|
||||
tencentcloud-sdk-nodejs@4.1.252:
|
||||
dependencies:
|
||||
form-data: 3.0.5
|
||||
get-stream: 6.0.1
|
||||
https-proxy-agent: 5.0.1
|
||||
ini: 2.0.0
|
||||
is-stream: 2.0.1
|
||||
json-bigint: 1.0.0
|
||||
node-fetch: 2.7.0
|
||||
tslib: 1.13.0
|
||||
uuid: 9.0.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
terser-webpack-plugin@5.6.1(webpack@5.106.0):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
@ -9544,6 +9604,8 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-bom: 3.0.0
|
||||
|
||||
tslib@1.13.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
@ -9683,6 +9745,8 @@ snapshots:
|
||||
|
||||
uuid@11.1.1: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
v8-compile-cache-lib@3.0.1: {}
|
||||
|
||||
v8-to-istanbul@9.3.0:
|
||||
|
||||
@ -197,3 +197,10 @@ WHERE table_schema = DATABASE()
|
||||
AND table_name = 'napcat_device_identity'
|
||||
AND column_name = 'mac_strategy'
|
||||
AND column_type = 'varchar(64)';
|
||||
|
||||
SELECT 'column_napcat_account_binding_device_identity_id' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'napcat_account_binding'
|
||||
AND column_name = 'device_identity_id'
|
||||
AND column_type = 'bigint';
|
||||
|
||||
@ -22,6 +22,29 @@ import { QqbotPluginPlatformModule } from './modules/qqbot/plugin-platform/plugi
|
||||
import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module';
|
||||
import { RuntimeModule } from './runtime';
|
||||
|
||||
/**
|
||||
* Builds TypeORM MySQL options from runtime config with an explicit session timezone.
|
||||
* @param configService - Nest ConfigService source for DB connection fields and DB_TIMEZONE override.
|
||||
* @returns TypeORM MySQL options consumed by AppModule startup.
|
||||
*/
|
||||
export function buildTypeOrmOptions(configService: ConfigService) {
|
||||
return {
|
||||
type: 'mysql' as const,
|
||||
host: configService.get('DB_HOST'),
|
||||
port: configService.get('DB_PORT'),
|
||||
username: configService.get('DB_USERNAME'),
|
||||
password: configService.get('DB_PASSWORD'),
|
||||
database: configService.get('DB_DATABASE'),
|
||||
timezone: configService.get<string>('DB_TIMEZONE') || '+08:00',
|
||||
synchronize: configService.get<string>('DB_SYNC') === 'true',
|
||||
entities: [
|
||||
__dirname + '/**/*.entity{.ts,.js}',
|
||||
__dirname + '/**/*.entities{.ts,.js}',
|
||||
],
|
||||
subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'],
|
||||
};
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
@ -44,22 +67,8 @@ import { RuntimeModule } from './runtime';
|
||||
* 创建 模块依赖注入工厂产物。
|
||||
* @param configService - Nest ConfigService 依赖;使用 `get` 字段生成结果。
|
||||
*/
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
type: 'mysql',
|
||||
host: configService.get('DB_HOST'),
|
||||
port: configService.get('DB_PORT'),
|
||||
username: configService.get('DB_USERNAME'),
|
||||
password: configService.get('DB_PASSWORD'),
|
||||
database: configService.get('DB_DATABASE'),
|
||||
synchronize: configService.get<string>('DB_SYNC') === 'true',
|
||||
entities: [
|
||||
__dirname + '/**/*.entity{.ts,.js}',
|
||||
__dirname + '/**/*.entities{.ts,.js}',
|
||||
],
|
||||
subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'],
|
||||
};
|
||||
},
|
||||
useFactory: async (configService: ConfigService) =>
|
||||
buildTypeOrmOptions(configService),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
MinioModule.registerAsync({
|
||||
|
||||
@ -14,11 +14,33 @@ import { AdminTimezoneController } from '@/modules/admin/platform-config/timezon
|
||||
import { AdminTimezoneService } from '@/modules/admin/platform-config/timezone/admin-timezone.service';
|
||||
import { AdminUser } from '@/modules/admin/identity/user/admin-user.entity';
|
||||
import { AssetModule } from '@/modules/asset/asset.module';
|
||||
import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module';
|
||||
import { QqbotPluginPlatformModule } from '@/modules/qqbot/plugin-platform/plugin-platform.module';
|
||||
import { RuntimeModule } from '@/runtime/runtime.module';
|
||||
import { WordpressMirrorModule } from '@/modules/wordpress/wordpress-mirror.module';
|
||||
import { EnvironmentDashboardService } from './environment-dashboard/application/environment-dashboard.service';
|
||||
import { EnvironmentDashboardSelfCheckService } from './environment-dashboard/application/environment-dashboard-self-check.service';
|
||||
import { EnvironmentEventMaterializer } from './environment-dashboard/application/environment-event.materializer';
|
||||
import { EnvironmentEventStreamService } from './environment-dashboard/application/environment-event-stream.service';
|
||||
import { EnvironmentDashboardController } from './environment-dashboard/presentation/environment-dashboard.controller';
|
||||
import { CaddyReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/caddy-readonly.adapter';
|
||||
import { EnvironmentReadonlyHttpClient } from './environment-dashboard/infrastructure/adapters/environment-readonly-http.client';
|
||||
import { JenkinsReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/jenkins-readonly.adapter';
|
||||
import { KubernetesReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/kubernetes-readonly.adapter';
|
||||
import { MihomoReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/mihomo-readonly.adapter';
|
||||
import { TencentCloudReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/tencent-cloud-readonly.adapter';
|
||||
import { WireguardReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/wireguard-readonly.adapter';
|
||||
import { LocalDevSignalCollector } from './environment-dashboard/infrastructure/collectors/local-dev-signal.collector';
|
||||
import { NasProdSignalCollector } from './environment-dashboard/infrastructure/collectors/nas-prod-signal.collector';
|
||||
import { EnvironmentDashboardCacheService } from './environment-dashboard/infrastructure/environment-dashboard-cache.service';
|
||||
import { EnvironmentDashboardConfigService } from './environment-dashboard/infrastructure/environment-dashboard-config.service';
|
||||
import { EnvironmentEventBusService } from './environment-dashboard/infrastructure/event/environment-event-bus.service';
|
||||
|
||||
export const ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS = [
|
||||
ComponentController,
|
||||
SystemLogController,
|
||||
AdminTimezoneController,
|
||||
EnvironmentDashboardController,
|
||||
];
|
||||
|
||||
export const ADMIN_PLATFORM_CONFIG_IMPORTED_CONTROLLERS = [
|
||||
@ -35,6 +57,22 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
|
||||
ComponentService,
|
||||
SystemLogService,
|
||||
AdminTimezoneService,
|
||||
EnvironmentDashboardService,
|
||||
EnvironmentDashboardSelfCheckService,
|
||||
EnvironmentDashboardCacheService,
|
||||
EnvironmentDashboardConfigService,
|
||||
LocalDevSignalCollector,
|
||||
NasProdSignalCollector,
|
||||
EnvironmentReadonlyHttpClient,
|
||||
JenkinsReadonlyAdapter,
|
||||
KubernetesReadonlyAdapter,
|
||||
TencentCloudReadonlyAdapter,
|
||||
CaddyReadonlyAdapter,
|
||||
WireguardReadonlyAdapter,
|
||||
MihomoReadonlyAdapter,
|
||||
EnvironmentEventBusService,
|
||||
EnvironmentEventMaterializer,
|
||||
EnvironmentEventStreamService,
|
||||
];
|
||||
|
||||
@Module({
|
||||
@ -44,6 +82,10 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
|
||||
DictModule,
|
||||
NoticeModule,
|
||||
AssetModule,
|
||||
RuntimeModule,
|
||||
QqbotCoreModule,
|
||||
QqbotPluginPlatformModule,
|
||||
WordpressMirrorModule,
|
||||
],
|
||||
controllers: ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS,
|
||||
providers: ADMIN_PLATFORM_CONFIG_PROVIDERS,
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import type { EnvironmentAction } from '../domain/environment-dashboard.types';
|
||||
|
||||
export const HIGH_RISK_ENVIRONMENT_ACTION_IDS = [
|
||||
'restart-api-pod',
|
||||
'trigger-jenkins-deploy',
|
||||
'run-db-migration',
|
||||
'recreate-napcat-container',
|
||||
'toggle-plugin',
|
||||
'run-plugin-task-now',
|
||||
'create-minio-bucket',
|
||||
'wordpress-import',
|
||||
'reload-caddy',
|
||||
'switch-openclash',
|
||||
'restart-tencent-cvm',
|
||||
'modify-wireguard-peer',
|
||||
] as const;
|
||||
|
||||
const READONLY_ACTIONS: EnvironmentAction[] = [
|
||||
{
|
||||
enabled: true,
|
||||
id: 'refresh-dashboard',
|
||||
kind: 'readonly',
|
||||
label: '刷新快照',
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
id: 'run-self-check',
|
||||
kind: 'readonly',
|
||||
label: '运行只读自检',
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
id: 'open-runtime-logs',
|
||||
kind: 'readonly',
|
||||
label: '打开运行日志',
|
||||
riskLevel: 'low',
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
id: 'open-service-route',
|
||||
kind: 'readonly',
|
||||
label: '打开服务入口',
|
||||
riskLevel: 'low',
|
||||
},
|
||||
];
|
||||
|
||||
const highRiskLabels: Record<
|
||||
(typeof HIGH_RISK_ENVIRONMENT_ACTION_IDS)[number],
|
||||
string
|
||||
> = {
|
||||
'create-minio-bucket': '创建 MinIO Bucket',
|
||||
'modify-wireguard-peer': '修改 WireGuard Peer',
|
||||
'recreate-napcat-container': '重建 NapCat 容器',
|
||||
'reload-caddy': '重载 Caddy',
|
||||
'restart-api-pod': '重启 API Pod',
|
||||
'restart-tencent-cvm': '重启腾讯云 CVM',
|
||||
'run-db-migration': '执行数据库迁移',
|
||||
'run-plugin-task-now': '立即运行插件任务',
|
||||
'switch-openclash': '切换 OpenClash 策略',
|
||||
'toggle-plugin': '启停插件',
|
||||
'trigger-jenkins-deploy': '触发 Jenkins 部署',
|
||||
'wordpress-import': '导入 WordPress 内容',
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the visible dashboard action catalog without adding write endpoints.
|
||||
* @returns Readonly actions plus disabled high-risk records with operator-facing reasons.
|
||||
*/
|
||||
export function getEnvironmentDashboardActions(): EnvironmentAction[] {
|
||||
const highRiskActions = HIGH_RISK_ENVIRONMENT_ACTION_IDS.map((id) => ({
|
||||
disabledReason: '第一版环境总览只读展示,高风险写操作需走专项流程。',
|
||||
enabled: false,
|
||||
id,
|
||||
kind: 'write-risk' as const,
|
||||
label: highRiskLabels[id],
|
||||
riskLevel: 'high' as const,
|
||||
}));
|
||||
|
||||
return [...READONLY_ACTIONS, ...highRiskActions];
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EnvironmentDashboardService } from './environment-dashboard.service';
|
||||
import { EnvironmentEventBusService } from '../infrastructure/event/environment-event-bus.service';
|
||||
import type { EnvironmentDashboardResponse } from '../domain/environment-dashboard.types';
|
||||
|
||||
@Injectable()
|
||||
export class EnvironmentDashboardSelfCheckService {
|
||||
/**
|
||||
* Initializes the readonly self-check service.
|
||||
* @param dashboardService - Snapshot service reused after publishing self-check evidence.
|
||||
* @param eventBus - Environment event bus receiving self-check lifecycle events.
|
||||
*/
|
||||
constructor(
|
||||
private readonly dashboardService: EnvironmentDashboardService,
|
||||
private readonly eventBus: EnvironmentEventBusService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Runs a readonly self-check without invoking any write action.
|
||||
* @returns Fresh dashboard snapshot after a self-check event is emitted.
|
||||
*/
|
||||
async runSelfCheck(): Promise<EnvironmentDashboardResponse> {
|
||||
const observedAt = new Date().toISOString();
|
||||
await this.eventBus.publish({
|
||||
eventId: `self-check-${Date.now()}`,
|
||||
observedAt,
|
||||
severity: 'ok',
|
||||
siteId: 'local-dev',
|
||||
sourceKind: 'local',
|
||||
summary: '环境总览只读自检已触发',
|
||||
topic: 'kt/env/local-dev/self-check/result',
|
||||
});
|
||||
return this.dashboardService.getDashboard({ forceRefresh: true });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import type {
|
||||
EnvironmentHealthStatus,
|
||||
EnvironmentSite,
|
||||
EnvironmentSiteStatus,
|
||||
} from '../domain/environment-dashboard.types';
|
||||
|
||||
export const ENVIRONMENT_HEALTH_STATUSES: EnvironmentHealthStatus[] = [
|
||||
'ok',
|
||||
'unwired',
|
||||
'unknown',
|
||||
'degraded',
|
||||
'isolated',
|
||||
'down',
|
||||
'blocked',
|
||||
];
|
||||
|
||||
const severityWeight: Record<EnvironmentHealthStatus, number> = {
|
||||
ok: 0,
|
||||
unwired: 1,
|
||||
unknown: 1,
|
||||
degraded: 2,
|
||||
isolated: 3,
|
||||
down: 4,
|
||||
blocked: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks the strongest health status from service or signal statuses.
|
||||
* @param statuses - Aggregated statuses from collectors, retained events, or derived signals.
|
||||
* @returns The most severe status, defaulting to `unknown` when no evidence exists.
|
||||
*/
|
||||
export function pickWorstHealthStatus(
|
||||
statuses: EnvironmentHealthStatus[],
|
||||
): EnvironmentHealthStatus {
|
||||
if (statuses.length <= 0) return 'unknown';
|
||||
return statuses
|
||||
.slice(1)
|
||||
.reduce<EnvironmentHealthStatus>(
|
||||
(current, next) =>
|
||||
severityWeight[next] > severityWeight[current] ? next : current,
|
||||
statuses[0],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps nested health statuses into the site-level status used by Admin.
|
||||
* @param statuses - Service or signal statuses collected for a single site.
|
||||
* @returns Site status that preserves isolation and avoids marking missing integrations green.
|
||||
*/
|
||||
export function mapSiteStatus(
|
||||
statuses: EnvironmentHealthStatus[],
|
||||
): EnvironmentSiteStatus {
|
||||
const worst = pickWorstHealthStatus(statuses);
|
||||
if (worst === 'ok') return 'online';
|
||||
if (worst === 'isolated') return 'isolated';
|
||||
if (worst === 'degraded' || worst === 'down' || worst === 'blocked') {
|
||||
return 'degraded';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts every signal status across all sites for dashboard summary cards.
|
||||
* @param sites - Dashboard site tree assembled by collectors and event materializers.
|
||||
* @returns Count map keyed by every supported health status.
|
||||
*/
|
||||
export function countSignals(
|
||||
sites: EnvironmentSite[],
|
||||
): Record<EnvironmentHealthStatus, number> {
|
||||
const counts = Object.fromEntries(
|
||||
ENVIRONMENT_HEALTH_STATUSES.map((status) => [status, 0]),
|
||||
) as Record<EnvironmentHealthStatus, number>;
|
||||
|
||||
sites.forEach((site) => {
|
||||
site.nodes.forEach((node) => {
|
||||
node.services.forEach((service) => {
|
||||
service.signals.forEach((signal) => {
|
||||
counts[signal.status] += 1;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes collector timestamps before they are persisted as evidence.
|
||||
* @param dateLike - Date, epoch milliseconds, or string timestamp from local/remote evidence.
|
||||
* @returns ISO timestamp when parseable; otherwise the current time so evidence is never blank.
|
||||
*/
|
||||
export function normalizeObservedAt(dateLike?: Date | number | string): string {
|
||||
if (dateLike === undefined || dateLike === null || dateLike === '') {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
const date = dateLike instanceof Date ? dateLike : new Date(dateLike);
|
||||
if (Number.isNaN(date.getTime())) return new Date().toISOString();
|
||||
return date.toISOString();
|
||||
}
|
||||
@ -0,0 +1,374 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { getEnvironmentDashboardActions } from './environment-dashboard-action.catalog';
|
||||
import {
|
||||
countSignals,
|
||||
mapSiteStatus,
|
||||
pickWorstHealthStatus,
|
||||
} from './environment-dashboard-status.mapper';
|
||||
import { EnvironmentEventMaterializer } from './environment-event.materializer';
|
||||
import { EnvironmentDashboardCacheService } from '../infrastructure/environment-dashboard-cache.service';
|
||||
import { EnvironmentDashboardConfigService } from '../infrastructure/environment-dashboard-config.service';
|
||||
import {
|
||||
errorEvidence,
|
||||
unwiredEvidence,
|
||||
} from '../infrastructure/environment-dashboard-evidence.mapper';
|
||||
import { LocalDevSignalCollector } from '../infrastructure/collectors/local-dev-signal.collector';
|
||||
import { NasProdSignalCollector } from '../infrastructure/collectors/nas-prod-signal.collector';
|
||||
import { CaddyReadonlyAdapter } from '../infrastructure/adapters/caddy-readonly.adapter';
|
||||
import { MihomoReadonlyAdapter } from '../infrastructure/adapters/mihomo-readonly.adapter';
|
||||
import { TencentCloudReadonlyAdapter } from '../infrastructure/adapters/tencent-cloud-readonly.adapter';
|
||||
import { WireguardReadonlyAdapter } from '../infrastructure/adapters/wireguard-readonly.adapter';
|
||||
import type {
|
||||
EnvironmentDashboardResponse,
|
||||
EnvironmentNode,
|
||||
EnvironmentService,
|
||||
EnvironmentSignal,
|
||||
EnvironmentSite,
|
||||
EnvironmentTopology,
|
||||
} from '../domain/environment-dashboard.types';
|
||||
|
||||
export interface EnvironmentDashboardSnapshotOptions {
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EnvironmentDashboardService {
|
||||
/**
|
||||
* Initializes the dashboard snapshot service.
|
||||
* @param eventMaterializer - Recent-event materializer fed by local/MQTT event sources.
|
||||
* @param cache - Short-lived dashboard cache invalidated by fresh backend events.
|
||||
* @param localDevCollector - Collector for local development API/Admin state.
|
||||
* @param nasProdCollector - Collector for NAS production internal service state.
|
||||
* @param tencentAdapter - Tencent Cloud readonly adapter for CVM evidence.
|
||||
* @param caddyAdapter - Caddy readonly adapter for public route evidence.
|
||||
* @param wireguardAdapter - WireGuard readonly adapter for Tencent/r4se reachability.
|
||||
* @param mihomoAdapter - Mihomo/OpenClash readonly adapter for r4se evidence.
|
||||
* @param config - Dashboard config reader used for explicit missing-key evidence.
|
||||
*/
|
||||
constructor(
|
||||
@Optional()
|
||||
private readonly eventMaterializer: EnvironmentEventMaterializer,
|
||||
@Optional()
|
||||
private readonly cache: EnvironmentDashboardCacheService = new EnvironmentDashboardCacheService(),
|
||||
@Optional()
|
||||
private readonly localDevCollector: LocalDevSignalCollector = new LocalDevSignalCollector(),
|
||||
@Optional()
|
||||
private readonly nasProdCollector: NasProdSignalCollector = new NasProdSignalCollector(),
|
||||
@Optional()
|
||||
private readonly tencentAdapter?: TencentCloudReadonlyAdapter,
|
||||
@Optional()
|
||||
private readonly caddyAdapter?: CaddyReadonlyAdapter,
|
||||
@Optional()
|
||||
private readonly wireguardAdapter?: WireguardReadonlyAdapter,
|
||||
@Optional()
|
||||
private readonly mihomoAdapter?: MihomoReadonlyAdapter,
|
||||
@Optional()
|
||||
private readonly config: EnvironmentDashboardConfigService = new EnvironmentDashboardConfigService(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Builds the current environment dashboard snapshot for Admin.
|
||||
* @param options - Snapshot control from Admin refresh/self-check; self-check sets `forceRefresh`.
|
||||
* @returns Aggregate status tree, topology, readonly actions, and recent events.
|
||||
*/
|
||||
async getDashboard(
|
||||
options: EnvironmentDashboardSnapshotOptions = {},
|
||||
): Promise<EnvironmentDashboardResponse> {
|
||||
return this.cache.getOrCreate(() => this.buildDashboard(), options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs all readonly collectors and assembles the dashboard response.
|
||||
* @returns Fresh dashboard snapshot before cache decoration.
|
||||
*/
|
||||
private async buildDashboard(): Promise<EnvironmentDashboardResponse> {
|
||||
const generatedAt = new Date().toISOString();
|
||||
const sites = await this.createSites(generatedAt);
|
||||
return {
|
||||
actions: getEnvironmentDashboardActions(),
|
||||
events: this.eventMaterializer?.getRecentEvents?.() || [],
|
||||
generatedAt,
|
||||
refreshedAt: generatedAt,
|
||||
sites,
|
||||
summary: this.createSummary(sites),
|
||||
topology: this.createTopology(sites),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the first-version site tree with explicit unwired remote evidence.
|
||||
* @param observedAt - Snapshot timestamp shared by generated signals.
|
||||
* @returns Four configured dashboard sites.
|
||||
*/
|
||||
private async createSites(observedAt: string): Promise<EnvironmentSite[]> {
|
||||
return [
|
||||
await this.localDevCollector.collect({ observedAt }),
|
||||
await this.nasProdCollector.collect({ observedAt }),
|
||||
await this.createTencentCloudSite(),
|
||||
await this.createR4seSite(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Tencent Cloud site from CVM, Caddy, and WireGuard readonly evidence.
|
||||
* @returns Tencent Cloud site with missing configuration surfaced as unwired signals.
|
||||
*/
|
||||
private async createTencentCloudSite(): Promise<EnvironmentSite> {
|
||||
const services = [
|
||||
await this.createRemoteAdapterService(
|
||||
'tencent-cvm',
|
||||
'Tencent Cloud CVM',
|
||||
'tencent-cvm',
|
||||
'Tencent Cloud CVM',
|
||||
[
|
||||
'ENV_DASHBOARD_TENCENT_SECRET_ID',
|
||||
'ENV_DASHBOARD_TENCENT_SECRET_KEY',
|
||||
'ENV_DASHBOARD_TENCENT_REGION',
|
||||
'ENV_DASHBOARD_TENCENT_INSTANCE_ID',
|
||||
],
|
||||
this.tencentAdapter,
|
||||
),
|
||||
await this.createRemoteAdapterService(
|
||||
'caddy-public',
|
||||
'Caddy Public Route',
|
||||
'caddy-public',
|
||||
'Caddy Public Route',
|
||||
['ENV_DASHBOARD_CADDY_PUBLIC_URL'],
|
||||
this.caddyAdapter,
|
||||
),
|
||||
await this.createRemoteAdapterService(
|
||||
'tencent-wireguard',
|
||||
'WireGuard',
|
||||
'tencent-wireguard',
|
||||
'Tencent WireGuard',
|
||||
['ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL'],
|
||||
this.wireguardAdapter,
|
||||
),
|
||||
];
|
||||
return this.createSiteFromServices(
|
||||
'tencent-cloud',
|
||||
'Tencent Cloud',
|
||||
'Tencent Cloud Node',
|
||||
services,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates r4se site from WireGuard and Mihomo/OpenClash readonly evidence.
|
||||
* @returns r4se site with missing configuration surfaced as unwired signals.
|
||||
*/
|
||||
private async createR4seSite(): Promise<EnvironmentSite> {
|
||||
const services = [
|
||||
await this.createRemoteAdapterService(
|
||||
'r4se-wireguard',
|
||||
'WireGuard',
|
||||
'r4se-wireguard',
|
||||
'r4se WireGuard',
|
||||
['ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL'],
|
||||
this.wireguardAdapter,
|
||||
),
|
||||
await this.createRemoteAdapterService(
|
||||
'r4se-mihomo',
|
||||
'Mihomo/OpenClash',
|
||||
'r4se-mihomo',
|
||||
'Mihomo/OpenClash',
|
||||
['ENV_DASHBOARD_R4SE_MIHOMO_URL', 'ENV_DASHBOARD_R4SE_MIHOMO_SECRET'],
|
||||
this.mihomoAdapter,
|
||||
),
|
||||
];
|
||||
return this.createSiteFromServices('r4se', 'r4se', 'r4se Node', services);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one remote service from an adapter or explicit missing configuration.
|
||||
* @param serviceId - Stable service id used by topology.
|
||||
* @param serviceLabel - Operator-facing service label.
|
||||
* @param signalId - Stable signal id used by Admin selection.
|
||||
* @param signalLabel - Operator-facing signal label.
|
||||
* @param requiredKeys - Public env keys required before adapter output can be trusted.
|
||||
* @param adapter - Optional readonly adapter implementation.
|
||||
* @returns Remote service with one normalized signal.
|
||||
*/
|
||||
private async createRemoteAdapterService(
|
||||
serviceId: string,
|
||||
serviceLabel: string,
|
||||
signalId: string,
|
||||
signalLabel: string,
|
||||
requiredKeys: string[],
|
||||
adapter?: { inspect(): Promise<Partial<EnvironmentSignal>> },
|
||||
): Promise<EnvironmentService> {
|
||||
const missing = this.config.missing(requiredKeys);
|
||||
if (missing.length > 0 || !adapter) {
|
||||
return this.createService(serviceId, serviceLabel, [
|
||||
{
|
||||
evidence: [unwiredEvidence(signalLabel, missing)],
|
||||
id: signalId,
|
||||
label: signalLabel,
|
||||
sourceKind: 'unwired',
|
||||
status: 'unwired',
|
||||
summary: '只读观测配置未接入',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
const signal = await adapter.inspect();
|
||||
return this.createService(serviceId, serviceLabel, [
|
||||
{
|
||||
evidence: signal.evidence || [],
|
||||
id: signal.id || signalId,
|
||||
label: signal.label || signalLabel,
|
||||
observedAt: signal.observedAt,
|
||||
sourceKind: signal.sourceKind || 'live',
|
||||
status: signal.status || 'unknown',
|
||||
summary: signal.summary || '只读观测已返回信号',
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
return this.createService(serviceId, serviceLabel, [
|
||||
{
|
||||
evidence: [errorEvidence(signalLabel, error)],
|
||||
id: signalId,
|
||||
label: signalLabel,
|
||||
sourceKind: 'derived',
|
||||
status: 'down',
|
||||
summary: '只读观测失败',
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a site wrapper around a single environment node.
|
||||
* @param siteId - Stable site id used by Admin selection and SSE events.
|
||||
* @param siteLabel - Operator-facing site label.
|
||||
* @param nodeLabel - Operator-facing node label.
|
||||
* @param services - Services collected for the site.
|
||||
* @returns Site object with status derived from child services.
|
||||
*/
|
||||
private createSiteFromServices(
|
||||
siteId: string,
|
||||
siteLabel: string,
|
||||
nodeLabel: string,
|
||||
services: EnvironmentService[],
|
||||
): EnvironmentSite {
|
||||
const node = this.createNode(`${siteId}-node`, nodeLabel, services);
|
||||
return {
|
||||
id: siteId,
|
||||
label: siteLabel,
|
||||
nodes: [node],
|
||||
status: mapSiteStatus(services.map((service) => service.status)),
|
||||
summary: `${siteLabel} readonly evidence snapshot`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a node and derives its status from child service status.
|
||||
* @param id - Stable node id for topology references.
|
||||
* @param label - Operator-facing node label.
|
||||
* @param services - Services owned by this node.
|
||||
* @returns Node with derived status.
|
||||
*/
|
||||
private createNode(
|
||||
id: string,
|
||||
label: string,
|
||||
services: EnvironmentService[],
|
||||
): EnvironmentNode {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
services,
|
||||
status: pickWorstHealthStatus(services.map((service) => service.status)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a service and derives its status from child signals.
|
||||
* @param id - Stable service id for topology and actions.
|
||||
* @param label - Operator-facing service label.
|
||||
* @param signals - Signals that support this service.
|
||||
* @returns Service with derived status.
|
||||
*/
|
||||
private createService(
|
||||
id: string,
|
||||
label: string,
|
||||
signals: EnvironmentService['signals'],
|
||||
): EnvironmentService {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
signals,
|
||||
status: pickWorstHealthStatus(signals.map((signal) => signal.status)),
|
||||
summary: signals.map((signal) => signal.summary).join(';'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds summary counters used by status cards.
|
||||
* @param sites - Dashboard site tree.
|
||||
* @returns Signal count summary in both compact and by-status forms.
|
||||
*/
|
||||
private createSummary(sites: EnvironmentSite[]) {
|
||||
const byStatus = countSignals(sites);
|
||||
return {
|
||||
...byStatus,
|
||||
byStatus,
|
||||
totalSignals: Object.values(byStatus).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a simple topology graph from sites, nodes, and services.
|
||||
* @param sites - Dashboard site tree.
|
||||
* @returns Topology nodes and service edges for Admin rendering.
|
||||
*/
|
||||
private createTopology(sites: EnvironmentSite[]): EnvironmentTopology {
|
||||
const nodes = sites.flatMap((site) => [
|
||||
{
|
||||
id: site.id,
|
||||
label: site.label,
|
||||
siteId: site.id,
|
||||
status: site.status,
|
||||
},
|
||||
...site.nodes.flatMap((node) => [
|
||||
{
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
siteId: site.id,
|
||||
status: node.status || 'unknown',
|
||||
},
|
||||
...node.services.map((service) => ({
|
||||
id: service.id,
|
||||
label: service.label,
|
||||
serviceId: service.id,
|
||||
siteId: site.id,
|
||||
status: service.status,
|
||||
})),
|
||||
]),
|
||||
]);
|
||||
const edges = sites.flatMap((site) =>
|
||||
site.nodes.flatMap((node) => [
|
||||
{
|
||||
from: site.id,
|
||||
id: `${site.id}-${node.id}`,
|
||||
label: 'contains',
|
||||
source: site.id,
|
||||
target: node.id,
|
||||
to: node.id,
|
||||
},
|
||||
...node.services.map((service) => ({
|
||||
from: node.id,
|
||||
id: `${node.id}-${service.id}`,
|
||||
label: 'runs',
|
||||
source: node.id,
|
||||
target: service.id,
|
||||
to: service.id,
|
||||
})),
|
||||
]),
|
||||
);
|
||||
return { edges, nodes };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { Observable, Subject, merge, of, timer } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { EnvironmentEventMaterializer } from './environment-event.materializer';
|
||||
import { EnvironmentEventBusService } from '../infrastructure/event/environment-event-bus.service';
|
||||
import type {
|
||||
EnvironmentEvent,
|
||||
EnvironmentStreamEvent,
|
||||
} from '../domain/environment-dashboard.types';
|
||||
|
||||
export interface EnvironmentEventStreamOptions {
|
||||
heartbeatMs?: number;
|
||||
replayLimit?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EnvironmentEventStreamService {
|
||||
private readonly replay: EnvironmentStreamEvent[] = [];
|
||||
private readonly streamSubject = new Subject<EnvironmentStreamEvent>();
|
||||
private readonly heartbeatMs: number;
|
||||
private readonly replayLimit: number;
|
||||
|
||||
/**
|
||||
* Initializes the SSE stream fan-out from the environment event bus.
|
||||
* @param eventBus - Local/MQTT event bus that receives backend environment events.
|
||||
* @param materializer - Event materializer that sanitizes events and invalidates cache.
|
||||
* @param options - Test/runtime overrides for replay window and heartbeat interval.
|
||||
*/
|
||||
constructor(
|
||||
private readonly eventBus: EnvironmentEventBusService,
|
||||
private readonly materializer: EnvironmentEventMaterializer,
|
||||
@Optional()
|
||||
options: EnvironmentEventStreamOptions = {},
|
||||
) {
|
||||
this.heartbeatMs =
|
||||
options.heartbeatMs ||
|
||||
Number(process.env.ENV_DASHBOARD_SSE_HEARTBEAT_MS) ||
|
||||
25_000;
|
||||
this.replayLimit =
|
||||
options.replayLimit ||
|
||||
Number(process.env.ENV_DASHBOARD_SSE_REPLAY_LIMIT) ||
|
||||
200;
|
||||
this.eventBus.subscribe((event) => {
|
||||
this.pushEvent(this.materializer.materialize(event));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an SSE-compatible observable with optional replay cursor.
|
||||
* @param lastEventId - Browser Last-Event-ID header or query fallback.
|
||||
* @returns Observable carrying replay, live events, and heartbeat notices.
|
||||
*/
|
||||
stream(lastEventId?: string): Observable<EnvironmentStreamEvent> {
|
||||
const replayEvents = this.getReplayEvents(lastEventId);
|
||||
const heartbeat$ = timer(this.heartbeatMs, this.heartbeatMs).pipe(
|
||||
map(() => this.createHeartbeatEvent()),
|
||||
);
|
||||
return merge(
|
||||
...replayEvents.map((event) => of(event)),
|
||||
this.streamSubject,
|
||||
heartbeat$,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a materialized event to replay history and current SSE subscribers.
|
||||
* @param event - Sanitized dashboard event.
|
||||
*/
|
||||
private pushEvent(event: EnvironmentEvent) {
|
||||
const streamEvent: EnvironmentStreamEvent = {
|
||||
data: event,
|
||||
id: event.id,
|
||||
type: event.type || 'environment-event',
|
||||
};
|
||||
this.replay.push(streamEvent);
|
||||
if (this.replay.length > this.replayLimit) {
|
||||
this.replay.splice(0, this.replay.length - this.replayLimit);
|
||||
}
|
||||
this.streamSubject.next(streamEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays recent events or asks the browser to reload one snapshot.
|
||||
* @param lastEventId - Last event id reported by the browser.
|
||||
* @returns Replay events for the new SSE subscription.
|
||||
*/
|
||||
private getReplayEvents(lastEventId?: string): EnvironmentStreamEvent[] {
|
||||
if (!lastEventId) return [];
|
||||
const index = this.replay.findIndex((event) => event.id === lastEventId);
|
||||
if (index === -1) return [this.createSnapshotRequiredEvent()];
|
||||
return this.replay.slice(index + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a heartbeat event that keeps the connection alive without refreshing state.
|
||||
* @returns SSE heartbeat event.
|
||||
*/
|
||||
private createHeartbeatEvent(): EnvironmentStreamEvent {
|
||||
const observedAt = new Date().toISOString();
|
||||
return {
|
||||
data: { message: 'alive', observedAt },
|
||||
id: `heartbeat-${Date.now()}`,
|
||||
type: 'heartbeat',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a snapshot instruction when replay continuity cannot be guaranteed.
|
||||
* @returns SSE snapshot-required notice.
|
||||
*/
|
||||
private createSnapshotRequiredEvent(): EnvironmentStreamEvent {
|
||||
return {
|
||||
data: {
|
||||
message: 'snapshot-required',
|
||||
observedAt: new Date().toISOString(),
|
||||
},
|
||||
id: `snapshot-required-${Date.now()}`,
|
||||
type: 'snapshot-required',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { Subject } from 'rxjs';
|
||||
import { EnvironmentDashboardCacheService } from '../infrastructure/environment-dashboard-cache.service';
|
||||
import type {
|
||||
EnvironmentEvent,
|
||||
EnvironmentEventEnvelope,
|
||||
} from '../domain/environment-dashboard.types';
|
||||
|
||||
export interface EnvironmentDashboardCacheInvalidator {
|
||||
invalidate(): void;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EnvironmentEventMaterializer {
|
||||
private readonly events: EnvironmentEvent[] = [];
|
||||
private readonly eventSubject = new Subject<EnvironmentEvent>();
|
||||
|
||||
/**
|
||||
* Initializes the materializer with an optional cache invalidator.
|
||||
* @param cache - Dashboard cache dependency; invalidated only for fresh signal events.
|
||||
* @param maxRecentEvents - Bounded recent-event history retained for dashboard snapshots.
|
||||
*/
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(EnvironmentDashboardCacheService)
|
||||
private readonly cache?: EnvironmentDashboardCacheInvalidator,
|
||||
@Optional()
|
||||
private readonly maxRecentEvents = 200,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Exposes materialized events for SSE stream fan-out.
|
||||
* @returns RxJS subject stream carrying already-sanitized dashboard events.
|
||||
*/
|
||||
events$() {
|
||||
return this.eventSubject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw envelope into safe dashboard event state.
|
||||
* @param envelope - Event from local collectors, MQTT retained messages, or bridge services.
|
||||
* @param now - Clock override used by tests to validate retained expiry behavior.
|
||||
* @returns Materialized event, with stale retained messages downgraded to unknown.
|
||||
*/
|
||||
materialize(
|
||||
envelope: EnvironmentEventEnvelope,
|
||||
now = new Date(),
|
||||
): EnvironmentEvent {
|
||||
const staleRetained = this.isStaleRetained(envelope, now);
|
||||
const event: EnvironmentEvent = {
|
||||
evidence: envelope.evidence,
|
||||
expiresAt: envelope.expiresAt,
|
||||
id: envelope.eventId,
|
||||
nodeId: envelope.nodeId,
|
||||
observedAt: envelope.observedAt,
|
||||
retained: envelope.retained,
|
||||
serviceId: envelope.serviceId,
|
||||
severity: staleRetained ? 'unknown' : envelope.severity,
|
||||
signalId: envelope.signalId,
|
||||
siteId: envelope.siteId,
|
||||
sourceKind: staleRetained ? 'cached' : envelope.sourceKind,
|
||||
summary: staleRetained
|
||||
? `${envelope.summary}( retained 已过期)`
|
||||
: envelope.summary,
|
||||
topic: envelope.topic,
|
||||
type: envelope.signalId ? 'environment-signal' : 'environment-event',
|
||||
};
|
||||
|
||||
this.appendRecentEvent(event);
|
||||
if (!staleRetained && envelope.signalId) {
|
||||
this.cache?.invalidate();
|
||||
}
|
||||
this.eventSubject.next(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads recent materialized events for the dashboard HTTP snapshot.
|
||||
* @returns Newest retained event array in insertion order.
|
||||
*/
|
||||
getRecentEvents() {
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether retained MQTT evidence is missing expiry or already expired.
|
||||
* @param envelope - Raw environment event envelope.
|
||||
* @param now - Clock used to compare the retained expiry boundary.
|
||||
* @returns True when the event must not create green status.
|
||||
*/
|
||||
private isStaleRetained(
|
||||
envelope: EnvironmentEventEnvelope,
|
||||
now: Date,
|
||||
): boolean {
|
||||
if (!envelope.retained) return false;
|
||||
if (!envelope.observedAt || !envelope.expiresAt) return true;
|
||||
return new Date(envelope.expiresAt).getTime() <= now.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event to bounded recent history.
|
||||
* @param event - Materialized dashboard event to retain for snapshots and replay.
|
||||
*/
|
||||
private appendRecentEvent(event: EnvironmentEvent) {
|
||||
this.events.push(event);
|
||||
if (this.events.length > this.maxRecentEvents) {
|
||||
this.events.splice(0, this.events.length - this.maxRecentEvents);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
export type EnvironmentHealthStatus =
|
||||
| 'ok'
|
||||
| 'degraded'
|
||||
| 'down'
|
||||
| 'blocked'
|
||||
| 'isolated'
|
||||
| 'unknown'
|
||||
| 'unwired';
|
||||
|
||||
export type EnvironmentSiteStatus =
|
||||
| 'online'
|
||||
| 'degraded'
|
||||
| 'isolated'
|
||||
| 'unknown';
|
||||
|
||||
export type EnvironmentSignalSourceKind =
|
||||
| 'live'
|
||||
| 'cached'
|
||||
| 'derived'
|
||||
| 'configured'
|
||||
| 'external-link'
|
||||
| 'unwired';
|
||||
|
||||
export type EnvironmentEventSourceKind =
|
||||
| 'local'
|
||||
| 'mqtt'
|
||||
| EnvironmentSignalSourceKind;
|
||||
|
||||
export type EnvironmentStreamEventType =
|
||||
| 'environment-event'
|
||||
| 'environment-signal'
|
||||
| 'snapshot-required'
|
||||
| 'heartbeat'
|
||||
| 'error';
|
||||
|
||||
export interface EnvironmentEvidence {
|
||||
source: string;
|
||||
sourceKind: EnvironmentSignalSourceKind;
|
||||
summary: string;
|
||||
observedAt?: string;
|
||||
expiresAt?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EnvironmentSignal {
|
||||
id: string;
|
||||
label: string;
|
||||
status: EnvironmentHealthStatus;
|
||||
sourceKind: EnvironmentSignalSourceKind;
|
||||
summary: string;
|
||||
evidence: EnvironmentEvidence[];
|
||||
observedAt?: string;
|
||||
staleAfterSeconds?: number;
|
||||
}
|
||||
|
||||
export interface EnvironmentService {
|
||||
id: string;
|
||||
label: string;
|
||||
status: EnvironmentHealthStatus;
|
||||
signals: EnvironmentSignal[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentNode {
|
||||
id: string;
|
||||
label: string;
|
||||
services: EnvironmentService[];
|
||||
status?: EnvironmentHealthStatus;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentSite {
|
||||
id: string;
|
||||
label: string;
|
||||
status: EnvironmentSiteStatus;
|
||||
nodes: EnvironmentNode[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentDashboardSummary {
|
||||
blocked?: number;
|
||||
degraded?: number;
|
||||
down?: number;
|
||||
ok?: number;
|
||||
unknown?: number;
|
||||
unwired?: number;
|
||||
totalSignals: number;
|
||||
byStatus?: Record<EnvironmentHealthStatus, number>;
|
||||
}
|
||||
|
||||
export interface EnvironmentTopologyNode {
|
||||
id: string;
|
||||
label: string;
|
||||
siteId: string;
|
||||
serviceId?: string;
|
||||
status: EnvironmentHealthStatus | EnvironmentSiteStatus;
|
||||
}
|
||||
|
||||
export interface EnvironmentTopologyEdge {
|
||||
id: string;
|
||||
from?: string;
|
||||
source: string;
|
||||
to?: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentTopology {
|
||||
nodes: EnvironmentTopologyNode[];
|
||||
edges: EnvironmentTopologyEdge[];
|
||||
}
|
||||
|
||||
export interface EnvironmentAction {
|
||||
id: string;
|
||||
label: string;
|
||||
siteId?: string;
|
||||
serviceId?: string;
|
||||
enabled: boolean;
|
||||
kind: 'readonly' | 'write-risk';
|
||||
riskLevel?: 'high' | 'low' | 'medium';
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentEvent {
|
||||
id: string;
|
||||
type?: EnvironmentStreamEventType;
|
||||
topic: string;
|
||||
siteId: string;
|
||||
nodeId?: string;
|
||||
serviceId?: string;
|
||||
signalId?: string;
|
||||
severity: EnvironmentHealthStatus;
|
||||
sourceKind: EnvironmentEventSourceKind;
|
||||
observedAt: string;
|
||||
expiresAt?: string;
|
||||
retained?: boolean;
|
||||
summary: string;
|
||||
evidence?: EnvironmentEvidence[];
|
||||
}
|
||||
|
||||
export interface EnvironmentEventEnvelope {
|
||||
eventId: string;
|
||||
topic: string;
|
||||
siteId: string;
|
||||
nodeId?: string;
|
||||
serviceId?: string;
|
||||
signalId?: string;
|
||||
severity: EnvironmentHealthStatus;
|
||||
sourceKind: EnvironmentEventSourceKind;
|
||||
observedAt: string;
|
||||
expiresAt?: string;
|
||||
retained?: boolean;
|
||||
summary: string;
|
||||
evidence?: EnvironmentEvidence[];
|
||||
}
|
||||
|
||||
export interface EnvironmentDashboardResponse {
|
||||
generatedAt: string;
|
||||
refreshedAt: string;
|
||||
summary: EnvironmentDashboardSummary;
|
||||
sites: EnvironmentSite[];
|
||||
topology: EnvironmentTopology;
|
||||
actions: EnvironmentAction[];
|
||||
events: EnvironmentEvent[];
|
||||
}
|
||||
|
||||
export interface EnvironmentStreamEvent {
|
||||
id?: string;
|
||||
type: EnvironmentStreamEventType;
|
||||
data:
|
||||
| EnvironmentEvent
|
||||
| EnvironmentSignal
|
||||
| { message: string; observedAt: string };
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||
import { EnvironmentReadonlyHttpClient } from './environment-readonly-http.client';
|
||||
import {
|
||||
createErrorAdapterSignal,
|
||||
createLiveAdapterSignal,
|
||||
createUnwiredAdapterSignal,
|
||||
isReadonlyHttpOk,
|
||||
joinReadonlyUrl,
|
||||
} from './environment-readonly-adapter.helpers';
|
||||
|
||||
@Injectable()
|
||||
export class CaddyReadonlyAdapter {
|
||||
private readonly http: EnvironmentReadonlyHttpClient;
|
||||
|
||||
/**
|
||||
* Initializes Caddy readonly adapter.
|
||||
* @param config - Environment dashboard config reader.
|
||||
* @param http - Readonly HTTP client used for public/admin probes.
|
||||
*/
|
||||
constructor(
|
||||
private readonly config: EnvironmentDashboardConfigService,
|
||||
@Optional() http?: EnvironmentReadonlyHttpClient,
|
||||
) {
|
||||
this.http = http || new EnvironmentReadonlyHttpClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects Caddy readonly integration readiness.
|
||||
* @returns Caddy signal; missing configuration is explicit unwired evidence.
|
||||
*/
|
||||
async inspect() {
|
||||
const missing = this.config.missing(['ENV_DASHBOARD_CADDY_PUBLIC_URL']);
|
||||
if (missing.length > 0) {
|
||||
return createUnwiredAdapterSignal(
|
||||
'caddy-public',
|
||||
'Caddy Public Route',
|
||||
missing,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const publicResponse = await this.http.head(
|
||||
this.config.get('ENV_DASHBOARD_CADDY_PUBLIC_URL'),
|
||||
);
|
||||
const adminUrl = this.config.get('ENV_DASHBOARD_CADDY_ADMIN_URL');
|
||||
const adminResponse = adminUrl
|
||||
? await this.http.get(joinReadonlyUrl(adminUrl, 'config/'))
|
||||
: undefined;
|
||||
const publicOk = isReadonlyHttpOk(publicResponse.status);
|
||||
const adminOk =
|
||||
!adminResponse || isReadonlyHttpOk(adminResponse.status);
|
||||
const status = publicOk && adminOk ? 'ok' : 'degraded';
|
||||
const summary = `Caddy public ${publicResponse.status}${
|
||||
adminResponse ? `, admin ${adminResponse.status}` : ''
|
||||
}`;
|
||||
|
||||
return createLiveAdapterSignal(
|
||||
'caddy-public',
|
||||
'Caddy Public Route',
|
||||
summary,
|
||||
{
|
||||
adminConfigured: Boolean(adminResponse),
|
||||
adminStatus: adminResponse?.status,
|
||||
publicStatus: publicResponse.status,
|
||||
},
|
||||
status,
|
||||
publicResponse.observedAt,
|
||||
);
|
||||
} catch (error) {
|
||||
return createErrorAdapterSignal(
|
||||
'caddy-public',
|
||||
'Caddy Public Route',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
import {
|
||||
errorEvidence,
|
||||
liveEvidence,
|
||||
unwiredEvidence,
|
||||
} from '../environment-dashboard-evidence.mapper';
|
||||
import type {
|
||||
EnvironmentHealthStatus,
|
||||
EnvironmentSignal,
|
||||
} from '../../domain/environment-dashboard.types';
|
||||
|
||||
/**
|
||||
* Creates a standard unwired signal for readonly adapters without configuration.
|
||||
* @param id - Stable signal id exposed through dashboard topology.
|
||||
* @param label - Operator-facing integration label.
|
||||
* @param missingKeys - Public missing env/config keys.
|
||||
* @returns Environment signal with explicit unwired evidence.
|
||||
*/
|
||||
export function createUnwiredAdapterSignal(
|
||||
id: string,
|
||||
label: string,
|
||||
missingKeys: string[],
|
||||
): EnvironmentSignal {
|
||||
return {
|
||||
evidence: [unwiredEvidence(label, missingKeys)],
|
||||
id,
|
||||
label,
|
||||
sourceKind: 'unwired',
|
||||
status: 'unwired',
|
||||
summary: '只读观测配置未接入',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dashboard signal from successful readonly remote evidence.
|
||||
* @param id - Stable signal id exposed through dashboard topology.
|
||||
* @param label - Operator-facing integration label.
|
||||
* @param summary - Short sanitized live probe summary.
|
||||
* @param metadata - Non-secret structured evidence extracted from the probe.
|
||||
* @param status - Dashboard status derived from the live probe response.
|
||||
* @param observedAt - Optional timestamp from the remote client response.
|
||||
* @returns Environment signal backed by live evidence.
|
||||
*/
|
||||
export function createLiveAdapterSignal(
|
||||
id: string,
|
||||
label: string,
|
||||
summary: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
status: EnvironmentHealthStatus = 'ok',
|
||||
observedAt?: Date | number | string,
|
||||
): EnvironmentSignal {
|
||||
const evidence = liveEvidence(label, summary, observedAt, metadata);
|
||||
return {
|
||||
evidence: [evidence],
|
||||
id,
|
||||
label,
|
||||
observedAt: evidence.observedAt,
|
||||
sourceKind: 'live',
|
||||
status,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dashboard signal from a failed configured readonly probe.
|
||||
* @param id - Stable signal id exposed through dashboard topology.
|
||||
* @param label - Operator-facing integration label.
|
||||
* @param error - Unknown exception or rejection reason from the probe.
|
||||
* @returns Degraded signal with sanitized error evidence.
|
||||
*/
|
||||
export function createErrorAdapterSignal(
|
||||
id: string,
|
||||
label: string,
|
||||
error: unknown,
|
||||
): EnvironmentSignal {
|
||||
const evidence = errorEvidence(label, error);
|
||||
return {
|
||||
evidence: [evidence],
|
||||
id,
|
||||
label,
|
||||
observedAt: evidence.observedAt,
|
||||
sourceKind: 'derived',
|
||||
status: 'degraded',
|
||||
summary: evidence.summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an HTTP status code is a successful readonly observation.
|
||||
* @param status - HTTP status code returned by the readonly client.
|
||||
* @returns True when the status is a 2xx or 3xx response.
|
||||
*/
|
||||
export function isReadonlyHttpOk(status: number): boolean {
|
||||
return status >= 200 && status < 400;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a configured readonly base URL with a relative API path.
|
||||
* @param baseUrl - Runtime configured URL without secrets.
|
||||
* @param path - Relative or absolute path segment for a readonly API endpoint.
|
||||
* @returns Fully qualified URL with duplicate separators normalized by URL.
|
||||
*/
|
||||
export function joinReadonlyUrl(baseUrl: string, path: string): string {
|
||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
return new URL(path.replace(/^\/+/, ''), normalizedBase).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the bounded body preview returned by EnvironmentReadonlyHttpClient.
|
||||
* @param bodyPreview - Sanitized body preview from a readonly HTTP response.
|
||||
* @returns Parsed JSON object or an empty object when the preview is absent or not JSON.
|
||||
*/
|
||||
export function parseJsonPreview(
|
||||
bodyPreview: string,
|
||||
): Record<string, unknown> {
|
||||
if (!bodyPreview) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(bodyPreview);
|
||||
return asRecord(parsed) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows an unknown value to a plain record for adapter metadata extraction.
|
||||
* @param value - Unknown JSON field or SDK payload.
|
||||
* @returns Record value when it is object-like and not an array.
|
||||
*/
|
||||
export function asRecord(
|
||||
value: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows an unknown value to an array for adapter metadata extraction.
|
||||
* @param value - Unknown JSON field or SDK payload.
|
||||
* @returns Array value when the input is an array; otherwise an empty array.
|
||||
*/
|
||||
export function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts string-like or numeric fields into safe summary strings.
|
||||
* @param value - Unknown JSON field or SDK payload.
|
||||
* @returns String representation for primitive values, otherwise undefined.
|
||||
*/
|
||||
export function asString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number') return `${value}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts numeric JSON fields into numbers for status calculations.
|
||||
* @param value - Unknown JSON field or SDK payload.
|
||||
* @returns Finite number or undefined when the value is not numeric.
|
||||
*/
|
||||
export function asNumber(value: unknown): number | undefined {
|
||||
const numberValue =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
return Number.isFinite(numberValue) ? numberValue : undefined;
|
||||
}
|
||||
@ -0,0 +1,132 @@
|
||||
import axios, { type AxiosRequestConfig } from 'axios';
|
||||
|
||||
export interface EnvironmentReadonlyHttpClientOptions {
|
||||
bodyPreviewLimit?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface EnvironmentReadonlyHttpResponse {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, unknown>;
|
||||
bodyPreview: string;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
const DEFAULT_BODY_PREVIEW_LIMIT = 512;
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
const SECRET_HEADER_PATTERN = /(authorization|cookie|token|secret|password)/i;
|
||||
|
||||
export type EnvironmentReadonlyHttpMethod = 'GET' | 'HEAD';
|
||||
|
||||
export interface EnvironmentReadonlyHttpRequestOptions {
|
||||
headers?: Record<string, string>;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class EnvironmentReadonlyHttpClient {
|
||||
private readonly bodyPreviewLimit: number;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
/**
|
||||
* Initializes the readonly HTTP client for environment probes.
|
||||
* @param options - Optional test/runtime tuning for timeout and evidence preview size.
|
||||
*/
|
||||
constructor(options: EnvironmentReadonlyHttpClientOptions = {}) {
|
||||
const configuredTimeout = Number(
|
||||
process.env.ENV_DASHBOARD_SIGNAL_TIMEOUT_MS || '',
|
||||
);
|
||||
this.bodyPreviewLimit =
|
||||
options.bodyPreviewLimit ?? DEFAULT_BODY_PREVIEW_LIMIT;
|
||||
this.timeoutMs =
|
||||
options.timeoutMs ||
|
||||
(Number.isFinite(configuredTimeout) && configuredTimeout > 0
|
||||
? configuredTimeout
|
||||
: DEFAULT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a GET probe against a readonly integration endpoint.
|
||||
* @param url - Fully qualified URL from environment dashboard configuration.
|
||||
* @param options - Optional params and headers; secret headers are never returned.
|
||||
* @returns Sanitized HTTP evidence response.
|
||||
*/
|
||||
get(url: string, options: EnvironmentReadonlyHttpRequestOptions = {}) {
|
||||
return this.request('GET', url, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a HEAD probe when body content is not needed.
|
||||
* @param url - Fully qualified URL from environment dashboard configuration.
|
||||
* @param options - Optional params and headers; secret headers are never returned.
|
||||
* @returns Sanitized HTTP evidence response without a body preview.
|
||||
*/
|
||||
head(url: string, options: EnvironmentReadonlyHttpRequestOptions = {}) {
|
||||
return this.request('HEAD', url, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a bounded readonly HTTP request and rejects write methods.
|
||||
* @param method - Method supplied by the adapter; only GET and HEAD are accepted.
|
||||
* @param url - Fully qualified URL from non-secret runtime configuration.
|
||||
* @param options - Optional request params and headers used only for the outbound call.
|
||||
* @returns Sanitized status, headers, and truncated body preview for evidence storage.
|
||||
*/
|
||||
async request(
|
||||
method: string,
|
||||
url: string,
|
||||
options: EnvironmentReadonlyHttpRequestOptions = {},
|
||||
): Promise<EnvironmentReadonlyHttpResponse> {
|
||||
const normalizedMethod = method.toUpperCase();
|
||||
if (normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD') {
|
||||
throw new Error('环境总览只读 HTTP client 只允许 GET/HEAD 请求');
|
||||
}
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: options.headers,
|
||||
method: normalizedMethod,
|
||||
params: options.params,
|
||||
timeout: this.timeoutMs,
|
||||
url,
|
||||
};
|
||||
const response = await axios.request(config);
|
||||
|
||||
return {
|
||||
bodyPreview:
|
||||
normalizedMethod === 'HEAD' ? '' : this.toBodyPreview(response.data),
|
||||
headers: this.sanitizeHeaders(
|
||||
response.headers as Record<string, unknown>,
|
||||
),
|
||||
observedAt: new Date().toISOString(),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts arbitrary response bodies into a bounded evidence preview.
|
||||
* @param body - Axios response body from readonly integrations.
|
||||
* @returns Short text preview with an ellipsis when truncated.
|
||||
*/
|
||||
private toBodyPreview(body: unknown): string {
|
||||
const text = typeof body === 'string' ? body : JSON.stringify(body ?? '');
|
||||
if (text.length <= this.bodyPreviewLimit) return text;
|
||||
return `${text.slice(0, this.bodyPreviewLimit)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes secret-like response headers before they enter evidence.
|
||||
* @param headers - Headers returned by axios or a mocked adapter response.
|
||||
* @returns Header map with secret values replaced by a fixed redaction marker.
|
||||
*/
|
||||
private sanitizeHeaders(
|
||||
headers: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [
|
||||
key,
|
||||
SECRET_HEADER_PATTERN.test(key) ? '[redacted]' : value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||
import { EnvironmentReadonlyHttpClient } from './environment-readonly-http.client';
|
||||
import {
|
||||
asNumber,
|
||||
asString,
|
||||
createErrorAdapterSignal,
|
||||
createLiveAdapterSignal,
|
||||
createUnwiredAdapterSignal,
|
||||
joinReadonlyUrl,
|
||||
parseJsonPreview,
|
||||
} from './environment-readonly-adapter.helpers';
|
||||
|
||||
@Injectable()
|
||||
export class JenkinsReadonlyAdapter {
|
||||
private readonly http: EnvironmentReadonlyHttpClient;
|
||||
|
||||
/**
|
||||
* Initializes Jenkins readonly adapter.
|
||||
* @param config - Environment dashboard config reader.
|
||||
* @param http - Readonly HTTP client used for Jenkins JSON API probes.
|
||||
*/
|
||||
constructor(
|
||||
private readonly config: EnvironmentDashboardConfigService,
|
||||
@Optional() http?: EnvironmentReadonlyHttpClient,
|
||||
) {
|
||||
this.http = http || new EnvironmentReadonlyHttpClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects Jenkins readonly integration readiness.
|
||||
* @returns Jenkins signal; missing configuration is explicit unwired evidence.
|
||||
*/
|
||||
async inspect() {
|
||||
const missing = this.config.missing([
|
||||
'ENV_DASHBOARD_JENKINS_URL',
|
||||
'ENV_DASHBOARD_JENKINS_JOB',
|
||||
]);
|
||||
if (missing.length > 0) {
|
||||
return createUnwiredAdapterSignal(
|
||||
'jenkins-build',
|
||||
'Jenkins Build',
|
||||
missing,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.http.get(this.buildLastBuildUrl(), {
|
||||
headers: this.createAuthHeaders(),
|
||||
});
|
||||
const body = parseJsonPreview(response.bodyPreview);
|
||||
const buildNumber = asNumber(body.number);
|
||||
const durationMs = asNumber(body.duration);
|
||||
const building = body.building === true;
|
||||
const result = building ? 'BUILDING' : asString(body.result) || 'UNKNOWN';
|
||||
const status = !building && result === 'SUCCESS' ? 'ok' : 'degraded';
|
||||
const summary = `Jenkins last build ${buildNumber ? `#${buildNumber} ` : ''}${result}`;
|
||||
|
||||
return createLiveAdapterSignal(
|
||||
'jenkins-build',
|
||||
'Jenkins Build',
|
||||
summary,
|
||||
{
|
||||
buildNumber,
|
||||
building,
|
||||
durationMs,
|
||||
httpStatus: response.status,
|
||||
result,
|
||||
},
|
||||
status,
|
||||
response.observedAt,
|
||||
);
|
||||
} catch (error) {
|
||||
return createErrorAdapterSignal(
|
||||
'jenkins-build',
|
||||
'Jenkins Build',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Jenkins last-build JSON API URL from folder-style job names.
|
||||
* @returns Jenkins readonly JSON API URL for the configured job.
|
||||
*/
|
||||
private buildLastBuildUrl(): string {
|
||||
const jobPath = this.config
|
||||
.get('ENV_DASHBOARD_JENKINS_JOB')
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => `job/${encodeURIComponent(segment)}`)
|
||||
.join('/');
|
||||
return joinReadonlyUrl(
|
||||
this.config.get('ENV_DASHBOARD_JENKINS_URL'),
|
||||
`${jobPath}/lastBuild/api/json`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates optional Jenkins basic-auth headers without returning token values as evidence.
|
||||
* @returns Headers for outbound Jenkins API request, or undefined when credentials are absent.
|
||||
*/
|
||||
private createAuthHeaders(): Record<string, string> | undefined {
|
||||
const username = this.config.get('ENV_DASHBOARD_JENKINS_USERNAME');
|
||||
const token = this.config.get('ENV_DASHBOARD_JENKINS_TOKEN');
|
||||
if (!username || !token) return undefined;
|
||||
return {
|
||||
Authorization: `Basic ${Buffer.from(`${username}:${token}`).toString(
|
||||
'base64',
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,196 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||
import { EnvironmentReadonlyHttpClient } from './environment-readonly-http.client';
|
||||
import {
|
||||
asArray,
|
||||
asNumber,
|
||||
asRecord,
|
||||
createErrorAdapterSignal,
|
||||
createLiveAdapterSignal,
|
||||
createUnwiredAdapterSignal,
|
||||
isReadonlyHttpOk,
|
||||
joinReadonlyUrl,
|
||||
parseJsonPreview,
|
||||
} from './environment-readonly-adapter.helpers';
|
||||
|
||||
@Injectable()
|
||||
export class KubernetesReadonlyAdapter {
|
||||
private readonly http: EnvironmentReadonlyHttpClient;
|
||||
|
||||
/**
|
||||
* Initializes Kubernetes readonly adapter.
|
||||
* @param config - Environment dashboard config reader.
|
||||
* @param http - Readonly HTTP client used for Kubernetes API probes.
|
||||
*/
|
||||
constructor(
|
||||
private readonly config: EnvironmentDashboardConfigService,
|
||||
@Optional() http?: EnvironmentReadonlyHttpClient,
|
||||
) {
|
||||
this.http = http || new EnvironmentReadonlyHttpClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects Kubernetes readonly integration readiness.
|
||||
* @returns K8s signal; missing configuration is explicit unwired evidence.
|
||||
*/
|
||||
async inspect() {
|
||||
const missing = this.config.missing([
|
||||
'ENV_DASHBOARD_K8S_API_SERVER',
|
||||
'ENV_DASHBOARD_K8S_NAMESPACE',
|
||||
'ENV_DASHBOARD_K8S_DEPLOYMENT',
|
||||
]);
|
||||
if (missing.length > 0) {
|
||||
return createUnwiredAdapterSignal(
|
||||
'k8s-deployment',
|
||||
'K8s Deployment',
|
||||
missing,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = this.createAuthHeaders();
|
||||
const deploymentResponse = await this.http.get(this.deploymentUrl(), {
|
||||
headers,
|
||||
});
|
||||
const podsResponse = await this.http.get(this.podsUrl(), {
|
||||
headers,
|
||||
params: this.podsParams(),
|
||||
});
|
||||
const deployment = this.extractDeploymentReadiness(
|
||||
parseJsonPreview(deploymentResponse.bodyPreview),
|
||||
);
|
||||
const pods = this.extractPodReadiness(
|
||||
parseJsonPreview(podsResponse.bodyPreview),
|
||||
);
|
||||
const httpOk =
|
||||
isReadonlyHttpOk(deploymentResponse.status) &&
|
||||
isReadonlyHttpOk(podsResponse.status);
|
||||
const replicasReady =
|
||||
deployment.desiredReplicas === 0 ||
|
||||
(deployment.readyReplicas >= deployment.desiredReplicas &&
|
||||
deployment.updatedReplicas >= deployment.desiredReplicas &&
|
||||
deployment.availableReplicas >= deployment.desiredReplicas);
|
||||
const status = httpOk && replicasReady ? 'ok' : 'degraded';
|
||||
const summary = `K8s deployment ready ${deployment.readyReplicas}/${deployment.desiredReplicas}, pods ${pods.podReadyCount}/${pods.podCount}`;
|
||||
|
||||
return createLiveAdapterSignal(
|
||||
'k8s-deployment',
|
||||
'K8s Deployment',
|
||||
summary,
|
||||
{
|
||||
availableReplicas: deployment.availableReplicas,
|
||||
deploymentHttpStatus: deploymentResponse.status,
|
||||
desiredReplicas: deployment.desiredReplicas,
|
||||
labelSelector: this.config.get('ENV_DASHBOARD_K8S_LABEL_SELECTOR'),
|
||||
podCount: pods.podCount,
|
||||
podHttpStatus: podsResponse.status,
|
||||
podReadyCount: pods.podReadyCount,
|
||||
podRunningCount: pods.podRunningCount,
|
||||
readyReplicas: deployment.readyReplicas,
|
||||
updatedReplicas: deployment.updatedReplicas,
|
||||
},
|
||||
status,
|
||||
deploymentResponse.observedAt,
|
||||
);
|
||||
} catch (error) {
|
||||
return createErrorAdapterSignal(
|
||||
'k8s-deployment',
|
||||
'K8s Deployment',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Kubernetes deployment API URL for the configured namespace and deployment.
|
||||
* @returns Readonly apps/v1 deployment endpoint.
|
||||
*/
|
||||
private deploymentUrl(): string {
|
||||
const namespace = encodeURIComponent(
|
||||
this.config.get('ENV_DASHBOARD_K8S_NAMESPACE'),
|
||||
);
|
||||
const deployment = encodeURIComponent(
|
||||
this.config.get('ENV_DASHBOARD_K8S_DEPLOYMENT'),
|
||||
);
|
||||
return joinReadonlyUrl(
|
||||
this.config.get('ENV_DASHBOARD_K8S_API_SERVER'),
|
||||
`/apis/apps/v1/namespaces/${namespace}/deployments/${deployment}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Kubernetes pods API URL for the configured namespace.
|
||||
* @returns Readonly core/v1 pods endpoint.
|
||||
*/
|
||||
private podsUrl(): string {
|
||||
const namespace = encodeURIComponent(
|
||||
this.config.get('ENV_DASHBOARD_K8S_NAMESPACE'),
|
||||
);
|
||||
return joinReadonlyUrl(
|
||||
this.config.get('ENV_DASHBOARD_K8S_API_SERVER'),
|
||||
`/api/v1/namespaces/${namespace}/pods`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates optional Kubernetes bearer-auth headers without exposing token values.
|
||||
* @returns Headers for outbound Kubernetes API request, or undefined when token is absent.
|
||||
*/
|
||||
private createAuthHeaders(): Record<string, string> | undefined {
|
||||
const token = this.config.get('ENV_DASHBOARD_K8S_BEARER_TOKEN');
|
||||
if (!token) return undefined;
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates optional Kubernetes pod list query params from safe selector config.
|
||||
* @returns Params object accepted by the readonly HTTP client.
|
||||
*/
|
||||
private podsParams(): Record<string, string> | undefined {
|
||||
const labelSelector = this.config.get('ENV_DASHBOARD_K8S_LABEL_SELECTOR');
|
||||
return labelSelector ? { labelSelector } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts deployment replica readiness from Kubernetes deployment JSON.
|
||||
* @param body - Parsed deployment JSON body from Kubernetes API.
|
||||
* @returns Replica counts used for dashboard status.
|
||||
*/
|
||||
private extractDeploymentReadiness(body: Record<string, unknown>) {
|
||||
const spec = asRecord(body.spec) || {};
|
||||
const status = asRecord(body.status) || {};
|
||||
const desiredReplicas =
|
||||
asNumber(spec.replicas) ?? asNumber(status.replicas) ?? 0;
|
||||
return {
|
||||
availableReplicas: asNumber(status.availableReplicas) || 0,
|
||||
desiredReplicas,
|
||||
readyReplicas: asNumber(status.readyReplicas) || 0,
|
||||
updatedReplicas: asNumber(status.updatedReplicas) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts pod phase and ready-condition counts from Kubernetes pod list JSON.
|
||||
* @param body - Parsed pod list JSON body from Kubernetes API.
|
||||
* @returns Pod counts used for dashboard evidence.
|
||||
*/
|
||||
private extractPodReadiness(body: Record<string, unknown>) {
|
||||
const pods = asArray(body.items);
|
||||
const podRunningCount = pods.filter((pod) => {
|
||||
const status = asRecord(asRecord(pod)?.status);
|
||||
return status?.phase === 'Running';
|
||||
}).length;
|
||||
const podReadyCount = pods.filter((pod) => {
|
||||
const status = asRecord(asRecord(pod)?.status);
|
||||
return asArray(status?.conditions).some((condition) => {
|
||||
const record = asRecord(condition);
|
||||
return record?.type === 'Ready' && record.status === 'True';
|
||||
});
|
||||
}).length;
|
||||
return {
|
||||
podCount: pods.length,
|
||||
podReadyCount,
|
||||
podRunningCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||
import { EnvironmentReadonlyHttpClient } from './environment-readonly-http.client';
|
||||
import {
|
||||
asArray,
|
||||
asRecord,
|
||||
asString,
|
||||
createErrorAdapterSignal,
|
||||
createLiveAdapterSignal,
|
||||
createUnwiredAdapterSignal,
|
||||
isReadonlyHttpOk,
|
||||
joinReadonlyUrl,
|
||||
parseJsonPreview,
|
||||
} from './environment-readonly-adapter.helpers';
|
||||
|
||||
@Injectable()
|
||||
export class MihomoReadonlyAdapter {
|
||||
private readonly http: EnvironmentReadonlyHttpClient;
|
||||
|
||||
/**
|
||||
* Initializes Mihomo/OpenClash readonly adapter.
|
||||
* @param config - Environment dashboard config reader.
|
||||
* @param http - Readonly HTTP client used for Mihomo external controller probes.
|
||||
*/
|
||||
constructor(
|
||||
private readonly config: EnvironmentDashboardConfigService,
|
||||
@Optional() http?: EnvironmentReadonlyHttpClient,
|
||||
) {
|
||||
this.http = http || new EnvironmentReadonlyHttpClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects Mihomo/OpenClash readonly API readiness.
|
||||
* @returns Mihomo signal; missing configuration is explicit unwired evidence.
|
||||
*/
|
||||
async inspect() {
|
||||
const missing = this.config.missing([
|
||||
'ENV_DASHBOARD_R4SE_MIHOMO_URL',
|
||||
'ENV_DASHBOARD_R4SE_MIHOMO_SECRET',
|
||||
]);
|
||||
if (missing.length > 0) {
|
||||
return createUnwiredAdapterSignal(
|
||||
'r4se-mihomo',
|
||||
'Mihomo/OpenClash',
|
||||
missing,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = this.authHeaders();
|
||||
const versionResponse = await this.http.get(this.apiUrl('version'), {
|
||||
headers,
|
||||
});
|
||||
const configsResponse = await this.http.get(this.apiUrl('configs'), {
|
||||
headers,
|
||||
});
|
||||
const proxiesResponse = await this.http.get(this.apiUrl('proxies'), {
|
||||
headers,
|
||||
});
|
||||
const version = parseJsonPreview(versionResponse.bodyPreview);
|
||||
const configs = parseJsonPreview(configsResponse.bodyPreview);
|
||||
const proxies = parseJsonPreview(proxiesResponse.bodyPreview);
|
||||
const httpOk =
|
||||
isReadonlyHttpOk(versionResponse.status) &&
|
||||
isReadonlyHttpOk(configsResponse.status) &&
|
||||
isReadonlyHttpOk(proxiesResponse.status);
|
||||
const mode = asString(configs.mode) || 'unknown';
|
||||
const proxyCount = this.countProxies(proxies);
|
||||
const versionText = asString(version.version) || 'unknown';
|
||||
const summary = `Mihomo ${versionText}, mode ${mode}, proxies ${proxyCount}`;
|
||||
|
||||
return createLiveAdapterSignal(
|
||||
'r4se-mihomo',
|
||||
'Mihomo/OpenClash',
|
||||
summary,
|
||||
{
|
||||
configsHttpStatus: configsResponse.status,
|
||||
mode,
|
||||
proxiesHttpStatus: proxiesResponse.status,
|
||||
proxyCount,
|
||||
version: versionText,
|
||||
versionHttpStatus: versionResponse.status,
|
||||
},
|
||||
httpOk ? 'ok' : 'degraded',
|
||||
versionResponse.observedAt,
|
||||
);
|
||||
} catch (error) {
|
||||
return createErrorAdapterSignal(
|
||||
'r4se-mihomo',
|
||||
'Mihomo/OpenClash',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Mihomo readonly external-controller URL.
|
||||
* @param path - Readonly API path such as version, configs, or proxies.
|
||||
* @returns Full Mihomo external-controller endpoint URL.
|
||||
*/
|
||||
private apiUrl(path: 'configs' | 'proxies' | 'version'): string {
|
||||
return joinReadonlyUrl(
|
||||
this.config.get('ENV_DASHBOARD_R4SE_MIHOMO_URL'),
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates Mihomo authorization headers without returning the secret as evidence.
|
||||
* @returns Bearer authorization header for outbound readonly requests.
|
||||
*/
|
||||
private authHeaders(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.config.get(
|
||||
'ENV_DASHBOARD_R4SE_MIHOMO_SECRET',
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts proxies from Mihomo response shapes without retaining proxy payloads.
|
||||
* @param body - Parsed proxies response body.
|
||||
* @returns Number of proxies reported by the readonly endpoint.
|
||||
*/
|
||||
private countProxies(body: Record<string, unknown>): number {
|
||||
const proxies = body.proxies;
|
||||
const proxyRecord = asRecord(proxies);
|
||||
if (proxyRecord) return Object.keys(proxyRecord).length;
|
||||
return asArray(proxies).length;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,152 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { cvm } from 'tencentcloud-sdk-nodejs/tencentcloud/services/cvm';
|
||||
import type { ClientConfig } from 'tencentcloud-sdk-nodejs/tencentcloud/common/interface';
|
||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||
import {
|
||||
asNumber,
|
||||
asRecord,
|
||||
asString,
|
||||
createErrorAdapterSignal,
|
||||
createLiveAdapterSignal,
|
||||
createUnwiredAdapterSignal,
|
||||
} from './environment-readonly-adapter.helpers';
|
||||
|
||||
interface TencentCloudDescribeInstancesResponse {
|
||||
InstanceSet?: Array<Record<string, unknown>>;
|
||||
RequestId?: string;
|
||||
TotalCount?: number;
|
||||
}
|
||||
|
||||
interface TencentCloudCvmClient {
|
||||
DescribeInstances(request: {
|
||||
InstanceIds: string[];
|
||||
Limit: number;
|
||||
}): Promise<TencentCloudDescribeInstancesResponse>;
|
||||
}
|
||||
|
||||
type TencentCloudCvmClientFactory = (
|
||||
clientConfig: ClientConfig,
|
||||
) => TencentCloudCvmClient;
|
||||
|
||||
/**
|
||||
* Creates the official Tencent Cloud CVM SDK client for readonly Describe APIs.
|
||||
* @param clientConfig - SDK credential, region, and endpoint configuration.
|
||||
* @returns CVM client limited by adapter usage to DescribeInstances.
|
||||
*/
|
||||
function createTencentCloudCvmClient(
|
||||
clientConfig: ClientConfig,
|
||||
): TencentCloudCvmClient {
|
||||
return new cvm.v20170312.Client(
|
||||
clientConfig,
|
||||
) as unknown as TencentCloudCvmClient;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TencentCloudReadonlyAdapter {
|
||||
private readonly createClient: TencentCloudCvmClientFactory;
|
||||
|
||||
/**
|
||||
* Initializes Tencent Cloud readonly adapter.
|
||||
* @param config - Environment dashboard config reader.
|
||||
* @param createClient - Optional factory used by tests to mock the Tencent SDK.
|
||||
*/
|
||||
constructor(
|
||||
private readonly config: EnvironmentDashboardConfigService,
|
||||
@Optional() createClient?: TencentCloudCvmClientFactory,
|
||||
) {
|
||||
this.createClient = createClient || createTencentCloudCvmClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects Tencent Cloud readonly integration readiness.
|
||||
* @returns Tencent Cloud signal; missing configuration is explicit unwired evidence.
|
||||
*/
|
||||
async inspect() {
|
||||
const missing = this.config.missing([
|
||||
'ENV_DASHBOARD_TENCENT_CLOUD_ENABLED',
|
||||
'ENV_DASHBOARD_TENCENT_SECRET_ID',
|
||||
'ENV_DASHBOARD_TENCENT_SECRET_KEY',
|
||||
'ENV_DASHBOARD_TENCENT_REGION',
|
||||
'ENV_DASHBOARD_TENCENT_INSTANCE_ID',
|
||||
]);
|
||||
if (!this.isEnabled()) {
|
||||
missing.unshift('ENV_DASHBOARD_TENCENT_CLOUD_ENABLED=true');
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
return createUnwiredAdapterSignal(
|
||||
'tencent-cvm',
|
||||
'Tencent Cloud CVM',
|
||||
missing,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.createClient(this.clientConfig())
|
||||
.DescribeInstances({
|
||||
InstanceIds: [this.config.get('ENV_DASHBOARD_TENCENT_INSTANCE_ID')],
|
||||
Limit: 1,
|
||||
});
|
||||
const instance = asRecord(response.InstanceSet?.[0]) || {};
|
||||
const instanceState = asString(instance.InstanceState) || 'UNKNOWN';
|
||||
const status =
|
||||
instanceState === 'RUNNING'
|
||||
? 'ok'
|
||||
: response.TotalCount === 0
|
||||
? 'unknown'
|
||||
: 'degraded';
|
||||
const summary = `Tencent Cloud CVM ${instanceState}`;
|
||||
|
||||
return createLiveAdapterSignal(
|
||||
'tencent-cvm',
|
||||
'Tencent Cloud CVM',
|
||||
summary,
|
||||
{
|
||||
cpu: asNumber(instance.CPU),
|
||||
instanceId: asString(instance.InstanceId),
|
||||
instanceState,
|
||||
memoryMb: asNumber(instance.Memory),
|
||||
requestId: response.RequestId,
|
||||
totalCount: response.TotalCount,
|
||||
},
|
||||
status,
|
||||
);
|
||||
} catch (error) {
|
||||
return createErrorAdapterSignal(
|
||||
'tencent-cvm',
|
||||
'Tencent Cloud CVM',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the explicit Tencent Cloud enablement guard before creating SDK clients.
|
||||
* @returns True only when the dashboard Tencent integration is explicitly enabled.
|
||||
*/
|
||||
private isEnabled(): boolean {
|
||||
return (
|
||||
this.config
|
||||
.get('ENV_DASHBOARD_TENCENT_CLOUD_ENABLED')
|
||||
.toLowerCase() === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Tencent Cloud SDK config without exposing credentials as evidence.
|
||||
* @returns ClientConfig accepted by the official Tencent Cloud SDK.
|
||||
*/
|
||||
private clientConfig(): ClientConfig {
|
||||
return {
|
||||
credential: {
|
||||
secretId: this.config.get('ENV_DASHBOARD_TENCENT_SECRET_ID'),
|
||||
secretKey: this.config.get('ENV_DASHBOARD_TENCENT_SECRET_KEY'),
|
||||
},
|
||||
profile: {
|
||||
httpProfile: {
|
||||
endpoint: 'cvm.tencentcloudapi.com',
|
||||
},
|
||||
},
|
||||
region: this.config.get('ENV_DASHBOARD_TENCENT_REGION'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||
import { EnvironmentReadonlyHttpClient } from './environment-readonly-http.client';
|
||||
import {
|
||||
createErrorAdapterSignal,
|
||||
createLiveAdapterSignal,
|
||||
createUnwiredAdapterSignal,
|
||||
isReadonlyHttpOk,
|
||||
} from './environment-readonly-adapter.helpers';
|
||||
|
||||
@Injectable()
|
||||
export class WireguardReadonlyAdapter {
|
||||
private readonly http: EnvironmentReadonlyHttpClient;
|
||||
|
||||
/**
|
||||
* Initializes WireGuard readonly adapter.
|
||||
* @param config - Environment dashboard config reader.
|
||||
* @param http - Readonly HTTP client used for WireGuard health endpoints.
|
||||
*/
|
||||
constructor(
|
||||
private readonly config: EnvironmentDashboardConfigService,
|
||||
@Optional() http?: EnvironmentReadonlyHttpClient,
|
||||
) {
|
||||
this.http = http || new EnvironmentReadonlyHttpClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects WireGuard readonly health endpoint readiness.
|
||||
* @returns WireGuard signal; missing configuration is explicit unwired evidence.
|
||||
*/
|
||||
async inspect() {
|
||||
const missing = this.config.missing([
|
||||
'ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL',
|
||||
'ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL',
|
||||
]);
|
||||
if (missing.length > 0) {
|
||||
return createUnwiredAdapterSignal('wireguard', 'WireGuard', missing);
|
||||
}
|
||||
|
||||
try {
|
||||
const tencentResponse = await this.http.get(
|
||||
this.config.get('ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL'),
|
||||
);
|
||||
const r4seResponse = await this.http.get(
|
||||
this.config.get('ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL'),
|
||||
);
|
||||
const endpointStatuses = [
|
||||
{ label: 'tencent-cloud', status: tencentResponse.status },
|
||||
{ label: 'r4se', status: r4seResponse.status },
|
||||
];
|
||||
const reachableCount = endpointStatuses.filter((endpoint) =>
|
||||
isReadonlyHttpOk(endpoint.status),
|
||||
).length;
|
||||
const status =
|
||||
reachableCount === endpointStatuses.length ? 'ok' : 'degraded';
|
||||
const summary = `WireGuard health endpoints reachable ${reachableCount}/${endpointStatuses.length}`;
|
||||
|
||||
return createLiveAdapterSignal(
|
||||
'wireguard',
|
||||
'WireGuard',
|
||||
summary,
|
||||
{
|
||||
endpointCount: endpointStatuses.length,
|
||||
endpointStatuses,
|
||||
reachableCount,
|
||||
},
|
||||
status,
|
||||
tencentResponse.observedAt,
|
||||
);
|
||||
} catch (error) {
|
||||
return createErrorAdapterSignal('wireguard', 'WireGuard', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,184 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { RuntimeHealthService } from '@/runtime/health/runtime-health.service';
|
||||
import { unwiredEvidence } from '../environment-dashboard-evidence.mapper';
|
||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||
import {
|
||||
mapSiteStatus,
|
||||
pickWorstHealthStatus,
|
||||
} from '../../application/environment-dashboard-status.mapper';
|
||||
import type {
|
||||
EnvironmentHealthStatus,
|
||||
EnvironmentNode,
|
||||
EnvironmentService,
|
||||
EnvironmentSignal,
|
||||
EnvironmentSite,
|
||||
} from '../../domain/environment-dashboard.types';
|
||||
import type { RuntimeHealthStatus } from '@/runtime/health/runtime-health.types';
|
||||
|
||||
export interface LocalDevSignalCollectContext {
|
||||
observedAt?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocalDevSignalCollector {
|
||||
/**
|
||||
* Initializes local development signal collection.
|
||||
* @param runtimeHealthService - Runtime module health reader; absent in narrow unit tests means the API signal is unknown instead of green.
|
||||
* @param config - Environment dashboard config reader used for Admin local route evidence.
|
||||
*/
|
||||
constructor(
|
||||
@Optional()
|
||||
private readonly runtimeHealthService?: RuntimeHealthService,
|
||||
@Optional()
|
||||
private readonly config: EnvironmentDashboardConfigService = new EnvironmentDashboardConfigService(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Collects local development API/Admin service evidence for the dashboard.
|
||||
* @param context - Snapshot context from the aggregator; `observedAt` keeps evidence timestamps aligned.
|
||||
* @returns Local development site with API runtime and Admin route signals.
|
||||
*/
|
||||
async collect(context: LocalDevSignalCollectContext = {}): Promise<EnvironmentSite> {
|
||||
const observedAt = context.observedAt || new Date().toISOString();
|
||||
const services = [
|
||||
this.createApiService(observedAt),
|
||||
this.createAdminService(observedAt),
|
||||
];
|
||||
const node = this.createNode('local-dev-node', 'Local Dev Workstation', services);
|
||||
return {
|
||||
id: 'local-dev',
|
||||
label: 'Local Dev',
|
||||
nodes: [node],
|
||||
status: mapSiteStatus(services.map((service) => service.status)),
|
||||
summary: 'Local development runtime snapshot',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the local API service from RuntimeHealthService output.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns API service with runtime health evidence.
|
||||
*/
|
||||
private createApiService(observedAt: string): EnvironmentService {
|
||||
const report = this.runtimeHealthService?.getRuntimeHealth();
|
||||
const status = this.mapRuntimeStatus(report?.status);
|
||||
const signal: EnvironmentSignal = {
|
||||
evidence: [
|
||||
{
|
||||
metadata: report
|
||||
? {
|
||||
checks: report.checks?.length || 0,
|
||||
runtimeStatus: report.status,
|
||||
}
|
||||
: undefined,
|
||||
observedAt: report?.checkedAt || observedAt,
|
||||
source: 'runtime-health',
|
||||
sourceKind: report ? 'live' : 'derived',
|
||||
summary: report
|
||||
? `Runtime health is ${report.status}`
|
||||
: 'RuntimeHealthService 未接入当前测试上下文',
|
||||
},
|
||||
],
|
||||
id: 'local-api-process',
|
||||
label: 'API Process',
|
||||
observedAt: report?.checkedAt || observedAt,
|
||||
sourceKind: report ? 'live' : 'derived',
|
||||
status,
|
||||
summary: report
|
||||
? `API runtime health: ${report.status}`
|
||||
: '等待 RuntimeHealthService 提供本机进程状态',
|
||||
};
|
||||
return this.createService('local-api', 'API Runtime', [signal]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the local Admin route signal from optional local URL configuration.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns Admin service with configured or unwired evidence.
|
||||
*/
|
||||
private createAdminService(observedAt: string): EnvironmentService {
|
||||
const adminUrl = this.config.get('ENV_DASHBOARD_ADMIN_LOCAL_URL');
|
||||
const signal: EnvironmentSignal = adminUrl
|
||||
? {
|
||||
evidence: [
|
||||
{
|
||||
metadata: { url: adminUrl },
|
||||
observedAt,
|
||||
source: 'Admin local URL',
|
||||
sourceKind: 'configured',
|
||||
summary: '本机 Admin 地址已配置',
|
||||
},
|
||||
],
|
||||
id: 'local-admin-route',
|
||||
label: 'Admin Local Route',
|
||||
observedAt,
|
||||
sourceKind: 'configured',
|
||||
status: 'unknown',
|
||||
summary: 'Admin 本机地址已配置,页面连通性由浏览器 smoke 验证',
|
||||
}
|
||||
: {
|
||||
evidence: [
|
||||
unwiredEvidence('Admin local URL', ['ENV_DASHBOARD_ADMIN_LOCAL_URL']),
|
||||
],
|
||||
id: 'local-admin-route',
|
||||
label: 'Admin Local Route',
|
||||
sourceKind: 'unwired',
|
||||
status: 'unwired',
|
||||
summary: '本机 Admin 地址未配置',
|
||||
};
|
||||
return this.createService('local-admin', 'Admin Frontend', [signal]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps runtime module health into environment dashboard signal status.
|
||||
* @param status - RuntimeHealthService status value.
|
||||
* @returns Dashboard health status preserving blocked/degraded semantics.
|
||||
*/
|
||||
private mapRuntimeStatus(status?: RuntimeHealthStatus): EnvironmentHealthStatus {
|
||||
if (status === 'live' || status === 'ready') return 'ok';
|
||||
if (status === 'blocked') return 'blocked';
|
||||
if (status === 'degraded') return 'degraded';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a service from child signals and derives aggregate status.
|
||||
* @param id - Stable service id used by topology and Admin selection.
|
||||
* @param label - Operator-facing service label.
|
||||
* @param signals - Signals supporting the service.
|
||||
* @returns Service object with worst-signal status.
|
||||
*/
|
||||
private createService(
|
||||
id: string,
|
||||
label: string,
|
||||
signals: EnvironmentSignal[],
|
||||
): EnvironmentService {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
signals,
|
||||
status: pickWorstHealthStatus(signals.map((signal) => signal.status)),
|
||||
summary: signals.map((signal) => signal.summary).join(';'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a local node and derives aggregate status from services.
|
||||
* @param id - Stable node id for topology edges.
|
||||
* @param label - Operator-facing node label.
|
||||
* @param services - Services owned by the local node.
|
||||
* @returns Node object with worst-service status.
|
||||
*/
|
||||
private createNode(
|
||||
id: string,
|
||||
label: string,
|
||||
services: EnvironmentService[],
|
||||
): EnvironmentNode {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
services,
|
||||
status: pickWorstHealthStatus(services.map((service) => service.status)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,649 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { RuntimeHealthService } from '@/runtime/health/runtime-health.service';
|
||||
import { MinioClientService } from '@/modules/asset/application/asset-minio.service';
|
||||
import { QqbotDashboardService } from '@/modules/qqbot/core/application/dashboard/qqbot-dashboard.service';
|
||||
import { QqbotPluginTaskService } from '@/modules/qqbot/plugin-platform/application/task';
|
||||
import { WordpressService } from '@/modules/wordpress/application/wordpress.service';
|
||||
import { errorEvidence, liveEvidence, unwiredEvidence } from '../environment-dashboard-evidence.mapper';
|
||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||
import { JenkinsReadonlyAdapter } from '../adapters/jenkins-readonly.adapter';
|
||||
import { KubernetesReadonlyAdapter } from '../adapters/kubernetes-readonly.adapter';
|
||||
import {
|
||||
mapSiteStatus,
|
||||
pickWorstHealthStatus,
|
||||
} from '../../application/environment-dashboard-status.mapper';
|
||||
import type {
|
||||
EnvironmentHealthStatus,
|
||||
EnvironmentNode,
|
||||
EnvironmentService,
|
||||
EnvironmentSignal,
|
||||
EnvironmentSite,
|
||||
} from '../../domain/environment-dashboard.types';
|
||||
import type { RuntimeHealthStatus } from '@/runtime/health/runtime-health.types';
|
||||
|
||||
export interface NasProdSignalCollectContext {
|
||||
observedAt?: string;
|
||||
}
|
||||
|
||||
type QqbotSummaryProbe =
|
||||
| { data: any; error?: never }
|
||||
| { data?: never; error: unknown };
|
||||
|
||||
@Injectable()
|
||||
export class NasProdSignalCollector {
|
||||
/**
|
||||
* Initializes NAS production collector from narrow readonly service ports.
|
||||
* @param runtimeHealthService - Runtime health reader proving API process/config state without touching deploy state.
|
||||
* @param qqbotDashboardService - QQBot summary reader; failures affect only QQBot/NapCat signals.
|
||||
* @param pluginTaskService - Plugin task page reader used to summarize scheduler state without executing tasks.
|
||||
* @param minioClientService - MinIO connection checker; errors remain scoped to the MinIO service.
|
||||
* @param wordpressService - WordPress optional admin login probe that reports availability without changing content.
|
||||
* @param jenkinsAdapter - Jenkins readonly adapter owned by the remote integration layer.
|
||||
* @param kubernetesAdapter - Kubernetes readonly adapter owned by the remote integration layer.
|
||||
* @param config - Dashboard config reader used to expose missing integration keys as unwired evidence.
|
||||
*/
|
||||
constructor(
|
||||
@Optional()
|
||||
private readonly runtimeHealthService?: RuntimeHealthService,
|
||||
@Optional()
|
||||
private readonly qqbotDashboardService?: QqbotDashboardService,
|
||||
@Optional()
|
||||
private readonly pluginTaskService?: QqbotPluginTaskService,
|
||||
@Optional()
|
||||
private readonly minioClientService?: MinioClientService,
|
||||
@Optional()
|
||||
private readonly wordpressService?: WordpressService,
|
||||
@Optional()
|
||||
private readonly jenkinsAdapter?: JenkinsReadonlyAdapter,
|
||||
@Optional()
|
||||
private readonly kubernetesAdapter?: KubernetesReadonlyAdapter,
|
||||
@Optional()
|
||||
private readonly config: EnvironmentDashboardConfigService = new EnvironmentDashboardConfigService(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Collects NAS production service evidence from internal modules and readonly remote adapters.
|
||||
* @param context - Snapshot context from the dashboard aggregator; `observedAt` aligns all evidence timestamps.
|
||||
* @returns NAS production site with individual service statuses and no cross-service failure leakage.
|
||||
*/
|
||||
async collect(context: NasProdSignalCollectContext = {}): Promise<EnvironmentSite> {
|
||||
const observedAt = context.observedAt || new Date().toISOString();
|
||||
const qqbotSummary = await this.readQqbotSummary();
|
||||
const services = [
|
||||
this.createNasApiService(observedAt),
|
||||
this.createNasAdminService(observedAt),
|
||||
this.createConfiguredDependencyService(
|
||||
'mysql',
|
||||
'MySQL',
|
||||
'数据库连通性由 API 运行态和业务 smoke 共同证明',
|
||||
observedAt,
|
||||
),
|
||||
this.createConfiguredDependencyService(
|
||||
'redis',
|
||||
'Redis',
|
||||
'队列和缓存连通性由运行态配置与业务 smoke 共同证明',
|
||||
observedAt,
|
||||
),
|
||||
this.createConfiguredDependencyService(
|
||||
'loki',
|
||||
'Loki',
|
||||
'日志聚合连通性由日志页面和线上 smoke 共同证明',
|
||||
observedAt,
|
||||
),
|
||||
await this.createMinioService(observedAt),
|
||||
await this.createWordpressService(observedAt),
|
||||
this.createQqbotService(qqbotSummary, observedAt),
|
||||
this.createNapcatService(qqbotSummary, observedAt),
|
||||
this.createPluginPlatformService(observedAt),
|
||||
await this.createPluginTaskService(observedAt),
|
||||
await this.createAdapterService(
|
||||
'jenkins',
|
||||
'Jenkins',
|
||||
'jenkins-build',
|
||||
'Jenkins Build',
|
||||
['ENV_DASHBOARD_JENKINS_URL', 'ENV_DASHBOARD_JENKINS_JOB'],
|
||||
this.jenkinsAdapter,
|
||||
),
|
||||
await this.createAdapterService(
|
||||
'kubernetes',
|
||||
'K8s',
|
||||
'k8s-deployment',
|
||||
'K8s Deployment',
|
||||
[
|
||||
'ENV_DASHBOARD_K8S_API_SERVER',
|
||||
'ENV_DASHBOARD_K8S_NAMESPACE',
|
||||
'ENV_DASHBOARD_K8S_DEPLOYMENT',
|
||||
],
|
||||
this.kubernetesAdapter,
|
||||
),
|
||||
];
|
||||
const node = this.createNode('nas-prod-node', 'NAS Production Host', services);
|
||||
return {
|
||||
id: 'nas-prod',
|
||||
label: 'NAS Production',
|
||||
nodes: [node],
|
||||
status: mapSiteStatus(services.map((service) => service.status)),
|
||||
summary: 'NAS online environment readonly snapshot',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads QQBot summary once so QQBot and NapCat services share the same evidence source.
|
||||
* @returns Probe result containing either summary data or a captured failure.
|
||||
*/
|
||||
private async readQqbotSummary(): Promise<QqbotSummaryProbe> {
|
||||
if (!this.qqbotDashboardService) return { error: new Error('QQBot dashboard service is not wired') };
|
||||
try {
|
||||
return { data: await this.qqbotDashboardService.summary() };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds NAS API status from RuntimeHealthService without coupling it to QQBot state.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns API service signal.
|
||||
*/
|
||||
private createNasApiService(observedAt: string): EnvironmentService {
|
||||
const report = this.runtimeHealthService?.getRuntimeHealth();
|
||||
const status = this.mapRuntimeStatus(report?.status);
|
||||
return this.createService('nas-api', 'API Runtime', [
|
||||
{
|
||||
evidence: [
|
||||
liveEvidence(
|
||||
'runtime-health',
|
||||
report ? `Runtime health is ${report.status}` : 'RuntimeHealthService 未接入',
|
||||
report?.checkedAt || observedAt,
|
||||
report
|
||||
? {
|
||||
checks: report.checks?.length || 0,
|
||||
runtimeStatus: report.status,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
],
|
||||
id: 'nas-api-runtime',
|
||||
label: 'API Runtime',
|
||||
observedAt: report?.checkedAt || observedAt,
|
||||
sourceKind: report ? 'live' : 'derived',
|
||||
status,
|
||||
summary: report ? `API runtime health: ${report.status}` : '等待 RuntimeHealthService 接入',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds NAS Admin evidence from known public route configuration.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns Admin frontend service.
|
||||
*/
|
||||
private createNasAdminService(observedAt: string): EnvironmentService {
|
||||
const publicUrl =
|
||||
this.config.get('ENV_DASHBOARD_ADMIN_PUBLIC_URL') ||
|
||||
this.config.get('ENV_DASHBOARD_CADDY_PUBLIC_URL');
|
||||
const signal: EnvironmentSignal = publicUrl
|
||||
? {
|
||||
evidence: [
|
||||
{
|
||||
metadata: { url: publicUrl },
|
||||
observedAt,
|
||||
source: 'Admin public route',
|
||||
sourceKind: 'configured',
|
||||
summary: 'Admin 公开入口已配置,页面连通性由浏览器 smoke 验证',
|
||||
},
|
||||
],
|
||||
id: 'nas-admin-route',
|
||||
label: 'Admin Public Route',
|
||||
observedAt,
|
||||
sourceKind: 'configured',
|
||||
status: 'unknown',
|
||||
summary: 'Admin 公开入口已配置',
|
||||
}
|
||||
: {
|
||||
evidence: [
|
||||
unwiredEvidence('Admin public route', [
|
||||
'ENV_DASHBOARD_ADMIN_PUBLIC_URL',
|
||||
'ENV_DASHBOARD_CADDY_PUBLIC_URL',
|
||||
]),
|
||||
],
|
||||
id: 'nas-admin-route',
|
||||
label: 'Admin Public Route',
|
||||
sourceKind: 'unwired',
|
||||
status: 'unwired',
|
||||
summary: 'Admin 公开入口未接入只读观测',
|
||||
};
|
||||
return this.createService('nas-admin', 'Admin Frontend', [signal]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dependency service that is visible but not falsely marked live.
|
||||
* @param id - Stable service and signal id prefix.
|
||||
* @param label - Operator-facing service label.
|
||||
* @param summary - Why this dependency is observed as configured/derived evidence.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns Dependency service with unknown status.
|
||||
*/
|
||||
private createConfiguredDependencyService(
|
||||
id: string,
|
||||
label: string,
|
||||
summary: string,
|
||||
observedAt: string,
|
||||
): EnvironmentService {
|
||||
return this.createService(id, label, [
|
||||
{
|
||||
evidence: [
|
||||
{
|
||||
observedAt,
|
||||
source: label,
|
||||
sourceKind: 'derived',
|
||||
summary,
|
||||
},
|
||||
],
|
||||
id: `${id}-signal`,
|
||||
label,
|
||||
observedAt,
|
||||
sourceKind: 'derived',
|
||||
status: 'unknown',
|
||||
summary,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds MinIO service evidence and scopes connection errors to MinIO only.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns MinIO service.
|
||||
*/
|
||||
private async createMinioService(observedAt: string): Promise<EnvironmentService> {
|
||||
if (!this.minioClientService) {
|
||||
return this.createUnknownService('minio', 'MinIO', 'MinioClientService 未接入', observedAt);
|
||||
}
|
||||
try {
|
||||
const result = await this.minioClientService.checkConnection();
|
||||
const status: EnvironmentHealthStatus = result?.exists ? 'ok' : 'degraded';
|
||||
return this.createService('minio', 'MinIO', [
|
||||
{
|
||||
evidence: [
|
||||
liveEvidence('minio', `Bucket ${result?.bucketName || ''} exists=${!!result?.exists}`, observedAt, {
|
||||
bucketName: result?.bucketName,
|
||||
exists: !!result?.exists,
|
||||
}),
|
||||
],
|
||||
id: 'minio-bucket',
|
||||
label: 'Default Bucket',
|
||||
observedAt,
|
||||
sourceKind: 'live',
|
||||
status,
|
||||
summary: result?.exists ? 'MinIO 默认 bucket 可用' : 'MinIO 默认 bucket 不存在',
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
return this.createService('minio', 'MinIO', [
|
||||
{
|
||||
evidence: [errorEvidence('minio', error, observedAt)],
|
||||
id: 'minio-bucket',
|
||||
label: 'Default Bucket',
|
||||
observedAt,
|
||||
sourceKind: 'derived',
|
||||
status: 'down',
|
||||
summary: 'MinIO 只读连通性检查失败',
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds WordPress availability evidence using the existing optional login probe.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns WordPress service.
|
||||
*/
|
||||
private async createWordpressService(observedAt: string): Promise<EnvironmentService> {
|
||||
if (!this.wordpressService) {
|
||||
return this.createUnknownService('wordpress', 'WordPress', 'WordpressService 未接入', observedAt);
|
||||
}
|
||||
try {
|
||||
const result = await this.wordpressService.tryLoginWithConfiguredAdmin();
|
||||
return this.createService('wordpress', 'WordPress', [
|
||||
{
|
||||
evidence: [
|
||||
liveEvidence('wordpress', result.available ? 'WordPress 管理员探针可用' : 'WordPress 管理员探针不可用', observedAt, {
|
||||
available: result.available,
|
||||
error: result.error,
|
||||
}),
|
||||
],
|
||||
id: 'wordpress-admin-login',
|
||||
label: 'WordPress Admin Probe',
|
||||
observedAt,
|
||||
sourceKind: 'live',
|
||||
status: result.available ? 'ok' : 'degraded',
|
||||
summary: result.available ? 'WordPress 集成可用' : 'WordPress 集成不可用',
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
return this.createService('wordpress', 'WordPress', [
|
||||
{
|
||||
evidence: [errorEvidence('wordpress', error, observedAt)],
|
||||
id: 'wordpress-admin-login',
|
||||
label: 'WordPress Admin Probe',
|
||||
observedAt,
|
||||
sourceKind: 'derived',
|
||||
status: 'down',
|
||||
summary: 'WordPress 只读探针失败',
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds QQBot core status from dashboard summary without affecting API service status.
|
||||
* @param probe - Shared QQBot summary probe.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns QQBot core service.
|
||||
*/
|
||||
private createQqbotService(
|
||||
probe: QqbotSummaryProbe,
|
||||
observedAt: string,
|
||||
): EnvironmentService {
|
||||
if (probe.error) {
|
||||
return this.createService('qqbot-core', 'QQBot Core', [
|
||||
{
|
||||
evidence: [errorEvidence('qqbot-dashboard', probe.error, observedAt)],
|
||||
id: 'qqbot-core-summary',
|
||||
label: 'QQBot Summary',
|
||||
observedAt,
|
||||
sourceKind: 'derived',
|
||||
status: this.qqbotDashboardService ? 'down' : 'unknown',
|
||||
summary: 'QQBot 摘要不可用',
|
||||
},
|
||||
]);
|
||||
}
|
||||
const accountTotal = Number(probe.data?.accountTotal || 0);
|
||||
const onlineTotal = Number(probe.data?.onlineTotal || 0);
|
||||
const status: EnvironmentHealthStatus =
|
||||
accountTotal > 0 && onlineTotal <= 0 ? 'degraded' : 'ok';
|
||||
return this.createService('qqbot-core', 'QQBot Core', [
|
||||
{
|
||||
evidence: [
|
||||
liveEvidence('qqbot-dashboard', `QQBot online ${onlineTotal}/${accountTotal}`, observedAt, {
|
||||
accountTotal,
|
||||
bus: probe.data?.bus,
|
||||
onlineTotal,
|
||||
}),
|
||||
],
|
||||
id: 'qqbot-core-summary',
|
||||
label: 'QQBot Summary',
|
||||
observedAt,
|
||||
sourceKind: 'live',
|
||||
status,
|
||||
summary: `QQBot 在线账号 ${onlineTotal}/${accountTotal}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds NapCat runtime visibility from QQBot runtime session evidence.
|
||||
* @param probe - Shared QQBot summary probe.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns NapCat runtime service.
|
||||
*/
|
||||
private createNapcatService(
|
||||
probe: QqbotSummaryProbe,
|
||||
observedAt: string,
|
||||
): EnvironmentService {
|
||||
if (probe.error) {
|
||||
return this.createUnknownService('napcat-runtime', 'NapCat Runtime', '等待 QQBot 摘要提供 NapCat 会话证据', observedAt);
|
||||
}
|
||||
const sessions = Array.isArray(probe.data?.runtime?.sessions)
|
||||
? probe.data.runtime.sessions
|
||||
: [];
|
||||
const enabled = probe.data?.runtime?.enabled !== false;
|
||||
const status: EnvironmentHealthStatus = !enabled
|
||||
? 'blocked'
|
||||
: sessions.length > 0
|
||||
? 'ok'
|
||||
: 'degraded';
|
||||
return this.createService('napcat-runtime', 'NapCat Runtime', [
|
||||
{
|
||||
evidence: [
|
||||
liveEvidence('qqbot-reverse-ws', `NapCat reverse WS sessions: ${sessions.length}`, observedAt, {
|
||||
enabled,
|
||||
sessionCount: sessions.length,
|
||||
}),
|
||||
],
|
||||
id: 'napcat-reverse-ws',
|
||||
label: 'Reverse WS Sessions',
|
||||
observedAt,
|
||||
sourceKind: 'live',
|
||||
status,
|
||||
summary: sessions.length > 0 ? 'NapCat reverse WS 有活跃会话' : 'NapCat reverse WS 暂无活跃会话',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds plugin platform presence evidence without exposing write actions.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns Plugin platform service.
|
||||
*/
|
||||
private createPluginPlatformService(observedAt: string): EnvironmentService {
|
||||
return this.createService('plugin-platform', 'Plugin Platform', [
|
||||
{
|
||||
evidence: [
|
||||
{
|
||||
observedAt,
|
||||
source: 'plugin-platform',
|
||||
sourceKind: this.pluginTaskService ? 'derived' : 'unwired',
|
||||
summary: this.pluginTaskService
|
||||
? '插件平台任务服务已接入只读摘要'
|
||||
: '插件平台任务服务未接入当前模块上下文',
|
||||
},
|
||||
],
|
||||
id: 'plugin-platform-provider',
|
||||
label: 'Plugin Platform Provider',
|
||||
observedAt,
|
||||
sourceKind: this.pluginTaskService ? 'derived' : 'unwired',
|
||||
status: this.pluginTaskService ? 'unknown' : 'unwired',
|
||||
summary: this.pluginTaskService
|
||||
? '插件平台 provider 可见'
|
||||
: '插件平台 provider 未接入',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds scheduled plugin task evidence without executing task jobs.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns Plugin task service.
|
||||
*/
|
||||
private async createPluginTaskService(observedAt: string): Promise<EnvironmentService> {
|
||||
if (!this.pluginTaskService) {
|
||||
return this.createUnknownService('plugin-tasks', 'Plugin Tasks', 'QqbotPluginTaskService 未接入', observedAt);
|
||||
}
|
||||
try {
|
||||
const page = await this.pluginTaskService.pageTasks({
|
||||
pageNo: 1,
|
||||
pageSize: 50,
|
||||
} as any);
|
||||
const list = Array.isArray(page?.list) ? page.list : [];
|
||||
const disabledCount = list.filter((task) => task?.enabled === false).length;
|
||||
const failedCount = list.filter((task) => /failed|error/i.test(`${task?.runtimeStatus || ''}`)).length;
|
||||
const status: EnvironmentHealthStatus =
|
||||
failedCount > 0 ? 'down' : disabledCount > 0 ? 'degraded' : 'ok';
|
||||
return this.createService('plugin-tasks', 'Plugin Tasks', [
|
||||
{
|
||||
evidence: [
|
||||
liveEvidence('plugin-tasks', `Plugin tasks total=${page?.total || list.length}, disabled=${disabledCount}`, observedAt, {
|
||||
disabledCount,
|
||||
failedCount,
|
||||
total: page?.total || list.length,
|
||||
}),
|
||||
],
|
||||
id: 'plugin-task-scheduler',
|
||||
label: 'Scheduled Tasks',
|
||||
observedAt,
|
||||
sourceKind: 'live',
|
||||
status,
|
||||
summary: disabledCount > 0 ? '存在已禁用插件定时任务' : '插件定时任务摘要可用',
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
return this.createService('plugin-tasks', 'Plugin Tasks', [
|
||||
{
|
||||
evidence: [errorEvidence('plugin-tasks', error, observedAt)],
|
||||
id: 'plugin-task-scheduler',
|
||||
label: 'Scheduled Tasks',
|
||||
observedAt,
|
||||
sourceKind: 'derived',
|
||||
status: 'down',
|
||||
summary: '插件定时任务摘要读取失败',
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds service evidence from a readonly remote adapter or explicit missing config keys.
|
||||
* @param serviceId - Stable service id used by topology.
|
||||
* @param serviceLabel - Operator-facing service label.
|
||||
* @param fallbackSignalId - Signal id used when config is missing.
|
||||
* @param fallbackSignalLabel - Signal label used when config is missing.
|
||||
* @param requiredKeys - Public env keys required for this readonly adapter.
|
||||
* @param adapter - Optional adapter implementation.
|
||||
* @returns Remote integration service.
|
||||
*/
|
||||
private async createAdapterService(
|
||||
serviceId: string,
|
||||
serviceLabel: string,
|
||||
fallbackSignalId: string,
|
||||
fallbackSignalLabel: string,
|
||||
requiredKeys: string[],
|
||||
adapter?: { inspect(): Promise<Partial<EnvironmentSignal>> },
|
||||
): Promise<EnvironmentService> {
|
||||
const missing = this.config.missing(requiredKeys);
|
||||
if (missing.length > 0 || !adapter) {
|
||||
return this.createService(serviceId, serviceLabel, [
|
||||
{
|
||||
evidence: [unwiredEvidence(fallbackSignalLabel, missing)],
|
||||
id: fallbackSignalId,
|
||||
label: fallbackSignalLabel,
|
||||
sourceKind: 'unwired',
|
||||
status: 'unwired',
|
||||
summary: '只读观测配置未接入',
|
||||
},
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const signal = await adapter.inspect();
|
||||
return this.createService(serviceId, serviceLabel, [
|
||||
{
|
||||
evidence: signal.evidence || [],
|
||||
id: signal.id || fallbackSignalId,
|
||||
label: signal.label || fallbackSignalLabel,
|
||||
observedAt: signal.observedAt,
|
||||
sourceKind: signal.sourceKind || 'live',
|
||||
status: signal.status || 'unknown',
|
||||
summary: signal.summary || '只读观测已返回信号',
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
return this.createService(serviceId, serviceLabel, [
|
||||
{
|
||||
evidence: [errorEvidence(fallbackSignalLabel, error)],
|
||||
id: fallbackSignalId,
|
||||
label: fallbackSignalLabel,
|
||||
sourceKind: 'derived',
|
||||
status: 'down',
|
||||
summary: '只读观测失败',
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a visible unknown service for dependencies whose safe reader is absent.
|
||||
* @param id - Stable service id.
|
||||
* @param label - Operator-facing service label.
|
||||
* @param summary - Reason why the service is not live evidence.
|
||||
* @param observedAt - Shared snapshot timestamp.
|
||||
* @returns Unknown service with derived evidence.
|
||||
*/
|
||||
private createUnknownService(
|
||||
id: string,
|
||||
label: string,
|
||||
summary: string,
|
||||
observedAt: string,
|
||||
): EnvironmentService {
|
||||
return this.createService(id, label, [
|
||||
{
|
||||
evidence: [
|
||||
{
|
||||
observedAt,
|
||||
source: label,
|
||||
sourceKind: 'derived',
|
||||
summary,
|
||||
},
|
||||
],
|
||||
id: `${id}-signal`,
|
||||
label,
|
||||
observedAt,
|
||||
sourceKind: 'derived',
|
||||
status: 'unknown',
|
||||
summary,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps runtime module health into environment dashboard signal status.
|
||||
* @param status - RuntimeHealthService status value.
|
||||
* @returns Dashboard health status preserving blocked/degraded semantics.
|
||||
*/
|
||||
private mapRuntimeStatus(status?: RuntimeHealthStatus): EnvironmentHealthStatus {
|
||||
if (status === 'live' || status === 'ready') return 'ok';
|
||||
if (status === 'blocked') return 'blocked';
|
||||
if (status === 'degraded') return 'degraded';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a service from child signals and derives aggregate status.
|
||||
* @param id - Stable service id used by topology and Admin selection.
|
||||
* @param label - Operator-facing service label.
|
||||
* @param signals - Signals supporting the service.
|
||||
* @returns Service object with worst-signal status.
|
||||
*/
|
||||
private createService(
|
||||
id: string,
|
||||
label: string,
|
||||
signals: EnvironmentSignal[],
|
||||
): EnvironmentService {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
signals,
|
||||
status: pickWorstHealthStatus(signals.map((signal) => signal.status)),
|
||||
summary: signals.map((signal) => signal.summary).join(';'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a NAS node and derives aggregate status from services.
|
||||
* @param id - Stable node id for topology edges.
|
||||
* @param label - Operator-facing node label.
|
||||
* @param services - Services owned by the NAS node.
|
||||
* @returns Node object with worst-service status.
|
||||
*/
|
||||
private createNode(
|
||||
id: string,
|
||||
label: string,
|
||||
services: EnvironmentService[],
|
||||
): EnvironmentNode {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
services,
|
||||
status: pickWorstHealthStatus(services.map((service) => service.status)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,132 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { cachedEvidence } from './environment-dashboard-evidence.mapper';
|
||||
import type {
|
||||
EnvironmentDashboardResponse,
|
||||
EnvironmentEvidence,
|
||||
EnvironmentSignal,
|
||||
EnvironmentSignalSourceKind,
|
||||
} from '../domain/environment-dashboard.types';
|
||||
|
||||
export interface EnvironmentDashboardCacheOptions {
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EnvironmentDashboardCacheService {
|
||||
private cached?: {
|
||||
expiresAt: number;
|
||||
snapshot: EnvironmentDashboardResponse;
|
||||
};
|
||||
|
||||
private readonly ttlMs: number;
|
||||
|
||||
/**
|
||||
* Initializes the short-lived dashboard cache used between event-driven invalidations.
|
||||
* @param options - Runtime/test cache options; `ttlMs` defaults from env and bounds how long successful snapshots can be reused.
|
||||
*/
|
||||
constructor(@Optional() options: EnvironmentDashboardCacheOptions = {}) {
|
||||
const envTtlMs = Number(process.env.ENV_DASHBOARD_CACHE_TTL_MS);
|
||||
this.ttlMs =
|
||||
options.ttlMs ??
|
||||
(Number.isFinite(envTtlMs) && envTtlMs > 0 ? envTtlMs : 15_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a fresh dashboard snapshot or a cached successful one when it is still valid.
|
||||
* @param factory - Collector aggregation callback that may perform readonly probes and can throw on unexpected infrastructure failures.
|
||||
* @param options - Per-call control; `forceRefresh` is used by self-check to bypass cached state.
|
||||
* @returns Dashboard snapshot, with reused non-unwired signals marked as cached evidence.
|
||||
*/
|
||||
async getOrCreate(
|
||||
factory: () => Promise<EnvironmentDashboardResponse>,
|
||||
options: { forceRefresh?: boolean } = {},
|
||||
): Promise<EnvironmentDashboardResponse> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!options.forceRefresh &&
|
||||
this.cached &&
|
||||
this.cached.expiresAt > now
|
||||
) {
|
||||
return this.toCachedSnapshot(this.cached.snapshot, this.cached.expiresAt);
|
||||
}
|
||||
|
||||
const snapshot = await factory();
|
||||
this.cached = {
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
snapshot: this.cloneSnapshot(snapshot),
|
||||
};
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears cached state after a fresh local/MQTT signal event invalidates the aggregate snapshot.
|
||||
*/
|
||||
invalidate(): void {
|
||||
this.cached = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a snapshot before storing or returning it so callers cannot mutate cache state.
|
||||
* @param snapshot - Dashboard response produced by collectors.
|
||||
* @returns Deep cloned dashboard response.
|
||||
*/
|
||||
private cloneSnapshot(
|
||||
snapshot: EnvironmentDashboardResponse,
|
||||
): EnvironmentDashboardResponse {
|
||||
return JSON.parse(JSON.stringify(snapshot)) as EnvironmentDashboardResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a valid cached snapshot into operator-visible cached evidence.
|
||||
* @param snapshot - Previously successful dashboard response.
|
||||
* @param expiresAtMs - Cache expiry timestamp used for evidence expiry metadata.
|
||||
* @returns Cloned response with non-unwired live/configured/derived/external-link signals marked cached.
|
||||
*/
|
||||
private toCachedSnapshot(
|
||||
snapshot: EnvironmentDashboardResponse,
|
||||
expiresAtMs: number,
|
||||
): EnvironmentDashboardResponse {
|
||||
const cachedAt = new Date().toISOString();
|
||||
const expiresAt = new Date(expiresAtMs).toISOString();
|
||||
const cloned = this.cloneSnapshot(snapshot);
|
||||
cloned.sites = cloned.sites.map((site) => ({
|
||||
...site,
|
||||
nodes: site.nodes.map((node) => ({
|
||||
...node,
|
||||
services: node.services.map((service) => ({
|
||||
...service,
|
||||
signals: service.signals.map((signal) =>
|
||||
this.toCachedSignal(signal, cachedAt, expiresAt),
|
||||
),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks one reusable signal as cached while preserving explicit missing-integration evidence.
|
||||
* @param signal - Signal from the last successful snapshot.
|
||||
* @param observedAt - Time when the cached value is returned to Admin.
|
||||
* @param expiresAt - Cache expiry ISO timestamp exposed as evidence metadata.
|
||||
* @returns Cached signal clone.
|
||||
*/
|
||||
private toCachedSignal(
|
||||
signal: EnvironmentSignal,
|
||||
observedAt: string,
|
||||
expiresAt: string,
|
||||
): EnvironmentSignal {
|
||||
if (signal.sourceKind === 'unwired') return signal;
|
||||
const sourceKind: EnvironmentSignalSourceKind = 'cached';
|
||||
const evidence: EnvironmentEvidence[] = [
|
||||
...signal.evidence,
|
||||
cachedEvidence(signal.label, signal.summary, observedAt, expiresAt),
|
||||
];
|
||||
return {
|
||||
...signal,
|
||||
evidence,
|
||||
observedAt: signal.observedAt || observedAt,
|
||||
sourceKind,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
|
||||
export type EnvironmentDashboardConfigSource = Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class EnvironmentDashboardConfigService {
|
||||
/**
|
||||
* Initializes environment dashboard config access.
|
||||
* @param source - Optional test override; production falls back to process.env.
|
||||
*/
|
||||
constructor(
|
||||
@Optional()
|
||||
private readonly source: EnvironmentDashboardConfigSource = process.env,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Reads one config value without exposing secrets to callers.
|
||||
* @param key - Public environment variable key used by dashboard integrations.
|
||||
* @returns Trimmed value or an empty string when absent.
|
||||
*/
|
||||
get(key: string): string {
|
||||
return `${this.source[key] || ''}`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds which required integration keys are absent.
|
||||
* @param keys - Public environment variable keys required by an adapter.
|
||||
* @returns Missing public key names for operator evidence.
|
||||
*/
|
||||
missing(keys: string[]): string[] {
|
||||
return keys.filter((key) => !this.get(key));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
import type { EnvironmentEvidence } from '../domain/environment-dashboard.types';
|
||||
import { normalizeObservedAt } from '../application/environment-dashboard-status.mapper';
|
||||
|
||||
/**
|
||||
* Creates live evidence from a successful local or remote readonly probe.
|
||||
* @param source - Integration or collector name that produced the evidence.
|
||||
* @param summary - Human-readable result summary shown in Admin.
|
||||
* @param observedAt - Probe timestamp from the source; normalized before returning.
|
||||
* @param metadata - Non-secret structured evidence such as counts or versions.
|
||||
* @returns Evidence record marked as live.
|
||||
*/
|
||||
export function liveEvidence(
|
||||
source: string,
|
||||
summary: string,
|
||||
observedAt?: Date | number | string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): EnvironmentEvidence {
|
||||
return {
|
||||
metadata,
|
||||
observedAt: normalizeObservedAt(observedAt),
|
||||
source,
|
||||
sourceKind: 'live',
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates evidence for integrations that are intentionally not wired yet.
|
||||
* @param source - Integration name such as Jenkins, K8s, Tencent Cloud, or r4se.
|
||||
* @param missingConfigKeys - Public env/config keys that explain why the probe is not live.
|
||||
* @param documentationPath - Optional repository documentation path for operators.
|
||||
* @returns Evidence record marked as unwired instead of pretending to be healthy.
|
||||
*/
|
||||
export function unwiredEvidence(
|
||||
source: string,
|
||||
missingConfigKeys: string[],
|
||||
documentationPath?: string,
|
||||
): EnvironmentEvidence {
|
||||
return {
|
||||
metadata: {
|
||||
documentationPath,
|
||||
missingConfigKeys,
|
||||
},
|
||||
observedAt: normalizeObservedAt(),
|
||||
source,
|
||||
sourceKind: 'unwired',
|
||||
summary:
|
||||
missingConfigKeys.length > 0
|
||||
? `缺少只读观测配置:${missingConfigKeys.join(', ')}`
|
||||
: '只读观测入口尚未接入',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates safe failure evidence from an adapter or collector exception.
|
||||
* @param source - Integration name where the failure happened.
|
||||
* @param error - Unknown thrown value from the readonly probe.
|
||||
* @param observedAt - Failure timestamp when the adapter observed the error.
|
||||
* @returns Evidence record with stack traces and object payloads reduced to a message.
|
||||
*/
|
||||
export function errorEvidence(
|
||||
source: string,
|
||||
error: unknown,
|
||||
observedAt?: Date | number | string,
|
||||
): EnvironmentEvidence {
|
||||
const summary =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: '只读观测失败';
|
||||
|
||||
return {
|
||||
observedAt: normalizeObservedAt(observedAt),
|
||||
source,
|
||||
sourceKind: 'derived',
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates cached evidence with an explicit expiry boundary.
|
||||
* @param source - Cache or retained-event source name.
|
||||
* @param summary - Operator-facing cached evidence summary.
|
||||
* @param observedAt - Original observation time; must not be refreshed by cache reads.
|
||||
* @param expiresAt - Expiry time after which the cache cannot produce green status.
|
||||
* @returns Evidence record marked as cached with normalized timestamps.
|
||||
*/
|
||||
export function cachedEvidence(
|
||||
source: string,
|
||||
summary: string,
|
||||
observedAt: Date | number | string,
|
||||
expiresAt: Date | number | string,
|
||||
): EnvironmentEvidence {
|
||||
return {
|
||||
expiresAt: normalizeObservedAt(expiresAt),
|
||||
observedAt: normalizeObservedAt(observedAt),
|
||||
source,
|
||||
sourceKind: 'cached',
|
||||
summary,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,189 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnModuleDestroy,
|
||||
type OnModuleInit,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import * as mqtt from 'mqtt';
|
||||
import type { MqttClient } from 'mqtt';
|
||||
import type { EnvironmentEventEnvelope } from '../../domain/environment-dashboard.types';
|
||||
|
||||
export interface EnvironmentEventBusOptions {
|
||||
clientId?: string;
|
||||
mode?: 'local' | 'mqtt';
|
||||
password?: string;
|
||||
topicPrefix?: string;
|
||||
url?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export type EnvironmentEventSubscriber = (
|
||||
event: EnvironmentEventEnvelope,
|
||||
) => void;
|
||||
|
||||
@Injectable()
|
||||
export class EnvironmentEventBusService
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(EnvironmentEventBusService.name);
|
||||
private readonly options: Required<
|
||||
Pick<EnvironmentEventBusOptions, 'clientId' | 'mode' | 'topicPrefix'>
|
||||
> &
|
||||
Omit<EnvironmentEventBusOptions, 'clientId' | 'mode' | 'topicPrefix'>;
|
||||
private readonly subscribers = new Set<EnvironmentEventSubscriber>();
|
||||
private client: MqttClient | null = null;
|
||||
|
||||
/**
|
||||
* Initializes the local or MQTT-backed dashboard event bus.
|
||||
* @param options - Optional test/runtime overrides; production defaults come from ENV_DASHBOARD_* variables.
|
||||
*/
|
||||
constructor(@Optional() options: EnvironmentEventBusOptions = {}) {
|
||||
this.options = {
|
||||
clientId:
|
||||
options.clientId ||
|
||||
process.env.ENV_DASHBOARD_MQTT_CLIENT_ID ||
|
||||
'kt-template-online-api-environment',
|
||||
mode:
|
||||
options.mode ||
|
||||
(process.env.ENV_DASHBOARD_EVENT_BUS as 'local' | 'mqtt') ||
|
||||
'local',
|
||||
password: options.password || process.env.ENV_DASHBOARD_MQTT_PASSWORD,
|
||||
topicPrefix:
|
||||
options.topicPrefix ||
|
||||
process.env.ENV_DASHBOARD_MQTT_TOPIC_PREFIX ||
|
||||
'kt/env',
|
||||
url: options.url || process.env.ENV_DASHBOARD_MQTT_URL,
|
||||
username: options.username || process.env.ENV_DASHBOARD_MQTT_USERNAME,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the MQTT subscription when configured; local mode has no external side effects.
|
||||
*/
|
||||
onModuleInit() {
|
||||
if (this.options.mode !== 'mqtt') return;
|
||||
if (!this.options.url) {
|
||||
this.emitLocal(this.createBrokerStatusEvent('MQTT broker unwired'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = mqtt.connect(this.options.url, {
|
||||
clientId: this.options.clientId,
|
||||
password: this.options.password,
|
||||
username: this.options.username,
|
||||
});
|
||||
this.client.subscribe(`${this.options.topicPrefix}/#`);
|
||||
this.client.on('message', this.handleMqttMessage.bind(this));
|
||||
this.client.on('close', this.handleMqttClose.bind(this));
|
||||
this.client.on('error', this.handleMqttError.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the MQTT client opened by this service.
|
||||
* @returns Promise that resolves after the client closes or immediately in local mode.
|
||||
*/
|
||||
async onModuleDestroy() {
|
||||
if (!this.client) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
this.client?.end(false, {}, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an in-process subscriber for local development, tests, and SSE fan-out.
|
||||
* @param subscriber - Callback owned by the consumer; invoked synchronously for each accepted event.
|
||||
* @returns Unsubscribe function that removes only the registered callback.
|
||||
*/
|
||||
subscribe(subscriber: EnvironmentEventSubscriber): () => void {
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an environment event through local subscribers and MQTT when connected.
|
||||
* @param event - Dashboard event envelope from collectors, bridges, or readonly adapters.
|
||||
*/
|
||||
async publish(event: EnvironmentEventEnvelope) {
|
||||
this.emitLocal(event);
|
||||
if (!this.client?.connected) return;
|
||||
this.client.publish(event.topic, JSON.stringify(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an inbound MQTT payload and dispatches valid environment events locally.
|
||||
* @param topic - MQTT topic delivered by the subscribed environment prefix.
|
||||
* @param payload - Raw MQTT payload expected to contain an EnvironmentEventEnvelope JSON object.
|
||||
* @param packet - MQTT packet metadata; retained messages are marked when the payload omits it.
|
||||
*/
|
||||
private handleMqttMessage(
|
||||
topic: string,
|
||||
payload: Buffer,
|
||||
packet: { retain?: boolean } = {},
|
||||
) {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
payload.toString('utf8'),
|
||||
) as Partial<EnvironmentEventEnvelope>;
|
||||
if (!parsed.eventId || !parsed.siteId || !parsed.severity) return;
|
||||
this.emitLocal({
|
||||
...parsed,
|
||||
observedAt: parsed.observedAt || new Date().toISOString(),
|
||||
retained: parsed.retained ?? !!packet.retain,
|
||||
sourceKind: parsed.sourceKind || 'mqtt',
|
||||
summary: parsed.summary || 'MQTT environment event',
|
||||
topic: parsed.topic || topic,
|
||||
} as EnvironmentEventEnvelope);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Environment MQTT payload ignored: ${
|
||||
err instanceof Error ? err.message : 'invalid json'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a broker close event into dashboard evidence without changing all services to down.
|
||||
*/
|
||||
private handleMqttClose() {
|
||||
this.emitLocal(this.createBrokerStatusEvent('MQTT broker disconnected'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts broker errors into local dashboard evidence without logging credentials or payloads.
|
||||
* @param err - MQTT client error emitted by the broker connection.
|
||||
*/
|
||||
private handleMqttError(err: Error) {
|
||||
this.emitLocal(
|
||||
this.createBrokerStatusEvent(`MQTT broker error: ${err.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an event to current local subscribers.
|
||||
* @param event - Environment event envelope accepted by the bus.
|
||||
*/
|
||||
private emitLocal(event: EnvironmentEventEnvelope) {
|
||||
this.subscribers.forEach((subscriber) => subscriber(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a low-severity broker status event for dashboard evidence.
|
||||
* @param summary - Operator-facing broker status summary.
|
||||
* @returns Local event envelope that represents event-layer health only.
|
||||
*/
|
||||
private createBrokerStatusEvent(summary: string): EnvironmentEventEnvelope {
|
||||
return {
|
||||
eventId: `env-bus-${Date.now()}`,
|
||||
observedAt: new Date().toISOString(),
|
||||
severity: 'unknown',
|
||||
siteId: 'local-dev',
|
||||
sourceKind: 'local',
|
||||
summary,
|
||||
topic: `${this.options.topicPrefix}/event/local-dev/environment-event-bus/status`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
export interface EnvironmentMqttTopics {
|
||||
signal(siteId: string, nodeId: string, serviceId: string): string;
|
||||
event(siteId: string, nodeId: string, serviceId: string): string;
|
||||
selfCheckResult(siteId: string): string;
|
||||
qqbotRuntime(selfId: string): string;
|
||||
qqbotNapcatLogin(selfId: string): string;
|
||||
pluginTaskRun(pluginKey: string, taskKey: string): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a dynamic MQTT topic segment without allowing wildcard escapes.
|
||||
* @param value - Site, node, service, account, plugin, or task identifier from runtime data.
|
||||
* @returns Safe single topic segment with MQTT wildcards and slashes removed.
|
||||
*/
|
||||
export function normalizeEnvironmentTopicSegment(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[#+]/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return normalized || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the environment dashboard topic catalog for a configured prefix.
|
||||
* @param topicPrefix - Non-secret MQTT topic prefix; defaults to `ENV_DASHBOARD_MQTT_TOPIC_PREFIX`.
|
||||
* @returns Topic builders used by dashboard adapters and QQBot bridge code.
|
||||
*/
|
||||
export function buildEnvironmentMqttTopics(
|
||||
topicPrefix = process.env.ENV_DASHBOARD_MQTT_TOPIC_PREFIX || 'kt/env',
|
||||
): EnvironmentMqttTopics {
|
||||
const prefix = topicPrefix.replace(/[\\/]+/g, '/').replace(/\/+$/g, '');
|
||||
|
||||
return {
|
||||
event: (siteId: string, nodeId: string, serviceId: string) =>
|
||||
[
|
||||
prefix,
|
||||
'event',
|
||||
normalizeEnvironmentTopicSegment(siteId),
|
||||
normalizeEnvironmentTopicSegment(nodeId),
|
||||
normalizeEnvironmentTopicSegment(serviceId),
|
||||
].join('/'),
|
||||
pluginTaskRun: (pluginKey: string, taskKey: string) =>
|
||||
[
|
||||
prefix,
|
||||
'qqbot',
|
||||
'plugin-task',
|
||||
normalizeEnvironmentTopicSegment(pluginKey),
|
||||
normalizeEnvironmentTopicSegment(taskKey),
|
||||
'run',
|
||||
].join('/'),
|
||||
qqbotNapcatLogin: (selfId: string) =>
|
||||
[
|
||||
prefix,
|
||||
'qqbot',
|
||||
'napcat',
|
||||
normalizeEnvironmentTopicSegment(selfId),
|
||||
'login',
|
||||
].join('/'),
|
||||
qqbotRuntime: (selfId: string) =>
|
||||
[
|
||||
prefix,
|
||||
'qqbot',
|
||||
'runtime',
|
||||
normalizeEnvironmentTopicSegment(selfId),
|
||||
].join('/'),
|
||||
selfCheckResult: (siteId: string) =>
|
||||
[prefix, 'self-check', normalizeEnvironmentTopicSegment(siteId)].join(
|
||||
'/',
|
||||
),
|
||||
signal: (siteId: string, nodeId: string, serviceId: string) =>
|
||||
[
|
||||
prefix,
|
||||
'signal',
|
||||
normalizeEnvironmentTopicSegment(siteId),
|
||||
normalizeEnvironmentTopicSegment(nodeId),
|
||||
normalizeEnvironmentTopicSegment(serviceId),
|
||||
].join('/'),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import type {
|
||||
EnvironmentEventEnvelope,
|
||||
EnvironmentHealthStatus,
|
||||
} from '../../domain/environment-dashboard.types';
|
||||
import { buildEnvironmentMqttTopics } from './environment-mqtt-topic.catalog';
|
||||
|
||||
export interface EnvironmentEventPublisher {
|
||||
publish(event: EnvironmentEventEnvelope): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface QqbotNapcatRuntimeEventInput {
|
||||
accountId?: string;
|
||||
message: string;
|
||||
observedAt?: string;
|
||||
selfId: string;
|
||||
severity: EnvironmentHealthStatus;
|
||||
}
|
||||
|
||||
export interface QqbotPluginTaskRunEventInput {
|
||||
message: string;
|
||||
observedAt?: string;
|
||||
pluginKey: string;
|
||||
severity: EnvironmentHealthStatus;
|
||||
taskKey: string;
|
||||
}
|
||||
|
||||
export class QqbotEnvironmentEventBridge {
|
||||
private readonly topics = buildEnvironmentMqttTopics();
|
||||
|
||||
/**
|
||||
* Initializes the bridge with the narrow dashboard event publisher contract.
|
||||
* @param publisher - Environment event bus-like publisher; no QQBot bus internals are required.
|
||||
*/
|
||||
constructor(private readonly publisher: EnvironmentEventPublisher) {}
|
||||
|
||||
/**
|
||||
* Publishes a NapCat runtime state transition into the environment dashboard bus.
|
||||
* @param input - QQBot/NapCat runtime event data from core or NapCat services.
|
||||
*/
|
||||
async publishNapcatRuntimeEvent(input: QqbotNapcatRuntimeEventInput) {
|
||||
await this.publisher.publish({
|
||||
eventId: this.createEventId(
|
||||
'qqbot-napcat',
|
||||
input.selfId,
|
||||
input.observedAt,
|
||||
),
|
||||
nodeId: 'nas-prod-qqbot',
|
||||
observedAt: input.observedAt || new Date().toISOString(),
|
||||
serviceId: 'napcat',
|
||||
severity: input.severity,
|
||||
signalId: `napcat-${input.selfId}`,
|
||||
siteId: 'nas-prod',
|
||||
sourceKind: 'local',
|
||||
summary: input.message,
|
||||
topic: this.topics.qqbotRuntime(input.selfId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a plugin task run event into the environment dashboard bus.
|
||||
* @param input - Plugin task run metadata from plugin-platform task services.
|
||||
*/
|
||||
async publishPluginTaskRun(input: QqbotPluginTaskRunEventInput) {
|
||||
await this.publisher.publish({
|
||||
eventId: this.createEventId(
|
||||
'qqbot-plugin-task',
|
||||
`${input.pluginKey}-${input.taskKey}`,
|
||||
input.observedAt,
|
||||
),
|
||||
nodeId: 'nas-prod-qqbot',
|
||||
observedAt: input.observedAt || new Date().toISOString(),
|
||||
serviceId: 'plugin-tasks',
|
||||
severity: input.severity,
|
||||
signalId: `plugin-task-${input.pluginKey}-${input.taskKey}`,
|
||||
siteId: 'nas-prod',
|
||||
sourceKind: 'local',
|
||||
summary: input.message,
|
||||
topic: this.topics.pluginTaskRun(input.pluginKey, input.taskKey),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates deterministic-ish event IDs from event kind and source ID.
|
||||
* @param kind - Bridge event category used to avoid ID collisions.
|
||||
* @param sourceId - QQBot account or plugin task identifier.
|
||||
* @param observedAt - Optional source timestamp that keeps tests deterministic.
|
||||
* @returns Event ID safe for SSE replay cursors.
|
||||
*/
|
||||
private createEventId(kind: string, sourceId: string, observedAt?: string) {
|
||||
const time = observedAt ? new Date(observedAt).getTime() : Date.now();
|
||||
return `${kind}-${sourceId}-${time}`;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,213 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class EnvironmentEvidenceDto {
|
||||
@ApiProperty({ description: '观测来源名称', example: 'Jenkins Build' })
|
||||
source!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '证据来源类型,unwired 表示只读观测配置未接入',
|
||||
example: 'unwired',
|
||||
})
|
||||
sourceKind!: string;
|
||||
|
||||
@ApiProperty({ description: '面向运维的证据摘要' })
|
||||
summary!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '证据采集时间 ISO 字符串' })
|
||||
observedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '证据过期时间 ISO 字符串' })
|
||||
expiresAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '已脱敏的附加元数据' })
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class EnvironmentSignalDto {
|
||||
@ApiProperty({ description: '稳定信号 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '信号展示名称' })
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ description: '信号健康状态', example: 'unwired' })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ description: '信号来源类型', example: 'unwired' })
|
||||
sourceKind!: string;
|
||||
|
||||
@ApiProperty({ description: '信号摘要' })
|
||||
summary!: string;
|
||||
|
||||
@ApiProperty({ type: [EnvironmentEvidenceDto] })
|
||||
evidence!: EnvironmentEvidenceDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: '信号观测时间 ISO 字符串' })
|
||||
observedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '信号新鲜度窗口,秒' })
|
||||
staleAfterSeconds?: number;
|
||||
}
|
||||
|
||||
export class EnvironmentServiceDto {
|
||||
@ApiProperty({ description: '稳定服务 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '服务展示名称' })
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ description: '服务聚合健康状态' })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ description: '服务摘要' })
|
||||
summary!: string;
|
||||
|
||||
@ApiProperty({ type: [EnvironmentSignalDto] })
|
||||
signals!: EnvironmentSignalDto[];
|
||||
}
|
||||
|
||||
export class EnvironmentNodeDto {
|
||||
@ApiProperty({ description: '稳定节点 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '节点展示名称' })
|
||||
label!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '节点聚合健康状态' })
|
||||
status?: string;
|
||||
|
||||
@ApiProperty({ type: [EnvironmentServiceDto] })
|
||||
services!: EnvironmentServiceDto[];
|
||||
}
|
||||
|
||||
export class EnvironmentSiteDto {
|
||||
@ApiProperty({ description: '稳定站点 ID', example: 'nas-prod' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '站点展示名称' })
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ description: '站点聚合状态' })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ description: '站点摘要' })
|
||||
summary!: string;
|
||||
|
||||
@ApiProperty({ type: [EnvironmentNodeDto] })
|
||||
nodes!: EnvironmentNodeDto[];
|
||||
}
|
||||
|
||||
export class EnvironmentDashboardSummaryDto {
|
||||
@ApiProperty({ description: '总信号数' })
|
||||
totalSignals!: number;
|
||||
|
||||
@ApiProperty({ description: '按健康状态聚合的信号数' })
|
||||
byStatus!: Record<string, number>;
|
||||
}
|
||||
|
||||
export class EnvironmentTopologyNodeDto {
|
||||
@ApiProperty({ description: '拓扑节点 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '拓扑节点名称' })
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ description: '所属站点 ID' })
|
||||
siteId!: string;
|
||||
|
||||
@ApiProperty({ description: '拓扑节点状态' })
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class EnvironmentTopologyEdgeDto {
|
||||
@ApiProperty({ description: '拓扑边 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '起点 ID' })
|
||||
source!: string;
|
||||
|
||||
@ApiProperty({ description: '终点 ID' })
|
||||
target!: string;
|
||||
|
||||
@ApiProperty({ description: '关系标签' })
|
||||
label!: string;
|
||||
}
|
||||
|
||||
export class EnvironmentTopologyDto {
|
||||
@ApiProperty({ type: [EnvironmentTopologyNodeDto] })
|
||||
nodes!: EnvironmentTopologyNodeDto[];
|
||||
|
||||
@ApiProperty({ type: [EnvironmentTopologyEdgeDto] })
|
||||
edges!: EnvironmentTopologyEdgeDto[];
|
||||
}
|
||||
|
||||
export class EnvironmentActionDto {
|
||||
@ApiProperty({ description: '动作 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '动作展示名称' })
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ description: '动作是否可执行' })
|
||||
enabled!: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: '禁用原因' })
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export class EnvironmentEventDto {
|
||||
@ApiProperty({ description: '事件 ID' })
|
||||
eventId!: string;
|
||||
|
||||
@ApiProperty({ description: '事件 topic' })
|
||||
topic!: string;
|
||||
|
||||
@ApiProperty({ description: '所属站点 ID' })
|
||||
siteId!: string;
|
||||
|
||||
@ApiProperty({ description: '事件严重级别' })
|
||||
severity!: string;
|
||||
|
||||
@ApiProperty({ description: '事件来源类型' })
|
||||
sourceKind!: string;
|
||||
|
||||
@ApiProperty({ description: '事件观测时间 ISO 字符串' })
|
||||
observedAt!: string;
|
||||
|
||||
@ApiProperty({ description: '事件摘要' })
|
||||
summary!: string;
|
||||
}
|
||||
|
||||
export class EnvironmentDashboardResponseDto {
|
||||
@ApiProperty({ description: '快照生成时间 ISO 字符串' })
|
||||
generatedAt!: string;
|
||||
|
||||
@ApiProperty({ description: '快照刷新时间 ISO 字符串' })
|
||||
refreshedAt!: string;
|
||||
|
||||
@ApiProperty({ type: EnvironmentDashboardSummaryDto })
|
||||
summary!: EnvironmentDashboardSummaryDto;
|
||||
|
||||
@ApiProperty({ type: [EnvironmentSiteDto] })
|
||||
sites!: EnvironmentSiteDto[];
|
||||
|
||||
@ApiProperty({ type: EnvironmentTopologyDto })
|
||||
topology!: EnvironmentTopologyDto;
|
||||
|
||||
@ApiProperty({ type: [EnvironmentActionDto] })
|
||||
actions!: EnvironmentActionDto[];
|
||||
|
||||
@ApiProperty({ type: [EnvironmentEventDto] })
|
||||
events!: EnvironmentEventDto[];
|
||||
}
|
||||
|
||||
export class EnvironmentStreamEventDto {
|
||||
@ApiProperty({ description: 'SSE 事件类型', example: 'environment-event' })
|
||||
type!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'SSE 事件 ID' })
|
||||
id?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '已脱敏事件数据' })
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Sse,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { vbenSuccess } from '@/common';
|
||||
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import { EnvironmentDashboardSelfCheckService } from '../application/environment-dashboard-self-check.service';
|
||||
import { EnvironmentDashboardService } from '../application/environment-dashboard.service';
|
||||
import { EnvironmentEventStreamService } from '../application/environment-event-stream.service';
|
||||
import {
|
||||
EnvironmentDashboardResponseDto,
|
||||
EnvironmentStreamEventDto,
|
||||
} from './dto/environment-dashboard.dto';
|
||||
|
||||
@ApiTags('Admin - 环境总览')
|
||||
@Controller('system/environment')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class EnvironmentDashboardController {
|
||||
/**
|
||||
* Initializes the environment dashboard controller.
|
||||
* @param dashboardService - Snapshot service used by Admin route load and manual refresh.
|
||||
* @param selfCheckService - Readonly self-check service used by the Admin action button.
|
||||
* @param streamService - SSE stream service used for realtime updates without polling.
|
||||
*/
|
||||
constructor(
|
||||
private readonly dashboardService: EnvironmentDashboardService,
|
||||
private readonly selfCheckService: EnvironmentDashboardSelfCheckService,
|
||||
private readonly streamService: EnvironmentEventStreamService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Loads the aggregate environment dashboard snapshot for Admin.
|
||||
* @returns Vben response containing the current dashboard snapshot.
|
||||
*/
|
||||
@Get('dashboard')
|
||||
@ApiOperation({ summary: '查询环境总览快照' })
|
||||
@ApiOkResponse({ type: EnvironmentDashboardResponseDto })
|
||||
async dashboard() {
|
||||
return vbenSuccess(await this.dashboardService.getDashboard());
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs readonly probes and returns a fresh dashboard snapshot.
|
||||
* @returns Vben response containing the self-check snapshot.
|
||||
*/
|
||||
@Post('self-check')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '运行环境总览只读自检' })
|
||||
@ApiOkResponse({ type: EnvironmentDashboardResponseDto })
|
||||
async selfCheck() {
|
||||
return vbenSuccess(await this.selfCheckService.runSelfCheck());
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes Admin to realtime environment events without exposing MQTT.
|
||||
* @param lastEventIdHeader Browser `Last-Event-ID` header used after reconnect.
|
||||
* @param lastEventIdQuery Query fallback used by local tests or proxy-limited clients.
|
||||
* @returns SSE observable with replay, live events, and heartbeats.
|
||||
*/
|
||||
@Sse('events/stream')
|
||||
@ApiOperation({ summary: '订阅环境总览实时事件' })
|
||||
@ApiOkResponse({ type: EnvironmentStreamEventDto })
|
||||
stream(
|
||||
@Headers('last-event-id') lastEventIdHeader?: string,
|
||||
@Query('lastEventId') lastEventIdQuery?: string,
|
||||
) {
|
||||
return this.streamService.stream(lastEventIdHeader || lastEventIdQuery);
|
||||
}
|
||||
}
|
||||
@ -92,6 +92,7 @@ export const QQBOT_CORE_PROVIDERS = [
|
||||
export const QQBOT_CORE_EXPORTS = [
|
||||
QqbotAccountService,
|
||||
QqbotConfigService,
|
||||
QqbotDashboardService,
|
||||
QqbotSendService,
|
||||
QqbotReverseWsService,
|
||||
];
|
||||
|
||||
@ -1,6 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import type { NapcatRuntimeProfileSnapshot } from '../../domain/runtime/napcat-profile.types';
|
||||
import { NapcatProtocolProfile } from '../../infrastructure/persistence/napcat-protocol-profile.entity';
|
||||
import { NapcatRuntimeProfile } from '../../infrastructure/persistence/napcat-runtime-profile.entity';
|
||||
|
||||
type RecordPlannedProfilesInput = {
|
||||
accountId: string;
|
||||
containerId?: string;
|
||||
dataDir: string;
|
||||
deviceIdentity?: {
|
||||
deviceIdentityId?: string;
|
||||
hostname: string;
|
||||
hostnameStrategy?: string;
|
||||
machineInfoPath: string;
|
||||
macAddress: string;
|
||||
macStrategy?: string;
|
||||
};
|
||||
protocolProfile: {
|
||||
napcatConfigHash?: string;
|
||||
napcatConfigJson?: Record<string, unknown>;
|
||||
o3HookGrayEnabled?: boolean;
|
||||
o3HookMode?: 0 | 1;
|
||||
onebotConfigHash?: string;
|
||||
onebotConfigJson?: Record<string, unknown>;
|
||||
packetBackend?: string;
|
||||
packetServer?: string;
|
||||
};
|
||||
runtimeProfile: NapcatRuntimeProfileSnapshot;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NapcatRuntimeProfileService {
|
||||
@ -8,7 +37,15 @@ export class NapcatRuntimeProfileService {
|
||||
* Initializes the profile resolver used before managed runtime script generation.
|
||||
* @param configService - Nest config provider that supplies image ref, UID/GID, shm size, and profile version.
|
||||
*/
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@Optional()
|
||||
@InjectRepository(NapcatRuntimeProfile)
|
||||
private readonly runtimeProfileRepository?: Repository<NapcatRuntimeProfile>,
|
||||
@Optional()
|
||||
@InjectRepository(NapcatProtocolProfile)
|
||||
private readonly protocolProfileRepository?: Repository<NapcatProtocolProfile>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolves the runtime profile for an account-owned NapCat container.
|
||||
@ -45,6 +82,95 @@ export class NapcatRuntimeProfileService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the planned runtime and protocol profile after Docker accepts a managed container rebuild.
|
||||
* @param input - Account/container identity, generated runtime profile, and config hashes that establish the expected state before live inspection.
|
||||
*/
|
||||
async recordPlannedProfiles(input: RecordPlannedProfilesInput) {
|
||||
const accountId = `${input.accountId || ''}`.trim();
|
||||
if (!accountId) return;
|
||||
|
||||
if (this.runtimeProfileRepository) {
|
||||
await this.runtimeProfileRepository.save(
|
||||
this.runtimeProfileRepository.create({
|
||||
accountId,
|
||||
baseImageDigest: null,
|
||||
containerId: input.containerId || null,
|
||||
desktopProfileVersion: input.runtimeProfile.desktopProfileVersion,
|
||||
deviceIdentityId:
|
||||
input.deviceIdentity?.deviceIdentityId ||
|
||||
input.runtimeProfile.deviceIdentityId ||
|
||||
null,
|
||||
fontconfigEvidence: null,
|
||||
hostnameStrategy:
|
||||
input.deviceIdentity?.hostnameStrategy ||
|
||||
'qqnt-visible-hostname-v1',
|
||||
imageDigest: null,
|
||||
imageRef: input.runtimeProfile.imageRef,
|
||||
lastCheckEvidence: {
|
||||
dataDir: input.dataDir,
|
||||
deviceIdentityId:
|
||||
input.deviceIdentity?.deviceIdentityId ||
|
||||
input.runtimeProfile.deviceIdentityId ||
|
||||
null,
|
||||
hostname: input.deviceIdentity?.hostname || null,
|
||||
machineInfoPath: input.deviceIdentity?.machineInfoPath || null,
|
||||
macAddress: input.deviceIdentity?.macAddress || null,
|
||||
},
|
||||
lastCheckedAt: null,
|
||||
locale: input.runtimeProfile.locale,
|
||||
localeAvailable: false,
|
||||
macStrategy:
|
||||
input.deviceIdentity?.macStrategy || 'docker-bridge-mac-v1',
|
||||
migrateDeviceIdentity: !!input.deviceIdentity,
|
||||
persistCache: input.runtimeProfile.persistCache,
|
||||
persistLocalShare: input.runtimeProfile.persistLocalShare,
|
||||
persistLogs: input.runtimeProfile.persistLogs,
|
||||
profileStatus: 'pending',
|
||||
profileVersion: this.getString(
|
||||
'QQBOT_NAPCAT_PROFILE_VERSION',
|
||||
'napcat-runtime-profile-v1',
|
||||
),
|
||||
runtimeGid: input.runtimeProfile.runtimeGid,
|
||||
runtimeUid: input.runtimeProfile.runtimeUid,
|
||||
shmSize: input.runtimeProfile.shmSize,
|
||||
timezoneEvidence: {
|
||||
expectedTimezone: input.runtimeProfile.timezone,
|
||||
},
|
||||
xdgCacheHome: input.runtimeProfile.xdgCacheHome,
|
||||
xdgConfigHome: input.runtimeProfile.xdgConfigHome,
|
||||
xdgDataHome: input.runtimeProfile.xdgDataHome,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.protocolProfileRepository) {
|
||||
await this.protocolProfileRepository.save(
|
||||
this.protocolProfileRepository.create({
|
||||
accountId,
|
||||
containerId: input.containerId || null,
|
||||
lastCheckEvidence: {
|
||||
configSource: 'managed-create-script',
|
||||
},
|
||||
lastCheckedAt: null,
|
||||
napcatConfigHash: input.protocolProfile.napcatConfigHash || null,
|
||||
napcatConfigJson: input.protocolProfile.napcatConfigJson || null,
|
||||
o3HookGrayEnabled: !!input.protocolProfile.o3HookGrayEnabled,
|
||||
o3HookMode: input.protocolProfile.o3HookMode ?? 1,
|
||||
onebotConfigHash: input.protocolProfile.onebotConfigHash || null,
|
||||
onebotConfigJson: input.protocolProfile.onebotConfigJson || null,
|
||||
packetBackend: input.protocolProfile.packetBackend || 'auto',
|
||||
packetServer: input.protocolProfile.packetServer || '',
|
||||
profileStatus: 'pending',
|
||||
profileVersion: this.getString(
|
||||
'QQBOT_NAPCAT_PROTOCOL_PROFILE_VERSION',
|
||||
'napcat-protocol-profile-v1',
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a trimmed string config value for profile generation.
|
||||
* @param key - Environment key that controls NapCat runtime profile generation.
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import type { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity';
|
||||
|
||||
export type NapcatDockerDeviceOptions = {
|
||||
accountId: string;
|
||||
dataDir: string;
|
||||
deviceEnvPath: string;
|
||||
deviceIdentityId?: string;
|
||||
hostname: string;
|
||||
machineIdPath: string;
|
||||
machineInfoPath: string;
|
||||
macAddress: string;
|
||||
macAddressHyphen: string;
|
||||
hostnameStrategy?: string;
|
||||
macStrategy?: string;
|
||||
runFlags: string[];
|
||||
};
|
||||
|
||||
@ -18,16 +23,30 @@ export type NapcatDockerDeviceOptions = {
|
||||
export function toNapcatDockerDeviceOptions(
|
||||
identity: Pick<
|
||||
NapcatDeviceIdentity,
|
||||
'dataDir' | 'hostname' | 'id' | 'machineIdPath' | 'macAddress'
|
||||
| 'accountId'
|
||||
| 'dataDir'
|
||||
| 'hostname'
|
||||
| 'hostnameStrategy'
|
||||
| 'id'
|
||||
| 'machineIdPath'
|
||||
| 'macAddress'
|
||||
| 'macStrategy'
|
||||
>,
|
||||
): NapcatDockerDeviceOptions {
|
||||
const machineInfoPath = `${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info`;
|
||||
|
||||
return {
|
||||
accountId: identity.accountId,
|
||||
dataDir: identity.dataDir,
|
||||
deviceEnvPath: `${identity.dataDir}/device.env`,
|
||||
deviceIdentityId: identity.id,
|
||||
hostname: identity.hostname,
|
||||
hostnameStrategy: identity.hostnameStrategy,
|
||||
machineIdPath: identity.machineIdPath,
|
||||
machineInfoPath,
|
||||
macAddress: identity.macAddress,
|
||||
macAddressHyphen: identity.macAddress.replace(/:/g, '-').toLowerCase(),
|
||||
macStrategy: identity.macStrategy,
|
||||
runFlags: [
|
||||
'--hostname "$NAPCAT_HOSTNAME"',
|
||||
'--mac-address "$NAPCAT_MAC_ADDRESS"',
|
||||
|
||||
@ -177,6 +177,8 @@ export class QqbotNapcatContainerService {
|
||||
);
|
||||
await this.createRemoteDockerContainer({
|
||||
account,
|
||||
accountId: deviceIdentity?.accountId,
|
||||
containerId: container.id,
|
||||
dataDir:
|
||||
deviceIdentity?.dataDir ||
|
||||
container.dataDir ||
|
||||
@ -246,6 +248,24 @@ export class QqbotNapcatContainerService {
|
||||
return toNapcatDockerDeviceOptions(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the device identity id that belongs to an account/container binding.
|
||||
* @param accountId - Internal QQBot account id used to select the persistent device identity.
|
||||
* @param containerId - Managed NapCat container id that should be stored on the identity row.
|
||||
* @returns Device identity id when the identity service is available.
|
||||
*/
|
||||
private async resolveBindingDeviceIdentityId(
|
||||
accountId: string,
|
||||
containerId: string,
|
||||
) {
|
||||
if (!this.deviceIdentityService) return undefined;
|
||||
const identity = await this.deviceIdentityService.resolveForAccount({
|
||||
accountId,
|
||||
containerId,
|
||||
});
|
||||
return identity.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 NapCat 登录运行态流程。
|
||||
* @param container - container 输入;使用 `id` 字段生成结果。
|
||||
@ -611,6 +631,10 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
|
||||
{ accountId, isDeleted: false },
|
||||
{ isPrimary: false },
|
||||
);
|
||||
const deviceIdentityId = await this.resolveBindingDeviceIdentityId(
|
||||
accountId,
|
||||
containerId,
|
||||
);
|
||||
|
||||
const existing = await this.bindingRepository.findOne({
|
||||
where: {
|
||||
@ -627,6 +651,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
|
||||
isDeleted: false,
|
||||
lastLoginAt: new Date(),
|
||||
remark: '',
|
||||
...(deviceIdentityId ? { deviceIdentityId } : {}),
|
||||
},
|
||||
);
|
||||
await this.removeOtherAccountContainers(accountId, containerId);
|
||||
@ -638,6 +663,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
|
||||
accountId,
|
||||
bindStatus: 'bound',
|
||||
containerId,
|
||||
...(deviceIdentityId ? { deviceIdentityId } : {}),
|
||||
isPrimary: true,
|
||||
lastLoginAt: new Date(),
|
||||
remark: '',
|
||||
@ -1319,6 +1345,8 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
try {
|
||||
await this.createRemoteDockerContainer({
|
||||
account: selfId,
|
||||
accountId,
|
||||
containerId: container.id,
|
||||
dataDir,
|
||||
deviceIdentity,
|
||||
image,
|
||||
@ -1337,11 +1365,15 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
},
|
||||
);
|
||||
if (accountId && this.deviceIdentityService) {
|
||||
await this.deviceIdentityService.resolveForAccount({
|
||||
const identity = await this.deviceIdentityService.resolveForAccount({
|
||||
accountId,
|
||||
containerId: container.id,
|
||||
selfId,
|
||||
});
|
||||
await this.bindingRepository.update(
|
||||
{ accountId, containerId: container.id, isDeleted: false },
|
||||
{ deviceIdentityId: identity.id },
|
||||
);
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
@ -1370,6 +1402,8 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
*/
|
||||
private async createRemoteDockerContainer(input: {
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
containerId?: string;
|
||||
dataDir: string;
|
||||
deviceIdentity?: NapcatDockerDeviceOptions;
|
||||
image: string;
|
||||
@ -1382,6 +1416,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
}) {
|
||||
const script = this.buildRemoteCreateScript(input);
|
||||
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);
|
||||
await this.recordPlannedProfiles(input);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1390,6 +1425,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
*/
|
||||
private buildRemoteCreateScript(input: {
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
containerId?: string;
|
||||
dataDir: string;
|
||||
deviceIdentity?: NapcatDockerDeviceOptions;
|
||||
@ -1433,20 +1469,36 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
? [
|
||||
`NAPCAT_HOSTNAME=${this.sh(input.deviceIdentity.hostname)}`,
|
||||
`NAPCAT_MAC_ADDRESS=${this.sh(input.deviceIdentity.macAddress)}`,
|
||||
`NAPCAT_MAC_HYPHEN=${this.sh(input.deviceIdentity.macAddressHyphen)}`,
|
||||
`MACHINE_ID_PATH=${this.sh(input.deviceIdentity.machineIdPath)}`,
|
||||
`MACHINE_INFO_PATH=${this.sh(input.deviceIdentity.machineInfoPath)}`,
|
||||
`DEVICE_ENV_PATH=${this.sh(input.deviceIdentity.deviceEnvPath)}`,
|
||||
].join('\n') + '\n'
|
||||
: '';
|
||||
const devicePrepareScript = input.deviceIdentity
|
||||
? `
|
||||
mkdir -p "$(dirname "$DEVICE_ENV_PATH")" "$(dirname "$MACHINE_INFO_PATH")"
|
||||
cat > "$DEVICE_ENV_PATH" <<EOF
|
||||
NAPCAT_HOSTNAME=$NAPCAT_HOSTNAME
|
||||
NAPCAT_MAC_ADDRESS=$NAPCAT_MAC_ADDRESS
|
||||
NAPCAT_MAC_HYPHEN=$NAPCAT_MAC_HYPHEN
|
||||
MACHINE_ID_PATH=$MACHINE_ID_PATH
|
||||
MACHINE_INFO_PATH=$MACHINE_INFO_PATH
|
||||
EOF
|
||||
if [ ! -s "$MACHINE_ID_PATH" ]; then
|
||||
printf '%s' "$NAME" | sha256sum | cut -c 1-32 > "$MACHINE_ID_PATH"
|
||||
fi
|
||||
if [ -s "$MACHINE_INFO_PATH" ]; then
|
||||
CURRENT_MACHINE_INFO_MAC="$(dd if="$MACHINE_INFO_PATH" bs=1 skip=4 2>/dev/null | tr 'A-Za-z' 'N-ZA-Mn-za-m' || true)"
|
||||
if [ "$CURRENT_MACHINE_INFO_MAC" != "$NAPCAT_MAC_HYPHEN" ]; then
|
||||
cp "$MACHINE_INFO_PATH" "$MACHINE_INFO_PATH.bak.$(date +%Y%m%d%H%M%S)"
|
||||
fi
|
||||
fi
|
||||
MACHINE_INFO_TMP="$MACHINE_INFO_PATH.tmp"
|
||||
printf '\\000\\000\\000\\021' > "$MACHINE_INFO_TMP"
|
||||
printf '%s' "$NAPCAT_MAC_HYPHEN" | tr 'A-Za-z' 'N-ZA-Mn-za-m' >> "$MACHINE_INFO_TMP"
|
||||
mv "$MACHINE_INFO_TMP" "$MACHINE_INFO_PATH"
|
||||
chmod 644 "$MACHINE_INFO_PATH"
|
||||
`
|
||||
: '';
|
||||
const deviceRunFlags = input.deviceIdentity
|
||||
@ -1467,8 +1519,9 @@ NAPCAT_SHM_SIZE=${this.sh(runtimeProfile.shmSize)}
|
||||
${accountHeader}
|
||||
${passwordHeader}
|
||||
${deviceHeader}
|
||||
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share"
|
||||
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share" "$DATA_DIR/runtime"
|
||||
chmod 700 "$DATA_DIR"
|
||||
chmod 700 "$DATA_DIR/runtime"
|
||||
${devicePrepareScript}
|
||||
|
||||
${configWriteScript}
|
||||
@ -1497,11 +1550,74 @@ ${accountRunFlag}${passwordRunFlag}${deviceRunFlags} -p "$PORT:6099" \\
|
||||
-v "$DATA_DIR/plugins:/app/napcat/plugins" \\
|
||||
-v "$DATA_DIR/cache:/app/.cache" \\
|
||||
-v "$DATA_DIR/local-share:/app/.local/share" \\
|
||||
-v "$DATA_DIR/runtime:/tmp/runtime-napcat" \\
|
||||
-v "$DATA_DIR/logs:/app/napcat/logs" \\
|
||||
"$IMAGE" >/dev/null
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the expected runtime and protocol state for a successfully created managed container.
|
||||
* @param input - Docker creation request whose identity and config values define the planned profile.
|
||||
*/
|
||||
private async recordPlannedProfiles(input: {
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
containerId?: string;
|
||||
dataDir: string;
|
||||
deviceIdentity?: NapcatDockerDeviceOptions;
|
||||
image: string;
|
||||
loginPassword?: string;
|
||||
name: string;
|
||||
port: number;
|
||||
reverseWsUrl: string;
|
||||
skipPull?: boolean;
|
||||
token: string;
|
||||
}) {
|
||||
const accountId =
|
||||
this.toolsService.toTrimmedString(input.accountId) ||
|
||||
this.toolsService.toTrimmedString(input.deviceIdentity?.accountId);
|
||||
if (!accountId || !input.deviceIdentity) return;
|
||||
|
||||
const account = `${input.account || ''}`.trim();
|
||||
const runtimeProfile = this.runtimeProfileService.resolveRuntimeProfile({
|
||||
accountId,
|
||||
containerId: input.containerId,
|
||||
dataDir: input.dataDir,
|
||||
deviceIdentityId: input.deviceIdentity.deviceIdentityId,
|
||||
});
|
||||
const configBundle = this.configWriterService.buildConfigFiles({
|
||||
account,
|
||||
reverseWsUrl: input.reverseWsUrl,
|
||||
token: input.token,
|
||||
});
|
||||
|
||||
await this.runtimeProfileService.recordPlannedProfiles({
|
||||
accountId,
|
||||
containerId: input.containerId,
|
||||
dataDir: input.dataDir,
|
||||
deviceIdentity: {
|
||||
deviceIdentityId: input.deviceIdentity.deviceIdentityId,
|
||||
hostname: input.deviceIdentity.hostname,
|
||||
hostnameStrategy: input.deviceIdentity.hostnameStrategy,
|
||||
machineInfoPath: input.deviceIdentity.machineInfoPath,
|
||||
macAddress: input.deviceIdentity.macAddress,
|
||||
macStrategy: input.deviceIdentity.macStrategy,
|
||||
},
|
||||
protocolProfile: {
|
||||
napcatConfigHash: configBundle.napcatConfigHash,
|
||||
napcatConfigJson: configBundle.napcatConfig,
|
||||
o3HookGrayEnabled: false,
|
||||
o3HookMode: configBundle.napcatConfig.o3HookMode,
|
||||
onebotConfigHash: configBundle.onebotConfigHash,
|
||||
onebotConfigJson: configBundle.onebotConfig,
|
||||
packetBackend: configBundle.napcatConfig.packetBackend,
|
||||
packetServer: configBundle.napcatConfig.packetServer,
|
||||
},
|
||||
runtimeProfile,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders NapCat config files as shell here-doc writes under the account config directory.
|
||||
* @param files - Config files generated by `NapcatConfigWriterService` for this container.
|
||||
|
||||
@ -4,12 +4,11 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ensureSnowflakeId } from '@/common';
|
||||
import {
|
||||
isRejectedVirtualMacPrefix,
|
||||
NAPCAT_PHYSICAL_OUI_PREFIXES,
|
||||
} from '../../../domain/runtime/napcat-physical-oui-catalog';
|
||||
import { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity';
|
||||
|
||||
const QQNT_VISIBLE_HOSTNAME_STRATEGY = 'qqnt-visible-hostname-v1';
|
||||
const QQNT_DOCKER_BRIDGE_MAC_STRATEGY = 'docker-bridge-mac-v1';
|
||||
|
||||
type ResolveNapcatDeviceIdentityInput = {
|
||||
accountId: string;
|
||||
containerId?: string;
|
||||
@ -62,11 +61,13 @@ export class NapcatDeviceIdentityService {
|
||||
accountId,
|
||||
containerId: input.containerId || null,
|
||||
dataDir,
|
||||
hostname: this.buildDesktopHostname(`${accountId}:${input.selfId || ''}`),
|
||||
hostnameStrategy: 'desktop-hostname-v1',
|
||||
hostname: this.buildQqntVisibleHostname(
|
||||
`${accountId}:${input.selfId || ''}`,
|
||||
),
|
||||
hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY,
|
||||
lastLoginEvidence: null,
|
||||
macAddress: this.buildPhysicalMacAddress(accountId, containerName),
|
||||
macStrategy: 'physical-oui-v1',
|
||||
macAddress: this.buildDockerBridgeMacAddress(accountId, containerName),
|
||||
macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||
machineIdPath: `${dataDir}/machine-id`,
|
||||
verificationStatus: 'pending',
|
||||
});
|
||||
@ -91,35 +92,33 @@ export class NapcatDeviceIdentityService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a stable desktop-like hostname that avoids QQ numbers and container naming terms.
|
||||
* Builds a short stable hostname that survives the upstream Docker entrypoint rewrite and remains visible to QQNT.
|
||||
* @param seed - Account/self-id seed used only for deterministic hashing, never copied into visible hostname text.
|
||||
*/
|
||||
private buildDesktopHostname(seed: string) {
|
||||
private buildQqntVisibleHostname(seed: string) {
|
||||
const hash = createHash('sha256').update(seed).digest('hex');
|
||||
return `ubuntu-pc-${hash.slice(0, 10)}`;
|
||||
return `pc-${hash.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a stable MAC using a physical-device-style OUI prefix.
|
||||
* Builds a stable Docker bridge MAC that can be mirrored into QQNT machine-info.
|
||||
* @param accountId - Account id used as a deterministic seed, not as visible output.
|
||||
* @param containerName - Container name mixed into the deterministic seed.
|
||||
*/
|
||||
private buildPhysicalMacAddress(accountId: string, containerName: string) {
|
||||
private buildDockerBridgeMacAddress(
|
||||
accountId: string,
|
||||
containerName: string,
|
||||
) {
|
||||
const hash = createHash('sha256')
|
||||
.update(`${accountId}:${containerName}:physical-oui-v1`)
|
||||
.update(`${accountId}:${containerName}:${QQNT_DOCKER_BRIDGE_MAC_STRATEGY}`)
|
||||
.digest('hex');
|
||||
const prefixIndex =
|
||||
Number.parseInt(hash.slice(0, 4), 16) %
|
||||
NAPCAT_PHYSICAL_OUI_PREFIXES.length;
|
||||
const prefix = NAPCAT_PHYSICAL_OUI_PREFIXES[prefixIndex];
|
||||
const suffix = [hash.slice(4, 6), hash.slice(6, 8), hash.slice(8, 10)];
|
||||
const mac = `${prefix}:${suffix.join(':')}`.toLowerCase();
|
||||
|
||||
if (isRejectedVirtualMacPrefix(mac)) {
|
||||
throw new Error(`Rejected generated virtual MAC prefix: ${mac}`);
|
||||
}
|
||||
|
||||
return mac;
|
||||
const suffix = [
|
||||
hash.slice(0, 2),
|
||||
hash.slice(2, 4),
|
||||
hash.slice(4, 6),
|
||||
hash.slice(6, 8),
|
||||
];
|
||||
return `02:42:${suffix.join(':')}`.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -135,16 +134,20 @@ export class NapcatDeviceIdentityService {
|
||||
selfId: string;
|
||||
},
|
||||
) {
|
||||
const nextHostname = this.buildDesktopHostname(
|
||||
const nextHostname = this.buildQqntVisibleHostname(
|
||||
`${input.accountId}:${input.selfId}`,
|
||||
);
|
||||
const nextMacAddress = this.buildPhysicalMacAddress(
|
||||
const nextMacAddress = this.buildDockerBridgeMacAddress(
|
||||
input.accountId,
|
||||
input.containerName,
|
||||
);
|
||||
const needsMigration =
|
||||
identity.hostname !== nextHostname ||
|
||||
isRejectedVirtualMacPrefix(identity.macAddress);
|
||||
identity.hostnameStrategy !== QQNT_VISIBLE_HOSTNAME_STRATEGY ||
|
||||
identity.macStrategy !== QQNT_DOCKER_BRIDGE_MAC_STRATEGY ||
|
||||
!/^pc-[a-f0-9]{8}$/.test(identity.hostname || '') ||
|
||||
!/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/i.test(
|
||||
identity.macAddress || '',
|
||||
);
|
||||
|
||||
if (!needsMigration) {
|
||||
return;
|
||||
@ -154,10 +157,10 @@ export class NapcatDeviceIdentityService {
|
||||
migration: {
|
||||
fromHostname: identity.hostname,
|
||||
fromMacAddress: identity.macAddress,
|
||||
strategy: 'physical-oui-v1',
|
||||
strategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||
toHostname: nextHostname,
|
||||
toMacAddress: nextMacAddress,
|
||||
trigger: 'legacy-docker-identity-upgrade',
|
||||
trigger: 'qqnt-device-name-regression-repair',
|
||||
},
|
||||
};
|
||||
|
||||
@ -165,18 +168,18 @@ export class NapcatDeviceIdentityService {
|
||||
{ id: identity.id },
|
||||
{
|
||||
hostname: nextHostname,
|
||||
hostnameStrategy: 'desktop-hostname-v1',
|
||||
hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY,
|
||||
lastLoginEvidence: migrationEvidence,
|
||||
macAddress: nextMacAddress,
|
||||
macStrategy: 'physical-oui-v1',
|
||||
macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||
},
|
||||
);
|
||||
Object.assign(identity, {
|
||||
hostname: nextHostname,
|
||||
hostnameStrategy: 'desktop-hostname-v1',
|
||||
hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY,
|
||||
lastLoginEvidence: migrationEvidence,
|
||||
macAddress: nextMacAddress,
|
||||
macStrategy: 'physical-oui-v1',
|
||||
macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -43,6 +43,7 @@ import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.serv
|
||||
QQBOT_PLUGIN_EXECUTION_PORT,
|
||||
QqbotPluginHttpClientService,
|
||||
QqbotPluginPlatformService,
|
||||
QqbotPluginTaskService,
|
||||
],
|
||||
imports: [
|
||||
ConfigModule,
|
||||
|
||||
@ -23,6 +23,7 @@ const REQUIRED_CONFIG_KEYS = [
|
||||
] as const;
|
||||
|
||||
const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
|
||||
'DB_TIMEZONE',
|
||||
'MINIO_ENDPOINT',
|
||||
'MINIO_PORT',
|
||||
'MINIO_ACCESS_KEY',
|
||||
@ -95,6 +96,7 @@ export class RuntimeConfigService {
|
||||
database: this.getString('DB_DATABASE'),
|
||||
username: this.getString('DB_USERNAME'),
|
||||
synchronize: this.getBoolean('DB_SYNC', false),
|
||||
timezone: this.getString('DB_TIMEZONE', '+08:00'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ export interface RuntimeDatabaseConfig {
|
||||
database: string;
|
||||
username: string;
|
||||
synchronize: boolean;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface RuntimeLokiConfig {
|
||||
|
||||
41
test/app/typeorm-timezone-config.spec.ts
Normal file
41
test/app/typeorm-timezone-config.spec.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { buildTypeOrmOptions } from '../../src/app.module';
|
||||
|
||||
/**
|
||||
* Creates a minimal ConfigService test double for TypeORM option generation.
|
||||
* @param values - Environment-like values returned by ConfigService.get().
|
||||
*/
|
||||
function createConfig(values: Record<string, unknown>) {
|
||||
return {
|
||||
get: jest.fn((key: string) => values[key]),
|
||||
} as unknown as ConfigService;
|
||||
}
|
||||
|
||||
describe('AppModule TypeORM options', () => {
|
||||
it('uses +08:00 as the default MySQL session timezone to keep persisted timestamps in China time', () => {
|
||||
const options = buildTypeOrmOptions(
|
||||
createConfig({
|
||||
DB_HOST: 'mysql',
|
||||
DB_PORT: 3306,
|
||||
DB_USERNAME: 'kt',
|
||||
DB_PASSWORD: 'password',
|
||||
DB_DATABASE: 'kt',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(options).toMatchObject({
|
||||
timezone: '+08:00',
|
||||
type: 'mysql',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows DB_TIMEZONE to override the default when a deployment needs a different MySQL session timezone', () => {
|
||||
const options = buildTypeOrmOptions(
|
||||
createConfig({
|
||||
DB_TIMEZONE: 'Z',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(options.timezone).toBe('Z');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
import {
|
||||
getEnvironmentDashboardActions,
|
||||
HIGH_RISK_ENVIRONMENT_ACTION_IDS,
|
||||
} from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard-action.catalog';
|
||||
|
||||
describe('environment dashboard action catalog', () => {
|
||||
it('keeps readonly actions enabled for dashboard workflows', () => {
|
||||
const actions = getEnvironmentDashboardActions();
|
||||
|
||||
expect(
|
||||
actions
|
||||
.filter((action) => action.kind === 'readonly')
|
||||
.map((action) => [action.id, action.enabled]),
|
||||
).toEqual([
|
||||
['refresh-dashboard', true],
|
||||
['run-self-check', true],
|
||||
['open-runtime-logs', true],
|
||||
['open-service-route', true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders all high-risk write actions as disabled records', () => {
|
||||
const actions = getEnvironmentDashboardActions();
|
||||
|
||||
HIGH_RISK_ENVIRONMENT_ACTION_IDS.forEach((id) => {
|
||||
const action = actions.find((candidate) => candidate.id === id);
|
||||
expect(action).toMatchObject({
|
||||
enabled: false,
|
||||
id,
|
||||
kind: 'write-risk',
|
||||
});
|
||||
expect(action?.disabledReason).toContain('只读');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,102 @@
|
||||
import { EnvironmentDashboardCacheService } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/environment-dashboard-cache.service';
|
||||
import type { EnvironmentDashboardResponse } from '../../../../src/modules/admin/platform-config/environment-dashboard/domain/environment-dashboard.types';
|
||||
|
||||
const dashboard: EnvironmentDashboardResponse = {
|
||||
actions: [],
|
||||
events: [],
|
||||
generatedAt: '2026-06-18T08:00:00.000Z',
|
||||
refreshedAt: '2026-06-18T08:00:00.000Z',
|
||||
sites: [
|
||||
{
|
||||
id: 'local-dev',
|
||||
label: 'Local Dev',
|
||||
nodes: [
|
||||
{
|
||||
id: 'local-dev-api',
|
||||
label: 'Local API',
|
||||
services: [
|
||||
{
|
||||
id: 'local-api',
|
||||
label: 'API Runtime',
|
||||
signals: [
|
||||
{
|
||||
evidence: [],
|
||||
id: 'local-api-process',
|
||||
label: 'API Process',
|
||||
sourceKind: 'live',
|
||||
status: 'ok',
|
||||
summary: 'API process is reachable',
|
||||
},
|
||||
],
|
||||
status: 'ok',
|
||||
},
|
||||
],
|
||||
status: 'ok',
|
||||
},
|
||||
],
|
||||
status: 'online',
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
byStatus: {
|
||||
blocked: 0,
|
||||
degraded: 0,
|
||||
down: 0,
|
||||
isolated: 0,
|
||||
ok: 1,
|
||||
unknown: 0,
|
||||
unwired: 0,
|
||||
},
|
||||
ok: 1,
|
||||
totalSignals: 1,
|
||||
},
|
||||
topology: { edges: [], nodes: [] },
|
||||
};
|
||||
|
||||
describe('EnvironmentDashboardCacheService', () => {
|
||||
it('caches successful snapshots and marks reused live signals as cached', async () => {
|
||||
const cache = new EnvironmentDashboardCacheService({ ttlMs: 60_000 });
|
||||
const factory = jest.fn(async () => dashboard);
|
||||
|
||||
await cache.getOrCreate(factory);
|
||||
const cached = await cache.getOrCreate(factory);
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
cached.sites[0].nodes[0].services[0].signals[0].sourceKind,
|
||||
).toBe('cached');
|
||||
expect(cached.generatedAt).toBe(dashboard.generatedAt);
|
||||
});
|
||||
|
||||
it('invalidates cached snapshots after a fresh event arrives', async () => {
|
||||
const cache = new EnvironmentDashboardCacheService({ ttlMs: 60_000 });
|
||||
const factory = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(dashboard)
|
||||
.mockResolvedValueOnce({
|
||||
...dashboard,
|
||||
generatedAt: '2026-06-18T08:01:00.000Z',
|
||||
});
|
||||
|
||||
await cache.getOrCreate(factory);
|
||||
cache.invalidate();
|
||||
const refreshed = await cache.getOrCreate(factory);
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
expect(refreshed.generatedAt).toBe('2026-06-18T08:01:00.000Z');
|
||||
});
|
||||
|
||||
it('does not turn failed collectors into cached green snapshots', async () => {
|
||||
const cache = new EnvironmentDashboardCacheService({ ttlMs: 60_000 });
|
||||
|
||||
await expect(
|
||||
cache.getOrCreate(async () => {
|
||||
throw new Error('collector failed');
|
||||
}),
|
||||
).rejects.toThrow('collector failed');
|
||||
|
||||
await expect(cache.getOrCreate(async () => dashboard)).resolves.toEqual(
|
||||
dashboard,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,65 @@
|
||||
import {
|
||||
cachedEvidence,
|
||||
errorEvidence,
|
||||
liveEvidence,
|
||||
unwiredEvidence,
|
||||
} from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/environment-dashboard-evidence.mapper';
|
||||
|
||||
describe('environment dashboard evidence mapper', () => {
|
||||
it('creates live evidence with metadata and normalized timestamp', () => {
|
||||
expect(
|
||||
liveEvidence('jenkins', 'build #1 success', '2026-06-18T01:00:00Z', {
|
||||
buildNumber: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
metadata: { buildNumber: 1 },
|
||||
observedAt: '2026-06-18T01:00:00.000Z',
|
||||
source: 'jenkins',
|
||||
sourceKind: 'live',
|
||||
summary: 'build #1 success',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps missing config visible as unwired evidence', () => {
|
||||
expect(
|
||||
unwiredEvidence('kubernetes', [
|
||||
'ENV_DASHBOARD_K8S_API_SERVER',
|
||||
'ENV_DASHBOARD_K8S_BEARER_TOKEN',
|
||||
]),
|
||||
).toMatchObject({
|
||||
metadata: {
|
||||
missingConfigKeys: [
|
||||
'ENV_DASHBOARD_K8S_API_SERVER',
|
||||
'ENV_DASHBOARD_K8S_BEARER_TOKEN',
|
||||
],
|
||||
},
|
||||
source: 'kubernetes',
|
||||
sourceKind: 'unwired',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes errors without leaking stack traces', () => {
|
||||
expect(errorEvidence('caddy', new Error('connect ECONNREFUSED'))).toEqual(
|
||||
expect.objectContaining({
|
||||
source: 'caddy',
|
||||
sourceKind: 'derived',
|
||||
summary: 'connect ECONNREFUSED',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds expiresAt to cached evidence so stale data cannot stay green forever', () => {
|
||||
expect(
|
||||
cachedEvidence(
|
||||
'mqtt',
|
||||
'retained signal',
|
||||
'2026-06-18T01:00:00Z',
|
||||
'2026-06-18T01:05:00Z',
|
||||
),
|
||||
).toMatchObject({
|
||||
expiresAt: '2026-06-18T01:05:00.000Z',
|
||||
observedAt: '2026-06-18T01:00:00.000Z',
|
||||
sourceKind: 'cached',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,36 @@
|
||||
import { EnvironmentDashboardSelfCheckService } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard-self-check.service';
|
||||
|
||||
describe('EnvironmentDashboardSelfCheckService', () => {
|
||||
it('publishes readonly evidence and forces a fresh dashboard snapshot', async () => {
|
||||
const dashboardService = {
|
||||
getDashboard: jest.fn(async () => ({
|
||||
actions: [],
|
||||
events: [],
|
||||
generatedAt: '2026-06-18T08:00:00.000Z',
|
||||
refreshedAt: '2026-06-18T08:00:00.000Z',
|
||||
sites: [],
|
||||
summary: { totalSignals: 0 },
|
||||
topology: { edges: [], nodes: [] },
|
||||
})),
|
||||
};
|
||||
const eventBus = {
|
||||
publish: jest.fn(async () => undefined),
|
||||
};
|
||||
const service = new EnvironmentDashboardSelfCheckService(
|
||||
dashboardService as any,
|
||||
eventBus as any,
|
||||
);
|
||||
|
||||
await service.runSelfCheck();
|
||||
|
||||
expect(eventBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
siteId: 'local-dev',
|
||||
sourceKind: 'local',
|
||||
}),
|
||||
);
|
||||
expect(dashboardService.getDashboard).toHaveBeenCalledWith({
|
||||
forceRefresh: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,69 @@
|
||||
import {
|
||||
countSignals,
|
||||
mapSiteStatus,
|
||||
normalizeObservedAt,
|
||||
pickWorstHealthStatus,
|
||||
} from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard-status.mapper';
|
||||
|
||||
describe('environment dashboard status mapper', () => {
|
||||
it('uses blocked as the strongest signal', () => {
|
||||
expect(pickWorstHealthStatus(['ok', 'down', 'blocked'])).toBe('blocked');
|
||||
});
|
||||
|
||||
it('keeps unwired integrations visible without marking a site healthy', () => {
|
||||
expect(mapSiteStatus(['ok', 'unwired'])).toBe('unknown');
|
||||
});
|
||||
|
||||
it('marks isolated remote sites separately from ordinary degradation', () => {
|
||||
expect(mapSiteStatus(['ok', 'isolated'])).toBe('isolated');
|
||||
});
|
||||
|
||||
it('counts all nested service signals', () => {
|
||||
expect(
|
||||
countSignals([
|
||||
{
|
||||
id: 'local-dev',
|
||||
label: 'Local Dev',
|
||||
status: 'online',
|
||||
nodes: [
|
||||
{
|
||||
id: 'local-dev-api',
|
||||
label: 'API',
|
||||
services: [
|
||||
{
|
||||
id: 'local-dev-api-service',
|
||||
label: 'API Service',
|
||||
status: 'ok',
|
||||
signals: [
|
||||
{
|
||||
id: 'runtime',
|
||||
label: 'Runtime',
|
||||
status: 'ok',
|
||||
sourceKind: 'live',
|
||||
summary: 'ready',
|
||||
evidence: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
blocked: 0,
|
||||
degraded: 0,
|
||||
down: 0,
|
||||
isolated: 0,
|
||||
ok: 1,
|
||||
unknown: 0,
|
||||
unwired: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes dates into ISO strings for evidence timestamps', () => {
|
||||
expect(normalizeObservedAt(new Date('2026-06-18T00:00:00.000Z'))).toBe(
|
||||
'2026-06-18T00:00:00.000Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,144 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import * as request from 'supertest';
|
||||
import { of } from 'rxjs';
|
||||
import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import { EnvironmentDashboardService } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard.service';
|
||||
import { EnvironmentDashboardSelfCheckService } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard-self-check.service';
|
||||
import { EnvironmentEventMaterializer } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-event.materializer';
|
||||
import { EnvironmentEventStreamService } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-event-stream.service';
|
||||
import { EnvironmentEventBusService } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/event/environment-event-bus.service';
|
||||
import { EnvironmentDashboardController } from '../../../../src/modules/admin/platform-config/environment-dashboard/presentation/environment-dashboard.controller';
|
||||
|
||||
const dashboard = {
|
||||
actions: [],
|
||||
events: [],
|
||||
generatedAt: '2026-06-18T08:00:00.000Z',
|
||||
refreshedAt: '2026-06-18T08:00:00.000Z',
|
||||
sites: [],
|
||||
summary: { byStatus: {}, totalSignals: 0 },
|
||||
topology: { edges: [], nodes: [] },
|
||||
};
|
||||
|
||||
describe('EnvironmentDashboardController', () => {
|
||||
let app: INestApplication;
|
||||
const dashboardService = {
|
||||
getDashboard: jest.fn(async () => dashboard),
|
||||
};
|
||||
const selfCheckService = {
|
||||
runSelfCheck: jest.fn(async () => dashboard),
|
||||
};
|
||||
const streamService = {
|
||||
stream: jest.fn(() =>
|
||||
of({
|
||||
data: { message: 'alive', observedAt: '2026-06-18T08:00:00.000Z' },
|
||||
id: 'heartbeat-1',
|
||||
type: 'heartbeat',
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [EnvironmentDashboardController],
|
||||
providers: [
|
||||
{ provide: EnvironmentDashboardService, useValue: dashboardService },
|
||||
{
|
||||
provide: EnvironmentDashboardSelfCheckService,
|
||||
useValue: selfCheckService,
|
||||
},
|
||||
{ provide: EnvironmentEventStreamService, useValue: streamService },
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('returns the dashboard snapshot through Vben response shape', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/system/environment/dashboard')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
code: 200,
|
||||
data: dashboard,
|
||||
});
|
||||
});
|
||||
|
||||
it('runs readonly self-check through POST', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/system/environment/self-check')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
code: 200,
|
||||
data: dashboard,
|
||||
});
|
||||
expect(selfCheckService.runSelfCheck).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes Last-Event-ID to the SSE stream service', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/system/environment/events/stream?lastEventId=evt-1')
|
||||
.set('Last-Event-ID', 'evt-header')
|
||||
.buffer(true)
|
||||
.parse((res, callback) => {
|
||||
res.once('data', () => callback(null, 'ok'));
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(streamService.stream).toHaveBeenCalledWith('evt-header');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnvironmentDashboardController readonly HTTP smoke', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [EnvironmentDashboardController],
|
||||
providers: [
|
||||
EnvironmentDashboardService,
|
||||
EnvironmentDashboardSelfCheckService,
|
||||
EnvironmentEventBusService,
|
||||
EnvironmentEventMaterializer,
|
||||
EnvironmentEventStreamService,
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('serves the real aggregate snapshot and readonly self-check over HTTP', async () => {
|
||||
const snapshotResponse = await request(app.getHttpServer())
|
||||
.get('/system/environment/dashboard')
|
||||
.expect(200);
|
||||
|
||||
expect(
|
||||
snapshotResponse.body.data.sites.map((site: { id: string }) => site.id),
|
||||
).toEqual(['local-dev', 'nas-prod', 'tencent-cloud', 'r4se']);
|
||||
expect(JSON.stringify(snapshotResponse.body.data)).toContain('unwired');
|
||||
|
||||
const selfCheckResponse = await request(app.getHttpServer())
|
||||
.post('/system/environment/self-check')
|
||||
.expect(200);
|
||||
|
||||
expect(selfCheckResponse.body.data.sites).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,69 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { of } from 'rxjs';
|
||||
import * as request from 'supertest';
|
||||
import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard';
|
||||
import { EnvironmentDashboardService } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard.service';
|
||||
import { EnvironmentDashboardSelfCheckService } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard-self-check.service';
|
||||
import { EnvironmentEventStreamService } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-event-stream.service';
|
||||
import { EnvironmentDashboardController } from '../../../../src/modules/admin/platform-config/environment-dashboard/presentation/environment-dashboard.controller';
|
||||
|
||||
describe('EnvironmentDashboardController SSE boundary', () => {
|
||||
let app: INestApplication;
|
||||
const dashboardService = {
|
||||
getDashboard: jest.fn(),
|
||||
};
|
||||
const selfCheckService = {
|
||||
runSelfCheck: jest.fn(),
|
||||
};
|
||||
const streamService = {
|
||||
stream: jest.fn(() =>
|
||||
of({
|
||||
data: {
|
||||
message: 'snapshot-required',
|
||||
observedAt: '2026-06-18T08:00:00.000Z',
|
||||
},
|
||||
id: 'snapshot-required-1',
|
||||
type: 'snapshot-required',
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [EnvironmentDashboardController],
|
||||
providers: [
|
||||
{ provide: EnvironmentDashboardService, useValue: dashboardService },
|
||||
{
|
||||
provide: EnvironmentDashboardSelfCheckService,
|
||||
useValue: selfCheckService,
|
||||
},
|
||||
{ provide: EnvironmentEventStreamService, useValue: streamService },
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: jest.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('streams through EnvironmentEventStreamService without dashboard polling', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/system/environment/events/stream?lastEventId=evt-query')
|
||||
.buffer(true)
|
||||
.parse((res, callback) => {
|
||||
res.once('data', () => callback(null, 'ok'));
|
||||
})
|
||||
.expect('content-type', /text\/event-stream/)
|
||||
.expect(200);
|
||||
|
||||
expect(streamService.stream).toHaveBeenCalledWith('evt-query');
|
||||
expect(dashboardService.getDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,67 @@
|
||||
import { EnvironmentDashboardService } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard.service';
|
||||
import { EnvironmentEventMaterializer } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-event.materializer';
|
||||
|
||||
describe('EnvironmentDashboardService', () => {
|
||||
it('returns four sites with explicit unwired remote evidence and full NAS topology', async () => {
|
||||
const service = new EnvironmentDashboardService(
|
||||
new EnvironmentEventMaterializer(),
|
||||
);
|
||||
|
||||
const dashboard = await service.getDashboard();
|
||||
|
||||
expect(dashboard.sites.map((site) => site.id)).toEqual([
|
||||
'local-dev',
|
||||
'nas-prod',
|
||||
'tencent-cloud',
|
||||
'r4se',
|
||||
]);
|
||||
expect(dashboard.summary.totalSignals).toBeGreaterThan(0);
|
||||
expect(dashboard.topology.nodes.map((node) => node.id)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'local-dev',
|
||||
'nas-prod',
|
||||
'tencent-cloud',
|
||||
'r4se',
|
||||
'nas-api',
|
||||
'nas-admin',
|
||||
'mysql',
|
||||
'redis',
|
||||
'loki',
|
||||
'minio',
|
||||
'wordpress',
|
||||
'qqbot-core',
|
||||
'napcat-runtime',
|
||||
'plugin-platform',
|
||||
'plugin-tasks',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
dashboard.sites
|
||||
.flatMap((site) => site.nodes)
|
||||
.flatMap((node) => node.services)
|
||||
.flatMap((serviceItem) => serviceItem.signals)
|
||||
.some(
|
||||
(signal) =>
|
||||
signal.sourceKind === 'unwired' &&
|
||||
/Jenkins|K8s|Tencent|r4se|WireGuard|Mihomo/.test(signal.label),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps high-risk actions visible but disabled', async () => {
|
||||
const service = new EnvironmentDashboardService(
|
||||
new EnvironmentEventMaterializer(),
|
||||
);
|
||||
|
||||
const dashboard = await service.getDashboard();
|
||||
const deployAction = dashboard.actions.find(
|
||||
(action) => action.id === 'trigger-jenkins-deploy',
|
||||
);
|
||||
|
||||
expect(deployAction).toMatchObject({
|
||||
enabled: false,
|
||||
id: 'trigger-jenkins-deploy',
|
||||
});
|
||||
expect(deployAction?.disabledReason).toContain('只读');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,59 @@
|
||||
import * as mqtt from 'mqtt';
|
||||
import { EnvironmentEventBusService } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/event/environment-event-bus.service';
|
||||
|
||||
jest.mock('mqtt', () => ({
|
||||
connect: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('environment event bus service', () => {
|
||||
it('publishes local events to in-process subscribers', async () => {
|
||||
const bus = new EnvironmentEventBusService({ mode: 'local' });
|
||||
const received: string[] = [];
|
||||
const unsubscribe = bus.subscribe((event) => {
|
||||
received.push(event.eventId);
|
||||
});
|
||||
|
||||
await bus.publish({
|
||||
eventId: 'evt-local-1',
|
||||
observedAt: '2026-06-18T01:00:00.000Z',
|
||||
severity: 'ok',
|
||||
siteId: 'local-dev',
|
||||
sourceKind: 'local',
|
||||
summary: 'local ready',
|
||||
topic: 'kt/env/event/local-dev/api/runtime',
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
expect(received).toEqual(['evt-local-1']);
|
||||
});
|
||||
|
||||
it('subscribes only to the environment prefix in mqtt mode and emits broker disconnect evidence', async () => {
|
||||
const handlers: Record<string, (...args: unknown[]) => void> = {};
|
||||
const client = {
|
||||
end: jest.fn(),
|
||||
on: jest.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers[event] = handler;
|
||||
return client;
|
||||
}),
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
};
|
||||
(mqtt.connect as jest.Mock).mockReturnValue(client);
|
||||
|
||||
const bus = new EnvironmentEventBusService({
|
||||
clientId: 'test-client',
|
||||
mode: 'mqtt',
|
||||
topicPrefix: 'kt/env',
|
||||
url: 'mqtt://broker.test',
|
||||
});
|
||||
const received: string[] = [];
|
||||
bus.subscribe((event) => {
|
||||
received.push(`${event.severity}:${event.summary}`);
|
||||
});
|
||||
await bus.onModuleInit();
|
||||
handlers.close();
|
||||
|
||||
expect(client.subscribe).toHaveBeenCalledWith('kt/env/#');
|
||||
expect(received).toContain('unknown:MQTT broker disconnected');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,59 @@
|
||||
import { take, toArray } from 'rxjs';
|
||||
import { EnvironmentEventMaterializer } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-event.materializer';
|
||||
import { EnvironmentEventStreamService } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-event-stream.service';
|
||||
import { EnvironmentEventBusService } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/event/environment-event-bus.service';
|
||||
|
||||
describe('EnvironmentEventStreamService', () => {
|
||||
it('emits materialized bus events to SSE subscribers', async () => {
|
||||
const bus = new EnvironmentEventBusService({ mode: 'local' });
|
||||
const materializer = new EnvironmentEventMaterializer(undefined, 5);
|
||||
const stream = new EnvironmentEventStreamService(bus, materializer, {
|
||||
heartbeatMs: 60_000,
|
||||
replayLimit: 5,
|
||||
});
|
||||
|
||||
const received = stream.stream().pipe(take(1), toArray()).toPromise();
|
||||
await bus.publish({
|
||||
eventId: 'evt-1',
|
||||
observedAt: '2026-06-18T08:00:00.000Z',
|
||||
severity: 'degraded',
|
||||
siteId: 'nas-prod',
|
||||
sourceKind: 'mqtt',
|
||||
summary: 'NapCat degraded',
|
||||
topic: 'kt/env/nas-prod/napcat/runtime/event',
|
||||
});
|
||||
|
||||
await expect(received).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ summary: 'NapCat degraded' }),
|
||||
id: 'evt-1',
|
||||
type: 'environment-event',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits snapshot-required when Last-Event-ID is outside replay window', async () => {
|
||||
const bus = new EnvironmentEventBusService({ mode: 'local' });
|
||||
const materializer = new EnvironmentEventMaterializer(undefined, 1);
|
||||
const stream = new EnvironmentEventStreamService(bus, materializer, {
|
||||
heartbeatMs: 60_000,
|
||||
replayLimit: 1,
|
||||
});
|
||||
|
||||
await bus.publish({
|
||||
eventId: 'evt-current',
|
||||
observedAt: '2026-06-18T08:00:00.000Z',
|
||||
severity: 'ok',
|
||||
siteId: 'local-dev',
|
||||
sourceKind: 'local',
|
||||
summary: 'current event',
|
||||
topic: 'kt/env/local-dev/api/event',
|
||||
});
|
||||
|
||||
const first = await stream.stream('evt-missing').pipe(take(1)).toPromise();
|
||||
|
||||
expect(first).toMatchObject({
|
||||
type: 'snapshot-required',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,52 @@
|
||||
import { EnvironmentEventMaterializer } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-event.materializer';
|
||||
|
||||
describe('environment event materializer', () => {
|
||||
it('rejects expired retained ok signals so they cannot create green status', () => {
|
||||
const cache = { invalidate: jest.fn() };
|
||||
const materializer = new EnvironmentEventMaterializer(cache);
|
||||
|
||||
const event = materializer.materialize(
|
||||
{
|
||||
eventId: 'evt-old-retained',
|
||||
expiresAt: '2026-06-18T00:00:00.000Z',
|
||||
observedAt: '2026-06-17T23:00:00.000Z',
|
||||
retained: true,
|
||||
severity: 'ok',
|
||||
siteId: 'nas-prod',
|
||||
sourceKind: 'mqtt',
|
||||
summary: 'old retained healthy signal',
|
||||
topic: 'kt/env/signal/nas-prod/k8s/api',
|
||||
},
|
||||
new Date('2026-06-18T01:00:00.000Z'),
|
||||
);
|
||||
|
||||
expect(event?.severity).toBe('unknown');
|
||||
expect(event?.summary).toContain('已过期');
|
||||
expect(cache.invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates dashboard cache for fresh signal events and appends recent events', () => {
|
||||
const cache = { invalidate: jest.fn() };
|
||||
const materializer = new EnvironmentEventMaterializer(cache);
|
||||
|
||||
const event = materializer.materialize(
|
||||
{
|
||||
eventId: 'evt-fresh',
|
||||
expiresAt: '2026-06-18T01:05:00.000Z',
|
||||
observedAt: '2026-06-18T01:00:00.000Z',
|
||||
retained: true,
|
||||
severity: 'degraded',
|
||||
signalId: 'api-pod',
|
||||
siteId: 'nas-prod',
|
||||
sourceKind: 'mqtt',
|
||||
summary: 'api pod restart count increased',
|
||||
topic: 'kt/env/signal/nas-prod/k8s/api',
|
||||
},
|
||||
new Date('2026-06-18T01:01:00.000Z'),
|
||||
);
|
||||
|
||||
expect(event?.id).toBe('evt-fresh');
|
||||
expect(cache.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(materializer.getRecentEvents()).toEqual([event]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,32 @@
|
||||
import {
|
||||
buildEnvironmentMqttTopics,
|
||||
normalizeEnvironmentTopicSegment,
|
||||
} from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/event/environment-mqtt-topic.catalog';
|
||||
|
||||
describe('environment mqtt topic catalog', () => {
|
||||
it('builds deterministic environment dashboard topics under the configured prefix', () => {
|
||||
const topics = buildEnvironmentMqttTopics('kt/env');
|
||||
|
||||
expect(topics.signal('nas-prod', 'k8s', 'api')).toBe(
|
||||
'kt/env/signal/nas-prod/k8s/api',
|
||||
);
|
||||
expect(topics.event('nas-prod', 'k8s', 'api')).toBe(
|
||||
'kt/env/event/nas-prod/k8s/api',
|
||||
);
|
||||
expect(topics.selfCheckResult('nas-prod')).toBe(
|
||||
'kt/env/self-check/nas-prod',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes dynamic segments so topic builders never escape the prefix', () => {
|
||||
expect(normalizeEnvironmentTopicSegment('/plugin task/#1')).toBe(
|
||||
'plugin-task--1',
|
||||
);
|
||||
expect(
|
||||
buildEnvironmentMqttTopics('kt/env').pluginTaskRun(
|
||||
'bangdream',
|
||||
'/bestdori sync',
|
||||
),
|
||||
).toBe('kt/env/qqbot/plugin-task/bangdream/bestdori-sync/run');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,65 @@
|
||||
import axios from 'axios';
|
||||
import { EnvironmentReadonlyHttpClient } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/environment-readonly-http.client';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const requestMock = axios.request as jest.Mock;
|
||||
|
||||
describe('environment readonly http client', () => {
|
||||
beforeEach(() => {
|
||||
requestMock.mockReset();
|
||||
});
|
||||
|
||||
it('allows GET requests and truncates large response bodies', async () => {
|
||||
requestMock.mockResolvedValue({
|
||||
data: 'x'.repeat(128),
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const client = new EnvironmentReadonlyHttpClient({
|
||||
bodyPreviewLimit: 16,
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
const result = await client.get('https://example.test/health', {
|
||||
headers: { Authorization: 'Bearer secret-token' },
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
timeout: 3000,
|
||||
url: 'https://example.test/health',
|
||||
}),
|
||||
);
|
||||
expect(result.bodyPreview).toBe('xxxxxxxxxxxxxxxx...');
|
||||
expect(JSON.stringify(result)).not.toContain('secret-token');
|
||||
});
|
||||
|
||||
it('allows HEAD requests without storing a body preview', async () => {
|
||||
requestMock.mockResolvedValue({
|
||||
data: '',
|
||||
headers: { server: 'caddy' },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const client = new EnvironmentReadonlyHttpClient({ timeoutMs: 3000 });
|
||||
const result = await client.head('https://example.test');
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'HEAD' }),
|
||||
);
|
||||
expect(result.bodyPreview).toBe('');
|
||||
});
|
||||
|
||||
it('rejects write methods at the client boundary', async () => {
|
||||
const client = new EnvironmentReadonlyHttpClient({ timeoutMs: 3000 });
|
||||
|
||||
await expect(
|
||||
client.request('POST', 'https://example.test/mutate'),
|
||||
).rejects.toThrow('只允许 GET/HEAD');
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,380 @@
|
||||
import { CaddyReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/caddy-readonly.adapter';
|
||||
import { JenkinsReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/jenkins-readonly.adapter';
|
||||
import { KubernetesReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/kubernetes-readonly.adapter';
|
||||
import { MihomoReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/mihomo-readonly.adapter';
|
||||
import { TencentCloudReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/tencent-cloud-readonly.adapter';
|
||||
import { WireguardReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/wireguard-readonly.adapter';
|
||||
import type {
|
||||
EnvironmentReadonlyHttpClient,
|
||||
EnvironmentReadonlyHttpResponse,
|
||||
} from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/environment-readonly-http.client';
|
||||
import { EnvironmentDashboardConfigService } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/environment-dashboard-config.service';
|
||||
|
||||
type ReadonlyHttpMock = jest.Mocked<EnvironmentReadonlyHttpClient>;
|
||||
|
||||
/**
|
||||
* Creates a deterministic sanitized HTTP response for readonly adapter tests.
|
||||
* @param body - Response body preview returned by the fake HTTP client.
|
||||
* @param status - HTTP status code observed by the adapter.
|
||||
* @returns HTTP response shaped like EnvironmentReadonlyHttpClient output.
|
||||
*/
|
||||
function httpResponse(
|
||||
body: unknown,
|
||||
status = 200,
|
||||
): EnvironmentReadonlyHttpResponse {
|
||||
return {
|
||||
bodyPreview: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
observedAt: '2026-06-18T01:00:00.000Z',
|
||||
status,
|
||||
statusText: status >= 200 && status < 400 ? 'OK' : 'ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mocked readonly HTTP client exposing only safe GET and HEAD probes.
|
||||
* @returns Jest mock for readonly HTTP client methods used by adapters.
|
||||
*/
|
||||
function createHttpMock(): ReadonlyHttpMock {
|
||||
return {
|
||||
get: jest.fn(),
|
||||
head: jest.fn(),
|
||||
request: jest.fn(),
|
||||
} as unknown as ReadonlyHttpMock;
|
||||
}
|
||||
|
||||
describe('environment remote readonly adapters', () => {
|
||||
const config = new EnvironmentDashboardConfigService({});
|
||||
|
||||
it.each([
|
||||
['Jenkins', new JenkinsReadonlyAdapter(config)],
|
||||
['K8s', new KubernetesReadonlyAdapter(config)],
|
||||
['Tencent Cloud', new TencentCloudReadonlyAdapter(config)],
|
||||
['Caddy', new CaddyReadonlyAdapter(config)],
|
||||
['WireGuard', new WireguardReadonlyAdapter(config)],
|
||||
['Mihomo', new MihomoReadonlyAdapter(config)],
|
||||
])(
|
||||
'%s returns unwired evidence when config is missing',
|
||||
async (_, adapter) => {
|
||||
const signal = await adapter.inspect();
|
||||
|
||||
expect(signal.status).toBe('unwired');
|
||||
expect(signal.sourceKind).toBe('unwired');
|
||||
expect(signal.evidence[0].summary).toContain('缺少只读观测配置');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps credential keys visible as missing config without exposing values', async () => {
|
||||
const tencentSignal = await new TencentCloudReadonlyAdapter(
|
||||
config,
|
||||
).inspect();
|
||||
const mihomoSignal = await new MihomoReadonlyAdapter(config).inspect();
|
||||
|
||||
expect(tencentSignal.evidence[0].metadata?.missingConfigKeys).toContain(
|
||||
'ENV_DASHBOARD_TENCENT_SECRET_KEY',
|
||||
);
|
||||
expect(mihomoSignal.evidence[0].metadata?.missingConfigKeys).toContain(
|
||||
'ENV_DASHBOARD_R4SE_MIHOMO_SECRET',
|
||||
);
|
||||
});
|
||||
|
||||
it('Jenkins reads the last build API and returns sanitized live evidence', async () => {
|
||||
const http = createHttpMock();
|
||||
http.get.mockResolvedValue(
|
||||
httpResponse({
|
||||
building: false,
|
||||
duration: 16000,
|
||||
number: 42,
|
||||
result: 'SUCCESS',
|
||||
url: 'https://jenkins.example/job/main/42/',
|
||||
}),
|
||||
);
|
||||
const adapter = new JenkinsReadonlyAdapter(
|
||||
new EnvironmentDashboardConfigService({
|
||||
ENV_DASHBOARD_JENKINS_JOB: 'KT-Template/KT-Template-API/main',
|
||||
ENV_DASHBOARD_JENKINS_TOKEN: 'jenkins-token',
|
||||
ENV_DASHBOARD_JENKINS_URL: 'https://jenkins.example',
|
||||
ENV_DASHBOARD_JENKINS_USERNAME: 'codex',
|
||||
}),
|
||||
http,
|
||||
);
|
||||
|
||||
const signal = await adapter.inspect();
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'https://jenkins.example/job/KT-Template/job/KT-Template-API/job/main/lastBuild/api/json',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: expect.stringMatching(/^Basic /),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(signal.status).toBe('ok');
|
||||
expect(signal.sourceKind).toBe('live');
|
||||
expect(signal.evidence[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceKind: 'live',
|
||||
summary: expect.stringContaining('SUCCESS'),
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(signal)).not.toContain('jenkins-token');
|
||||
});
|
||||
|
||||
it('Kubernetes reads deployment and pod APIs with readonly bearer auth', async () => {
|
||||
const http = createHttpMock();
|
||||
http.get
|
||||
.mockResolvedValueOnce(
|
||||
httpResponse({
|
||||
spec: { replicas: 2 },
|
||||
status: {
|
||||
availableReplicas: 2,
|
||||
readyReplicas: 2,
|
||||
replicas: 2,
|
||||
updatedReplicas: 2,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
httpResponse({
|
||||
items: [
|
||||
{
|
||||
status: {
|
||||
conditions: [{ status: 'True', type: 'Ready' }],
|
||||
phase: 'Running',
|
||||
},
|
||||
},
|
||||
{
|
||||
status: {
|
||||
conditions: [{ status: 'True', type: 'Ready' }],
|
||||
phase: 'Running',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const adapter = new KubernetesReadonlyAdapter(
|
||||
new EnvironmentDashboardConfigService({
|
||||
ENV_DASHBOARD_K8S_API_SERVER: 'https://k8s.example',
|
||||
ENV_DASHBOARD_K8S_BEARER_TOKEN: 'k8s-token',
|
||||
ENV_DASHBOARD_K8S_DEPLOYMENT: 'kt-template-online-api',
|
||||
ENV_DASHBOARD_K8S_LABEL_SELECTOR: 'app=kt-template-online-api',
|
||||
ENV_DASHBOARD_K8S_NAMESPACE: 'kt-prod',
|
||||
}),
|
||||
http,
|
||||
);
|
||||
|
||||
const signal = await adapter.inspect();
|
||||
|
||||
expect(http.get).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://k8s.example/apis/apps/v1/namespaces/kt-prod/deployments/kt-template-online-api',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer k8s-token' },
|
||||
}),
|
||||
);
|
||||
expect(http.get).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://k8s.example/api/v1/namespaces/kt-prod/pods',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer k8s-token' },
|
||||
params: { labelSelector: 'app=kt-template-online-api' },
|
||||
}),
|
||||
);
|
||||
expect(signal.status).toBe('ok');
|
||||
expect(signal.evidence[0].metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
podReadyCount: 2,
|
||||
readyReplicas: 2,
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(signal)).not.toContain('k8s-token');
|
||||
});
|
||||
|
||||
it('Tencent Cloud reads CVM DescribeInstances through the official SDK', async () => {
|
||||
const describeInstances = jest.fn().mockResolvedValue({
|
||||
InstanceSet: [
|
||||
{
|
||||
CPU: 2,
|
||||
InstanceId: 'ins-test',
|
||||
InstanceName: 'api-prod',
|
||||
InstanceState: 'RUNNING',
|
||||
Memory: 4096,
|
||||
},
|
||||
],
|
||||
RequestId: 'req-1',
|
||||
TotalCount: 1,
|
||||
});
|
||||
const createClient = jest.fn(() => ({
|
||||
DescribeInstances: describeInstances,
|
||||
}));
|
||||
const adapter = new TencentCloudReadonlyAdapter(
|
||||
new EnvironmentDashboardConfigService({
|
||||
ENV_DASHBOARD_TENCENT_CLOUD_ENABLED: 'true',
|
||||
ENV_DASHBOARD_TENCENT_INSTANCE_ID: 'ins-test',
|
||||
ENV_DASHBOARD_TENCENT_REGION: 'ap-guangzhou',
|
||||
ENV_DASHBOARD_TENCENT_SECRET_ID: 'secret-id',
|
||||
ENV_DASHBOARD_TENCENT_SECRET_KEY: 'secret-key',
|
||||
}),
|
||||
createClient,
|
||||
);
|
||||
|
||||
const signal = await adapter.inspect();
|
||||
|
||||
expect(createClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
credential: {
|
||||
secretId: 'secret-id',
|
||||
secretKey: 'secret-key',
|
||||
},
|
||||
region: 'ap-guangzhou',
|
||||
}),
|
||||
);
|
||||
expect(describeInstances).toHaveBeenCalledWith({
|
||||
InstanceIds: ['ins-test'],
|
||||
Limit: 1,
|
||||
});
|
||||
expect(signal.status).toBe('ok');
|
||||
expect(signal.evidence[0].metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
instanceState: 'RUNNING',
|
||||
totalCount: 1,
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(signal)).not.toContain('secret-key');
|
||||
});
|
||||
|
||||
it('Caddy checks public HEAD and admin GET without unsafe methods', async () => {
|
||||
const http = createHttpMock();
|
||||
http.head.mockResolvedValue(httpResponse('', 200));
|
||||
http.get.mockResolvedValue(httpResponse({ apps: { http: {} } }, 200));
|
||||
const adapter = new CaddyReadonlyAdapter(
|
||||
new EnvironmentDashboardConfigService({
|
||||
ENV_DASHBOARD_CADDY_ADMIN_URL: 'http://caddy-admin.example',
|
||||
ENV_DASHBOARD_CADDY_PUBLIC_URL: 'https://kt.example',
|
||||
}),
|
||||
http,
|
||||
);
|
||||
|
||||
const signal = await adapter.inspect();
|
||||
|
||||
expect(http.head).toHaveBeenCalledWith('https://kt.example');
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'http://caddy-admin.example/config/',
|
||||
);
|
||||
expect(signal.status).toBe('ok');
|
||||
expect(signal.evidence[0].metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
adminConfigured: true,
|
||||
publicStatus: 200,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('WireGuard checks only configured readonly health endpoints', async () => {
|
||||
const http = createHttpMock();
|
||||
http.get
|
||||
.mockResolvedValueOnce(httpResponse({ status: 'ok' }, 200))
|
||||
.mockResolvedValueOnce(httpResponse({ status: 'ok' }, 200));
|
||||
const adapter = new WireguardReadonlyAdapter(
|
||||
new EnvironmentDashboardConfigService({
|
||||
ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL:
|
||||
'https://r4se.example/wg/health',
|
||||
ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL:
|
||||
'https://tencent.example/wg/health',
|
||||
}),
|
||||
http,
|
||||
);
|
||||
|
||||
const signal = await adapter.inspect();
|
||||
|
||||
expect(http.get).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://tencent.example/wg/health',
|
||||
);
|
||||
expect(http.get).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://r4se.example/wg/health',
|
||||
);
|
||||
expect(signal.status).toBe('ok');
|
||||
expect(signal.evidence[0].metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
endpointCount: 2,
|
||||
reachableCount: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('Mihomo reads version, configs, and proxies without mutating selectors', async () => {
|
||||
const http = createHttpMock();
|
||||
http.get
|
||||
.mockResolvedValueOnce(httpResponse({ version: '1.18.0' }))
|
||||
.mockResolvedValueOnce(httpResponse({ mode: 'rule' }))
|
||||
.mockResolvedValueOnce(
|
||||
httpResponse({ proxies: { DIRECT: {}, Proxy: {} } }),
|
||||
);
|
||||
const adapter = new MihomoReadonlyAdapter(
|
||||
new EnvironmentDashboardConfigService({
|
||||
ENV_DASHBOARD_R4SE_MIHOMO_SECRET: 'mihomo-secret',
|
||||
ENV_DASHBOARD_R4SE_MIHOMO_URL: 'http://mihomo.example',
|
||||
}),
|
||||
http,
|
||||
);
|
||||
|
||||
const signal = await adapter.inspect();
|
||||
|
||||
expect(http.get).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'http://mihomo.example/version',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer mihomo-secret' },
|
||||
}),
|
||||
);
|
||||
expect(http.get).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'http://mihomo.example/configs',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer mihomo-secret' },
|
||||
}),
|
||||
);
|
||||
expect(http.get).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'http://mihomo.example/proxies',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer mihomo-secret' },
|
||||
}),
|
||||
);
|
||||
expect(http.head).not.toHaveBeenCalled();
|
||||
expect(signal.status).toBe('ok');
|
||||
expect(signal.evidence[0].metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: 'rule',
|
||||
proxyCount: 2,
|
||||
version: '1.18.0',
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(signal)).not.toContain('mihomo-secret');
|
||||
});
|
||||
|
||||
it('returns degraded error evidence when a configured readonly probe fails', async () => {
|
||||
const http = createHttpMock();
|
||||
http.get.mockRejectedValue(new Error('connect ECONNREFUSED'));
|
||||
const adapter = new JenkinsReadonlyAdapter(
|
||||
new EnvironmentDashboardConfigService({
|
||||
ENV_DASHBOARD_JENKINS_JOB: 'main',
|
||||
ENV_DASHBOARD_JENKINS_URL: 'https://jenkins.example',
|
||||
}),
|
||||
http,
|
||||
);
|
||||
|
||||
const signal = await adapter.inspect();
|
||||
|
||||
expect(signal.status).toBe('degraded');
|
||||
expect(signal.sourceKind).toBe('derived');
|
||||
expect(signal.evidence[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceKind: 'derived',
|
||||
summary: 'connect ECONNREFUSED',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,47 @@
|
||||
import { EnvironmentDashboardConfigService } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/environment-dashboard-config.service';
|
||||
import { LocalDevSignalCollector } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/collectors/local-dev-signal.collector';
|
||||
|
||||
describe('LocalDevSignalCollector', () => {
|
||||
it('uses runtime health for the local API service and configured Admin URL evidence', async () => {
|
||||
const runtime = {
|
||||
getRuntimeHealth: jest.fn(() => ({
|
||||
checkedAt: '2026-06-18T08:00:00.000Z',
|
||||
checks: [
|
||||
{
|
||||
critical: true,
|
||||
message: 'process live',
|
||||
name: 'process',
|
||||
status: 'live',
|
||||
},
|
||||
],
|
||||
service: 'kt-template-online-api',
|
||||
status: 'degraded',
|
||||
})),
|
||||
};
|
||||
const config = new EnvironmentDashboardConfigService({
|
||||
ENV_DASHBOARD_ADMIN_LOCAL_URL: 'http://127.0.0.1:5999',
|
||||
});
|
||||
const collector = new LocalDevSignalCollector(runtime as any, config);
|
||||
|
||||
const site = await collector.collect({
|
||||
observedAt: '2026-06-18T08:00:00.000Z',
|
||||
});
|
||||
|
||||
const apiService = site.nodes[0].services.find(
|
||||
(service) => service.id === 'local-api',
|
||||
);
|
||||
const adminService = site.nodes[0].services.find(
|
||||
(service) => service.id === 'local-admin',
|
||||
);
|
||||
|
||||
expect(site.id).toBe('local-dev');
|
||||
expect(apiService?.status).toBe('degraded');
|
||||
expect(apiService?.signals[0].evidence[0].metadata).toMatchObject({
|
||||
runtimeStatus: 'degraded',
|
||||
});
|
||||
expect(adminService?.signals[0]).toMatchObject({
|
||||
sourceKind: 'configured',
|
||||
status: 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,79 @@
|
||||
import { EnvironmentDashboardConfigService } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/environment-dashboard-config.service';
|
||||
import { NasProdSignalCollector } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/collectors/nas-prod-signal.collector';
|
||||
|
||||
describe('NasProdSignalCollector', () => {
|
||||
it('contains QQBot offline state without marking API down', async () => {
|
||||
const collector = new NasProdSignalCollector(
|
||||
{
|
||||
getRuntimeHealth: jest.fn(() => ({
|
||||
checkedAt: '2026-06-18T08:00:00.000Z',
|
||||
checks: [],
|
||||
service: 'kt-template-online-api',
|
||||
status: 'live',
|
||||
})),
|
||||
} as any,
|
||||
{
|
||||
summary: jest.fn(async () => ({
|
||||
accountTotal: 2,
|
||||
bus: { connected: false, mode: 'local' },
|
||||
onlineTotal: 0,
|
||||
runtime: { enabled: true, sessions: [] },
|
||||
})),
|
||||
} as any,
|
||||
{
|
||||
pageTasks: jest.fn(async () => ({
|
||||
list: [
|
||||
{
|
||||
enabled: false,
|
||||
pluginKey: 'bangdream',
|
||||
runtimeStatus: 'disabled',
|
||||
taskKey: 'sync-bestdori-assets',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
} as any,
|
||||
{
|
||||
checkConnection: jest.fn(async () => {
|
||||
throw new Error('minio offline');
|
||||
}),
|
||||
} as any,
|
||||
{
|
||||
tryLoginWithConfiguredAdmin: jest.fn(async () => ({
|
||||
available: false,
|
||||
error: { message: 'wp unavailable', status: 502 },
|
||||
result: null,
|
||||
})),
|
||||
} as any,
|
||||
{ inspect: jest.fn(async () => ({ id: 'jenkins-build' })) } as any,
|
||||
{ inspect: jest.fn(async () => ({ id: 'k8s-deployment' })) } as any,
|
||||
new EnvironmentDashboardConfigService({}),
|
||||
);
|
||||
|
||||
const site = await collector.collect({
|
||||
observedAt: '2026-06-18T08:00:00.000Z',
|
||||
});
|
||||
const services = site.nodes.flatMap((node) => node.services);
|
||||
|
||||
expect(services.find((service) => service.id === 'nas-api')?.status).toBe(
|
||||
'ok',
|
||||
);
|
||||
expect(services.find((service) => service.id === 'qqbot-core')?.status).toBe(
|
||||
'degraded',
|
||||
);
|
||||
expect(services.find((service) => service.id === 'plugin-tasks')?.status).toBe(
|
||||
'degraded',
|
||||
);
|
||||
expect(services.find((service) => service.id === 'minio')?.status).toBe(
|
||||
'down',
|
||||
);
|
||||
expect(
|
||||
services.find((service) => service.id === 'jenkins')?.signals[0]
|
||||
.sourceKind,
|
||||
).toBe('unwired');
|
||||
expect(
|
||||
services.find((service) => service.id === 'kubernetes')?.signals[0]
|
||||
.sourceKind,
|
||||
).toBe('unwired');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,48 @@
|
||||
import { QqbotEnvironmentEventBridge } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/event/qqbot-environment-event.bridge';
|
||||
|
||||
describe('qqbot environment event bridge', () => {
|
||||
it('maps NapCat runtime events into dashboard environment envelopes', async () => {
|
||||
const bus = { publish: jest.fn() };
|
||||
const bridge = new QqbotEnvironmentEventBridge(bus);
|
||||
|
||||
await bridge.publishNapcatRuntimeEvent({
|
||||
accountId: 'account-1',
|
||||
message: 'NapCat offline',
|
||||
observedAt: '2026-06-18T01:00:00.000Z',
|
||||
selfId: '1914728559',
|
||||
severity: 'down',
|
||||
});
|
||||
|
||||
expect(bus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nodeId: 'nas-prod-qqbot',
|
||||
serviceId: 'napcat',
|
||||
severity: 'down',
|
||||
signalId: 'napcat-1914728559',
|
||||
siteId: 'nas-prod',
|
||||
sourceKind: 'local',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps plugin task runs without depending on QQBot topic constants', async () => {
|
||||
const bus = { publish: jest.fn() };
|
||||
const bridge = new QqbotEnvironmentEventBridge(bus);
|
||||
|
||||
await bridge.publishPluginTaskRun({
|
||||
message: 'Bestdori sync disabled',
|
||||
observedAt: '2026-06-18T01:00:00.000Z',
|
||||
pluginKey: 'bangdream',
|
||||
severity: 'degraded',
|
||||
taskKey: 'bestdori-sync',
|
||||
});
|
||||
|
||||
expect(bus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serviceId: 'plugin-tasks',
|
||||
signalId: 'plugin-task-bangdream-bestdori-sync',
|
||||
topic: 'kt/env/qqbot/plugin-task/bangdream/bestdori-sync/run',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -172,7 +172,7 @@ describe('NapCat device identity persistence', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('generates a real-device style hostname without QQBot or container words', async () => {
|
||||
it('generates a short QQNT-visible hostname without QQBot or container words', async () => {
|
||||
const repository = createIdentityRepository();
|
||||
const service = new NapcatDeviceIdentityService(
|
||||
repository as any,
|
||||
@ -185,11 +185,11 @@ describe('NapCat device identity persistence', () => {
|
||||
selfId: '10001',
|
||||
});
|
||||
|
||||
expect(identity.hostname).toMatch(/^(ubuntu|linux)-pc-[a-f0-9]{8,12}$/);
|
||||
expect(identity.hostname).toMatch(/^pc-[a-f0-9]{8}$/);
|
||||
expect(identity.hostname).not.toMatch(/10001|qq|bot|napcat|docker/i);
|
||||
});
|
||||
|
||||
it('generates a stable MAC from approved physical OUI prefixes', async () => {
|
||||
it('generates a stable Docker bridge MAC that matches QQNT machine-info expectations', async () => {
|
||||
const repository = createIdentityRepository();
|
||||
const service = new NapcatDeviceIdentityService(
|
||||
repository as any,
|
||||
@ -202,16 +202,15 @@ describe('NapCat device identity persistence', () => {
|
||||
selfId: '10001',
|
||||
});
|
||||
|
||||
expect(identity.macAddress).toMatch(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/);
|
||||
expect(identity.macAddress).not.toMatch(/^02:42/i);
|
||||
expect(identity.macAddress).toMatch(/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/);
|
||||
expect(identity.macAddress).not.toMatch(/^52:54:00/i);
|
||||
expect(identity.macAddress).not.toMatch(
|
||||
/^(00:05:69|00:0c:29|00:1c:14|00:50:56)/i,
|
||||
);
|
||||
expect(identity.macStrategy).toBe('physical-oui-v1');
|
||||
expect(identity.macStrategy).toBe('docker-bridge-mac-v1');
|
||||
});
|
||||
|
||||
it('records migration evidence when an existing Docker-style identity is upgraded', async () => {
|
||||
it('records regression-repair evidence when an existing identity is realigned for QQNT', async () => {
|
||||
const repository = createIdentityRepository();
|
||||
repository.seedIdentity({
|
||||
accountId: 'account-10001',
|
||||
@ -237,13 +236,14 @@ describe('NapCat device identity persistence', () => {
|
||||
selfId: '10001',
|
||||
});
|
||||
|
||||
expect(identity.macAddress).toMatch(/^02:42:/i);
|
||||
expect(identity.macAddress).not.toBe('02:42:aa:bb:cc:dd');
|
||||
expect(identity.hostname).not.toBe('kt-qqbot-napcat-10001');
|
||||
expect(identity.lastLoginEvidence).toMatchObject({
|
||||
migration: {
|
||||
fromMacAddress: '02:42:aa:bb:cc:dd',
|
||||
strategy: 'physical-oui-v1',
|
||||
trigger: 'legacy-docker-identity-upgrade',
|
||||
strategy: 'docker-bridge-mac-v1',
|
||||
trigger: 'qqnt-device-name-regression-repair',
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -268,7 +268,9 @@ describe('NapCat device identity persistence', () => {
|
||||
deviceEnvPath: `${identity.dataDir}/device.env`,
|
||||
hostname: identity.hostname,
|
||||
machineIdPath: identity.machineIdPath,
|
||||
machineInfoPath: `${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info`,
|
||||
macAddress: identity.macAddress,
|
||||
macAddressHyphen: identity.macAddress.replace(/:/g, '-'),
|
||||
}),
|
||||
);
|
||||
expect(dockerOptions.runFlags).toEqual(
|
||||
@ -311,10 +313,20 @@ describe('NapCat device identity persistence', () => {
|
||||
expect(script).toContain(`NAPCAT_HOSTNAME='${identity.hostname}'`);
|
||||
expect(script).toContain(`NAPCAT_MAC_ADDRESS='${identity.macAddress}'`);
|
||||
expect(script).toContain(`MACHINE_ID_PATH='${identity.machineIdPath}'`);
|
||||
expect(script).toContain(
|
||||
`MACHINE_INFO_PATH='${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info'`,
|
||||
);
|
||||
expect(script).toContain(
|
||||
`NAPCAT_MAC_HYPHEN='${identity.macAddress.replace(/:/g, '-')}'`,
|
||||
);
|
||||
expect(script).toContain('cat > "$DEVICE_ENV_PATH" <<EOF');
|
||||
expect(script).toContain('MACHINE_INFO_PATH.bak.$(date +%Y%m%d%H%M%S)');
|
||||
expect(script).toContain("printf '\\000\\000\\000\\021'");
|
||||
expect(script).toContain("tr 'A-Za-z' 'N-ZA-Mn-za-m'");
|
||||
expect(script).toContain('--hostname "$NAPCAT_HOSTNAME"');
|
||||
expect(script).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
|
||||
expect(script).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
|
||||
expect(script).toContain('-v "$DATA_DIR/runtime:/tmp/runtime-napcat"');
|
||||
});
|
||||
|
||||
it('uses persisted device identity when preparing an account managed container', async () => {
|
||||
@ -330,6 +342,7 @@ describe('NapCat device identity persistence', () => {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
})),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const containerRepository = {
|
||||
create: jest.fn((input) => input),
|
||||
@ -386,9 +399,19 @@ describe('NapCat device identity persistence', () => {
|
||||
}),
|
||||
);
|
||||
const createScript = containerService.runProcess.mock.calls[0][2];
|
||||
expect(bindingRepository.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: 'account-10001',
|
||||
containerId: 'container-created',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
deviceIdentityId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"');
|
||||
expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
|
||||
expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
|
||||
expect(createScript).toContain('-v "$DATA_DIR/runtime:/tmp/runtime-napcat"');
|
||||
});
|
||||
|
||||
it('reuses persisted device identity when rebuilding an existing account container login env', async () => {
|
||||
|
||||
@ -135,6 +135,94 @@ describe('NapCat runtime profile generation', () => {
|
||||
expect(profile.locale).not.toBe('C.UTF-8');
|
||||
});
|
||||
|
||||
it('persists planned runtime and protocol profiles when a managed container is rebuilt', async () => {
|
||||
const runtimeProfileRepository = {
|
||||
create: jest.fn((input) => ({ ...input })),
|
||||
save: jest.fn(async (input) => input),
|
||||
};
|
||||
const protocolProfileRepository = {
|
||||
create: jest.fn((input) => ({ ...input })),
|
||||
save: jest.fn(async (input) => input),
|
||||
};
|
||||
const service = new (NapcatRuntimeProfileService as any)(
|
||||
{
|
||||
get: jest.fn((key: string, defaultValue?: string) => {
|
||||
const values: Record<string, string> = {
|
||||
QQBOT_NAPCAT_IMAGE: 'kt-napcat-desktop-cn@sha256:profiledigest',
|
||||
QQBOT_NAPCAT_PROFILE_VERSION: 'napcat-runtime-profile-v1',
|
||||
QQBOT_NAPCAT_PROTOCOL_PROFILE_VERSION: 'napcat-protocol-profile-v1',
|
||||
};
|
||||
return values[key] || defaultValue || '';
|
||||
}),
|
||||
},
|
||||
runtimeProfileRepository,
|
||||
protocolProfileRepository,
|
||||
);
|
||||
const runtimeProfile = service.resolveRuntimeProfile({
|
||||
accountId: 'account-1',
|
||||
containerId: 'container-1',
|
||||
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/pc-a1b2c3d4',
|
||||
deviceIdentityId: 'identity-1',
|
||||
});
|
||||
|
||||
await service.recordPlannedProfiles({
|
||||
accountId: 'account-1',
|
||||
containerId: 'container-1',
|
||||
dataDir: runtimeProfile.dataDir,
|
||||
deviceIdentity: {
|
||||
deviceIdentityId: 'identity-1',
|
||||
hostname: 'pc-a1b2c3d4',
|
||||
hostnameStrategy: 'qqnt-visible-hostname-v1',
|
||||
machineInfoPath:
|
||||
'/vol1/docker/kt-qqbot/napcat-instances/pc-a1b2c3d4/QQ/nt_qq/global/nt_data/msf/machine-info',
|
||||
macAddress: '02:42:aa:bb:cc:dd',
|
||||
macStrategy: 'docker-bridge-mac-v1',
|
||||
},
|
||||
protocolProfile: {
|
||||
napcatConfigHash: 'napcat-hash',
|
||||
napcatConfigJson: { o3HookMode: 1 },
|
||||
o3HookGrayEnabled: false,
|
||||
o3HookMode: 1,
|
||||
onebotConfigHash: 'onebot-hash',
|
||||
onebotConfigJson: { network: { websocketClients: [] } },
|
||||
packetBackend: 'auto',
|
||||
packetServer: '',
|
||||
},
|
||||
runtimeProfile,
|
||||
});
|
||||
|
||||
expect(runtimeProfileRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: 'account-1',
|
||||
containerId: 'container-1',
|
||||
deviceIdentityId: 'identity-1',
|
||||
hostnameStrategy: 'qqnt-visible-hostname-v1',
|
||||
lastCheckEvidence: expect.objectContaining({
|
||||
dataDir: runtimeProfile.dataDir,
|
||||
machineInfoPath:
|
||||
'/vol1/docker/kt-qqbot/napcat-instances/pc-a1b2c3d4/QQ/nt_qq/global/nt_data/msf/machine-info',
|
||||
macAddress: '02:42:aa:bb:cc:dd',
|
||||
}),
|
||||
macStrategy: 'docker-bridge-mac-v1',
|
||||
profileStatus: 'pending',
|
||||
profileVersion: 'napcat-runtime-profile-v1',
|
||||
timezoneEvidence: {
|
||||
expectedTimezone: 'Asia/Shanghai',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(protocolProfileRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: 'account-1',
|
||||
containerId: 'container-1',
|
||||
napcatConfigHash: 'napcat-hash',
|
||||
onebotConfigHash: 'onebot-hash',
|
||||
profileStatus: 'pending',
|
||||
profileVersion: 'napcat-protocol-profile-v1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes account-level NapCat and OneBot configs with minimal reverse WS only', () => {
|
||||
const writer = new NapcatConfigWriterService(new ToolsService());
|
||||
const webuiAuthValue = 'KT_TEST_WEBUI_AUTH_VALUE';
|
||||
|
||||
@ -36,9 +36,33 @@ describe('RuntimeConfigService', () => {
|
||||
database: 'kt',
|
||||
username: 'admin',
|
||||
synchronize: true,
|
||||
timezone: '+08:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces DB_TIMEZONE in the safe runtime database profile and config checks', () => {
|
||||
const service = createService({
|
||||
DB_HOST: '127.0.0.1',
|
||||
DB_PORT: '3307',
|
||||
DB_DATABASE: 'kt',
|
||||
DB_USERNAME: 'admin',
|
||||
DB_TIMEZONE: 'Z',
|
||||
});
|
||||
|
||||
expect(service.readDatabaseProfile()).toEqual(
|
||||
expect.objectContaining({
|
||||
timezone: 'Z',
|
||||
}),
|
||||
);
|
||||
expect(service.getConfigChecks()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: 'DB_TIMEZONE',
|
||||
level: 'optional',
|
||||
present: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('masks secrets in checks and snapshots', () => {
|
||||
const service = createService({
|
||||
ADMIN_TOKEN_SECRET: 'abcdef123456',
|
||||
|
||||
@ -17,6 +17,7 @@ function createSnapshot(
|
||||
database: 'kt',
|
||||
username: 'root',
|
||||
synchronize: false,
|
||||
timezone: '+08:00',
|
||||
},
|
||||
loki: {
|
||||
transportEnabled: true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user