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:
@@ -73,10 +73,11 @@ export class ProbeEngine {
|
||||
console.warn("探针执行失败:", result.reason);
|
||||
if (!target) continue;
|
||||
this.writeResult({
|
||||
detail: null,
|
||||
durationMs: null,
|
||||
failure: errorFailure("internal", "engine", formatReason(result.reason)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: target.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -110,7 +111,7 @@ export class ProbeEngine {
|
||||
durationMs: result.durationMs,
|
||||
failure: result.failure,
|
||||
matched: result.matched,
|
||||
statusDetail: result.statusDetail,
|
||||
observation: result.observation ?? null,
|
||||
targetId: result.targetId,
|
||||
timestamp: result.timestamp,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,9 @@ import { checkExitCode } from "./expect";
|
||||
import { commandCheckerSchemas } from "./schema";
|
||||
import { validateCommandConfig } from "./validate";
|
||||
|
||||
const STDOUT_PREVIEW_MAX = 1024;
|
||||
const STDERR_PREVIEW_MAX = 1024;
|
||||
|
||||
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
|
||||
readonly configKey = "cmd";
|
||||
|
||||
@@ -20,6 +23,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
|
||||
readonly type = "cmd";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const exitCode = observation["exitCode"];
|
||||
return typeof exitCode === "number" ? `exitCode=${exitCode}` : null;
|
||||
}
|
||||
|
||||
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
@@ -37,10 +45,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -70,10 +79,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
} catch {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -83,24 +93,33 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
const stdoutPreview = truncatePreview(outputResult.stdout, STDOUT_PREVIEW_MAX);
|
||||
const stderrPreview = truncatePreview(outputResult.stderr, STDERR_PREVIEW_MAX);
|
||||
const observation: Record<string, unknown> = { error: null, exitCode, stderrPreview, stdoutPreview };
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
const message = `输出超过限制 ${t.cmd.maxOutputBytes} 字节`;
|
||||
observation["error"] = message;
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`),
|
||||
failure: errorFailure("exitCode", "output", message),
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
const message = `命令执行超时 (${t.timeoutMs}ms)`;
|
||||
observation["error"] = message;
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
|
||||
failure: errorFailure("exitCode", "timeout", message),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -109,10 +128,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: exitCodeResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -125,10 +145,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -138,10 +159,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
const stdoutResult = checkContentRules(outputResult.stdout, t.expect.stdout, { path: "stdout", phase: "stdout" });
|
||||
if (!stdoutResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: stdoutResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -152,10 +174,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
const stderrResult = checkContentRules(outputResult.stderr, t.expect.stderr, { path: "stderr", phase: "stderr" });
|
||||
if (!stderrResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: stderrResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -163,10 +186,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -269,3 +293,8 @@ async function readOutput(
|
||||
|
||||
return { exceeded, stderr: err, stdout: out };
|
||||
}
|
||||
|
||||
function truncatePreview(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { dbCheckerSchemas } from "./schema";
|
||||
import { validateDbConfig } from "./validate";
|
||||
|
||||
const PROBE_QUERY = "SELECT 1";
|
||||
const ROWS_PREVIEW_MAX = 5;
|
||||
|
||||
export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
readonly configKey = "db";
|
||||
@@ -21,16 +22,27 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
|
||||
readonly type = "db";
|
||||
|
||||
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 rowCount = observation["rowCount"];
|
||||
if (typeof rowCount === "number") {
|
||||
return `${rowCount} rows`;
|
||||
}
|
||||
return "connected";
|
||||
}
|
||||
|
||||
async execute(t: ResolvedDbTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
let db: SQL | undefined;
|
||||
|
||||
try {
|
||||
// 创建连接(SQLite 不需要 max 选项)
|
||||
db = new SQL(t.db.url);
|
||||
|
||||
// 监听 abort signal
|
||||
ctx.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
@@ -41,24 +53,30 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
// 连接测试(Bun SQL 是 lazy 的,首次查询才真正连接)
|
||||
try {
|
||||
await db.unsafe(PROBE_QUERY);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const errorMsg = isError(error) ? error.message : String(error);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)),
|
||||
failure: errorFailure("connect", "connect", errorMsg),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: { connected: false, error: errorMsg, rowCount: null, rowsPreview: null },
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 无 query 时仅测试连接
|
||||
if (!t.db.query) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const observation: Record<string, unknown> = {
|
||||
connected: true,
|
||||
error: null,
|
||||
rowCount: null,
|
||||
rowsPreview: null,
|
||||
};
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
@@ -66,55 +84,60 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: "connected",
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "connected",
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 执行用户 SQL
|
||||
let rows: unknown[];
|
||||
try {
|
||||
rows = await db.unsafe(t.db.query);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const errorMsg = isError(error) ? error.message : String(error);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
|
||||
failure: errorFailure("query", "query", errorMsg),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: { connected: true, error: errorMsg, rowCount: null, rowsPreview: null },
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const rowCount = Array.isArray(rows) ? rows.length : 0;
|
||||
const rowsPreview = Array.isArray(rows) ? rows.slice(0, ROWS_PREVIEW_MAX) : null;
|
||||
const observation: Record<string, unknown> = { connected: true, error: null, rowCount, rowsPreview };
|
||||
|
||||
// 检查是否超时
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// duration 断言
|
||||
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
@@ -122,39 +145,40 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// rowCount 断言
|
||||
if (t.expect?.rowCount) {
|
||||
const rowCountResult = checkRowCount(Array.isArray(rows) ? rows.length : 0, t.expect.rowCount);
|
||||
const rowCountResult = checkRowCount(rowCount, t.expect.rowCount);
|
||||
if (!rowCountResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: rowCountResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// rows 断言
|
||||
if (t.expect?.rows && t.expect.rows.length > 0) {
|
||||
const rowsResult = checkRows(rows, t.expect.rows);
|
||||
if (!rowsResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: rowsResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -162,14 +186,14 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
}
|
||||
|
||||
if (t.expect?.result && t.expect.result.length > 0) {
|
||||
const rowCount = Array.isArray(rows) ? rows.length : 0;
|
||||
const resultCheck = checkContentRules({ rowCount, rows }, t.expect.result, { path: "result", phase: "result" });
|
||||
if (!resultCheck.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: resultCheck.failure,
|
||||
matched: false,
|
||||
statusDetail: `${rowCount} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -177,10 +201,11 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { httpCheckerSchemas } from "./schema";
|
||||
import { validateHttpConfig } from "./validate";
|
||||
|
||||
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
|
||||
const BODY_PREVIEW_BYTES = 1024;
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
|
||||
|
||||
@@ -23,6 +24,11 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
|
||||
readonly type = "http";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const statusCode = observation["statusCode"];
|
||||
return typeof statusCode === "number" ? `HTTP ${statusCode}` : null;
|
||||
}
|
||||
|
||||
async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const expect = t.expect;
|
||||
@@ -39,39 +45,102 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
});
|
||||
|
||||
const statusCode = response.status;
|
||||
const responseHeaders = Object.fromEntries(response.headers);
|
||||
const responseHeaders = truncateHeaders(Object.fromEntries(response.headers));
|
||||
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
||||
const bodyReadResult = await readBodyStream(
|
||||
response,
|
||||
hasBodyRules ? t.http.maxBodyBytes : BODY_PREVIEW_BYTES,
|
||||
!hasBodyRules,
|
||||
);
|
||||
let bodyPreview: null | string = null;
|
||||
let bodyText: null | string = null;
|
||||
let bodyDecodeFailure: CheckResult["failure"] = null;
|
||||
|
||||
if (bodyReadResult.data.byteLength > 0) {
|
||||
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
|
||||
if (decodeResult.ok) {
|
||||
bodyText = decodeResult.text;
|
||||
bodyPreview = truncateBodyPreview(decodeResult.text);
|
||||
} else {
|
||||
bodyDecodeFailure = decodeResult.failure;
|
||||
}
|
||||
}
|
||||
|
||||
const statusResult = checkStatus(statusCode, expect?.status ?? [200]);
|
||||
if (!statusResult.matched) {
|
||||
return makeResult(t, timestamp, performance.now() - start, statusResult.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
statusResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
const headersResult = checkHeaders(responseHeaders, expect?.headers);
|
||||
const headersResult = checkHeaders(Object.fromEntries(response.headers), expect?.headers);
|
||||
if (!headersResult.matched) {
|
||||
return makeResult(t, timestamp, performance.now() - start, headersResult.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
headersResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
||||
|
||||
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.durationMs) : null;
|
||||
if (earlyTimeout) {
|
||||
return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
earlyTimeout.elapsed,
|
||||
earlyTimeout.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
if (!bodyReadResult.ok) {
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyReadResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyDecodeFailure) {
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyDecodeFailure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasBodyRules) {
|
||||
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
|
||||
if (!bodyReadResult.ok) {
|
||||
return makeResult(t, timestamp, performance.now() - start, bodyReadResult.failure, statusCode);
|
||||
}
|
||||
|
||||
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
|
||||
if (!decodeResult.ok) {
|
||||
return makeResult(t, timestamp, performance.now() - start, decodeResult.failure, statusCode);
|
||||
}
|
||||
|
||||
const bodyResult = checkContentRules(decodeResult.text, expect.body, { path: "body", phase: "body" });
|
||||
const bodyResult = checkContentRules(bodyText ?? "", expect.body, { path: "body", phase: "body" });
|
||||
if (!bodyResult.matched) {
|
||||
return makeResult(t, timestamp, performance.now() - start, bodyResult.failure, statusCode);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
performance.now() - start,
|
||||
bodyResult.failure,
|
||||
response,
|
||||
responseHeaders,
|
||||
bodyPreview,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,15 +151,16 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
phase: "duration",
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return makeResult(t, timestamp, durationMs, durationResult.failure, statusCode);
|
||||
return makeResult(t, timestamp, durationMs, durationResult.failure, response, responseHeaders, bodyPreview);
|
||||
}
|
||||
|
||||
return makeResult(t, timestamp, durationMs, null, statusCode);
|
||||
return makeResult(t, timestamp, durationMs, null, response, responseHeaders, bodyPreview);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"request",
|
||||
@@ -98,7 +168,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -155,6 +225,17 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
|
||||
const result = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {
|
||||
let newInit = { ...init };
|
||||
const method = init.method?.toUpperCase();
|
||||
@@ -269,13 +350,28 @@ function makeResult(
|
||||
timestamp: string,
|
||||
elapsed: number,
|
||||
failure: CheckResult["failure"],
|
||||
statusCode: number,
|
||||
response: Response,
|
||||
headers: Record<string, string>,
|
||||
bodyPreview: null | string = null,
|
||||
): CheckResult {
|
||||
const contentType = response.headers.get("content-type");
|
||||
const contentLengthHeader = response.headers.get("content-length");
|
||||
const contentLength = contentLengthHeader ? Number(contentLengthHeader) : null;
|
||||
|
||||
const observation: Record<string, unknown> = {
|
||||
bodyPreview,
|
||||
contentLength: Number.isFinite(contentLength) ? contentLength : null,
|
||||
contentType,
|
||||
headers,
|
||||
statusCode: response.status,
|
||||
};
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs: Math.round(elapsed),
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -284,7 +380,8 @@ function makeResult(
|
||||
async function readBodyStream(
|
||||
response: Response,
|
||||
maxBodyBytes: number,
|
||||
): Promise<{ data: Uint8Array; ok: true } | { failure: CheckResult["failure"]; ok: false }> {
|
||||
truncateOnLimit = false,
|
||||
): Promise<{ data: Uint8Array; failure: CheckResult["failure"]; ok: false } | { data: Uint8Array; ok: true }> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return { data: new Uint8Array(0), ok: true };
|
||||
@@ -300,12 +397,19 @@ async function readBodyStream(
|
||||
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > maxBodyBytes) {
|
||||
const allowedBytes = value.byteLength - (totalBytes - maxBodyBytes);
|
||||
if (truncateOnLimit && allowedBytes > 0) {
|
||||
chunks.push(value.slice(0, allowedBytes));
|
||||
}
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* ignore cancel error */
|
||||
}
|
||||
const data = assembleChunks(chunks, Math.min(totalBytes, maxBodyBytes));
|
||||
if (truncateOnLimit) return { data, ok: true };
|
||||
return {
|
||||
data,
|
||||
failure: errorFailure("body", "body", `响应体大小超过限制 ${maxBodyBytes}`),
|
||||
ok: false,
|
||||
};
|
||||
@@ -317,12 +421,16 @@ async function readBodyStream(
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const result = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return { data: result, ok: true };
|
||||
return { data: assembleChunks(chunks, totalBytes), ok: true };
|
||||
}
|
||||
|
||||
function truncateBodyPreview(text: string, maxLen = 1024): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen);
|
||||
}
|
||||
|
||||
function truncateHeaders(headers: Record<string, string>, maxCount = 20): Record<string, string> {
|
||||
const entries = Object.entries(headers);
|
||||
if (entries.length <= maxCount) return headers;
|
||||
return Object.fromEntries(entries.slice(0, maxCount));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,35 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
|
||||
readonly type = "ping";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const alive = observation["alive"];
|
||||
const transmitted = observation["transmitted"];
|
||||
const received = observation["received"];
|
||||
|
||||
if (alive !== true) {
|
||||
const rx = typeof received === "number" ? received : 0;
|
||||
const tx = typeof transmitted === "number" ? transmitted : 0;
|
||||
return `unreachable (${rx}/${tx} received)`;
|
||||
}
|
||||
|
||||
const avg = observation["avgLatencyMs"];
|
||||
const loss = observation["packetLoss"];
|
||||
const avgStr = typeof avg === "number" ? formatNumber(avg) : "n/a";
|
||||
const lossStr = typeof loss === "number" ? formatNumber(loss) : "0";
|
||||
const rx = typeof received === "number" ? received : 0;
|
||||
const tx = typeof transmitted === "number" ? transmitted : 0;
|
||||
let detail = `alive, avg ${avgStr}ms, loss ${lossStr}% (${rx}/${tx})`;
|
||||
|
||||
if (typeof loss === "number" && loss > 0) {
|
||||
const max = observation["maxLatencyMs"];
|
||||
if (typeof max === "number") {
|
||||
detail = `${detail}, max ${formatNumber(max)}ms`;
|
||||
}
|
||||
}
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
async execute(t: ResolvedPingTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
@@ -36,10 +65,11 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("ping", "spawn", `ping 命令不可用: ${isError(error) ? error.message : String(error)}`),
|
||||
matched: false,
|
||||
statusDetail: "ping command not found",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -63,10 +93,11 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("ping", "timeout", `ping 执行超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -75,21 +106,42 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
const stats = parsePingOutput(stdout, process.platform);
|
||||
if (!stats) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("ping", "parse", "无法解析 ping 输出"),
|
||||
matched: false,
|
||||
statusDetail: truncateOutput(stdout),
|
||||
observation: {
|
||||
alive: false,
|
||||
avgLatencyMs: null,
|
||||
error: "parse failed",
|
||||
maxLatencyMs: null,
|
||||
minLatencyMs: null,
|
||||
packetLoss: 100,
|
||||
received: 0,
|
||||
transmitted: 0,
|
||||
},
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const result = checkStats(stats, t.expect, durationMs);
|
||||
const observation: Record<string, unknown> = {
|
||||
alive: stats.alive,
|
||||
avgLatencyMs: stats.avgLatencyMs,
|
||||
error: null,
|
||||
maxLatencyMs: stats.maxLatencyMs,
|
||||
minLatencyMs: stats.minLatencyMs,
|
||||
packetLoss: stats.packetLoss,
|
||||
received: stats.received,
|
||||
transmitted: stats.transmitted,
|
||||
};
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: result.failure,
|
||||
matched: result.matched,
|
||||
statusDetail: buildStatusDetail(stats),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -126,17 +178,6 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildStatusDetail(stats: PingStats): string {
|
||||
if (!stats.alive) return `unreachable (${stats.received}/${stats.transmitted} received)`;
|
||||
const avg = stats.avgLatencyMs === null ? "n/a" : formatNumber(stats.avgLatencyMs);
|
||||
const loss = formatNumber(stats.packetLoss);
|
||||
let detail = `alive, avg ${avg}ms, loss ${loss}% (${stats.received}/${stats.transmitted})`;
|
||||
if (stats.packetLoss > 0 && stats.maxLatencyMs !== null) {
|
||||
detail = `${detail}, max ${formatNumber(stats.maxLatencyMs)}ms`;
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function checkStats(stats: PingStats, expect: PingExpectConfig | undefined, durationMs: number) {
|
||||
const aliveResult = checkAlive(stats.alive, expect?.alive ?? true);
|
||||
if (!aliveResult.matched) return aliveResult;
|
||||
@@ -179,8 +220,3 @@ async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function truncateOutput(output: string, maxLen = 80): string {
|
||||
if (output.length <= maxLen) return output;
|
||||
return `${output.slice(0, maxLen)}…`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { LlmCheckObservation, LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
|
||||
import type { LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
|
||||
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueMatcher } from "../../expect/matcher";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
buildObservationFromApiCallError,
|
||||
buildObservationFromGenerateText,
|
||||
buildObservationFromStreamText,
|
||||
toPersistedObservation,
|
||||
} from "./observation";
|
||||
import { createProviderModel } from "./provider";
|
||||
import { llmCheckerSchemas } from "./schema";
|
||||
@@ -24,6 +25,43 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
readonly schemas = llmCheckerSchemas;
|
||||
readonly type = "llm";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const provider = observation["provider"];
|
||||
const mode = observation["mode"];
|
||||
const parts: string[] = [`LLM ${String(provider)} ${String(mode)}`];
|
||||
|
||||
const http = observation["http"] as null | Record<string, unknown> | undefined;
|
||||
if (http && typeof http["status"] === "number") {
|
||||
parts.push(String(http["status"]));
|
||||
}
|
||||
|
||||
if (typeof observation["finishReason"] === "string") {
|
||||
parts.push(`finish=${observation["finishReason"]}`);
|
||||
}
|
||||
|
||||
if (typeof observation["rawFinishReason"] === "string") {
|
||||
parts.push(`raw=${observation["rawFinishReason"]}`);
|
||||
}
|
||||
|
||||
const stream = observation["stream"] as null | Record<string, unknown> | undefined;
|
||||
if (stream && typeof stream["firstTokenMs"] === "number") {
|
||||
parts.push(`firstToken=${stream["firstTokenMs"]}ms`);
|
||||
}
|
||||
|
||||
if (typeof observation["outputLength"] === "number") {
|
||||
parts.push(`output=${observation["outputLength"]} chars`);
|
||||
}
|
||||
|
||||
const usage = observation["usage"] as null | Record<string, unknown> | undefined;
|
||||
if (usage) {
|
||||
const inputTokens = typeof usage["inputTokens"] === "number" ? usage["inputTokens"] : 0;
|
||||
const outputTokens = typeof usage["outputTokens"] === "number" ? usage["outputTokens"] : 0;
|
||||
parts.push(`usage=${inputTokens}/${outputTokens} tokens`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async execute(t: ResolvedLlmTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const expect = t.expect;
|
||||
@@ -45,10 +83,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
|
||||
if (observation.http === null) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("request", "request", error.message),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -63,10 +102,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
const failure = expectResult.failure ?? durationResult.failure;
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: buildStatusDetail(observation),
|
||||
observation: toPersistedObservation(observation),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -74,6 +114,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
|
||||
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"request",
|
||||
@@ -81,7 +122,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -222,10 +263,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
const failure = expectResult.failure ?? durationResult.failure;
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: buildStatusDetail(observation),
|
||||
observation: toPersistedObservation(observation),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -268,10 +310,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
|
||||
const failure = expectResult.failure ?? durationResult.failure;
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure,
|
||||
matched: failure === null,
|
||||
statusDetail: buildStatusDetail(observation),
|
||||
observation: toPersistedObservation(observation),
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -291,33 +334,3 @@ function buildSdkOptions(config: ResolvedLlmTarget["llm"]): Record<string, unkno
|
||||
if (opts.seed !== undefined) options["seed"] = opts.seed;
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildStatusDetail(observation: LlmCheckObservation): string {
|
||||
const parts: string[] = [`LLM ${observation.provider} ${observation.mode}`];
|
||||
|
||||
if (observation.http) {
|
||||
parts.push(String(observation.http.status));
|
||||
}
|
||||
|
||||
if (observation.finishReason) {
|
||||
parts.push(`finish=${observation.finishReason}`);
|
||||
}
|
||||
|
||||
if (observation.rawFinishReason) {
|
||||
parts.push(`raw=${observation.rawFinishReason}`);
|
||||
}
|
||||
|
||||
if (observation.stream?.firstTokenMs != null) {
|
||||
parts.push(`firstToken=${observation.stream.firstTokenMs}ms`);
|
||||
}
|
||||
|
||||
if (observation.outputText !== null) {
|
||||
parts.push(`output=${observation.outputText.length} chars`);
|
||||
}
|
||||
|
||||
if (observation.usage) {
|
||||
parts.push(`usage=${observation.usage.inputTokens}/${observation.usage.outputTokens} tokens`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import type {
|
||||
LlmCheckObservation,
|
||||
LlmHttpMetadata,
|
||||
LlmMode,
|
||||
LlmPersistedHttpMetadata,
|
||||
LlmPersistedObservation,
|
||||
LlmProvider,
|
||||
LlmStreamObservation,
|
||||
LlmUsageObservation,
|
||||
} from "./types";
|
||||
|
||||
import { LLM_HEADERS_MAX, LLM_OUTPUT_PREVIEW_MAX } from "./types";
|
||||
|
||||
export function buildObservationFromApiCallError(
|
||||
error: APICallError,
|
||||
provider: LlmProvider,
|
||||
@@ -129,3 +133,42 @@ export async function buildObservationFromStreamText(
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPersistedObservation(obs: LlmCheckObservation): LlmPersistedObservation {
|
||||
const outputText = obs.outputText;
|
||||
const outputPreview =
|
||||
outputText !== null
|
||||
? outputText.length <= LLM_OUTPUT_PREVIEW_MAX
|
||||
? outputText
|
||||
: outputText.slice(0, LLM_OUTPUT_PREVIEW_MAX)
|
||||
: null;
|
||||
const outputLength = outputText !== null ? outputText.length : null;
|
||||
|
||||
const http: LlmPersistedHttpMetadata | null = obs.http
|
||||
? {
|
||||
headers: truncateHeaders(obs.http.headers, LLM_HEADERS_MAX),
|
||||
status: obs.http.status,
|
||||
statusText: obs.http.statusText,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
finishReason: obs.finishReason,
|
||||
http,
|
||||
mode: obs.mode,
|
||||
model: obs.model,
|
||||
outputLength,
|
||||
outputPreview,
|
||||
provider: obs.provider,
|
||||
rawFinishReason: obs.rawFinishReason,
|
||||
stream: obs.stream,
|
||||
usage: obs.usage,
|
||||
warnings: obs.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function truncateHeaders(headers: Record<string, string>, maxCount: number): Record<string, string> {
|
||||
const entries = Object.entries(headers);
|
||||
if (entries.length <= maxCount) return headers;
|
||||
return Object.fromEntries(entries.slice(0, maxCount));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface LlmCheckObservation {
|
||||
usage: LlmUsageObservation | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface LlmDefaultsConfig {
|
||||
headers?: Record<string, string>;
|
||||
ignoreSSL?: boolean;
|
||||
@@ -33,13 +34,37 @@ export interface LlmExpectConfig {
|
||||
stream?: LlmStreamExpect;
|
||||
usage?: LlmUsageExpect;
|
||||
}
|
||||
|
||||
export interface LlmHttpMetadata {
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export interface LlmPersistedHttpMetadata {
|
||||
[key: string]: unknown;
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export interface LlmPersistedObservation {
|
||||
[key: string]: unknown;
|
||||
finishReason: null | string;
|
||||
http: LlmPersistedHttpMetadata | null;
|
||||
mode: LlmMode;
|
||||
model: string;
|
||||
outputLength: null | number;
|
||||
outputPreview: null | string;
|
||||
provider: LlmProvider;
|
||||
rawFinishReason: null | string;
|
||||
stream: LlmStreamObservation | null;
|
||||
usage: LlmUsageObservation | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export const LLM_HEADERS_MAX = 20;
|
||||
export const LLM_OUTPUT_PREVIEW_MAX = 512;
|
||||
|
||||
export type LlmMode = "http" | "stream";
|
||||
|
||||
export interface LlmOptions {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface CheckerContext {
|
||||
}
|
||||
|
||||
export interface CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
|
||||
buildDetail(observation: Record<string, unknown>): null | string;
|
||||
readonly configKey: string;
|
||||
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
|
||||
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
|
||||
|
||||
@@ -13,7 +13,6 @@ import { udpCheckerSchemas } from "./schema";
|
||||
import { validateUdpConfig } from "./validate";
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = 4096;
|
||||
const RESPONSE_PREVIEW_MAX = 80;
|
||||
|
||||
type UdpExchangeResult =
|
||||
| {
|
||||
@@ -35,6 +34,24 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
readonly schemas = udpCheckerSchemas;
|
||||
readonly type = "udp";
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const responded = observation["responded"];
|
||||
const durationMs = observation["durationMs"];
|
||||
const duration = typeof durationMs === "number" ? `${durationMs}ms` : "?ms";
|
||||
if (responded !== true) {
|
||||
return `no response in ${duration}`;
|
||||
}
|
||||
const responseSize = observation["responseSize"];
|
||||
const parts: string[] = [
|
||||
`responded in ${duration}, ${typeof responseSize === "number" ? responseSize : "?"} bytes`,
|
||||
];
|
||||
const preview = observation["responsePreview"];
|
||||
if (typeof preview === "string" && preview.length > 0) {
|
||||
parts.push(`response: ${preview.length > 80 ? `${preview.slice(0, 80)}…` : preview}`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async execute(t: ResolvedUdpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
@@ -47,35 +64,21 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
|
||||
if (!exchangeResult.ok) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
if (expect?.responded === false) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: exchangeResult.error,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
const observation: Record<string, unknown> = {
|
||||
durationMs,
|
||||
error: exchangeResult.error,
|
||||
responded: false,
|
||||
responsePreview: null,
|
||||
responseSize: null,
|
||||
sourceAddress: null,
|
||||
sourcePort: null,
|
||||
};
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("response", "response", exchangeResult.error),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const expectedResponded = expect?.responded ?? true;
|
||||
const respondedResult = checkResponded(exchangeResult.responded, expectedResponded);
|
||||
if (!respondedResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: respondedResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -83,6 +86,31 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
|
||||
if (!exchangeResult.responded) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const expectedResponded = expect?.responded ?? true;
|
||||
const noResponseMessage = "未收到 UDP 响应";
|
||||
const error = expectedResponded ? noResponseMessage : null;
|
||||
const observation: Record<string, unknown> = {
|
||||
durationMs,
|
||||
error,
|
||||
responded: false,
|
||||
responsePreview: null,
|
||||
responseSize: null,
|
||||
sourceAddress: null,
|
||||
sourcePort: null,
|
||||
};
|
||||
|
||||
if (expectedResponded) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("response", "response", noResponseMessage),
|
||||
matched: false,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
@@ -90,39 +118,69 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: buildNoResponseDetail(durationMs),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: buildNoResponseDetail(durationMs),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const responsePreview = truncateResponsePreview(encodeResponse(exchangeResult.data, t.udp.responseEncoding));
|
||||
|
||||
const observation: Record<string, unknown> = {
|
||||
durationMs,
|
||||
error: null,
|
||||
responded: true,
|
||||
responsePreview,
|
||||
responseSize: exchangeResult.data.byteLength,
|
||||
sourceAddress: exchangeResult.sourceAddress,
|
||||
sourcePort: exchangeResult.sourcePort,
|
||||
};
|
||||
|
||||
const expectedResponded = expect?.responded ?? true;
|
||||
const respondedResult = checkResponded(true, expectedResponded);
|
||||
if (!respondedResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: respondedResult.failure,
|
||||
matched: false,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (exchangeResult.flags.truncated) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("response", "response", "响应 datagram 被内核截断"),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (exchangeResult.data.byteLength > t.udp.maxResponseBytes) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
observation["error"] = `响应超过 ${t.udp.maxResponseBytes} 字节限制 (${exchangeResult.data.byteLength} bytes)`;
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"response",
|
||||
@@ -130,7 +188,7 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
`响应超过 ${t.udp.maxResponseBytes} 字节限制 (${exchangeResult.data.byteLength} bytes)`,
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -139,12 +197,12 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
if (expect?.responseSize) {
|
||||
const sizeResult = checkResponseSize(exchangeResult.data.byteLength, expect.responseSize);
|
||||
if (!sizeResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: sizeResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -155,12 +213,12 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
const responseText = encodeResponse(exchangeResult.data, t.udp.responseEncoding);
|
||||
const textResult = checkResponseText(responseText, expect.response);
|
||||
if (!textResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: textResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -170,12 +228,12 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
if (expect?.sourceHost) {
|
||||
const sourceResult = checkSourceHost(exchangeResult.sourceAddress, expect.sourceHost);
|
||||
if (!sourceResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: sourceResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -185,19 +243,18 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
if (expect?.sourcePort) {
|
||||
const sourceResult = checkSourcePort(exchangeResult.sourcePort, expect.sourcePort);
|
||||
if (!sourceResult.matched) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: sourceResult.failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
@@ -205,40 +262,33 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
});
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: buildRespondedDetail(
|
||||
exchangeResult.data.byteLength,
|
||||
durationMs,
|
||||
t.udp.responseEncoding,
|
||||
exchangeResult.data,
|
||||
),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: buildRespondedDetail(
|
||||
exchangeResult.data.byteLength,
|
||||
durationMs,
|
||||
t.udp.responseEncoding,
|
||||
exchangeResult.data,
|
||||
),
|
||||
observation,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("response", "response", isError(error) ? error.message : String(error)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
@@ -287,20 +337,6 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildNoResponseDetail(durationMs: number): string {
|
||||
return `no response in ${durationMs}ms`;
|
||||
}
|
||||
|
||||
function buildRespondedDetail(size: number, durationMs: number, encoding: string, data: Uint8Array): string {
|
||||
let detail = `responded in ${durationMs}ms, ${size} bytes`;
|
||||
if (size > 0 && size <= RESPONSE_PREVIEW_MAX) {
|
||||
const preview = encodeResponse(data, encoding as "base64" | "hex" | "text");
|
||||
const truncated = preview.length > RESPONSE_PREVIEW_MAX ? `${preview.slice(0, RESPONSE_PREVIEW_MAX)}…` : preview;
|
||||
detail = `${detail}, response: ${truncated}`;
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function simplifyUdpError(message: string): string {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
|
||||
@@ -311,6 +347,11 @@ function simplifyUdpError(message: string): string {
|
||||
return message;
|
||||
}
|
||||
|
||||
function truncateResponsePreview(text: string, maxLen = 512): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen);
|
||||
}
|
||||
|
||||
async function udpExchange(
|
||||
hostname: string,
|
||||
port: number,
|
||||
|
||||
@@ -28,7 +28,7 @@ CREATE TABLE IF NOT EXISTS check_results (
|
||||
timestamp TEXT NOT NULL,
|
||||
matched INTEGER NOT NULL,
|
||||
duration_ms REAL,
|
||||
status_detail TEXT,
|
||||
observation TEXT,
|
||||
failure TEXT,
|
||||
FOREIGN KEY (target_id) REFERENCES targets(id) ON DELETE CASCADE
|
||||
)
|
||||
@@ -281,21 +281,21 @@ export class ProbeStore {
|
||||
durationMs: null | number;
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
statusDetail: null | string;
|
||||
observation: null | Record<string, unknown>;
|
||||
targetId: string;
|
||||
timestamp: string;
|
||||
}): void {
|
||||
if (this.closed) return;
|
||||
this.db
|
||||
.query(
|
||||
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, observation, failure) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
result.targetId,
|
||||
result.timestamp,
|
||||
result.matched ? 1 : 0,
|
||||
result.durationMs,
|
||||
result.statusDetail,
|
||||
result.observation ? JSON.stringify(result.observation) : null,
|
||||
result.failure ? JSON.stringify(result.failure) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface StoredCheckResult {
|
||||
failure: null | string;
|
||||
id: number;
|
||||
matched: number;
|
||||
status_detail: null | string;
|
||||
observation: null | string;
|
||||
target_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
|
||||
import type { StoredCheckResult } from "./checker/types";
|
||||
|
||||
import { checkerRegistry } from "./checker/runner";
|
||||
|
||||
export function createApiError(error: string, status: number): ApiErrorResponse {
|
||||
return { error, status };
|
||||
}
|
||||
@@ -45,7 +47,7 @@ export function jsonResponse(
|
||||
});
|
||||
}
|
||||
|
||||
export function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
export function mapCheckResult(row: StoredCheckResult, type: string): CheckResult {
|
||||
let failure: CheckFailure | null = null;
|
||||
if (row.failure) {
|
||||
try {
|
||||
@@ -56,11 +58,32 @@ export function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
}
|
||||
}
|
||||
|
||||
let observation: null | Record<string, unknown> = null;
|
||||
if (row.observation) {
|
||||
try {
|
||||
observation = JSON.parse(row.observation) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.warn(`无法解析 observation 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
|
||||
observation = null;
|
||||
}
|
||||
}
|
||||
|
||||
let detail: null | string = null;
|
||||
if (observation !== null) {
|
||||
try {
|
||||
const checker = checkerRegistry.get(type);
|
||||
detail = checker.buildDetail(observation);
|
||||
} catch {
|
||||
detail = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
detail,
|
||||
durationMs: row.duration_ms,
|
||||
failure,
|
||||
matched: row.matched === 1,
|
||||
statusDetail: row.status_detail,
|
||||
observation,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode):
|
||||
group: target.grp,
|
||||
id: target.id,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
latestCheck: latest ? mapCheckResult(latest, target.type) : null,
|
||||
name: target.name,
|
||||
recentSamples: recentSamples.map((sample) => ({
|
||||
durationMs: sample.duration_ms,
|
||||
|
||||
@@ -21,7 +21,7 @@ export function handleHistory(idStr: string, url: URL, store: ProbeStore, mode:
|
||||
|
||||
const result = store.getHistory(idResult.id, timeResult.from, timeResult.to, pageResult.page, pageResult.pageSize);
|
||||
const response: HistoryResponse = {
|
||||
items: result.items.map(mapCheckResult),
|
||||
items: result.items.map((row) => mapCheckResult(row, target.type)),
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
|
||||
Reference in New Issue
Block a user