feat: 发布环境总览总控面板接口
This commit is contained in:
commit
da1ba28bf9
34
.env.example
34
.env.example
@ -116,6 +116,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
|
||||
|
||||
79
API.md
79
API.md
@ -76,24 +76,39 @@ 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` |
|
||||
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` |
|
||||
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 分组 | 关键变量 |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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` |
|
||||
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
|
||||
| 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 与基础后台
|
||||
@ -426,16 +441,16 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
|
||||
插件平台使用统一 `plugin.json` manifest 描述插件 key、版本、入口、操作、事件、定时任务、权限和运行预算;后端会校验路径必须留在插件包内,权限必须命中白名单,安装包 content hash 必须与 manifest 匹配。`tasks` 字段声明平台托管的定时任务:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| -------------- | ---------------------------------------------- |
|
||||
| `key` | 全局唯一任务 key,例如 `bangdream.bestdori.sync-main-data` |
|
||||
| `name` | Admin 展示名称 |
|
||||
| `handlerName` | 插件入口暴露的任务处理器名称 |
|
||||
| `defaultCron` | 5 段 cron 表达式,不允许每分钟执行,并按 BullMQ/cron-parser 语义校验 |
|
||||
| `timeoutMs` | 单次任务执行预算 |
|
||||
| `enabled` | 安装/启用时是否默认调度 |
|
||||
| `permissions` | 任务需要的插件权限,例如 `runtime.http`、`plugin.storage.write` |
|
||||
| `description` | 可选说明 |
|
||||
| 字段 | 说明 |
|
||||
| ------------- | -------------------------------------------------------------------- |
|
||||
| `key` | 全局唯一任务 key,例如 `bangdream.bestdori.sync-main-data` |
|
||||
| `name` | Admin 展示名称 |
|
||||
| `handlerName` | 插件入口暴露的任务处理器名称 |
|
||||
| `defaultCron` | 5 段 cron 表达式,不允许每分钟执行,并按 BullMQ/cron-parser 语义校验 |
|
||||
| `timeoutMs` | 单次任务执行预算 |
|
||||
| `enabled` | 安装/启用时是否默认调度 |
|
||||
| `permissions` | 任务需要的插件权限,例如 `runtime.http`、`plugin.storage.write` |
|
||||
| `description` | 可选说明 |
|
||||
|
||||
CLI 入口:
|
||||
|
||||
@ -465,15 +480,15 @@ 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 |
|
||||
| `POST` | `/qqbot/plugin-platform/tasks/:id/disable` | 停用任务并移除调度 |
|
||||
| `POST` | `/qqbot/plugin-platform/tasks/:id/cron` | 修改 5 段 cron,校验通过后重建调度 |
|
||||
| `POST` | `/qqbot/plugin-platform/tasks/:id/run` | 手动提交一次任务 |
|
||||
| `GET` | `/qqbot/plugin-platform/tasks/:id/runs` | 任务运行记录分页 |
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ------------------------------------------ | ------------------------------------ |
|
||||
| `GET` | `/qqbot/plugin-platform/tasks/page` | 插件定时任务分页,支持插件、状态过滤 |
|
||||
| `GET` | `/qqbot/plugin-platform/tasks/:id` | 任务详情 |
|
||||
| `POST` | `/qqbot/plugin-platform/tasks/:id/enable` | 启用任务并注册 BullMQ Job Scheduler |
|
||||
| `POST` | `/qqbot/plugin-platform/tasks/:id/disable` | 停用任务并移除调度 |
|
||||
| `POST` | `/qqbot/plugin-platform/tasks/:id/cron` | 修改 5 段 cron,校验通过后重建调度 |
|
||||
| `POST` | `/qqbot/plugin-platform/tasks/:id/run` | 手动提交一次任务 |
|
||||
| `GET` | `/qqbot/plugin-platform/tasks/:id/runs` | 任务运行记录分页 |
|
||||
|
||||
`src/modules/qqbot/plugin-platform/runtime` 当前提供 host-side driver 边界和超时/崩溃事件归档;实际 worker/child-process driver 可以在后续批次接入,但插件侧只能通过受控 SDK 访问发送队列、配置、存储、HTTP、资产和事件上下文。
|
||||
|
||||
|
||||
51
README.md
51
README.md
@ -16,18 +16,18 @@
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 说明 |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
|
||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||
| 模块 | 说明 |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志、环境总览面板 |
|
||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 |
|
||||
| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||
| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
||||
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
||||
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
|
||||
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
|
||||
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 |
|
||||
| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||
| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
||||
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
||||
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
|
||||
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
@ -55,17 +55,18 @@ 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_*` |
|
||||
| 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` |
|
||||
| 分组 | 变量 |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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` |
|
||||
|
||||
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
|
||||
|
||||
@ -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,也不做账号级每小时/每日累计发送预算。
|
||||
|
||||
## 启动
|
||||
@ -201,6 +204,6 @@ BangDream 图片能力改动:
|
||||
|
||||
## 来源与许可证
|
||||
|
||||
| 一级来源 | 使用方式 | License |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------- |
|
||||
| 一级来源 | 使用方式 | License |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------- |
|
||||
| [Tsugu BangDream Bot](https://github.com/Yamamoto-2/tsugu-bangdream-bot) | BangDream QQBot 后端能力已重构合入 `src/modules/qqbot/plugins/bangdream/src`,保留本地 `TSUGU-LICENSE` | MIT |
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -43,6 +43,7 @@ import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.serv
|
||||
QQBOT_PLUGIN_EXECUTION_PORT,
|
||||
QqbotPluginHttpClientService,
|
||||
QqbotPluginPlatformService,
|
||||
QqbotPluginTaskService,
|
||||
],
|
||||
imports: [
|
||||
ConfigModule,
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user