diff --git a/.env.example b/.env.example index 93df9ca..c10e0d8 100644 --- a/.env.example +++ b/.env.example @@ -125,6 +125,8 @@ ENV_DASHBOARD_MQTT_PASSWORD= ENV_DASHBOARD_MQTT_TOPIC_PREFIX=kt/env ENV_DASHBOARD_SSE_REPLAY_LIMIT=200 ENV_DASHBOARD_SSE_HEARTBEAT_MS=25000 +ENV_DASHBOARD_ADMIN_LOCAL_URL= +ENV_DASHBOARD_ADMIN_PUBLIC_URL= ENV_DASHBOARD_JENKINS_URL= ENV_DASHBOARD_JENKINS_JOB=KT-Template/KT-Template-API/main ENV_DASHBOARD_JENKINS_USERNAME= diff --git a/API.md b/API.md index 5daec2c..18e73b0 100644 --- a/API.md +++ b/API.md @@ -107,6 +107,8 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 真实密码、Token、OAuth secret 和生产 env 不提交到 Git。 +Env Dashboard 的 `ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只作为 Admin 入口展示证据;Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 仍通过对应 `ENV_DASHBOARD_*` 只读配置接入,缺失配置必须返回 `unwired` 证据。 + QQBot 插件 worker 队列依赖 Redis。K8s 生产清单提供内部 Redis Service `kt-qqbot-plugin-redis:6379`,用于 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` / `QQBOT_PLUGIN_QUEUE_REDIS_PORT`。`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS` 控制串行队列的排队等待窗口,避免排队时间挤占插件操作本身的 `timeoutMs` 执行预算。插件定时任务可通过 `QQBOT_PLUGIN_TASK_QUEUE_REDIS_*` 使用独立 BullMQ prefix;未配置 host 时复用插件 worker Redis 连接。 ## Admin 与基础后台 diff --git a/README.md b/README.md index a501df9..35bcf2c 100644 --- a/README.md +++ b/README.md @@ -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`。 -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,也不做账号级每小时/每日累计发送预算。 diff --git a/src/modules/admin/platform-config/admin-platform-config.module.ts b/src/modules/admin/platform-config/admin-platform-config.module.ts index 241802c..349c907 100644 --- a/src/modules/admin/platform-config/admin-platform-config.module.ts +++ b/src/modules/admin/platform-config/admin-platform-config.module.ts @@ -14,6 +14,10 @@ import { AdminTimezoneController } from '@/modules/admin/platform-config/timezon import { AdminTimezoneService } from '@/modules/admin/platform-config/timezone/admin-timezone.service'; import { AdminUser } from '@/modules/admin/identity/user/admin-user.entity'; import { AssetModule } from '@/modules/asset/asset.module'; +import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module'; +import { QqbotPluginPlatformModule } from '@/modules/qqbot/plugin-platform/plugin-platform.module'; +import { RuntimeModule } from '@/runtime/runtime.module'; +import { WordpressMirrorModule } from '@/modules/wordpress/wordpress-mirror.module'; import { EnvironmentDashboardService } from './environment-dashboard/application/environment-dashboard.service'; import { EnvironmentDashboardSelfCheckService } from './environment-dashboard/application/environment-dashboard-self-check.service'; import { EnvironmentEventMaterializer } from './environment-dashboard/application/environment-event.materializer'; @@ -26,6 +30,9 @@ import { KubernetesReadonlyAdapter } from './environment-dashboard/infrastructur import { MihomoReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/mihomo-readonly.adapter'; import { TencentCloudReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/tencent-cloud-readonly.adapter'; import { WireguardReadonlyAdapter } from './environment-dashboard/infrastructure/adapters/wireguard-readonly.adapter'; +import { LocalDevSignalCollector } from './environment-dashboard/infrastructure/collectors/local-dev-signal.collector'; +import { NasProdSignalCollector } from './environment-dashboard/infrastructure/collectors/nas-prod-signal.collector'; +import { EnvironmentDashboardCacheService } from './environment-dashboard/infrastructure/environment-dashboard-cache.service'; import { EnvironmentDashboardConfigService } from './environment-dashboard/infrastructure/environment-dashboard-config.service'; import { EnvironmentEventBusService } from './environment-dashboard/infrastructure/event/environment-event-bus.service'; @@ -52,7 +59,10 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [ AdminTimezoneService, EnvironmentDashboardService, EnvironmentDashboardSelfCheckService, + EnvironmentDashboardCacheService, EnvironmentDashboardConfigService, + LocalDevSignalCollector, + NasProdSignalCollector, EnvironmentReadonlyHttpClient, JenkinsReadonlyAdapter, KubernetesReadonlyAdapter, @@ -72,6 +82,10 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [ DictModule, NoticeModule, AssetModule, + RuntimeModule, + QqbotCoreModule, + QqbotPluginPlatformModule, + WordpressMirrorModule, ], controllers: ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS, providers: ADMIN_PLATFORM_CONFIG_PROVIDERS, diff --git a/src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard-self-check.service.ts b/src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard-self-check.service.ts index b5313e9..25e639e 100644 --- a/src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard-self-check.service.ts +++ b/src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard-self-check.service.ts @@ -30,6 +30,6 @@ export class EnvironmentDashboardSelfCheckService { summary: '环境总览只读自检已触发', topic: 'kt/env/local-dev/self-check/result', }); - return this.dashboardService.getDashboard(); + return this.dashboardService.getDashboard({ forceRefresh: true }); } } diff --git a/src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard.service.ts b/src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard.service.ts index 636172a..15d67d0 100644 --- a/src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard.service.ts +++ b/src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; import { getEnvironmentDashboardActions } from './environment-dashboard-action.catalog'; import { countSignals, @@ -6,36 +6,87 @@ import { pickWorstHealthStatus, } from './environment-dashboard-status.mapper'; 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 { EnvironmentDashboardResponse, - EnvironmentHealthStatus, EnvironmentNode, EnvironmentService, + EnvironmentSignal, EnvironmentSite, EnvironmentTopology, } from '../domain/environment-dashboard.types'; +export interface EnvironmentDashboardSnapshotOptions { + forceRefresh?: boolean; +} + @Injectable() export class EnvironmentDashboardService { /** * Initializes the dashboard snapshot service. * @param eventMaterializer - Recent-event materializer fed by local/MQTT event sources. + * @param cache - Short-lived dashboard cache invalidated by fresh backend events. + * @param localDevCollector - Collector for local development API/Admin state. + * @param nasProdCollector - Collector for NAS production internal service state. + * @param tencentAdapter - Tencent Cloud readonly adapter for CVM evidence. + * @param caddyAdapter - Caddy readonly adapter for public route evidence. + * @param wireguardAdapter - WireGuard readonly adapter for Tencent/r4se reachability. + * @param mihomoAdapter - Mihomo/OpenClash readonly adapter for r4se evidence. + * @param config - Dashboard config reader used for explicit missing-key evidence. */ constructor( + @Optional() private readonly eventMaterializer: EnvironmentEventMaterializer, + @Optional() + private readonly cache: EnvironmentDashboardCacheService = new EnvironmentDashboardCacheService(), + @Optional() + private readonly localDevCollector: LocalDevSignalCollector = new LocalDevSignalCollector(), + @Optional() + private readonly nasProdCollector: NasProdSignalCollector = new NasProdSignalCollector(), + @Optional() + private readonly tencentAdapter?: TencentCloudReadonlyAdapter, + @Optional() + private readonly caddyAdapter?: CaddyReadonlyAdapter, + @Optional() + private readonly wireguardAdapter?: WireguardReadonlyAdapter, + @Optional() + private readonly mihomoAdapter?: MihomoReadonlyAdapter, + @Optional() + private readonly config: EnvironmentDashboardConfigService = new EnvironmentDashboardConfigService(), ) {} /** * Builds the current environment dashboard snapshot for Admin. + * @param options - Snapshot control from Admin refresh/self-check; self-check sets `forceRefresh`. * @returns Aggregate status tree, topology, readonly actions, and recent events. */ - async getDashboard(): Promise { + async getDashboard( + options: EnvironmentDashboardSnapshotOptions = {}, + ): Promise { + 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 { const generatedAt = new Date().toISOString(); - const sites = this.createSites(generatedAt); + const sites = await this.createSites(generatedAt); return { actions: getEnvironmentDashboardActions(), - events: this.eventMaterializer.getRecentEvents(), + events: this.eventMaterializer?.getRecentEvents?.() || [], generatedAt, refreshedAt: generatedAt, sites, @@ -49,103 +100,165 @@ export class EnvironmentDashboardService { * @param observedAt - Snapshot timestamp shared by generated signals. * @returns Four configured dashboard sites. */ - private createSites(observedAt: string): EnvironmentSite[] { + private async createSites(observedAt: string): Promise { return [ - { - id: 'local-dev', - label: 'Local Dev', - nodes: [ - 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']], - ]), + await this.localDevCollector.collect({ observedAt }), + await this.nasProdCollector.collect({ observedAt }), + await this.createTencentCloudSite(), + await this.createR4seSite(), ]; } /** - * Creates a remote site whose first version starts from explicit unwired evidence. - * @param id - Stable site id used by Admin selection and SSE events. - * @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. + * Creates Tencent Cloud site from CVM, Caddy, and WireGuard readonly evidence. + * @returns Tencent Cloud site with missing configuration surfaced as unwired signals. */ - private createSiteWithUnwiredSignals( - id: string, - label: string, - signals: Array<[string, string, string[]]>, - ): EnvironmentSite { - const node = this.createNode( - `${id}-node`, - label, - signals.map(([signalId, signalLabel, missingKeys]) => - this.createService(signalId, signalLabel, [ - { - evidence: [unwiredEvidence(signalLabel, missingKeys)], - id: signalId, - label: signalLabel, - sourceKind: missingKeys.length > 0 ? 'unwired' : 'derived', - status: missingKeys.length > 0 ? 'unwired' : 'unknown', - summary: - missingKeys.length > 0 - ? '只读观测配置未接入' - : '等待运行态事件或专项适配器接入', - }, - ]), + private async createTencentCloudSite(): Promise { + const services = [ + await this.createRemoteAdapterService( + 'tencent-cvm', + 'Tencent Cloud CVM', + 'tencent-cvm', + 'Tencent Cloud CVM', + [ + 'ENV_DASHBOARD_TENCENT_SECRET_ID', + 'ENV_DASHBOARD_TENCENT_SECRET_KEY', + 'ENV_DASHBOARD_TENCENT_REGION', + 'ENV_DASHBOARD_TENCENT_INSTANCE_ID', + ], + this.tencentAdapter, ), + await this.createRemoteAdapterService( + 'caddy-public', + 'Caddy Public Route', + 'caddy-public', + 'Caddy Public Route', + ['ENV_DASHBOARD_CADDY_PUBLIC_URL'], + this.caddyAdapter, + ), + await this.createRemoteAdapterService( + 'tencent-wireguard', + 'WireGuard', + 'tencent-wireguard', + 'Tencent WireGuard', + ['ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL'], + this.wireguardAdapter, + ), + ]; + return this.createSiteFromServices( + 'tencent-cloud', + 'Tencent Cloud', + 'Tencent Cloud Node', + services, ); + } + + /** + * Creates r4se site from WireGuard and Mihomo/OpenClash readonly evidence. + * @returns r4se site with missing configuration surfaced as unwired signals. + */ + private async createR4seSite(): Promise { + 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> }, + ): Promise { + const missing = this.config.missing(requiredKeys); + if (missing.length > 0 || !adapter) { + return this.createService(serviceId, serviceLabel, [ + { + evidence: [unwiredEvidence(signalLabel, missing)], + id: signalId, + label: signalLabel, + sourceKind: 'unwired', + status: 'unwired', + summary: '只读观测配置未接入', + }, + ]); + } + + try { + const signal = await adapter.inspect(); + return this.createService(serviceId, serviceLabel, [ + { + evidence: signal.evidence || [], + id: signal.id || signalId, + label: signal.label || signalLabel, + observedAt: signal.observedAt, + sourceKind: signal.sourceKind || 'live', + status: signal.status || 'unknown', + summary: signal.summary || '只读观测已返回信号', + }, + ]); + } catch (error) { + return this.createService(serviceId, serviceLabel, [ + { + evidence: [errorEvidence(signalLabel, error)], + id: signalId, + label: signalLabel, + sourceKind: 'derived', + status: 'down', + summary: '只读观测失败', + }, + ]); + } + } + + /** + * Creates a site wrapper around a single environment node. + * @param siteId - Stable site id used by Admin selection and SSE events. + * @param siteLabel - Operator-facing site label. + * @param nodeLabel - Operator-facing node label. + * @param services - Services collected for the site. + * @returns Site object with status derived from child services. + */ + private createSiteFromServices( + siteId: string, + siteLabel: string, + nodeLabel: string, + services: EnvironmentService[], + ): EnvironmentSite { + const node = this.createNode(`${siteId}-node`, nodeLabel, services); return { - id, - label, + id: siteId, + label: siteLabel, nodes: [node], - status: mapSiteStatus(this.collectNodeStatuses([node])), - summary: `${label} readonly evidence snapshot`, + status: mapSiteStatus(services.map((service) => service.status)), + 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. * @param sites - Dashboard site tree. diff --git a/src/modules/admin/platform-config/environment-dashboard/application/environment-event.materializer.ts b/src/modules/admin/platform-config/environment-dashboard/application/environment-event.materializer.ts index aca4453..5bdee3f 100644 --- a/src/modules/admin/platform-config/environment-dashboard/application/environment-event.materializer.ts +++ b/src/modules/admin/platform-config/environment-dashboard/application/environment-event.materializer.ts @@ -1,5 +1,6 @@ -import { Injectable, Optional } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import { Subject } from 'rxjs'; +import { EnvironmentDashboardCacheService } from '../infrastructure/environment-dashboard-cache.service'; import type { EnvironmentEvent, EnvironmentEventEnvelope, @@ -21,6 +22,7 @@ export class EnvironmentEventMaterializer { */ constructor( @Optional() + @Inject(EnvironmentDashboardCacheService) private readonly cache?: EnvironmentDashboardCacheInvalidator, @Optional() private readonly maxRecentEvents = 200, diff --git a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/caddy-readonly.adapter.ts b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/caddy-readonly.adapter.ts index ffaf8c5..cf2d2df 100644 --- a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/caddy-readonly.adapter.ts +++ b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/caddy-readonly.adapter.ts @@ -1,14 +1,29 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; 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() export class CaddyReadonlyAdapter { + private readonly http: EnvironmentReadonlyHttpClient; + /** * Initializes Caddy readonly adapter. * @param config - Environment dashboard config reader. + * @param http - Readonly HTTP client used for public/admin probes. */ - constructor(private readonly config: EnvironmentDashboardConfigService) {} + constructor( + private readonly config: EnvironmentDashboardConfigService, + @Optional() http?: EnvironmentReadonlyHttpClient, + ) { + this.http = http || new EnvironmentReadonlyHttpClient(); + } /** * Inspects Caddy readonly integration readiness. @@ -16,10 +31,48 @@ export class CaddyReadonlyAdapter { */ async inspect() { const missing = this.config.missing(['ENV_DASHBOARD_CADDY_PUBLIC_URL']); - return createUnwiredAdapterSignal( - 'caddy-public', - 'Caddy Public Route', - missing, - ); + if (missing.length > 0) { + return createUnwiredAdapterSignal( + 'caddy-public', + 'Caddy Public Route', + missing, + ); + } + + try { + const publicResponse = await this.http.head( + this.config.get('ENV_DASHBOARD_CADDY_PUBLIC_URL'), + ); + const adminUrl = this.config.get('ENV_DASHBOARD_CADDY_ADMIN_URL'); + const adminResponse = adminUrl + ? await this.http.get(joinReadonlyUrl(adminUrl, 'config/')) + : undefined; + const publicOk = isReadonlyHttpOk(publicResponse.status); + const adminOk = + !adminResponse || isReadonlyHttpOk(adminResponse.status); + const status = publicOk && adminOk ? 'ok' : 'degraded'; + const summary = `Caddy public ${publicResponse.status}${ + adminResponse ? `, admin ${adminResponse.status}` : '' + }`; + + return createLiveAdapterSignal( + 'caddy-public', + 'Caddy Public Route', + summary, + { + adminConfigured: Boolean(adminResponse), + adminStatus: adminResponse?.status, + publicStatus: publicResponse.status, + }, + status, + publicResponse.observedAt, + ); + } catch (error) { + return createErrorAdapterSignal( + 'caddy-public', + 'Caddy Public Route', + error, + ); + } } } diff --git a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/environment-readonly-adapter.helpers.ts b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/environment-readonly-adapter.helpers.ts index 0a1019f..6900cd7 100644 --- a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/environment-readonly-adapter.helpers.ts +++ b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/environment-readonly-adapter.helpers.ts @@ -1,5 +1,12 @@ -import { unwiredEvidence } from '../environment-dashboard-evidence.mapper'; -import type { EnvironmentSignal } from '../../domain/environment-dashboard.types'; +import { + errorEvidence, + liveEvidence, + unwiredEvidence, +} from '../environment-dashboard-evidence.mapper'; +import type { + EnvironmentHealthStatus, + EnvironmentSignal, +} from '../../domain/environment-dashboard.types'; /** * Creates a standard unwired signal for readonly adapters without configuration. @@ -22,3 +29,142 @@ export function createUnwiredAdapterSignal( 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 = {}, + 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 { + 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 | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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; +} diff --git a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/jenkins-readonly.adapter.ts b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/jenkins-readonly.adapter.ts index 2c3dec8..939a80d 100644 --- a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/jenkins-readonly.adapter.ts +++ b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/jenkins-readonly.adapter.ts @@ -1,14 +1,31 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; 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() export class JenkinsReadonlyAdapter { + private readonly http: EnvironmentReadonlyHttpClient; + /** * Initializes Jenkins readonly adapter. * @param config - Environment dashboard config reader. + * @param http - Readonly HTTP client used for Jenkins JSON API probes. */ - constructor(private readonly config: EnvironmentDashboardConfigService) {} + constructor( + private readonly config: EnvironmentDashboardConfigService, + @Optional() http?: EnvironmentReadonlyHttpClient, + ) { + this.http = http || new EnvironmentReadonlyHttpClient(); + } /** * Inspects Jenkins readonly integration readiness. @@ -19,10 +36,78 @@ export class JenkinsReadonlyAdapter { 'ENV_DASHBOARD_JENKINS_URL', 'ENV_DASHBOARD_JENKINS_JOB', ]); - return createUnwiredAdapterSignal( - 'jenkins-build', - 'Jenkins Build', - missing, + if (missing.length > 0) { + return createUnwiredAdapterSignal( + 'jenkins-build', + 'Jenkins Build', + missing, + ); + } + + try { + const response = await this.http.get(this.buildLastBuildUrl(), { + headers: this.createAuthHeaders(), + }); + const body = parseJsonPreview(response.bodyPreview); + const buildNumber = asNumber(body.number); + const durationMs = asNumber(body.duration); + const building = body.building === true; + const result = building ? 'BUILDING' : asString(body.result) || 'UNKNOWN'; + const status = !building && result === 'SUCCESS' ? 'ok' : 'degraded'; + const summary = `Jenkins last build ${buildNumber ? `#${buildNumber} ` : ''}${result}`; + + return createLiveAdapterSignal( + 'jenkins-build', + 'Jenkins Build', + summary, + { + buildNumber, + building, + durationMs, + httpStatus: response.status, + result, + }, + status, + response.observedAt, + ); + } catch (error) { + return createErrorAdapterSignal( + 'jenkins-build', + 'Jenkins Build', + error, + ); + } + } + + /** + * Builds Jenkins last-build JSON API URL from folder-style job names. + * @returns Jenkins readonly JSON API URL for the configured job. + */ + private buildLastBuildUrl(): string { + const jobPath = this.config + .get('ENV_DASHBOARD_JENKINS_JOB') + .split('/') + .filter(Boolean) + .map((segment) => `job/${encodeURIComponent(segment)}`) + .join('/'); + return joinReadonlyUrl( + this.config.get('ENV_DASHBOARD_JENKINS_URL'), + `${jobPath}/lastBuild/api/json`, ); } + + /** + * Creates optional Jenkins basic-auth headers without returning token values as evidence. + * @returns Headers for outbound Jenkins API request, or undefined when credentials are absent. + */ + private createAuthHeaders(): Record | 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', + )}`, + }; + } } diff --git a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/kubernetes-readonly.adapter.ts b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/kubernetes-readonly.adapter.ts index 917e48d..27fb84e 100644 --- a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/kubernetes-readonly.adapter.ts +++ b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/kubernetes-readonly.adapter.ts @@ -1,14 +1,33 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; 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() export class KubernetesReadonlyAdapter { + private readonly http: EnvironmentReadonlyHttpClient; + /** * Initializes Kubernetes readonly adapter. * @param config - Environment dashboard config reader. + * @param http - Readonly HTTP client used for Kubernetes API probes. */ - constructor(private readonly config: EnvironmentDashboardConfigService) {} + constructor( + private readonly config: EnvironmentDashboardConfigService, + @Optional() http?: EnvironmentReadonlyHttpClient, + ) { + this.http = http || new EnvironmentReadonlyHttpClient(); + } /** * Inspects Kubernetes readonly integration readiness. @@ -20,10 +39,158 @@ export class KubernetesReadonlyAdapter { 'ENV_DASHBOARD_K8S_NAMESPACE', 'ENV_DASHBOARD_K8S_DEPLOYMENT', ]); - return createUnwiredAdapterSignal( - 'k8s-deployment', - 'K8s Deployment', - missing, + if (missing.length > 0) { + return createUnwiredAdapterSignal( + 'k8s-deployment', + 'K8s Deployment', + missing, + ); + } + + try { + const headers = this.createAuthHeaders(); + const deploymentResponse = await this.http.get(this.deploymentUrl(), { + headers, + }); + const podsResponse = await this.http.get(this.podsUrl(), { + headers, + params: this.podsParams(), + }); + const deployment = this.extractDeploymentReadiness( + parseJsonPreview(deploymentResponse.bodyPreview), + ); + const pods = this.extractPodReadiness( + parseJsonPreview(podsResponse.bodyPreview), + ); + const httpOk = + isReadonlyHttpOk(deploymentResponse.status) && + isReadonlyHttpOk(podsResponse.status); + const replicasReady = + deployment.desiredReplicas === 0 || + (deployment.readyReplicas >= deployment.desiredReplicas && + deployment.updatedReplicas >= deployment.desiredReplicas && + deployment.availableReplicas >= deployment.desiredReplicas); + const status = httpOk && replicasReady ? 'ok' : 'degraded'; + const summary = `K8s deployment ready ${deployment.readyReplicas}/${deployment.desiredReplicas}, pods ${pods.podReadyCount}/${pods.podCount}`; + + return createLiveAdapterSignal( + 'k8s-deployment', + 'K8s Deployment', + summary, + { + availableReplicas: deployment.availableReplicas, + deploymentHttpStatus: deploymentResponse.status, + desiredReplicas: deployment.desiredReplicas, + labelSelector: this.config.get('ENV_DASHBOARD_K8S_LABEL_SELECTOR'), + podCount: pods.podCount, + podHttpStatus: podsResponse.status, + podReadyCount: pods.podReadyCount, + podRunningCount: pods.podRunningCount, + readyReplicas: deployment.readyReplicas, + updatedReplicas: deployment.updatedReplicas, + }, + status, + deploymentResponse.observedAt, + ); + } catch (error) { + return createErrorAdapterSignal( + 'k8s-deployment', + 'K8s Deployment', + error, + ); + } + } + + /** + * Builds Kubernetes deployment API URL for the configured namespace and deployment. + * @returns Readonly apps/v1 deployment endpoint. + */ + private deploymentUrl(): string { + const namespace = encodeURIComponent( + this.config.get('ENV_DASHBOARD_K8S_NAMESPACE'), + ); + const deployment = encodeURIComponent( + this.config.get('ENV_DASHBOARD_K8S_DEPLOYMENT'), + ); + return joinReadonlyUrl( + this.config.get('ENV_DASHBOARD_K8S_API_SERVER'), + `/apis/apps/v1/namespaces/${namespace}/deployments/${deployment}`, ); } + + /** + * Builds Kubernetes pods API URL for the configured namespace. + * @returns Readonly core/v1 pods endpoint. + */ + private podsUrl(): string { + const namespace = encodeURIComponent( + this.config.get('ENV_DASHBOARD_K8S_NAMESPACE'), + ); + return joinReadonlyUrl( + this.config.get('ENV_DASHBOARD_K8S_API_SERVER'), + `/api/v1/namespaces/${namespace}/pods`, + ); + } + + /** + * Creates optional Kubernetes bearer-auth headers without exposing token values. + * @returns Headers for outbound Kubernetes API request, or undefined when token is absent. + */ + private createAuthHeaders(): Record | 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 | 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) { + 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) { + 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, + }; + } } diff --git a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/mihomo-readonly.adapter.ts b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/mihomo-readonly.adapter.ts index 2e44c17..f65e6c9 100644 --- a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/mihomo-readonly.adapter.ts +++ b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/mihomo-readonly.adapter.ts @@ -1,14 +1,33 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; 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() export class MihomoReadonlyAdapter { + private readonly http: EnvironmentReadonlyHttpClient; + /** * Initializes Mihomo/OpenClash readonly adapter. * @param config - Environment dashboard config reader. + * @param http - Readonly HTTP client used for Mihomo external controller probes. */ - constructor(private readonly config: EnvironmentDashboardConfigService) {} + constructor( + private readonly config: EnvironmentDashboardConfigService, + @Optional() http?: EnvironmentReadonlyHttpClient, + ) { + this.http = http || new EnvironmentReadonlyHttpClient(); + } /** * Inspects Mihomo/OpenClash readonly API readiness. @@ -19,10 +38,94 @@ export class MihomoReadonlyAdapter { 'ENV_DASHBOARD_R4SE_MIHOMO_URL', 'ENV_DASHBOARD_R4SE_MIHOMO_SECRET', ]); - return createUnwiredAdapterSignal( - 'r4se-mihomo', - 'Mihomo/OpenClash', - missing, + if (missing.length > 0) { + return createUnwiredAdapterSignal( + 'r4se-mihomo', + 'Mihomo/OpenClash', + missing, + ); + } + + try { + const headers = this.authHeaders(); + const versionResponse = await this.http.get(this.apiUrl('version'), { + headers, + }); + const configsResponse = await this.http.get(this.apiUrl('configs'), { + headers, + }); + const proxiesResponse = await this.http.get(this.apiUrl('proxies'), { + headers, + }); + const version = parseJsonPreview(versionResponse.bodyPreview); + const configs = parseJsonPreview(configsResponse.bodyPreview); + const proxies = parseJsonPreview(proxiesResponse.bodyPreview); + const httpOk = + isReadonlyHttpOk(versionResponse.status) && + isReadonlyHttpOk(configsResponse.status) && + isReadonlyHttpOk(proxiesResponse.status); + const mode = asString(configs.mode) || 'unknown'; + const proxyCount = this.countProxies(proxies); + const versionText = asString(version.version) || 'unknown'; + const summary = `Mihomo ${versionText}, mode ${mode}, proxies ${proxyCount}`; + + return createLiveAdapterSignal( + 'r4se-mihomo', + 'Mihomo/OpenClash', + summary, + { + configsHttpStatus: configsResponse.status, + mode, + proxiesHttpStatus: proxiesResponse.status, + proxyCount, + version: versionText, + versionHttpStatus: versionResponse.status, + }, + httpOk ? 'ok' : 'degraded', + versionResponse.observedAt, + ); + } catch (error) { + return createErrorAdapterSignal( + 'r4se-mihomo', + 'Mihomo/OpenClash', + error, + ); + } + } + + /** + * Builds a Mihomo readonly external-controller URL. + * @param path - Readonly API path such as version, configs, or proxies. + * @returns Full Mihomo external-controller endpoint URL. + */ + private apiUrl(path: 'configs' | 'proxies' | 'version'): string { + return joinReadonlyUrl( + this.config.get('ENV_DASHBOARD_R4SE_MIHOMO_URL'), + path, ); } + + /** + * Creates Mihomo authorization headers without returning the secret as evidence. + * @returns Bearer authorization header for outbound readonly requests. + */ + private authHeaders(): Record { + 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): number { + const proxies = body.proxies; + const proxyRecord = asRecord(proxies); + if (proxyRecord) return Object.keys(proxyRecord).length; + return asArray(proxies).length; + } } diff --git a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/tencent-cloud-readonly.adapter.ts b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/tencent-cloud-readonly.adapter.ts index 8f4b5da..d4d873c 100644 --- a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/tencent-cloud-readonly.adapter.ts +++ b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/tencent-cloud-readonly.adapter.ts @@ -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 { createUnwiredAdapterSignal } from './environment-readonly-adapter.helpers'; +import { + asNumber, + asRecord, + asString, + createErrorAdapterSignal, + createLiveAdapterSignal, + createUnwiredAdapterSignal, +} from './environment-readonly-adapter.helpers'; + +interface TencentCloudDescribeInstancesResponse { + InstanceSet?: Array>; + RequestId?: string; + TotalCount?: number; +} + +interface TencentCloudCvmClient { + DescribeInstances(request: { + InstanceIds: string[]; + Limit: number; + }): Promise; +} + +type TencentCloudCvmClientFactory = ( + clientConfig: ClientConfig, +) => TencentCloudCvmClient; + +/** + * Creates the official Tencent Cloud CVM SDK client for readonly Describe APIs. + * @param clientConfig - SDK credential, region, and endpoint configuration. + * @returns CVM client limited by adapter usage to DescribeInstances. + */ +function createTencentCloudCvmClient( + clientConfig: ClientConfig, +): TencentCloudCvmClient { + return new cvm.v20170312.Client( + clientConfig, + ) as unknown as TencentCloudCvmClient; +} @Injectable() export class TencentCloudReadonlyAdapter { + private readonly createClient: TencentCloudCvmClientFactory; + /** * Initializes Tencent Cloud readonly adapter. * @param config - Environment dashboard config reader. + * @param createClient - Optional factory used by tests to mock the Tencent SDK. */ - constructor(private readonly config: EnvironmentDashboardConfigService) {} + constructor( + private readonly config: EnvironmentDashboardConfigService, + @Optional() createClient?: TencentCloudCvmClientFactory, + ) { + this.createClient = createClient || createTencentCloudCvmClient; + } /** * Inspects Tencent Cloud readonly integration readiness. @@ -16,15 +63,90 @@ export class TencentCloudReadonlyAdapter { */ async inspect() { const missing = this.config.missing([ + 'ENV_DASHBOARD_TENCENT_CLOUD_ENABLED', 'ENV_DASHBOARD_TENCENT_SECRET_ID', 'ENV_DASHBOARD_TENCENT_SECRET_KEY', 'ENV_DASHBOARD_TENCENT_REGION', 'ENV_DASHBOARD_TENCENT_INSTANCE_ID', ]); - return createUnwiredAdapterSignal( - 'tencent-cvm', - 'Tencent Cloud CVM', - missing, + if (!this.isEnabled()) { + missing.unshift('ENV_DASHBOARD_TENCENT_CLOUD_ENABLED=true'); + } + if (missing.length > 0) { + return createUnwiredAdapterSignal( + 'tencent-cvm', + 'Tencent Cloud CVM', + missing, + ); + } + + try { + const response = await this.createClient(this.clientConfig()) + .DescribeInstances({ + InstanceIds: [this.config.get('ENV_DASHBOARD_TENCENT_INSTANCE_ID')], + Limit: 1, + }); + const instance = asRecord(response.InstanceSet?.[0]) || {}; + const instanceState = asString(instance.InstanceState) || 'UNKNOWN'; + const status = + instanceState === 'RUNNING' + ? 'ok' + : response.TotalCount === 0 + ? 'unknown' + : 'degraded'; + const summary = `Tencent Cloud CVM ${instanceState}`; + + return createLiveAdapterSignal( + 'tencent-cvm', + 'Tencent Cloud CVM', + summary, + { + cpu: asNumber(instance.CPU), + instanceId: asString(instance.InstanceId), + instanceState, + memoryMb: asNumber(instance.Memory), + requestId: response.RequestId, + totalCount: response.TotalCount, + }, + status, + ); + } catch (error) { + return createErrorAdapterSignal( + 'tencent-cvm', + 'Tencent Cloud CVM', + error, + ); + } + } + + /** + * Checks the explicit Tencent Cloud enablement guard before creating SDK clients. + * @returns True only when the dashboard Tencent integration is explicitly enabled. + */ + private isEnabled(): boolean { + return ( + this.config + .get('ENV_DASHBOARD_TENCENT_CLOUD_ENABLED') + .toLowerCase() === 'true' ); } + + /** + * Builds Tencent Cloud SDK config without exposing credentials as evidence. + * @returns ClientConfig accepted by the official Tencent Cloud SDK. + */ + private clientConfig(): ClientConfig { + return { + credential: { + secretId: this.config.get('ENV_DASHBOARD_TENCENT_SECRET_ID'), + secretKey: this.config.get('ENV_DASHBOARD_TENCENT_SECRET_KEY'), + }, + profile: { + httpProfile: { + endpoint: 'cvm.tencentcloudapi.com', + }, + }, + region: this.config.get('ENV_DASHBOARD_TENCENT_REGION'), + }; + } } diff --git a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/wireguard-readonly.adapter.ts b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/wireguard-readonly.adapter.ts index 4486001..0831a0d 100644 --- a/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/wireguard-readonly.adapter.ts +++ b/src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/wireguard-readonly.adapter.ts @@ -1,14 +1,28 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Optional } from '@nestjs/common'; 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() export class WireguardReadonlyAdapter { + private readonly http: EnvironmentReadonlyHttpClient; + /** * Initializes WireGuard readonly adapter. * @param config - Environment dashboard config reader. + * @param http - Readonly HTTP client used for WireGuard health endpoints. */ - constructor(private readonly config: EnvironmentDashboardConfigService) {} + constructor( + private readonly config: EnvironmentDashboardConfigService, + @Optional() http?: EnvironmentReadonlyHttpClient, + ) { + this.http = http || new EnvironmentReadonlyHttpClient(); + } /** * Inspects WireGuard readonly health endpoint readiness. @@ -19,6 +33,42 @@ export class WireguardReadonlyAdapter { 'ENV_DASHBOARD_TENCENT_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); + } } } diff --git a/src/modules/admin/platform-config/environment-dashboard/infrastructure/collectors/local-dev-signal.collector.ts b/src/modules/admin/platform-config/environment-dashboard/infrastructure/collectors/local-dev-signal.collector.ts new file mode 100644 index 0000000..f2c3fdb --- /dev/null +++ b/src/modules/admin/platform-config/environment-dashboard/infrastructure/collectors/local-dev-signal.collector.ts @@ -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 { + 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)), + }; + } +} diff --git a/src/modules/admin/platform-config/environment-dashboard/infrastructure/collectors/nas-prod-signal.collector.ts b/src/modules/admin/platform-config/environment-dashboard/infrastructure/collectors/nas-prod-signal.collector.ts new file mode 100644 index 0000000..fb5134c --- /dev/null +++ b/src/modules/admin/platform-config/environment-dashboard/infrastructure/collectors/nas-prod-signal.collector.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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> }, + ): Promise { + 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)), + }; + } +} diff --git a/src/modules/admin/platform-config/environment-dashboard/infrastructure/environment-dashboard-cache.service.ts b/src/modules/admin/platform-config/environment-dashboard/infrastructure/environment-dashboard-cache.service.ts new file mode 100644 index 0000000..8dbd523 --- /dev/null +++ b/src/modules/admin/platform-config/environment-dashboard/infrastructure/environment-dashboard-cache.service.ts @@ -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, + options: { forceRefresh?: boolean } = {}, + ): Promise { + 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, + }; + } +} diff --git a/src/modules/qqbot/core/qqbot-core.module.ts b/src/modules/qqbot/core/qqbot-core.module.ts index e796776..1fbfa1f 100644 --- a/src/modules/qqbot/core/qqbot-core.module.ts +++ b/src/modules/qqbot/core/qqbot-core.module.ts @@ -92,6 +92,7 @@ export const QQBOT_CORE_PROVIDERS = [ export const QQBOT_CORE_EXPORTS = [ QqbotAccountService, QqbotConfigService, + QqbotDashboardService, QqbotSendService, QqbotReverseWsService, ]; diff --git a/src/modules/qqbot/plugin-platform/plugin-platform.module.ts b/src/modules/qqbot/plugin-platform/plugin-platform.module.ts index c792658..c04b710 100644 --- a/src/modules/qqbot/plugin-platform/plugin-platform.module.ts +++ b/src/modules/qqbot/plugin-platform/plugin-platform.module.ts @@ -43,6 +43,7 @@ import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.serv QQBOT_PLUGIN_EXECUTION_PORT, QqbotPluginHttpClientService, QqbotPluginPlatformService, + QqbotPluginTaskService, ], imports: [ ConfigModule, diff --git a/test/modules/admin/environment-dashboard/environment-dashboard-cache.service.spec.ts b/test/modules/admin/environment-dashboard/environment-dashboard-cache.service.spec.ts new file mode 100644 index 0000000..c98c0d7 --- /dev/null +++ b/test/modules/admin/environment-dashboard/environment-dashboard-cache.service.spec.ts @@ -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, + ); + }); +}); diff --git a/test/modules/admin/environment-dashboard/environment-dashboard-self-check.service.spec.ts b/test/modules/admin/environment-dashboard/environment-dashboard-self-check.service.spec.ts new file mode 100644 index 0000000..9053bcd --- /dev/null +++ b/test/modules/admin/environment-dashboard/environment-dashboard-self-check.service.spec.ts @@ -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, + }); + }); +}); diff --git a/test/modules/admin/environment-dashboard/environment-dashboard.events.controller.spec.ts b/test/modules/admin/environment-dashboard/environment-dashboard.events.controller.spec.ts new file mode 100644 index 0000000..438210c --- /dev/null +++ b/test/modules/admin/environment-dashboard/environment-dashboard.events.controller.spec.ts @@ -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(); + }); +}); diff --git a/test/modules/admin/environment-dashboard/environment-dashboard.service.spec.ts b/test/modules/admin/environment-dashboard/environment-dashboard.service.spec.ts index 80065fa..4d973b3 100644 --- a/test/modules/admin/environment-dashboard/environment-dashboard.service.spec.ts +++ b/test/modules/admin/environment-dashboard/environment-dashboard.service.spec.ts @@ -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'; 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( new EnvironmentEventMaterializer(), ); @@ -16,6 +16,25 @@ describe('EnvironmentDashboardService', () => { 'r4se', ]); expect(dashboard.summary.totalSignals).toBeGreaterThan(0); + expect(dashboard.topology.nodes.map((node) => node.id)).toEqual( + expect.arrayContaining([ + 'local-dev', + 'nas-prod', + 'tencent-cloud', + 'r4se', + 'nas-api', + 'nas-admin', + 'mysql', + 'redis', + 'loki', + 'minio', + 'wordpress', + 'qqbot-core', + 'napcat-runtime', + 'plugin-platform', + 'plugin-tasks', + ]), + ); expect( dashboard.sites .flatMap((site) => site.nodes) diff --git a/test/modules/admin/environment-dashboard/environment-remote-adapters.spec.ts b/test/modules/admin/environment-dashboard/environment-remote-adapters.spec.ts index e0bd908..c7a76d8 100644 --- a/test/modules/admin/environment-dashboard/environment-remote-adapters.spec.ts +++ b/test/modules/admin/environment-dashboard/environment-remote-adapters.spec.ts @@ -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 { TencentCloudReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/tencent-cloud-readonly.adapter'; import { WireguardReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/wireguard-readonly.adapter'; +import type { + EnvironmentReadonlyHttpClient, + EnvironmentReadonlyHttpResponse, +} from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/environment-readonly-http.client'; import { EnvironmentDashboardConfigService } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/environment-dashboard-config.service'; +type ReadonlyHttpMock = jest.Mocked; + +/** + * Creates a deterministic sanitized HTTP response for readonly adapter tests. + * @param body - Response body preview returned by the fake HTTP client. + * @param status - HTTP status code observed by the adapter. + * @returns HTTP response shaped like EnvironmentReadonlyHttpClient output. + */ +function httpResponse( + body: unknown, + status = 200, +): EnvironmentReadonlyHttpResponse { + return { + bodyPreview: typeof body === 'string' ? body : JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + observedAt: '2026-06-18T01:00:00.000Z', + status, + statusText: status >= 200 && status < 400 ? 'OK' : 'ERROR', + }; +} + +/** + * Creates a mocked readonly HTTP client exposing only safe GET and HEAD probes. + * @returns Jest mock for readonly HTTP client methods used by adapters. + */ +function createHttpMock(): ReadonlyHttpMock { + return { + get: jest.fn(), + head: jest.fn(), + request: jest.fn(), + } as unknown as ReadonlyHttpMock; +} + describe('environment remote readonly adapters', () => { const config = new EnvironmentDashboardConfigService({}); @@ -40,4 +77,304 @@ describe('environment remote readonly adapters', () => { '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', + }), + ); + }); }); diff --git a/test/modules/admin/environment-dashboard/local-dev-signal.collector.spec.ts b/test/modules/admin/environment-dashboard/local-dev-signal.collector.spec.ts new file mode 100644 index 0000000..080c4d1 --- /dev/null +++ b/test/modules/admin/environment-dashboard/local-dev-signal.collector.spec.ts @@ -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', + }); + }); +}); diff --git a/test/modules/admin/environment-dashboard/nas-prod-signal.collector.spec.ts b/test/modules/admin/environment-dashboard/nas-prod-signal.collector.spec.ts new file mode 100644 index 0000000..73db6a2 --- /dev/null +++ b/test/modules/admin/environment-dashboard/nas-prod-signal.collector.spec.ts @@ -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'); + }); +});