1
0

feat: 结构化 observation 替代 statusDetail,API 层动态构造 detail

- CheckResult: statusDetail -> observation (持久化) + detail (API 动态派生)
- 存储: status_detail 列 -> observation TEXT (JSON)
- CheckerDefinition: 新增 buildDetail(observation) 方法
- 各 checker 返回结构化 observation,API 层通过 registry 调用 buildDetail
- HTTP: bodyPreview 在 status/header 失败时也提前采集
- UDP: observation 包含 durationMs,未响应归为 error failure
- CMD: 超时/输出超限时保留已收集 observation
- TCP: connectTimeMs 仅含连接建立耗时,不含 banner 等待
- 新增 buildDetail 单测和 mapCheckResult 覆盖测试
- 同步 openspec 主规范,归档 checker-observation 变更
This commit is contained in:
2026-05-19 22:49:00 +08:00
parent 22c06820fa
commit 375dd3492b
64 changed files with 915 additions and 965 deletions

View File

@@ -15,7 +15,7 @@ const DEFAULT_BANNER_READ_TIMEOUT = 2000;
const DEFAULT_MAX_BANNER_BYTES = 4096;
type ConnectAndBannerResult =
| { banner?: string; bannerExceeded?: boolean; ok: true; socket: { close(): void } }
| { banner?: string; bannerExceeded?: boolean; connectTimeMs: number; ok: true; socket: { close(): void } }
| { error: string; ok: false };
export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
@@ -25,6 +25,21 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
readonly type = "tcp";
buildDetail(observation: Record<string, unknown>): null | string {
const connected = observation["connected"];
if (connected !== true) {
const error = observation["error"];
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
}
const connectTimeMs = observation["connectTimeMs"];
const banner = observation["banner"];
const parts: string[] = [`connected in ${typeof connectTimeMs === "number" ? connectTimeMs : "?"}ms`];
if (typeof banner === "string" && banner.length > 0) {
parts.push(`banner: ${truncateBanner(banner)}`);
}
return parts.join(", ");
}
async execute(t: ResolvedTcpTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
@@ -42,36 +57,46 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
if (!connectResult.ok) {
const durationMs = Math.round(performance.now() - start);
const observation: Record<string, unknown> = {
banner: null,
connected: false,
connectTimeMs: null,
error: connectResult.error,
};
if (expect?.connected === false) {
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: connectResult.error,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: errorFailure("connect", "connect", connectResult.error),
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
}
const socket = connectResult.socket;
const connectTimeMs = connectResult.connectTimeMs;
if (ctx.signal.aborted) {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("connect", "connect", `连接超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -83,10 +108,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: connectedResult.failure,
matched: false,
statusDetail: "connected",
observation: { banner: null, connected: true, connectTimeMs, error: null },
targetId: t.id,
timestamp,
};
@@ -96,10 +122,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("banner", "banner", `banner 数据超过 ${t.tcp.maxBannerBytes} 字节限制`),
matched: false,
statusDetail: null,
observation: { banner: null, connected: true, connectTimeMs, error: null },
targetId: t.id,
timestamp,
};
@@ -108,15 +135,23 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
const banner = connectResult.banner ?? "";
closeSocket(socket);
const observation: Record<string, unknown> = {
banner: banner ? truncateBannerForObservation(banner) : null,
connected: true,
connectTimeMs,
error: null,
};
if (expect?.banner) {
const bannerCheck = checkBanner(banner, expect.banner);
if (!bannerCheck.matched) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: bannerCheck.failure,
matched: false,
statusDetail: banner ? truncateBanner(banner) : null,
observation,
targetId: t.id,
timestamp,
};
@@ -131,26 +166,29 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: buildStatusDetail(banner, durationMs),
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: buildStatusDetail(banner, durationMs),
observation,
targetId: t.id,
timestamp,
};
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure(
"connect",
@@ -158,7 +196,7 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
ctx.signal.aborted ? `连接超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -221,12 +259,6 @@ function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
return result;
}
function buildStatusDetail(banner: string, durationMs: number): string {
const base = `connected in ${durationMs}ms`;
if (!banner) return base;
return `${base}, banner: ${truncateBanner(banner)}`;
}
function closeSocket(socket: { close(): void }) {
try {
socket.close();
@@ -292,11 +324,13 @@ async function connectAndMaybeReadBanner(
};
try {
const connectStart = performance.now();
const socket = await Bun.connect({
hostname,
port,
socket: socketHandlers,
});
const connectTimeMs = Math.round(performance.now() - connectStart);
if (signal.aborted) {
closeSocket(socket);
@@ -304,7 +338,7 @@ async function connectAndMaybeReadBanner(
}
if (!readBanner) {
return { bannerExceeded: false, ok: true, socket };
return { bannerExceeded: false, connectTimeMs, ok: true, socket };
}
const timer = setTimeout(() => {
@@ -332,11 +366,11 @@ async function connectAndMaybeReadBanner(
signal.removeEventListener("abort", onAbort);
if (bannerExceeded) {
return { bannerExceeded: true, ok: true, socket };
return { bannerExceeded: true, connectTimeMs, ok: true, socket };
}
const banner = new TextDecoder().decode(assembleChunks(chunks, totalBytes));
return { banner, bannerExceeded: false, ok: true, socket };
return { banner, bannerExceeded: false, connectTimeMs, ok: true, socket };
} catch (error) {
if (signal.aborted) {
return { error: "连接超时", ok: false };
@@ -360,3 +394,8 @@ function truncateBanner(banner: string, maxLen = 80): string {
if (banner.length <= maxLen) return banner;
return `${banner.slice(0, maxLen)}`;
}
function truncateBannerForObservation(banner: string, maxLen = 256): string {
if (banner.length <= maxLen) return banner;
return banner.slice(0, maxLen);
}