feat: 完善环境总览只读观测接口
This commit is contained in:
parent
2fdbbc210f
commit
7aacdbff4d
@ -125,6 +125,8 @@ ENV_DASHBOARD_MQTT_PASSWORD=
|
|||||||
ENV_DASHBOARD_MQTT_TOPIC_PREFIX=kt/env
|
ENV_DASHBOARD_MQTT_TOPIC_PREFIX=kt/env
|
||||||
ENV_DASHBOARD_SSE_REPLAY_LIMIT=200
|
ENV_DASHBOARD_SSE_REPLAY_LIMIT=200
|
||||||
ENV_DASHBOARD_SSE_HEARTBEAT_MS=25000
|
ENV_DASHBOARD_SSE_HEARTBEAT_MS=25000
|
||||||
|
ENV_DASHBOARD_ADMIN_LOCAL_URL=
|
||||||
|
ENV_DASHBOARD_ADMIN_PUBLIC_URL=
|
||||||
ENV_DASHBOARD_JENKINS_URL=
|
ENV_DASHBOARD_JENKINS_URL=
|
||||||
ENV_DASHBOARD_JENKINS_JOB=KT-Template/KT-Template-API/main
|
ENV_DASHBOARD_JENKINS_JOB=KT-Template/KT-Template-API/main
|
||||||
ENV_DASHBOARD_JENKINS_USERNAME=
|
ENV_DASHBOARD_JENKINS_USERNAME=
|
||||||
|
|||||||
2
API.md
2
API.md
@ -107,6 +107,8 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
|||||||
|
|
||||||
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。
|
真实密码、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 连接。
|
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 与基础后台
|
## Admin 与基础后台
|
||||||
|
|||||||
@ -74,7 +74,7 @@ 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`。
|
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 状态。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin;前端不直连 MQTT,也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。
|
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,也不做账号级每小时/每日累计发送预算。
|
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,也不做账号级每小时/每日累计发送预算。
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,10 @@ import { AdminTimezoneController } from '@/modules/admin/platform-config/timezon
|
|||||||
import { AdminTimezoneService } from '@/modules/admin/platform-config/timezone/admin-timezone.service';
|
import { AdminTimezoneService } from '@/modules/admin/platform-config/timezone/admin-timezone.service';
|
||||||
import { AdminUser } from '@/modules/admin/identity/user/admin-user.entity';
|
import { AdminUser } from '@/modules/admin/identity/user/admin-user.entity';
|
||||||
import { AssetModule } from '@/modules/asset/asset.module';
|
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 { EnvironmentDashboardService } from './environment-dashboard/application/environment-dashboard.service';
|
||||||
import { EnvironmentDashboardSelfCheckService } from './environment-dashboard/application/environment-dashboard-self-check.service';
|
import { EnvironmentDashboardSelfCheckService } from './environment-dashboard/application/environment-dashboard-self-check.service';
|
||||||
import { EnvironmentEventMaterializer } from './environment-dashboard/application/environment-event.materializer';
|
import { EnvironmentEventMaterializer } from './environment-dashboard/application/environment-event.materializer';
|
||||||
@ -26,6 +30,9 @@ import { KubernetesReadonlyAdapter } from './environment-dashboard/infrastructur
|
|||||||
import { MihomoReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/mihomo-readonly.adapter';
|
import { MihomoReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/mihomo-readonly.adapter';
|
||||||
import { TencentCloudReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/tencent-cloud-readonly.adapter';
|
import { TencentCloudReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/tencent-cloud-readonly.adapter';
|
||||||
import { WireguardReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/wireguard-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 { EnvironmentDashboardConfigService } from './environment-dashboard/infrastructure/environment-dashboard-config.service';
|
||||||
import { EnvironmentEventBusService } from './environment-dashboard/infrastructure/event/environment-event-bus.service';
|
import { EnvironmentEventBusService } from './environment-dashboard/infrastructure/event/environment-event-bus.service';
|
||||||
|
|
||||||
@ -52,7 +59,10 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
|
|||||||
AdminTimezoneService,
|
AdminTimezoneService,
|
||||||
EnvironmentDashboardService,
|
EnvironmentDashboardService,
|
||||||
EnvironmentDashboardSelfCheckService,
|
EnvironmentDashboardSelfCheckService,
|
||||||
|
EnvironmentDashboardCacheService,
|
||||||
EnvironmentDashboardConfigService,
|
EnvironmentDashboardConfigService,
|
||||||
|
LocalDevSignalCollector,
|
||||||
|
NasProdSignalCollector,
|
||||||
EnvironmentReadonlyHttpClient,
|
EnvironmentReadonlyHttpClient,
|
||||||
JenkinsReadonlyAdapter,
|
JenkinsReadonlyAdapter,
|
||||||
KubernetesReadonlyAdapter,
|
KubernetesReadonlyAdapter,
|
||||||
@ -72,6 +82,10 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
|
|||||||
DictModule,
|
DictModule,
|
||||||
NoticeModule,
|
NoticeModule,
|
||||||
AssetModule,
|
AssetModule,
|
||||||
|
RuntimeModule,
|
||||||
|
QqbotCoreModule,
|
||||||
|
QqbotPluginPlatformModule,
|
||||||
|
WordpressMirrorModule,
|
||||||
],
|
],
|
||||||
controllers: ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS,
|
controllers: ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS,
|
||||||
providers: ADMIN_PLATFORM_CONFIG_PROVIDERS,
|
providers: ADMIN_PLATFORM_CONFIG_PROVIDERS,
|
||||||
|
|||||||
@ -30,6 +30,6 @@ export class EnvironmentDashboardSelfCheckService {
|
|||||||
summary: '环境总览只读自检已触发',
|
summary: '环境总览只读自检已触发',
|
||||||
topic: 'kt/env/local-dev/self-check/result',
|
topic: 'kt/env/local-dev/self-check/result',
|
||||||
});
|
});
|
||||||
return this.dashboardService.getDashboard();
|
return this.dashboardService.getDashboard({ forceRefresh: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { getEnvironmentDashboardActions } from './environment-dashboard-action.catalog';
|
import { getEnvironmentDashboardActions } from './environment-dashboard-action.catalog';
|
||||||
import {
|
import {
|
||||||
countSignals,
|
countSignals,
|
||||||
@ -6,36 +6,87 @@ import {
|
|||||||
pickWorstHealthStatus,
|
pickWorstHealthStatus,
|
||||||
} from './environment-dashboard-status.mapper';
|
} from './environment-dashboard-status.mapper';
|
||||||
import { EnvironmentEventMaterializer } from './environment-event.materializer';
|
import { EnvironmentEventMaterializer } from './environment-event.materializer';
|
||||||
import { unwiredEvidence } from '../infrastructure/environment-dashboard-evidence.mapper';
|
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 {
|
import type {
|
||||||
EnvironmentDashboardResponse,
|
EnvironmentDashboardResponse,
|
||||||
EnvironmentHealthStatus,
|
|
||||||
EnvironmentNode,
|
EnvironmentNode,
|
||||||
EnvironmentService,
|
EnvironmentService,
|
||||||
|
EnvironmentSignal,
|
||||||
EnvironmentSite,
|
EnvironmentSite,
|
||||||
EnvironmentTopology,
|
EnvironmentTopology,
|
||||||
} from '../domain/environment-dashboard.types';
|
} from '../domain/environment-dashboard.types';
|
||||||
|
|
||||||
|
export interface EnvironmentDashboardSnapshotOptions {
|
||||||
|
forceRefresh?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EnvironmentDashboardService {
|
export class EnvironmentDashboardService {
|
||||||
/**
|
/**
|
||||||
* Initializes the dashboard snapshot service.
|
* Initializes the dashboard snapshot service.
|
||||||
* @param eventMaterializer - Recent-event materializer fed by local/MQTT event sources.
|
* @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(
|
constructor(
|
||||||
|
@Optional()
|
||||||
private readonly eventMaterializer: EnvironmentEventMaterializer,
|
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.
|
* 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.
|
* @returns Aggregate status tree, topology, readonly actions, and recent events.
|
||||||
*/
|
*/
|
||||||
async getDashboard(): Promise<EnvironmentDashboardResponse> {
|
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 generatedAt = new Date().toISOString();
|
||||||
const sites = this.createSites(generatedAt);
|
const sites = await this.createSites(generatedAt);
|
||||||
return {
|
return {
|
||||||
actions: getEnvironmentDashboardActions(),
|
actions: getEnvironmentDashboardActions(),
|
||||||
events: this.eventMaterializer.getRecentEvents(),
|
events: this.eventMaterializer?.getRecentEvents?.() || [],
|
||||||
generatedAt,
|
generatedAt,
|
||||||
refreshedAt: generatedAt,
|
refreshedAt: generatedAt,
|
||||||
sites,
|
sites,
|
||||||
@ -49,103 +100,165 @@ export class EnvironmentDashboardService {
|
|||||||
* @param observedAt - Snapshot timestamp shared by generated signals.
|
* @param observedAt - Snapshot timestamp shared by generated signals.
|
||||||
* @returns Four configured dashboard sites.
|
* @returns Four configured dashboard sites.
|
||||||
*/
|
*/
|
||||||
private createSites(observedAt: string): EnvironmentSite[] {
|
private async createSites(observedAt: string): Promise<EnvironmentSite[]> {
|
||||||
return [
|
return [
|
||||||
{
|
await this.localDevCollector.collect({ observedAt }),
|
||||||
id: 'local-dev',
|
await this.nasProdCollector.collect({ observedAt }),
|
||||||
label: 'Local Dev',
|
await this.createTencentCloudSite(),
|
||||||
nodes: [
|
await this.createR4seSite(),
|
||||||
this.createNode('local-dev-api', 'Local API', [
|
|
||||||
this.createService('local-api', 'API Runtime', [
|
|
||||||
{
|
|
||||||
evidence: [
|
|
||||||
{
|
|
||||||
observedAt,
|
|
||||||
source: 'runtime',
|
|
||||||
sourceKind: 'live',
|
|
||||||
summary: 'NestJS process answered dashboard request',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
id: 'local-api-process',
|
|
||||||
label: 'API Process',
|
|
||||||
observedAt,
|
|
||||||
sourceKind: 'live',
|
|
||||||
status: 'ok',
|
|
||||||
summary: 'API process is reachable',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
status: 'online',
|
|
||||||
summary: 'Local development runtime snapshot',
|
|
||||||
},
|
|
||||||
this.createSiteWithUnwiredSignals('nas-prod', 'NAS Production', [
|
|
||||||
['jenkins-build', 'Jenkins Build', ['ENV_DASHBOARD_JENKINS_URL']],
|
|
||||||
['k8s-deployment', 'K8s Deployment', ['ENV_DASHBOARD_K8S_API_SERVER']],
|
|
||||||
['qqbot-runtime', 'QQBot/NapCat', []],
|
|
||||||
['plugin-tasks', 'Plugin Tasks', []],
|
|
||||||
]),
|
|
||||||
this.createSiteWithUnwiredSignals('tencent-cloud', 'Tencent Cloud', [
|
|
||||||
['tencent-cvm', 'Tencent CVM', ['ENV_DASHBOARD_TENCENT_SECRET_ID']],
|
|
||||||
[
|
|
||||||
'caddy-public',
|
|
||||||
'Caddy Public Route',
|
|
||||||
['ENV_DASHBOARD_CADDY_PUBLIC_URL'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'tencent-wireguard',
|
|
||||||
'WireGuard',
|
|
||||||
['ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL'],
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
this.createSiteWithUnwiredSignals('r4se', 'r4se', [
|
|
||||||
[
|
|
||||||
'r4se-wireguard',
|
|
||||||
'WireGuard',
|
|
||||||
['ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL'],
|
|
||||||
],
|
|
||||||
['r4se-mihomo', 'Mihomo/OpenClash', ['ENV_DASHBOARD_R4SE_MIHOMO_URL']],
|
|
||||||
]),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a remote site whose first version starts from explicit unwired evidence.
|
* Creates Tencent Cloud site from CVM, Caddy, and WireGuard readonly evidence.
|
||||||
* @param id - Stable site id used by Admin selection and SSE events.
|
* @returns Tencent Cloud site with missing configuration surfaced as unwired signals.
|
||||||
* @param label - Human-readable site label.
|
|
||||||
* @param signals - Signal id, label, and missing configuration keys.
|
|
||||||
* @returns Site object with service-level status derived from child signals.
|
|
||||||
*/
|
*/
|
||||||
private createSiteWithUnwiredSignals(
|
private async createTencentCloudSite(): Promise<EnvironmentSite> {
|
||||||
id: string,
|
const services = [
|
||||||
label: string,
|
await this.createRemoteAdapterService(
|
||||||
signals: Array<[string, string, string[]]>,
|
'tencent-cvm',
|
||||||
): EnvironmentSite {
|
'Tencent Cloud CVM',
|
||||||
const node = this.createNode(
|
'tencent-cvm',
|
||||||
`${id}-node`,
|
'Tencent Cloud CVM',
|
||||||
label,
|
[
|
||||||
signals.map(([signalId, signalLabel, missingKeys]) =>
|
'ENV_DASHBOARD_TENCENT_SECRET_ID',
|
||||||
this.createService(signalId, signalLabel, [
|
'ENV_DASHBOARD_TENCENT_SECRET_KEY',
|
||||||
{
|
'ENV_DASHBOARD_TENCENT_REGION',
|
||||||
evidence: [unwiredEvidence(signalLabel, missingKeys)],
|
'ENV_DASHBOARD_TENCENT_INSTANCE_ID',
|
||||||
id: signalId,
|
],
|
||||||
label: signalLabel,
|
this.tencentAdapter,
|
||||||
sourceKind: missingKeys.length > 0 ? 'unwired' : 'derived',
|
|
||||||
status: missingKeys.length > 0 ? 'unwired' : 'unknown',
|
|
||||||
summary:
|
|
||||||
missingKeys.length > 0
|
|
||||||
? '只读观测配置未接入'
|
|
||||||
: '等待运行态事件或专项适配器接入',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
|
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 {
|
return {
|
||||||
id,
|
id: siteId,
|
||||||
label,
|
label: siteLabel,
|
||||||
nodes: [node],
|
nodes: [node],
|
||||||
status: mapSiteStatus(this.collectNodeStatuses([node])),
|
status: mapSiteStatus(services.map((service) => service.status)),
|
||||||
summary: `${label} readonly evidence snapshot`,
|
summary: `${siteLabel} readonly evidence snapshot`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,19 +303,6 @@ export class EnvironmentDashboardService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregates node statuses from nested service status.
|
|
||||||
* @param nodes - Nodes belonging to a site.
|
|
||||||
* @returns Flat list of service health statuses.
|
|
||||||
*/
|
|
||||||
private collectNodeStatuses(
|
|
||||||
nodes: EnvironmentNode[],
|
|
||||||
): EnvironmentHealthStatus[] {
|
|
||||||
return nodes.flatMap((node) =>
|
|
||||||
node.services.map((service) => service.status),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds summary counters used by status cards.
|
* Builds summary counters used by status cards.
|
||||||
* @param sites - Dashboard site tree.
|
* @param sites - Dashboard site tree.
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Optional } from '@nestjs/common';
|
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
import { EnvironmentDashboardCacheService } from '../infrastructure/environment-dashboard-cache.service';
|
||||||
import type {
|
import type {
|
||||||
EnvironmentEvent,
|
EnvironmentEvent,
|
||||||
EnvironmentEventEnvelope,
|
EnvironmentEventEnvelope,
|
||||||
@ -21,6 +22,7 @@ export class EnvironmentEventMaterializer {
|
|||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
@Optional()
|
@Optional()
|
||||||
|
@Inject(EnvironmentDashboardCacheService)
|
||||||
private readonly cache?: EnvironmentDashboardCacheInvalidator,
|
private readonly cache?: EnvironmentDashboardCacheInvalidator,
|
||||||
@Optional()
|
@Optional()
|
||||||
private readonly maxRecentEvents = 200,
|
private readonly maxRecentEvents = 200,
|
||||||
|
|||||||
@ -1,14 +1,29 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||||
import { createUnwiredAdapterSignal } from './environment-readonly-adapter.helpers';
|
import { EnvironmentReadonlyHttpClient } from './environment-readonly-http.client';
|
||||||
|
import {
|
||||||
|
createErrorAdapterSignal,
|
||||||
|
createLiveAdapterSignal,
|
||||||
|
createUnwiredAdapterSignal,
|
||||||
|
isReadonlyHttpOk,
|
||||||
|
joinReadonlyUrl,
|
||||||
|
} from './environment-readonly-adapter.helpers';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CaddyReadonlyAdapter {
|
export class CaddyReadonlyAdapter {
|
||||||
|
private readonly http: EnvironmentReadonlyHttpClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes Caddy readonly adapter.
|
* Initializes Caddy readonly adapter.
|
||||||
* @param config - Environment dashboard config reader.
|
* @param config - Environment dashboard config reader.
|
||||||
|
* @param http - Readonly HTTP client used for public/admin probes.
|
||||||
*/
|
*/
|
||||||
constructor(private readonly config: EnvironmentDashboardConfigService) {}
|
constructor(
|
||||||
|
private readonly config: EnvironmentDashboardConfigService,
|
||||||
|
@Optional() http?: EnvironmentReadonlyHttpClient,
|
||||||
|
) {
|
||||||
|
this.http = http || new EnvironmentReadonlyHttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspects Caddy readonly integration readiness.
|
* Inspects Caddy readonly integration readiness.
|
||||||
@ -16,10 +31,48 @@ export class CaddyReadonlyAdapter {
|
|||||||
*/
|
*/
|
||||||
async inspect() {
|
async inspect() {
|
||||||
const missing = this.config.missing(['ENV_DASHBOARD_CADDY_PUBLIC_URL']);
|
const missing = this.config.missing(['ENV_DASHBOARD_CADDY_PUBLIC_URL']);
|
||||||
return createUnwiredAdapterSignal(
|
if (missing.length > 0) {
|
||||||
'caddy-public',
|
return createUnwiredAdapterSignal(
|
||||||
'Caddy Public Route',
|
'caddy-public',
|
||||||
missing,
|
'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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
import { unwiredEvidence } from '../environment-dashboard-evidence.mapper';
|
import {
|
||||||
import type { EnvironmentSignal } from '../../domain/environment-dashboard.types';
|
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.
|
* Creates a standard unwired signal for readonly adapters without configuration.
|
||||||
@ -22,3 +29,142 @@ export function createUnwiredAdapterSignal(
|
|||||||
summary: '只读观测配置未接入',
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,14 +1,31 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||||
import { createUnwiredAdapterSignal } from './environment-readonly-adapter.helpers';
|
import { EnvironmentReadonlyHttpClient } from './environment-readonly-http.client';
|
||||||
|
import {
|
||||||
|
asNumber,
|
||||||
|
asString,
|
||||||
|
createErrorAdapterSignal,
|
||||||
|
createLiveAdapterSignal,
|
||||||
|
createUnwiredAdapterSignal,
|
||||||
|
joinReadonlyUrl,
|
||||||
|
parseJsonPreview,
|
||||||
|
} from './environment-readonly-adapter.helpers';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JenkinsReadonlyAdapter {
|
export class JenkinsReadonlyAdapter {
|
||||||
|
private readonly http: EnvironmentReadonlyHttpClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes Jenkins readonly adapter.
|
* Initializes Jenkins readonly adapter.
|
||||||
* @param config - Environment dashboard config reader.
|
* @param config - Environment dashboard config reader.
|
||||||
|
* @param http - Readonly HTTP client used for Jenkins JSON API probes.
|
||||||
*/
|
*/
|
||||||
constructor(private readonly config: EnvironmentDashboardConfigService) {}
|
constructor(
|
||||||
|
private readonly config: EnvironmentDashboardConfigService,
|
||||||
|
@Optional() http?: EnvironmentReadonlyHttpClient,
|
||||||
|
) {
|
||||||
|
this.http = http || new EnvironmentReadonlyHttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspects Jenkins readonly integration readiness.
|
* Inspects Jenkins readonly integration readiness.
|
||||||
@ -19,10 +36,78 @@ export class JenkinsReadonlyAdapter {
|
|||||||
'ENV_DASHBOARD_JENKINS_URL',
|
'ENV_DASHBOARD_JENKINS_URL',
|
||||||
'ENV_DASHBOARD_JENKINS_JOB',
|
'ENV_DASHBOARD_JENKINS_JOB',
|
||||||
]);
|
]);
|
||||||
return createUnwiredAdapterSignal(
|
if (missing.length > 0) {
|
||||||
'jenkins-build',
|
return createUnwiredAdapterSignal(
|
||||||
'Jenkins Build',
|
'jenkins-build',
|
||||||
missing,
|
'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',
|
||||||
|
)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,33 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||||
import { createUnwiredAdapterSignal } from './environment-readonly-adapter.helpers';
|
import { EnvironmentReadonlyHttpClient } from './environment-readonly-http.client';
|
||||||
|
import {
|
||||||
|
asArray,
|
||||||
|
asNumber,
|
||||||
|
asRecord,
|
||||||
|
createErrorAdapterSignal,
|
||||||
|
createLiveAdapterSignal,
|
||||||
|
createUnwiredAdapterSignal,
|
||||||
|
isReadonlyHttpOk,
|
||||||
|
joinReadonlyUrl,
|
||||||
|
parseJsonPreview,
|
||||||
|
} from './environment-readonly-adapter.helpers';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KubernetesReadonlyAdapter {
|
export class KubernetesReadonlyAdapter {
|
||||||
|
private readonly http: EnvironmentReadonlyHttpClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes Kubernetes readonly adapter.
|
* Initializes Kubernetes readonly adapter.
|
||||||
* @param config - Environment dashboard config reader.
|
* @param config - Environment dashboard config reader.
|
||||||
|
* @param http - Readonly HTTP client used for Kubernetes API probes.
|
||||||
*/
|
*/
|
||||||
constructor(private readonly config: EnvironmentDashboardConfigService) {}
|
constructor(
|
||||||
|
private readonly config: EnvironmentDashboardConfigService,
|
||||||
|
@Optional() http?: EnvironmentReadonlyHttpClient,
|
||||||
|
) {
|
||||||
|
this.http = http || new EnvironmentReadonlyHttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspects Kubernetes readonly integration readiness.
|
* Inspects Kubernetes readonly integration readiness.
|
||||||
@ -20,10 +39,158 @@ export class KubernetesReadonlyAdapter {
|
|||||||
'ENV_DASHBOARD_K8S_NAMESPACE',
|
'ENV_DASHBOARD_K8S_NAMESPACE',
|
||||||
'ENV_DASHBOARD_K8S_DEPLOYMENT',
|
'ENV_DASHBOARD_K8S_DEPLOYMENT',
|
||||||
]);
|
]);
|
||||||
return createUnwiredAdapterSignal(
|
if (missing.length > 0) {
|
||||||
'k8s-deployment',
|
return createUnwiredAdapterSignal(
|
||||||
'K8s Deployment',
|
'k8s-deployment',
|
||||||
missing,
|
'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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,33 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||||
import { createUnwiredAdapterSignal } from './environment-readonly-adapter.helpers';
|
import { EnvironmentReadonlyHttpClient } from './environment-readonly-http.client';
|
||||||
|
import {
|
||||||
|
asArray,
|
||||||
|
asRecord,
|
||||||
|
asString,
|
||||||
|
createErrorAdapterSignal,
|
||||||
|
createLiveAdapterSignal,
|
||||||
|
createUnwiredAdapterSignal,
|
||||||
|
isReadonlyHttpOk,
|
||||||
|
joinReadonlyUrl,
|
||||||
|
parseJsonPreview,
|
||||||
|
} from './environment-readonly-adapter.helpers';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MihomoReadonlyAdapter {
|
export class MihomoReadonlyAdapter {
|
||||||
|
private readonly http: EnvironmentReadonlyHttpClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes Mihomo/OpenClash readonly adapter.
|
* Initializes Mihomo/OpenClash readonly adapter.
|
||||||
* @param config - Environment dashboard config reader.
|
* @param config - Environment dashboard config reader.
|
||||||
|
* @param http - Readonly HTTP client used for Mihomo external controller probes.
|
||||||
*/
|
*/
|
||||||
constructor(private readonly config: EnvironmentDashboardConfigService) {}
|
constructor(
|
||||||
|
private readonly config: EnvironmentDashboardConfigService,
|
||||||
|
@Optional() http?: EnvironmentReadonlyHttpClient,
|
||||||
|
) {
|
||||||
|
this.http = http || new EnvironmentReadonlyHttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspects Mihomo/OpenClash readonly API readiness.
|
* Inspects Mihomo/OpenClash readonly API readiness.
|
||||||
@ -19,10 +38,94 @@ export class MihomoReadonlyAdapter {
|
|||||||
'ENV_DASHBOARD_R4SE_MIHOMO_URL',
|
'ENV_DASHBOARD_R4SE_MIHOMO_URL',
|
||||||
'ENV_DASHBOARD_R4SE_MIHOMO_SECRET',
|
'ENV_DASHBOARD_R4SE_MIHOMO_SECRET',
|
||||||
]);
|
]);
|
||||||
return createUnwiredAdapterSignal(
|
if (missing.length > 0) {
|
||||||
'r4se-mihomo',
|
return createUnwiredAdapterSignal(
|
||||||
'Mihomo/OpenClash',
|
'r4se-mihomo',
|
||||||
missing,
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,61 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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 { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||||
import { createUnwiredAdapterSignal } from './environment-readonly-adapter.helpers';
|
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()
|
@Injectable()
|
||||||
export class TencentCloudReadonlyAdapter {
|
export class TencentCloudReadonlyAdapter {
|
||||||
|
private readonly createClient: TencentCloudCvmClientFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes Tencent Cloud readonly adapter.
|
* Initializes Tencent Cloud readonly adapter.
|
||||||
* @param config - Environment dashboard config reader.
|
* @param config - Environment dashboard config reader.
|
||||||
|
* @param createClient - Optional factory used by tests to mock the Tencent SDK.
|
||||||
*/
|
*/
|
||||||
constructor(private readonly config: EnvironmentDashboardConfigService) {}
|
constructor(
|
||||||
|
private readonly config: EnvironmentDashboardConfigService,
|
||||||
|
@Optional() createClient?: TencentCloudCvmClientFactory,
|
||||||
|
) {
|
||||||
|
this.createClient = createClient || createTencentCloudCvmClient;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspects Tencent Cloud readonly integration readiness.
|
* Inspects Tencent Cloud readonly integration readiness.
|
||||||
@ -16,15 +63,90 @@ export class TencentCloudReadonlyAdapter {
|
|||||||
*/
|
*/
|
||||||
async inspect() {
|
async inspect() {
|
||||||
const missing = this.config.missing([
|
const missing = this.config.missing([
|
||||||
|
'ENV_DASHBOARD_TENCENT_CLOUD_ENABLED',
|
||||||
'ENV_DASHBOARD_TENCENT_SECRET_ID',
|
'ENV_DASHBOARD_TENCENT_SECRET_ID',
|
||||||
'ENV_DASHBOARD_TENCENT_SECRET_KEY',
|
'ENV_DASHBOARD_TENCENT_SECRET_KEY',
|
||||||
'ENV_DASHBOARD_TENCENT_REGION',
|
'ENV_DASHBOARD_TENCENT_REGION',
|
||||||
'ENV_DASHBOARD_TENCENT_INSTANCE_ID',
|
'ENV_DASHBOARD_TENCENT_INSTANCE_ID',
|
||||||
]);
|
]);
|
||||||
return createUnwiredAdapterSignal(
|
if (!this.isEnabled()) {
|
||||||
'tencent-cvm',
|
missing.unshift('ENV_DASHBOARD_TENCENT_CLOUD_ENABLED=true');
|
||||||
'Tencent Cloud CVM',
|
}
|
||||||
missing,
|
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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,28 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
|
||||||
import { createUnwiredAdapterSignal } from './environment-readonly-adapter.helpers';
|
import { EnvironmentReadonlyHttpClient } from './environment-readonly-http.client';
|
||||||
|
import {
|
||||||
|
createErrorAdapterSignal,
|
||||||
|
createLiveAdapterSignal,
|
||||||
|
createUnwiredAdapterSignal,
|
||||||
|
isReadonlyHttpOk,
|
||||||
|
} from './environment-readonly-adapter.helpers';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WireguardReadonlyAdapter {
|
export class WireguardReadonlyAdapter {
|
||||||
|
private readonly http: EnvironmentReadonlyHttpClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes WireGuard readonly adapter.
|
* Initializes WireGuard readonly adapter.
|
||||||
* @param config - Environment dashboard config reader.
|
* @param config - Environment dashboard config reader.
|
||||||
|
* @param http - Readonly HTTP client used for WireGuard health endpoints.
|
||||||
*/
|
*/
|
||||||
constructor(private readonly config: EnvironmentDashboardConfigService) {}
|
constructor(
|
||||||
|
private readonly config: EnvironmentDashboardConfigService,
|
||||||
|
@Optional() http?: EnvironmentReadonlyHttpClient,
|
||||||
|
) {
|
||||||
|
this.http = http || new EnvironmentReadonlyHttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspects WireGuard readonly health endpoint readiness.
|
* Inspects WireGuard readonly health endpoint readiness.
|
||||||
@ -19,6 +33,42 @@ export class WireguardReadonlyAdapter {
|
|||||||
'ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL',
|
'ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL',
|
||||||
'ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL',
|
'ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL',
|
||||||
]);
|
]);
|
||||||
return createUnwiredAdapterSignal('wireguard', 'WireGuard', missing);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -92,6 +92,7 @@ export const QQBOT_CORE_PROVIDERS = [
|
|||||||
export const QQBOT_CORE_EXPORTS = [
|
export const QQBOT_CORE_EXPORTS = [
|
||||||
QqbotAccountService,
|
QqbotAccountService,
|
||||||
QqbotConfigService,
|
QqbotConfigService,
|
||||||
|
QqbotDashboardService,
|
||||||
QqbotSendService,
|
QqbotSendService,
|
||||||
QqbotReverseWsService,
|
QqbotReverseWsService,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.serv
|
|||||||
QQBOT_PLUGIN_EXECUTION_PORT,
|
QQBOT_PLUGIN_EXECUTION_PORT,
|
||||||
QqbotPluginHttpClientService,
|
QqbotPluginHttpClientService,
|
||||||
QqbotPluginPlatformService,
|
QqbotPluginPlatformService,
|
||||||
|
QqbotPluginTaskService,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
|
|||||||
@ -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,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 { 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,7 +2,7 @@ import { EnvironmentDashboardService } from '../../../../src/modules/admin/platf
|
|||||||
import { EnvironmentEventMaterializer } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-event.materializer';
|
import { EnvironmentEventMaterializer } from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-event.materializer';
|
||||||
|
|
||||||
describe('EnvironmentDashboardService', () => {
|
describe('EnvironmentDashboardService', () => {
|
||||||
it('returns four sites with explicit unwired remote evidence', async () => {
|
it('returns four sites with explicit unwired remote evidence and full NAS topology', async () => {
|
||||||
const service = new EnvironmentDashboardService(
|
const service = new EnvironmentDashboardService(
|
||||||
new EnvironmentEventMaterializer(),
|
new EnvironmentEventMaterializer(),
|
||||||
);
|
);
|
||||||
@ -16,6 +16,25 @@ describe('EnvironmentDashboardService', () => {
|
|||||||
'r4se',
|
'r4se',
|
||||||
]);
|
]);
|
||||||
expect(dashboard.summary.totalSignals).toBeGreaterThan(0);
|
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(
|
expect(
|
||||||
dashboard.sites
|
dashboard.sites
|
||||||
.flatMap((site) => site.nodes)
|
.flatMap((site) => site.nodes)
|
||||||
|
|||||||
@ -4,8 +4,45 @@ import { KubernetesReadonlyAdapter } from '../../../../src/modules/admin/platfor
|
|||||||
import { MihomoReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/mihomo-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 { 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 { 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';
|
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', () => {
|
describe('environment remote readonly adapters', () => {
|
||||||
const config = new EnvironmentDashboardConfigService({});
|
const config = new EnvironmentDashboardConfigService({});
|
||||||
|
|
||||||
@ -40,4 +77,304 @@ describe('environment remote readonly adapters', () => {
|
|||||||
'ENV_DASHBOARD_R4SE_MIHOMO_SECRET',
|
'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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user