feat: 完善环境总览只读观测接口

This commit is contained in:
sunlei 2026-06-18 17:33:59 +08:00
parent 2fdbbc210f
commit 7aacdbff4d
26 changed files with 2654 additions and 152 deletions

View File

@ -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=

2
API.md
View File

@ -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 与基础后台

View File

@ -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/MACNapCat/OneBot 配置 hashKT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口watchdog quick -> password 熔断,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network也不做账号级每小时/每日累计发送预算。

View File

@ -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,

View File

@ -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 });
}
}

View File

@ -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<EnvironmentDashboardResponse> {
async getDashboard(
options: EnvironmentDashboardSnapshotOptions = {},
): Promise<EnvironmentDashboardResponse> {
return this.cache.getOrCreate(() => this.buildDashboard(), options);
}
/**
* Runs all readonly collectors and assembles the dashboard response.
* @returns Fresh dashboard snapshot before cache decoration.
*/
private async buildDashboard(): Promise<EnvironmentDashboardResponse> {
const generatedAt = new Date().toISOString();
const 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<EnvironmentSite[]> {
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<EnvironmentSite> {
const services = [
await this.createRemoteAdapterService(
'tencent-cvm',
'Tencent Cloud CVM',
'tencent-cvm',
'Tencent Cloud CVM',
[
'ENV_DASHBOARD_TENCENT_SECRET_ID',
'ENV_DASHBOARD_TENCENT_SECRET_KEY',
'ENV_DASHBOARD_TENCENT_REGION',
'ENV_DASHBOARD_TENCENT_INSTANCE_ID',
],
this.tencentAdapter,
),
await this.createRemoteAdapterService(
'caddy-public',
'Caddy Public Route',
'caddy-public',
'Caddy Public Route',
['ENV_DASHBOARD_CADDY_PUBLIC_URL'],
this.caddyAdapter,
),
await this.createRemoteAdapterService(
'tencent-wireguard',
'WireGuard',
'tencent-wireguard',
'Tencent WireGuard',
['ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL'],
this.wireguardAdapter,
),
];
return this.createSiteFromServices(
'tencent-cloud',
'Tencent Cloud',
'Tencent Cloud Node',
services,
);
}
/**
* Creates r4se site from WireGuard and Mihomo/OpenClash readonly evidence.
* @returns r4se site with missing configuration surfaced as unwired signals.
*/
private async createR4seSite(): Promise<EnvironmentSite> {
const services = [
await this.createRemoteAdapterService(
'r4se-wireguard',
'WireGuard',
'r4se-wireguard',
'r4se WireGuard',
['ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL'],
this.wireguardAdapter,
),
await this.createRemoteAdapterService(
'r4se-mihomo',
'Mihomo/OpenClash',
'r4se-mihomo',
'Mihomo/OpenClash',
['ENV_DASHBOARD_R4SE_MIHOMO_URL', 'ENV_DASHBOARD_R4SE_MIHOMO_SECRET'],
this.mihomoAdapter,
),
];
return this.createSiteFromServices('r4se', 'r4se', 'r4se Node', services);
}
/**
* Creates one remote service from an adapter or explicit missing configuration.
* @param serviceId - Stable service id used by topology.
* @param serviceLabel - Operator-facing service label.
* @param signalId - Stable signal id used by Admin selection.
* @param signalLabel - Operator-facing signal label.
* @param requiredKeys - Public env keys required before adapter output can be trusted.
* @param adapter - Optional readonly adapter implementation.
* @returns Remote service with one normalized signal.
*/
private async createRemoteAdapterService(
serviceId: string,
serviceLabel: string,
signalId: string,
signalLabel: string,
requiredKeys: string[],
adapter?: { inspect(): Promise<Partial<EnvironmentSignal>> },
): Promise<EnvironmentService> {
const missing = this.config.missing(requiredKeys);
if (missing.length > 0 || !adapter) {
return this.createService(serviceId, serviceLabel, [
{
evidence: [unwiredEvidence(signalLabel, missing)],
id: signalId,
label: signalLabel,
sourceKind: 'unwired',
status: 'unwired',
summary: '只读观测配置未接入',
},
]);
}
try {
const signal = await adapter.inspect();
return this.createService(serviceId, serviceLabel, [
{
evidence: signal.evidence || [],
id: signal.id || signalId,
label: signal.label || signalLabel,
observedAt: signal.observedAt,
sourceKind: signal.sourceKind || 'live',
status: signal.status || 'unknown',
summary: signal.summary || '只读观测已返回信号',
},
]);
} catch (error) {
return this.createService(serviceId, serviceLabel, [
{
evidence: [errorEvidence(signalLabel, error)],
id: signalId,
label: signalLabel,
sourceKind: 'derived',
status: 'down',
summary: '只读观测失败',
},
]);
}
}
/**
* Creates a site wrapper around a single environment node.
* @param siteId - Stable site id used by Admin selection and SSE events.
* @param siteLabel - Operator-facing site label.
* @param nodeLabel - Operator-facing node label.
* @param services - Services collected for the site.
* @returns Site object with status derived from child services.
*/
private createSiteFromServices(
siteId: string,
siteLabel: string,
nodeLabel: string,
services: EnvironmentService[],
): EnvironmentSite {
const node = this.createNode(`${siteId}-node`, nodeLabel, services);
return {
id,
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.

View File

@ -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,

View File

@ -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,
);
}
}
}

View File

@ -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<string, unknown> = {},
status: EnvironmentHealthStatus = 'ok',
observedAt?: Date | number | string,
): EnvironmentSignal {
const evidence = liveEvidence(label, summary, observedAt, metadata);
return {
evidence: [evidence],
id,
label,
observedAt: evidence.observedAt,
sourceKind: 'live',
status,
summary,
};
}
/**
* Creates a dashboard signal from a failed configured readonly probe.
* @param id - Stable signal id exposed through dashboard topology.
* @param label - Operator-facing integration label.
* @param error - Unknown exception or rejection reason from the probe.
* @returns Degraded signal with sanitized error evidence.
*/
export function createErrorAdapterSignal(
id: string,
label: string,
error: unknown,
): EnvironmentSignal {
const evidence = errorEvidence(label, error);
return {
evidence: [evidence],
id,
label,
observedAt: evidence.observedAt,
sourceKind: 'derived',
status: 'degraded',
summary: evidence.summary,
};
}
/**
* Checks whether an HTTP status code is a successful readonly observation.
* @param status - HTTP status code returned by the readonly client.
* @returns True when the status is a 2xx or 3xx response.
*/
export function isReadonlyHttpOk(status: number): boolean {
return status >= 200 && status < 400;
}
/**
* Joins a configured readonly base URL with a relative API path.
* @param baseUrl - Runtime configured URL without secrets.
* @param path - Relative or absolute path segment for a readonly API endpoint.
* @returns Fully qualified URL with duplicate separators normalized by URL.
*/
export function joinReadonlyUrl(baseUrl: string, path: string): string {
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
return new URL(path.replace(/^\/+/, ''), normalizedBase).toString();
}
/**
* Parses the bounded body preview returned by EnvironmentReadonlyHttpClient.
* @param bodyPreview - Sanitized body preview from a readonly HTTP response.
* @returns Parsed JSON object or an empty object when the preview is absent or not JSON.
*/
export function parseJsonPreview(
bodyPreview: string,
): Record<string, unknown> {
if (!bodyPreview) return {};
try {
const parsed = JSON.parse(bodyPreview);
return asRecord(parsed) || {};
} catch {
return {};
}
}
/**
* Narrows an unknown value to a plain record for adapter metadata extraction.
* @param value - Unknown JSON field or SDK payload.
* @returns Record value when it is object-like and not an array.
*/
export function asRecord(
value: unknown,
): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
/**
* Narrows an unknown value to an array for adapter metadata extraction.
* @param value - Unknown JSON field or SDK payload.
* @returns Array value when the input is an array; otherwise an empty array.
*/
export function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
/**
* Converts string-like or numeric fields into safe summary strings.
* @param value - Unknown JSON field or SDK payload.
* @returns String representation for primitive values, otherwise undefined.
*/
export function asString(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (typeof value === 'number') return `${value}`;
return undefined;
}
/**
* Converts numeric JSON fields into numbers for status calculations.
* @param value - Unknown JSON field or SDK payload.
* @returns Finite number or undefined when the value is not numeric.
*/
export function asNumber(value: unknown): number | undefined {
const numberValue =
typeof value === 'number'
? value
: typeof value === 'string'
? Number(value)
: Number.NaN;
return Number.isFinite(numberValue) ? numberValue : undefined;
}

View File

@ -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<string, string> | undefined {
const username = this.config.get('ENV_DASHBOARD_JENKINS_USERNAME');
const token = this.config.get('ENV_DASHBOARD_JENKINS_TOKEN');
if (!username || !token) return undefined;
return {
Authorization: `Basic ${Buffer.from(`${username}:${token}`).toString(
'base64',
)}`,
};
}
}

View File

@ -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<string, string> | undefined {
const token = this.config.get('ENV_DASHBOARD_K8S_BEARER_TOKEN');
if (!token) return undefined;
return { Authorization: `Bearer ${token}` };
}
/**
* Creates optional Kubernetes pod list query params from safe selector config.
* @returns Params object accepted by the readonly HTTP client.
*/
private podsParams(): Record<string, string> | undefined {
const labelSelector = this.config.get('ENV_DASHBOARD_K8S_LABEL_SELECTOR');
return labelSelector ? { labelSelector } : undefined;
}
/**
* Extracts deployment replica readiness from Kubernetes deployment JSON.
* @param body - Parsed deployment JSON body from Kubernetes API.
* @returns Replica counts used for dashboard status.
*/
private extractDeploymentReadiness(body: Record<string, unknown>) {
const spec = asRecord(body.spec) || {};
const status = asRecord(body.status) || {};
const desiredReplicas =
asNumber(spec.replicas) ?? asNumber(status.replicas) ?? 0;
return {
availableReplicas: asNumber(status.availableReplicas) || 0,
desiredReplicas,
readyReplicas: asNumber(status.readyReplicas) || 0,
updatedReplicas: asNumber(status.updatedReplicas) || 0,
};
}
/**
* Extracts pod phase and ready-condition counts from Kubernetes pod list JSON.
* @param body - Parsed pod list JSON body from Kubernetes API.
* @returns Pod counts used for dashboard evidence.
*/
private extractPodReadiness(body: Record<string, unknown>) {
const pods = asArray(body.items);
const podRunningCount = pods.filter((pod) => {
const status = asRecord(asRecord(pod)?.status);
return status?.phase === 'Running';
}).length;
const podReadyCount = pods.filter((pod) => {
const status = asRecord(asRecord(pod)?.status);
return asArray(status?.conditions).some((condition) => {
const record = asRecord(condition);
return record?.type === 'Ready' && record.status === 'True';
});
}).length;
return {
podCount: pods.length,
podReadyCount,
podRunningCount,
};
}
}

View File

@ -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<string, string> {
return {
Authorization: `Bearer ${this.config.get(
'ENV_DASHBOARD_R4SE_MIHOMO_SECRET',
)}`,
};
}
/**
* Counts proxies from Mihomo response shapes without retaining proxy payloads.
* @param body - Parsed proxies response body.
* @returns Number of proxies reported by the readonly endpoint.
*/
private countProxies(body: Record<string, unknown>): number {
const proxies = body.proxies;
const proxyRecord = asRecord(proxies);
if (proxyRecord) return Object.keys(proxyRecord).length;
return asArray(proxies).length;
}
}

View File

@ -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<Record<string, unknown>>;
RequestId?: string;
TotalCount?: number;
}
interface TencentCloudCvmClient {
DescribeInstances(request: {
InstanceIds: string[];
Limit: number;
}): Promise<TencentCloudDescribeInstancesResponse>;
}
type TencentCloudCvmClientFactory = (
clientConfig: ClientConfig,
) => TencentCloudCvmClient;
/**
* Creates the official Tencent Cloud CVM SDK client for readonly Describe APIs.
* @param clientConfig - SDK credential, region, and endpoint configuration.
* @returns CVM client limited by adapter usage to DescribeInstances.
*/
function createTencentCloudCvmClient(
clientConfig: ClientConfig,
): TencentCloudCvmClient {
return new cvm.v20170312.Client(
clientConfig,
) as unknown as TencentCloudCvmClient;
}
@Injectable()
export class TencentCloudReadonlyAdapter {
private readonly createClient: TencentCloudCvmClientFactory;
/**
* Initializes Tencent Cloud readonly adapter.
* @param config - Environment dashboard config reader.
* @param createClient - Optional factory used by tests to mock the Tencent SDK.
*/
constructor(private readonly config: EnvironmentDashboardConfigService) {}
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'),
};
}
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,184 @@
import { Injectable, Optional } from '@nestjs/common';
import { RuntimeHealthService } from '@/runtime/health/runtime-health.service';
import { unwiredEvidence } from '../environment-dashboard-evidence.mapper';
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
import {
mapSiteStatus,
pickWorstHealthStatus,
} from '../../application/environment-dashboard-status.mapper';
import type {
EnvironmentHealthStatus,
EnvironmentNode,
EnvironmentService,
EnvironmentSignal,
EnvironmentSite,
} from '../../domain/environment-dashboard.types';
import type { RuntimeHealthStatus } from '@/runtime/health/runtime-health.types';
export interface LocalDevSignalCollectContext {
observedAt?: string;
}
@Injectable()
export class LocalDevSignalCollector {
/**
* Initializes local development signal collection.
* @param runtimeHealthService - Runtime module health reader; absent in narrow unit tests means the API signal is unknown instead of green.
* @param config - Environment dashboard config reader used for Admin local route evidence.
*/
constructor(
@Optional()
private readonly runtimeHealthService?: RuntimeHealthService,
@Optional()
private readonly config: EnvironmentDashboardConfigService = new EnvironmentDashboardConfigService(),
) {}
/**
* Collects local development API/Admin service evidence for the dashboard.
* @param context - Snapshot context from the aggregator; `observedAt` keeps evidence timestamps aligned.
* @returns Local development site with API runtime and Admin route signals.
*/
async collect(context: LocalDevSignalCollectContext = {}): Promise<EnvironmentSite> {
const observedAt = context.observedAt || new Date().toISOString();
const services = [
this.createApiService(observedAt),
this.createAdminService(observedAt),
];
const node = this.createNode('local-dev-node', 'Local Dev Workstation', services);
return {
id: 'local-dev',
label: 'Local Dev',
nodes: [node],
status: mapSiteStatus(services.map((service) => service.status)),
summary: 'Local development runtime snapshot',
};
}
/**
* Builds the local API service from RuntimeHealthService output.
* @param observedAt - Shared snapshot timestamp.
* @returns API service with runtime health evidence.
*/
private createApiService(observedAt: string): EnvironmentService {
const report = this.runtimeHealthService?.getRuntimeHealth();
const status = this.mapRuntimeStatus(report?.status);
const signal: EnvironmentSignal = {
evidence: [
{
metadata: report
? {
checks: report.checks?.length || 0,
runtimeStatus: report.status,
}
: undefined,
observedAt: report?.checkedAt || observedAt,
source: 'runtime-health',
sourceKind: report ? 'live' : 'derived',
summary: report
? `Runtime health is ${report.status}`
: 'RuntimeHealthService 未接入当前测试上下文',
},
],
id: 'local-api-process',
label: 'API Process',
observedAt: report?.checkedAt || observedAt,
sourceKind: report ? 'live' : 'derived',
status,
summary: report
? `API runtime health: ${report.status}`
: '等待 RuntimeHealthService 提供本机进程状态',
};
return this.createService('local-api', 'API Runtime', [signal]);
}
/**
* Builds the local Admin route signal from optional local URL configuration.
* @param observedAt - Shared snapshot timestamp.
* @returns Admin service with configured or unwired evidence.
*/
private createAdminService(observedAt: string): EnvironmentService {
const adminUrl = this.config.get('ENV_DASHBOARD_ADMIN_LOCAL_URL');
const signal: EnvironmentSignal = adminUrl
? {
evidence: [
{
metadata: { url: adminUrl },
observedAt,
source: 'Admin local URL',
sourceKind: 'configured',
summary: '本机 Admin 地址已配置',
},
],
id: 'local-admin-route',
label: 'Admin Local Route',
observedAt,
sourceKind: 'configured',
status: 'unknown',
summary: 'Admin 本机地址已配置,页面连通性由浏览器 smoke 验证',
}
: {
evidence: [
unwiredEvidence('Admin local URL', ['ENV_DASHBOARD_ADMIN_LOCAL_URL']),
],
id: 'local-admin-route',
label: 'Admin Local Route',
sourceKind: 'unwired',
status: 'unwired',
summary: '本机 Admin 地址未配置',
};
return this.createService('local-admin', 'Admin Frontend', [signal]);
}
/**
* Maps runtime module health into environment dashboard signal status.
* @param status - RuntimeHealthService status value.
* @returns Dashboard health status preserving blocked/degraded semantics.
*/
private mapRuntimeStatus(status?: RuntimeHealthStatus): EnvironmentHealthStatus {
if (status === 'live' || status === 'ready') return 'ok';
if (status === 'blocked') return 'blocked';
if (status === 'degraded') return 'degraded';
return 'unknown';
}
/**
* Creates a service from child signals and derives aggregate status.
* @param id - Stable service id used by topology and Admin selection.
* @param label - Operator-facing service label.
* @param signals - Signals supporting the service.
* @returns Service object with worst-signal status.
*/
private createService(
id: string,
label: string,
signals: EnvironmentSignal[],
): EnvironmentService {
return {
id,
label,
signals,
status: pickWorstHealthStatus(signals.map((signal) => signal.status)),
summary: signals.map((signal) => signal.summary).join(''),
};
}
/**
* Creates a local node and derives aggregate status from services.
* @param id - Stable node id for topology edges.
* @param label - Operator-facing node label.
* @param services - Services owned by the local node.
* @returns Node object with worst-service status.
*/
private createNode(
id: string,
label: string,
services: EnvironmentService[],
): EnvironmentNode {
return {
id,
label,
services,
status: pickWorstHealthStatus(services.map((service) => service.status)),
};
}
}

View File

@ -0,0 +1,649 @@
import { Injectable, Optional } from '@nestjs/common';
import { RuntimeHealthService } from '@/runtime/health/runtime-health.service';
import { MinioClientService } from '@/modules/asset/application/asset-minio.service';
import { QqbotDashboardService } from '@/modules/qqbot/core/application/dashboard/qqbot-dashboard.service';
import { QqbotPluginTaskService } from '@/modules/qqbot/plugin-platform/application/task';
import { WordpressService } from '@/modules/wordpress/application/wordpress.service';
import { errorEvidence, liveEvidence, unwiredEvidence } from '../environment-dashboard-evidence.mapper';
import { EnvironmentDashboardConfigService } from '../environment-dashboard-config.service';
import { JenkinsReadonlyAdapter } from '../adapters/jenkins-readonly.adapter';
import { KubernetesReadonlyAdapter } from '../adapters/kubernetes-readonly.adapter';
import {
mapSiteStatus,
pickWorstHealthStatus,
} from '../../application/environment-dashboard-status.mapper';
import type {
EnvironmentHealthStatus,
EnvironmentNode,
EnvironmentService,
EnvironmentSignal,
EnvironmentSite,
} from '../../domain/environment-dashboard.types';
import type { RuntimeHealthStatus } from '@/runtime/health/runtime-health.types';
export interface NasProdSignalCollectContext {
observedAt?: string;
}
type QqbotSummaryProbe =
| { data: any; error?: never }
| { data?: never; error: unknown };
@Injectable()
export class NasProdSignalCollector {
/**
* Initializes NAS production collector from narrow readonly service ports.
* @param runtimeHealthService - Runtime health reader proving API process/config state without touching deploy state.
* @param qqbotDashboardService - QQBot summary reader; failures affect only QQBot/NapCat signals.
* @param pluginTaskService - Plugin task page reader used to summarize scheduler state without executing tasks.
* @param minioClientService - MinIO connection checker; errors remain scoped to the MinIO service.
* @param wordpressService - WordPress optional admin login probe that reports availability without changing content.
* @param jenkinsAdapter - Jenkins readonly adapter owned by the remote integration layer.
* @param kubernetesAdapter - Kubernetes readonly adapter owned by the remote integration layer.
* @param config - Dashboard config reader used to expose missing integration keys as unwired evidence.
*/
constructor(
@Optional()
private readonly runtimeHealthService?: RuntimeHealthService,
@Optional()
private readonly qqbotDashboardService?: QqbotDashboardService,
@Optional()
private readonly pluginTaskService?: QqbotPluginTaskService,
@Optional()
private readonly minioClientService?: MinioClientService,
@Optional()
private readonly wordpressService?: WordpressService,
@Optional()
private readonly jenkinsAdapter?: JenkinsReadonlyAdapter,
@Optional()
private readonly kubernetesAdapter?: KubernetesReadonlyAdapter,
@Optional()
private readonly config: EnvironmentDashboardConfigService = new EnvironmentDashboardConfigService(),
) {}
/**
* Collects NAS production service evidence from internal modules and readonly remote adapters.
* @param context - Snapshot context from the dashboard aggregator; `observedAt` aligns all evidence timestamps.
* @returns NAS production site with individual service statuses and no cross-service failure leakage.
*/
async collect(context: NasProdSignalCollectContext = {}): Promise<EnvironmentSite> {
const observedAt = context.observedAt || new Date().toISOString();
const qqbotSummary = await this.readQqbotSummary();
const services = [
this.createNasApiService(observedAt),
this.createNasAdminService(observedAt),
this.createConfiguredDependencyService(
'mysql',
'MySQL',
'数据库连通性由 API 运行态和业务 smoke 共同证明',
observedAt,
),
this.createConfiguredDependencyService(
'redis',
'Redis',
'队列和缓存连通性由运行态配置与业务 smoke 共同证明',
observedAt,
),
this.createConfiguredDependencyService(
'loki',
'Loki',
'日志聚合连通性由日志页面和线上 smoke 共同证明',
observedAt,
),
await this.createMinioService(observedAt),
await this.createWordpressService(observedAt),
this.createQqbotService(qqbotSummary, observedAt),
this.createNapcatService(qqbotSummary, observedAt),
this.createPluginPlatformService(observedAt),
await this.createPluginTaskService(observedAt),
await this.createAdapterService(
'jenkins',
'Jenkins',
'jenkins-build',
'Jenkins Build',
['ENV_DASHBOARD_JENKINS_URL', 'ENV_DASHBOARD_JENKINS_JOB'],
this.jenkinsAdapter,
),
await this.createAdapterService(
'kubernetes',
'K8s',
'k8s-deployment',
'K8s Deployment',
[
'ENV_DASHBOARD_K8S_API_SERVER',
'ENV_DASHBOARD_K8S_NAMESPACE',
'ENV_DASHBOARD_K8S_DEPLOYMENT',
],
this.kubernetesAdapter,
),
];
const node = this.createNode('nas-prod-node', 'NAS Production Host', services);
return {
id: 'nas-prod',
label: 'NAS Production',
nodes: [node],
status: mapSiteStatus(services.map((service) => service.status)),
summary: 'NAS online environment readonly snapshot',
};
}
/**
* Reads QQBot summary once so QQBot and NapCat services share the same evidence source.
* @returns Probe result containing either summary data or a captured failure.
*/
private async readQqbotSummary(): Promise<QqbotSummaryProbe> {
if (!this.qqbotDashboardService) return { error: new Error('QQBot dashboard service is not wired') };
try {
return { data: await this.qqbotDashboardService.summary() };
} catch (error) {
return { error };
}
}
/**
* Builds NAS API status from RuntimeHealthService without coupling it to QQBot state.
* @param observedAt - Shared snapshot timestamp.
* @returns API service signal.
*/
private createNasApiService(observedAt: string): EnvironmentService {
const report = this.runtimeHealthService?.getRuntimeHealth();
const status = this.mapRuntimeStatus(report?.status);
return this.createService('nas-api', 'API Runtime', [
{
evidence: [
liveEvidence(
'runtime-health',
report ? `Runtime health is ${report.status}` : 'RuntimeHealthService 未接入',
report?.checkedAt || observedAt,
report
? {
checks: report.checks?.length || 0,
runtimeStatus: report.status,
}
: undefined,
),
],
id: 'nas-api-runtime',
label: 'API Runtime',
observedAt: report?.checkedAt || observedAt,
sourceKind: report ? 'live' : 'derived',
status,
summary: report ? `API runtime health: ${report.status}` : '等待 RuntimeHealthService 接入',
},
]);
}
/**
* Builds NAS Admin evidence from known public route configuration.
* @param observedAt - Shared snapshot timestamp.
* @returns Admin frontend service.
*/
private createNasAdminService(observedAt: string): EnvironmentService {
const publicUrl =
this.config.get('ENV_DASHBOARD_ADMIN_PUBLIC_URL') ||
this.config.get('ENV_DASHBOARD_CADDY_PUBLIC_URL');
const signal: EnvironmentSignal = publicUrl
? {
evidence: [
{
metadata: { url: publicUrl },
observedAt,
source: 'Admin public route',
sourceKind: 'configured',
summary: 'Admin 公开入口已配置,页面连通性由浏览器 smoke 验证',
},
],
id: 'nas-admin-route',
label: 'Admin Public Route',
observedAt,
sourceKind: 'configured',
status: 'unknown',
summary: 'Admin 公开入口已配置',
}
: {
evidence: [
unwiredEvidence('Admin public route', [
'ENV_DASHBOARD_ADMIN_PUBLIC_URL',
'ENV_DASHBOARD_CADDY_PUBLIC_URL',
]),
],
id: 'nas-admin-route',
label: 'Admin Public Route',
sourceKind: 'unwired',
status: 'unwired',
summary: 'Admin 公开入口未接入只读观测',
};
return this.createService('nas-admin', 'Admin Frontend', [signal]);
}
/**
* Creates a dependency service that is visible but not falsely marked live.
* @param id - Stable service and signal id prefix.
* @param label - Operator-facing service label.
* @param summary - Why this dependency is observed as configured/derived evidence.
* @param observedAt - Shared snapshot timestamp.
* @returns Dependency service with unknown status.
*/
private createConfiguredDependencyService(
id: string,
label: string,
summary: string,
observedAt: string,
): EnvironmentService {
return this.createService(id, label, [
{
evidence: [
{
observedAt,
source: label,
sourceKind: 'derived',
summary,
},
],
id: `${id}-signal`,
label,
observedAt,
sourceKind: 'derived',
status: 'unknown',
summary,
},
]);
}
/**
* Builds MinIO service evidence and scopes connection errors to MinIO only.
* @param observedAt - Shared snapshot timestamp.
* @returns MinIO service.
*/
private async createMinioService(observedAt: string): Promise<EnvironmentService> {
if (!this.minioClientService) {
return this.createUnknownService('minio', 'MinIO', 'MinioClientService 未接入', observedAt);
}
try {
const result = await this.minioClientService.checkConnection();
const status: EnvironmentHealthStatus = result?.exists ? 'ok' : 'degraded';
return this.createService('minio', 'MinIO', [
{
evidence: [
liveEvidence('minio', `Bucket ${result?.bucketName || ''} exists=${!!result?.exists}`, observedAt, {
bucketName: result?.bucketName,
exists: !!result?.exists,
}),
],
id: 'minio-bucket',
label: 'Default Bucket',
observedAt,
sourceKind: 'live',
status,
summary: result?.exists ? 'MinIO 默认 bucket 可用' : 'MinIO 默认 bucket 不存在',
},
]);
} catch (error) {
return this.createService('minio', 'MinIO', [
{
evidence: [errorEvidence('minio', error, observedAt)],
id: 'minio-bucket',
label: 'Default Bucket',
observedAt,
sourceKind: 'derived',
status: 'down',
summary: 'MinIO 只读连通性检查失败',
},
]);
}
}
/**
* Builds WordPress availability evidence using the existing optional login probe.
* @param observedAt - Shared snapshot timestamp.
* @returns WordPress service.
*/
private async createWordpressService(observedAt: string): Promise<EnvironmentService> {
if (!this.wordpressService) {
return this.createUnknownService('wordpress', 'WordPress', 'WordpressService 未接入', observedAt);
}
try {
const result = await this.wordpressService.tryLoginWithConfiguredAdmin();
return this.createService('wordpress', 'WordPress', [
{
evidence: [
liveEvidence('wordpress', result.available ? 'WordPress 管理员探针可用' : 'WordPress 管理员探针不可用', observedAt, {
available: result.available,
error: result.error,
}),
],
id: 'wordpress-admin-login',
label: 'WordPress Admin Probe',
observedAt,
sourceKind: 'live',
status: result.available ? 'ok' : 'degraded',
summary: result.available ? 'WordPress 集成可用' : 'WordPress 集成不可用',
},
]);
} catch (error) {
return this.createService('wordpress', 'WordPress', [
{
evidence: [errorEvidence('wordpress', error, observedAt)],
id: 'wordpress-admin-login',
label: 'WordPress Admin Probe',
observedAt,
sourceKind: 'derived',
status: 'down',
summary: 'WordPress 只读探针失败',
},
]);
}
}
/**
* Builds QQBot core status from dashboard summary without affecting API service status.
* @param probe - Shared QQBot summary probe.
* @param observedAt - Shared snapshot timestamp.
* @returns QQBot core service.
*/
private createQqbotService(
probe: QqbotSummaryProbe,
observedAt: string,
): EnvironmentService {
if (probe.error) {
return this.createService('qqbot-core', 'QQBot Core', [
{
evidence: [errorEvidence('qqbot-dashboard', probe.error, observedAt)],
id: 'qqbot-core-summary',
label: 'QQBot Summary',
observedAt,
sourceKind: 'derived',
status: this.qqbotDashboardService ? 'down' : 'unknown',
summary: 'QQBot 摘要不可用',
},
]);
}
const accountTotal = Number(probe.data?.accountTotal || 0);
const onlineTotal = Number(probe.data?.onlineTotal || 0);
const status: EnvironmentHealthStatus =
accountTotal > 0 && onlineTotal <= 0 ? 'degraded' : 'ok';
return this.createService('qqbot-core', 'QQBot Core', [
{
evidence: [
liveEvidence('qqbot-dashboard', `QQBot online ${onlineTotal}/${accountTotal}`, observedAt, {
accountTotal,
bus: probe.data?.bus,
onlineTotal,
}),
],
id: 'qqbot-core-summary',
label: 'QQBot Summary',
observedAt,
sourceKind: 'live',
status,
summary: `QQBot 在线账号 ${onlineTotal}/${accountTotal}`,
},
]);
}
/**
* Builds NapCat runtime visibility from QQBot runtime session evidence.
* @param probe - Shared QQBot summary probe.
* @param observedAt - Shared snapshot timestamp.
* @returns NapCat runtime service.
*/
private createNapcatService(
probe: QqbotSummaryProbe,
observedAt: string,
): EnvironmentService {
if (probe.error) {
return this.createUnknownService('napcat-runtime', 'NapCat Runtime', '等待 QQBot 摘要提供 NapCat 会话证据', observedAt);
}
const sessions = Array.isArray(probe.data?.runtime?.sessions)
? probe.data.runtime.sessions
: [];
const enabled = probe.data?.runtime?.enabled !== false;
const status: EnvironmentHealthStatus = !enabled
? 'blocked'
: sessions.length > 0
? 'ok'
: 'degraded';
return this.createService('napcat-runtime', 'NapCat Runtime', [
{
evidence: [
liveEvidence('qqbot-reverse-ws', `NapCat reverse WS sessions: ${sessions.length}`, observedAt, {
enabled,
sessionCount: sessions.length,
}),
],
id: 'napcat-reverse-ws',
label: 'Reverse WS Sessions',
observedAt,
sourceKind: 'live',
status,
summary: sessions.length > 0 ? 'NapCat reverse WS 有活跃会话' : 'NapCat reverse WS 暂无活跃会话',
},
]);
}
/**
* Builds plugin platform presence evidence without exposing write actions.
* @param observedAt - Shared snapshot timestamp.
* @returns Plugin platform service.
*/
private createPluginPlatformService(observedAt: string): EnvironmentService {
return this.createService('plugin-platform', 'Plugin Platform', [
{
evidence: [
{
observedAt,
source: 'plugin-platform',
sourceKind: this.pluginTaskService ? 'derived' : 'unwired',
summary: this.pluginTaskService
? '插件平台任务服务已接入只读摘要'
: '插件平台任务服务未接入当前模块上下文',
},
],
id: 'plugin-platform-provider',
label: 'Plugin Platform Provider',
observedAt,
sourceKind: this.pluginTaskService ? 'derived' : 'unwired',
status: this.pluginTaskService ? 'unknown' : 'unwired',
summary: this.pluginTaskService
? '插件平台 provider 可见'
: '插件平台 provider 未接入',
},
]);
}
/**
* Builds scheduled plugin task evidence without executing task jobs.
* @param observedAt - Shared snapshot timestamp.
* @returns Plugin task service.
*/
private async createPluginTaskService(observedAt: string): Promise<EnvironmentService> {
if (!this.pluginTaskService) {
return this.createUnknownService('plugin-tasks', 'Plugin Tasks', 'QqbotPluginTaskService 未接入', observedAt);
}
try {
const page = await this.pluginTaskService.pageTasks({
pageNo: 1,
pageSize: 50,
} as any);
const list = Array.isArray(page?.list) ? page.list : [];
const disabledCount = list.filter((task) => task?.enabled === false).length;
const failedCount = list.filter((task) => /failed|error/i.test(`${task?.runtimeStatus || ''}`)).length;
const status: EnvironmentHealthStatus =
failedCount > 0 ? 'down' : disabledCount > 0 ? 'degraded' : 'ok';
return this.createService('plugin-tasks', 'Plugin Tasks', [
{
evidence: [
liveEvidence('plugin-tasks', `Plugin tasks total=${page?.total || list.length}, disabled=${disabledCount}`, observedAt, {
disabledCount,
failedCount,
total: page?.total || list.length,
}),
],
id: 'plugin-task-scheduler',
label: 'Scheduled Tasks',
observedAt,
sourceKind: 'live',
status,
summary: disabledCount > 0 ? '存在已禁用插件定时任务' : '插件定时任务摘要可用',
},
]);
} catch (error) {
return this.createService('plugin-tasks', 'Plugin Tasks', [
{
evidence: [errorEvidence('plugin-tasks', error, observedAt)],
id: 'plugin-task-scheduler',
label: 'Scheduled Tasks',
observedAt,
sourceKind: 'derived',
status: 'down',
summary: '插件定时任务摘要读取失败',
},
]);
}
}
/**
* Builds service evidence from a readonly remote adapter or explicit missing config keys.
* @param serviceId - Stable service id used by topology.
* @param serviceLabel - Operator-facing service label.
* @param fallbackSignalId - Signal id used when config is missing.
* @param fallbackSignalLabel - Signal label used when config is missing.
* @param requiredKeys - Public env keys required for this readonly adapter.
* @param adapter - Optional adapter implementation.
* @returns Remote integration service.
*/
private async createAdapterService(
serviceId: string,
serviceLabel: string,
fallbackSignalId: string,
fallbackSignalLabel: string,
requiredKeys: string[],
adapter?: { inspect(): Promise<Partial<EnvironmentSignal>> },
): Promise<EnvironmentService> {
const missing = this.config.missing(requiredKeys);
if (missing.length > 0 || !adapter) {
return this.createService(serviceId, serviceLabel, [
{
evidence: [unwiredEvidence(fallbackSignalLabel, missing)],
id: fallbackSignalId,
label: fallbackSignalLabel,
sourceKind: 'unwired',
status: 'unwired',
summary: '只读观测配置未接入',
},
]);
}
try {
const signal = await adapter.inspect();
return this.createService(serviceId, serviceLabel, [
{
evidence: signal.evidence || [],
id: signal.id || fallbackSignalId,
label: signal.label || fallbackSignalLabel,
observedAt: signal.observedAt,
sourceKind: signal.sourceKind || 'live',
status: signal.status || 'unknown',
summary: signal.summary || '只读观测已返回信号',
},
]);
} catch (error) {
return this.createService(serviceId, serviceLabel, [
{
evidence: [errorEvidence(fallbackSignalLabel, error)],
id: fallbackSignalId,
label: fallbackSignalLabel,
sourceKind: 'derived',
status: 'down',
summary: '只读观测失败',
},
]);
}
}
/**
* Creates a visible unknown service for dependencies whose safe reader is absent.
* @param id - Stable service id.
* @param label - Operator-facing service label.
* @param summary - Reason why the service is not live evidence.
* @param observedAt - Shared snapshot timestamp.
* @returns Unknown service with derived evidence.
*/
private createUnknownService(
id: string,
label: string,
summary: string,
observedAt: string,
): EnvironmentService {
return this.createService(id, label, [
{
evidence: [
{
observedAt,
source: label,
sourceKind: 'derived',
summary,
},
],
id: `${id}-signal`,
label,
observedAt,
sourceKind: 'derived',
status: 'unknown',
summary,
},
]);
}
/**
* Maps runtime module health into environment dashboard signal status.
* @param status - RuntimeHealthService status value.
* @returns Dashboard health status preserving blocked/degraded semantics.
*/
private mapRuntimeStatus(status?: RuntimeHealthStatus): EnvironmentHealthStatus {
if (status === 'live' || status === 'ready') return 'ok';
if (status === 'blocked') return 'blocked';
if (status === 'degraded') return 'degraded';
return 'unknown';
}
/**
* Creates a service from child signals and derives aggregate status.
* @param id - Stable service id used by topology and Admin selection.
* @param label - Operator-facing service label.
* @param signals - Signals supporting the service.
* @returns Service object with worst-signal status.
*/
private createService(
id: string,
label: string,
signals: EnvironmentSignal[],
): EnvironmentService {
return {
id,
label,
signals,
status: pickWorstHealthStatus(signals.map((signal) => signal.status)),
summary: signals.map((signal) => signal.summary).join(''),
};
}
/**
* Creates a NAS node and derives aggregate status from services.
* @param id - Stable node id for topology edges.
* @param label - Operator-facing node label.
* @param services - Services owned by the NAS node.
* @returns Node object with worst-service status.
*/
private createNode(
id: string,
label: string,
services: EnvironmentService[],
): EnvironmentNode {
return {
id,
label,
services,
status: pickWorstHealthStatus(services.map((service) => service.status)),
};
}
}

View File

@ -0,0 +1,132 @@
import { Injectable, Optional } from '@nestjs/common';
import { cachedEvidence } from './environment-dashboard-evidence.mapper';
import type {
EnvironmentDashboardResponse,
EnvironmentEvidence,
EnvironmentSignal,
EnvironmentSignalSourceKind,
} from '../domain/environment-dashboard.types';
export interface EnvironmentDashboardCacheOptions {
ttlMs?: number;
}
@Injectable()
export class EnvironmentDashboardCacheService {
private cached?: {
expiresAt: number;
snapshot: EnvironmentDashboardResponse;
};
private readonly ttlMs: number;
/**
* Initializes the short-lived dashboard cache used between event-driven invalidations.
* @param options - Runtime/test cache options; `ttlMs` defaults from env and bounds how long successful snapshots can be reused.
*/
constructor(@Optional() options: EnvironmentDashboardCacheOptions = {}) {
const envTtlMs = Number(process.env.ENV_DASHBOARD_CACHE_TTL_MS);
this.ttlMs =
options.ttlMs ??
(Number.isFinite(envTtlMs) && envTtlMs > 0 ? envTtlMs : 15_000);
}
/**
* Returns a fresh dashboard snapshot or a cached successful one when it is still valid.
* @param factory - Collector aggregation callback that may perform readonly probes and can throw on unexpected infrastructure failures.
* @param options - Per-call control; `forceRefresh` is used by self-check to bypass cached state.
* @returns Dashboard snapshot, with reused non-unwired signals marked as cached evidence.
*/
async getOrCreate(
factory: () => Promise<EnvironmentDashboardResponse>,
options: { forceRefresh?: boolean } = {},
): Promise<EnvironmentDashboardResponse> {
const now = Date.now();
if (
!options.forceRefresh &&
this.cached &&
this.cached.expiresAt > now
) {
return this.toCachedSnapshot(this.cached.snapshot, this.cached.expiresAt);
}
const snapshot = await factory();
this.cached = {
expiresAt: Date.now() + this.ttlMs,
snapshot: this.cloneSnapshot(snapshot),
};
return snapshot;
}
/**
* Clears cached state after a fresh local/MQTT signal event invalidates the aggregate snapshot.
*/
invalidate(): void {
this.cached = undefined;
}
/**
* Clones a snapshot before storing or returning it so callers cannot mutate cache state.
* @param snapshot - Dashboard response produced by collectors.
* @returns Deep cloned dashboard response.
*/
private cloneSnapshot(
snapshot: EnvironmentDashboardResponse,
): EnvironmentDashboardResponse {
return JSON.parse(JSON.stringify(snapshot)) as EnvironmentDashboardResponse;
}
/**
* Converts a valid cached snapshot into operator-visible cached evidence.
* @param snapshot - Previously successful dashboard response.
* @param expiresAtMs - Cache expiry timestamp used for evidence expiry metadata.
* @returns Cloned response with non-unwired live/configured/derived/external-link signals marked cached.
*/
private toCachedSnapshot(
snapshot: EnvironmentDashboardResponse,
expiresAtMs: number,
): EnvironmentDashboardResponse {
const cachedAt = new Date().toISOString();
const expiresAt = new Date(expiresAtMs).toISOString();
const cloned = this.cloneSnapshot(snapshot);
cloned.sites = cloned.sites.map((site) => ({
...site,
nodes: site.nodes.map((node) => ({
...node,
services: node.services.map((service) => ({
...service,
signals: service.signals.map((signal) =>
this.toCachedSignal(signal, cachedAt, expiresAt),
),
})),
})),
}));
return cloned;
}
/**
* Marks one reusable signal as cached while preserving explicit missing-integration evidence.
* @param signal - Signal from the last successful snapshot.
* @param observedAt - Time when the cached value is returned to Admin.
* @param expiresAt - Cache expiry ISO timestamp exposed as evidence metadata.
* @returns Cached signal clone.
*/
private toCachedSignal(
signal: EnvironmentSignal,
observedAt: string,
expiresAt: string,
): EnvironmentSignal {
if (signal.sourceKind === 'unwired') return signal;
const sourceKind: EnvironmentSignalSourceKind = 'cached';
const evidence: EnvironmentEvidence[] = [
...signal.evidence,
cachedEvidence(signal.label, signal.summary, observedAt, expiresAt),
];
return {
...signal,
evidence,
observedAt: signal.observedAt || observedAt,
sourceKind,
};
}
}

View File

@ -92,6 +92,7 @@ export const QQBOT_CORE_PROVIDERS = [
export const QQBOT_CORE_EXPORTS = [
QqbotAccountService,
QqbotConfigService,
QqbotDashboardService,
QqbotSendService,
QqbotReverseWsService,
];

View File

@ -43,6 +43,7 @@ import { QQBOT_PLUGIN_RUNTIME_FACTORY } from './application/plugin-platform.serv
QQBOT_PLUGIN_EXECUTION_PORT,
QqbotPluginHttpClientService,
QqbotPluginPlatformService,
QqbotPluginTaskService,
],
imports: [
ConfigModule,

View File

@ -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,
);
});
});

View File

@ -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,
});
});
});

View File

@ -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();
});
});

View File

@ -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)

View File

@ -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<EnvironmentReadonlyHttpClient>;
/**
* Creates a deterministic sanitized HTTP response for readonly adapter tests.
* @param body - Response body preview returned by the fake HTTP client.
* @param status - HTTP status code observed by the adapter.
* @returns HTTP response shaped like EnvironmentReadonlyHttpClient output.
*/
function httpResponse(
body: unknown,
status = 200,
): EnvironmentReadonlyHttpResponse {
return {
bodyPreview: typeof body === 'string' ? body : JSON.stringify(body),
headers: { 'content-type': 'application/json' },
observedAt: '2026-06-18T01:00:00.000Z',
status,
statusText: status >= 200 && status < 400 ? 'OK' : 'ERROR',
};
}
/**
* Creates a mocked readonly HTTP client exposing only safe GET and HEAD probes.
* @returns Jest mock for readonly HTTP client methods used by adapters.
*/
function createHttpMock(): ReadonlyHttpMock {
return {
get: jest.fn(),
head: jest.fn(),
request: jest.fn(),
} as unknown as ReadonlyHttpMock;
}
describe('environment remote readonly adapters', () => {
const config = new EnvironmentDashboardConfigService({});
@ -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',
}),
);
});
});

View File

@ -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',
});
});
});

View File

@ -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');
});
});