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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user