feat: 重构为多类型 checker 通用框架,支持 HTTP 与命令检查
- 引入 typed target 判别联合,支持 http 与 command 两种 checker - expect 重构为有序规则数组,按配置顺序快速失败并生成结构化 failure - 新增 command runner,支持 exec + args 本地命令执行 - 引入全局并发限制 maxConcurrentChecks 和 size 解析 (KB/MB/GB) - HTTP/command 各自独立 expect pipeline,应用领域默认成功语义 - SQLite schema、API、Dashboard 全链路调整为 checker 通用契约 - 补充完整测试覆盖(192 tests),更新 README 与示例配置
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ApiErrorResponse,
|
||||
CheckFailure,
|
||||
CheckResult,
|
||||
HealthResponse,
|
||||
RuntimeMode,
|
||||
@@ -136,7 +137,7 @@ function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore,
|
||||
|
||||
const trend: TrendPoint[] = store.getTrend(id, hours).map((row) => ({
|
||||
hour: row.hour,
|
||||
avgLatencyMs: row.avgLatencyMs,
|
||||
avgDurationMs: row.avgDurationMs,
|
||||
availability: Math.round(row.availability * 100) / 100,
|
||||
totalChecks: row.totalChecks,
|
||||
}));
|
||||
@@ -150,7 +151,7 @@ function createSummaryResponse(store: ProbeStore): SummaryResponse {
|
||||
total: summary.total,
|
||||
up: summary.up,
|
||||
down: summary.down,
|
||||
avgLatencyMs: summary.avgLatencyMs,
|
||||
avgDurationMs: summary.avgDurationMs,
|
||||
lastCheckTime: summary.lastCheckTime,
|
||||
};
|
||||
}
|
||||
@@ -165,29 +166,34 @@ function createTargetsResponse(store: ProbeStore): TargetStatus[] {
|
||||
return {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
url: target.url,
|
||||
method: target.method,
|
||||
type: target.type,
|
||||
target: target.target,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
sparkline: store.getSparkline(target.id),
|
||||
stats: {
|
||||
totalChecks: stats.totalChecks,
|
||||
availability: stats.availability,
|
||||
avgLatencyMs: stats.avgLatencyMs,
|
||||
p99LatencyMs: stats.p99LatencyMs,
|
||||
avgDurationMs: stats.avgDurationMs,
|
||||
p99DurationMs: stats.p99DurationMs,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
let failure: CheckFailure | null = null;
|
||||
if (row.failure) {
|
||||
failure = JSON.parse(row.failure) as CheckFailure;
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: row.timestamp,
|
||||
success: row.success === 1,
|
||||
statusCode: row.status_code,
|
||||
latencyMs: row.latency_ms,
|
||||
error: row.error,
|
||||
matched: row.matched === 1,
|
||||
durationMs: row.duration_ms,
|
||||
statusDetail: row.status_detail,
|
||||
failure,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import type { BodyExpectConfig, CssExpect, ExpectOperator, ExpectValue } from "./types";
|
||||
import * as cheerio from "cheerio";
|
||||
import * as xpath from "xpath";
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
|
||||
const segments = path.slice(2).split(".");
|
||||
let current: unknown = json;
|
||||
|
||||
for (const seg of segments) {
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch) {
|
||||
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
|
||||
const idx = parseInt(bracketMatch[2]!, 10);
|
||||
if (!Array.isArray(current) || idx >= current.length) return undefined;
|
||||
current = current[idx];
|
||||
} else {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[seg];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
for (const [key, expected] of Object.entries(op)) {
|
||||
if (expected === undefined) continue;
|
||||
|
||||
switch (key) {
|
||||
case "equals":
|
||||
if (actual !== expected) return false;
|
||||
break;
|
||||
case "contains":
|
||||
if (!String(actual).includes(expected as string)) return false;
|
||||
break;
|
||||
case "match":
|
||||
if (!new RegExp(expected as string).test(String(actual))) return false;
|
||||
break;
|
||||
case "empty": {
|
||||
const isEmpty =
|
||||
actual === null ||
|
||||
actual === undefined ||
|
||||
actual === "" ||
|
||||
(Array.isArray(actual) && actual.length === 0) ||
|
||||
(typeof actual === "object" && Object.keys(actual as object).length === 0);
|
||||
if (expected !== isEmpty) return false;
|
||||
break;
|
||||
}
|
||||
case "exists":
|
||||
if (expected) {
|
||||
if (actual === undefined) return false;
|
||||
} else {
|
||||
if (actual !== undefined) return false;
|
||||
}
|
||||
break;
|
||||
case "gte":
|
||||
if (!(Number(actual) >= (expected as number))) return false;
|
||||
break;
|
||||
case "lte":
|
||||
if (!(Number(actual) <= (expected as number))) return false;
|
||||
break;
|
||||
case "gt":
|
||||
if (!(Number(actual) > (expected as number))) return false;
|
||||
break;
|
||||
case "lt":
|
||||
if (!(Number(actual) < (expected as number))) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||
if (isObject(expected)) {
|
||||
return applyOperator(actual, expected as ExpectOperator);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
||||
}
|
||||
|
||||
function checkBodyContains(body: string, contains: string): boolean {
|
||||
return body.includes(contains);
|
||||
}
|
||||
|
||||
function checkBodyRegex(body: string, regex: string): boolean {
|
||||
return new RegExp(regex).test(body);
|
||||
}
|
||||
|
||||
function checkBodyJson(body: string, rules: Record<string, ExpectValue>): boolean {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [path, expected] of Object.entries(rules)) {
|
||||
const actual = evaluateJsonPath(json, path);
|
||||
if (!checkExpectValue(actual, expected)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkBodyCss(body: string, rules: Record<string, CssExpect>): boolean {
|
||||
let $: cheerio.CheerioAPI;
|
||||
try {
|
||||
$ = cheerio.load(body);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [selector, expected] of Object.entries(rules)) {
|
||||
if (!checkCssRule($, selector, expected)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkCssRule($: cheerio.CheerioAPI, selector: string, expected: CssExpect): boolean {
|
||||
if (!isObject(expected)) {
|
||||
const el = $(selector);
|
||||
return el.length > 0 && el.text() === String(expected);
|
||||
}
|
||||
|
||||
const rule = expected as ExpectOperator & { attr?: string };
|
||||
const { attr, ...operators } = rule;
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
if (attr !== undefined) {
|
||||
return $(selector).attr(attr) !== undefined;
|
||||
}
|
||||
return $(selector).length > 0;
|
||||
}
|
||||
|
||||
if (operators.exists === true) {
|
||||
return $(selector).length > 0;
|
||||
}
|
||||
if (operators.exists === false) {
|
||||
return $(selector).length === 0;
|
||||
}
|
||||
|
||||
const el = $(selector);
|
||||
if (el.length === 0) return false;
|
||||
|
||||
const actual = attr ? el.attr(attr) : el.text();
|
||||
return applyOperator(actual ?? "", operators);
|
||||
}
|
||||
|
||||
function checkBodyXpath(body: string, rules: Record<string, ExpectValue>): boolean {
|
||||
let doc: ReturnType<DOMParser["parseFromString"]>;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(body, "text/xml");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [path, expected] of Object.entries(rules)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const nodes = xpath.select(path, doc as any);
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) return false;
|
||||
|
||||
const node = nodes[0]!;
|
||||
const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? "";
|
||||
|
||||
if (!checkExpectValue(actual, expected)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkBodyExpect(body: string, config?: BodyExpectConfig): boolean {
|
||||
if (!config) return true;
|
||||
|
||||
if (config.contains !== undefined && !checkBodyContains(body, config.contains)) return false;
|
||||
if (config.regex !== undefined && !checkBodyRegex(body, config.regex)) return false;
|
||||
if (config.json !== undefined && !checkBodyJson(body, config.json)) return false;
|
||||
if (config.css !== undefined && !checkBodyCss(body, config.css)) return false;
|
||||
if (config.xpath !== undefined && !checkBodyXpath(body, config.xpath)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
155
src/server/checker/command-runner.ts
Normal file
155
src/server/checker/command-runner.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { CheckResult, ResolvedCommandTarget } from "./types";
|
||||
import { checkCommandExpect } from "./expect/command";
|
||||
import { errorFailure } from "./expect/failure";
|
||||
|
||||
async function readOutput(
|
||||
stdout: ReadableStream<Uint8Array>,
|
||||
stderr: ReadableStream<Uint8Array>,
|
||||
kill: () => void,
|
||||
maxBytes: number,
|
||||
): Promise<{ stdout: string; stderr: string; exceeded: boolean }> {
|
||||
let totalBytes = 0;
|
||||
let exceeded = false;
|
||||
let killed = false;
|
||||
|
||||
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
if (totalBytes > maxBytes && !killed) {
|
||||
exceeded = true;
|
||||
killed = true;
|
||||
try {
|
||||
kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* stream already closed */
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
const [out, err] = await Promise.all([readStream(stdout), readStream(stderr)]);
|
||||
|
||||
return { stdout: out, stderr: err, exceeded };
|
||||
}
|
||||
|
||||
export async function runCommandCheck(target: ResolvedCommandTarget): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
let proc: ReturnType<typeof Bun.spawn>;
|
||||
|
||||
try {
|
||||
proc = Bun.spawn([target.command.exec, ...target.command.args], {
|
||||
cwd: target.command.cwd,
|
||||
env: target.command.env,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: false,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "spawn", error instanceof Error ? error.message : String(error)),
|
||||
};
|
||||
}
|
||||
|
||||
let timedOut = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}, target.timeoutMs);
|
||||
|
||||
let outputResult: { stdout: string; stderr: string; exceeded: boolean };
|
||||
|
||||
try {
|
||||
outputResult = await readOutput(
|
||||
proc.stdout as ReadableStream<Uint8Array>,
|
||||
proc.stderr as ReadableStream<Uint8Array>,
|
||||
() => proc.kill(),
|
||||
target.command.maxOutputBytes,
|
||||
);
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: false,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
};
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: false,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${target.command.maxOutputBytes} 字节`),
|
||||
};
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: false,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${target.timeoutMs}ms)`),
|
||||
};
|
||||
}
|
||||
|
||||
const obs = { exitCode, stdout: outputResult.stdout, stderr: outputResult.stderr, durationMs };
|
||||
const expectResult = checkCommandExpect(obs, target.expect);
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: expectResult.matched,
|
||||
matched: expectResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: expectResult.failure,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,39 @@
|
||||
import type { ProbeConfig, ResolvedTarget } from "./types";
|
||||
import type {
|
||||
CommandDefaultsConfig,
|
||||
CommandTargetConfig,
|
||||
DefaultsConfig,
|
||||
HttpDefaultsConfig,
|
||||
HttpExpectConfig,
|
||||
HttpTargetConfig,
|
||||
ProbeConfig,
|
||||
ResolvedCommandTarget,
|
||||
ResolvedHttpTarget,
|
||||
ResolvedTarget,
|
||||
RuntimeConfig,
|
||||
TargetConfig,
|
||||
TargetType,
|
||||
} from "./types";
|
||||
import { parseSize } from "./size";
|
||||
import { resolve } from "node:path";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 3000;
|
||||
const DEFAULT_DATA_DIR = "./data";
|
||||
const DEFAULT_INTERVAL = "30s";
|
||||
const DEFAULT_TIMEOUT = "10s";
|
||||
const DEFAULT_METHOD = "GET";
|
||||
const DEFAULT_HTTP_METHOD = "GET";
|
||||
const DEFAULT_MAX_BODY_BYTES = "100MB";
|
||||
const DEFAULT_MAX_OUTPUT_BYTES = "100MB";
|
||||
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
||||
const SUPPORTED_TYPES: TargetType[] = ["http", "command"];
|
||||
|
||||
export interface ResolvedConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
dataDir: string;
|
||||
configDir: string;
|
||||
maxConcurrentChecks: number;
|
||||
targets: ResolvedTarget[];
|
||||
}
|
||||
|
||||
@@ -30,7 +53,9 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
|
||||
validateConfig(raw);
|
||||
|
||||
const configDir = dirname(resolve(configPath));
|
||||
const server = raw.server ?? {};
|
||||
const runtime = raw.runtime ?? {};
|
||||
const defaults = raw.defaults ?? {};
|
||||
|
||||
const host = server.host ?? DEFAULT_HOST;
|
||||
@@ -41,23 +66,102 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`);
|
||||
}
|
||||
|
||||
const maxConcurrentChecks = validateRuntime(runtime);
|
||||
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
|
||||
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
|
||||
const defaultMethod = defaults.method ?? DEFAULT_METHOD;
|
||||
const defaultHeaders = defaults.headers ?? {};
|
||||
|
||||
const targets: ResolvedTarget[] = raw.targets.map((target) => ({
|
||||
const targets: ResolvedTarget[] = raw.targets.map((target) =>
|
||||
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
|
||||
);
|
||||
|
||||
return { host, port, dataDir, configDir, maxConcurrentChecks, targets };
|
||||
}
|
||||
|
||||
function validateRuntime(runtime: RuntimeConfig): number {
|
||||
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
|
||||
|
||||
if (
|
||||
typeof runtime.maxConcurrentChecks !== "number" ||
|
||||
!Number.isInteger(runtime.maxConcurrentChecks) ||
|
||||
runtime.maxConcurrentChecks <= 0
|
||||
) {
|
||||
throw new Error("runtime.maxConcurrentChecks 必须为正整数");
|
||||
}
|
||||
|
||||
return runtime.maxConcurrentChecks;
|
||||
}
|
||||
|
||||
function resolveTarget(
|
||||
target: TargetConfig,
|
||||
defaults: DefaultsConfig,
|
||||
defaultIntervalMs: number,
|
||||
defaultTimeoutMs: number,
|
||||
configDir: string,
|
||||
): ResolvedTarget {
|
||||
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
|
||||
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
|
||||
|
||||
if (target.type === "http") {
|
||||
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs);
|
||||
}
|
||||
|
||||
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir);
|
||||
}
|
||||
|
||||
function resolveHttpTarget(
|
||||
target: TargetConfig & { type: "http"; http: HttpTargetConfig },
|
||||
httpDefaults: HttpDefaultsConfig | undefined,
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
): ResolvedHttpTarget {
|
||||
const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
|
||||
|
||||
return {
|
||||
type: "http",
|
||||
name: target.name,
|
||||
url: target.url,
|
||||
method: target.method ?? defaultMethod,
|
||||
headers: { ...defaultHeaders, ...(target.headers ?? {}) },
|
||||
body: target.body,
|
||||
intervalMs: parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL),
|
||||
timeoutMs: parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT),
|
||||
expect: target.expect,
|
||||
}));
|
||||
http: {
|
||||
url: target.http.url,
|
||||
method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD,
|
||||
headers: { ...(httpDefaults?.headers ?? {}), ...(target.http.headers ?? {}) },
|
||||
body: target.http.body,
|
||||
maxBodyBytes,
|
||||
},
|
||||
intervalMs,
|
||||
timeoutMs,
|
||||
expect: target.expect as HttpExpectConfig | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { host, port, dataDir, targets };
|
||||
function resolveCommandTarget(
|
||||
target: TargetConfig & { type: "command"; command: CommandTargetConfig },
|
||||
commandDefaults: CommandDefaultsConfig | undefined,
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
configDir: string,
|
||||
): ResolvedCommandTarget {
|
||||
const cwd = target.command.cwd ?? commandDefaults?.cwd ?? ".";
|
||||
const resolvedCwd = resolve(configDir, cwd);
|
||||
|
||||
const maxOutputBytes = parseSize(
|
||||
target.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
||||
);
|
||||
|
||||
const env = { ...process.env, ...(target.command.env ?? {}) } as Record<string, string>;
|
||||
|
||||
return {
|
||||
type: "command",
|
||||
name: target.name,
|
||||
command: {
|
||||
exec: target.command.exec,
|
||||
args: target.command.args ?? [],
|
||||
cwd: resolvedCwd,
|
||||
env,
|
||||
maxOutputBytes,
|
||||
},
|
||||
intervalMs,
|
||||
timeoutMs,
|
||||
expect: target.expect as import("./types").CommandExpectConfig | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function validateConfig(config: ProbeConfig): void {
|
||||
@@ -68,21 +172,41 @@ function validateConfig(config: ProbeConfig): void {
|
||||
const names = new Set<string>();
|
||||
|
||||
for (let i = 0; i < config.targets.length; i++) {
|
||||
const target = config.targets[i]!;
|
||||
const raw = config.targets[i] as unknown as Record<string, unknown>;
|
||||
|
||||
if (!target.name || typeof target.name !== "string" || target.name.trim() === "") {
|
||||
const name = raw["name"];
|
||||
if (!name || typeof name !== "string" || (name as string).trim() === "") {
|
||||
throw new Error(`第 ${i + 1} 个 target 缺少 name 字段`);
|
||||
}
|
||||
|
||||
if (!target.url || typeof target.url !== "string" || target.url.trim() === "") {
|
||||
throw new Error(`target "${target.name}" 缺少 url 字段`);
|
||||
const type = raw["type"];
|
||||
if (!type || typeof type !== "string") {
|
||||
throw new Error(`target "${name}" 缺少 type 字段`);
|
||||
}
|
||||
|
||||
if (names.has(target.name)) {
|
||||
throw new Error(`target name 重复: "${target.name}"`);
|
||||
if (!SUPPORTED_TYPES.includes(type as TargetType)) {
|
||||
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${SUPPORTED_TYPES.join(", ")}`);
|
||||
}
|
||||
|
||||
names.add(target.name);
|
||||
if (type === "http") {
|
||||
const http = raw["http"] as Record<string, unknown> | undefined;
|
||||
if (!http?.["url"] || typeof http["url"] !== "string" || (http["url"] as string).trim() === "") {
|
||||
throw new Error(`target "${name}" 缺少 http.url 字段`);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "command") {
|
||||
const cmd = raw["command"] as Record<string, unknown> | undefined;
|
||||
if (!cmd?.["exec"] || typeof cmd["exec"] !== "string" || (cmd["exec"] as string).trim() === "") {
|
||||
throw new Error(`target "${name}" 缺少 command.exec 字段`);
|
||||
}
|
||||
}
|
||||
|
||||
if (names.has(name as string)) {
|
||||
throw new Error(`target name 重复: "${name}"`);
|
||||
}
|
||||
|
||||
names.add(name as string);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +214,6 @@ const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
|
||||
|
||||
export function parseDuration(value: string): number {
|
||||
const match = DURATION_REGEX.exec(value);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`无效的时长格式: "${value}",支持格式如 "30s"、"5m"、"500ms"`);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import type { CheckResult, ResolvedTarget } from "./types";
|
||||
import type { ProbeStore } from "./store";
|
||||
import { fetchTarget } from "./fetcher";
|
||||
import { runHttpCheck } from "./fetcher";
|
||||
import { runCommandCheck } from "./command-runner";
|
||||
|
||||
export class ProbeEngine {
|
||||
private timers: ReturnType<typeof setInterval>[] = [];
|
||||
private store: ProbeStore;
|
||||
private targets: ResolvedTarget[];
|
||||
private targetNameToId: Map<string, number> = new Map();
|
||||
private maxConcurrentChecks: number;
|
||||
private running = 0;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
constructor(store: ProbeStore, targets: ResolvedTarget[]) {
|
||||
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
|
||||
this.store = store;
|
||||
this.targets = targets;
|
||||
this.maxConcurrentChecks = maxConcurrentChecks ?? 10;
|
||||
this.refreshCache();
|
||||
}
|
||||
|
||||
@@ -46,8 +52,36 @@ export class ProbeEngine {
|
||||
return groups;
|
||||
}
|
||||
|
||||
private async acquire(): Promise<void> {
|
||||
if (this.running < this.maxConcurrentChecks) {
|
||||
this.running++;
|
||||
return;
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.queue.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
this.running--;
|
||||
}
|
||||
}
|
||||
|
||||
private async probeGroup(targets: ResolvedTarget[]): Promise<void> {
|
||||
const results = await Promise.allSettled(targets.map((t) => this.probeOne(t)));
|
||||
const results = await Promise.allSettled(
|
||||
targets.map(async (target) => {
|
||||
await this.acquire();
|
||||
try {
|
||||
return await this.runCheck(target);
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
@@ -56,23 +90,27 @@ export class ProbeEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private async probeOne(target: ResolvedTarget): Promise<CheckResult> {
|
||||
return fetchTarget(target);
|
||||
private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
|
||||
switch (target.type) {
|
||||
case "http":
|
||||
return runHttpCheck(target);
|
||||
case "command":
|
||||
return runCommandCheck(target);
|
||||
}
|
||||
}
|
||||
|
||||
private writeResult(result: CheckResult): void {
|
||||
const targetId = this.targetNameToId.get(result.targetName);
|
||||
|
||||
if (!targetId) return;
|
||||
|
||||
this.store.insertCheckResult({
|
||||
targetId,
|
||||
timestamp: result.timestamp,
|
||||
success: result.success,
|
||||
statusCode: result.statusCode,
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
matched: result.matched,
|
||||
durationMs: result.durationMs,
|
||||
statusDetail: result.statusDetail,
|
||||
failure: result.failure,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,6 +120,4 @@ export class ProbeEngine {
|
||||
this.targetNameToId.set(target.name, target.id);
|
||||
}
|
||||
}
|
||||
|
||||
private targets: ResolvedTarget[];
|
||||
}
|
||||
|
||||
302
src/server/checker/expect/body.ts
Normal file
302
src/server/checker/expect/body.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import type { BodyRule, CheckFailure, CssRule, ExpectOperator, ExpectValue, JsonRule, XpathRule } from "../types";
|
||||
import * as cheerio from "cheerio";
|
||||
import * as xpath from "xpath";
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import { mismatchFailure, errorFailure } from "./failure";
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
|
||||
const segments = path.slice(2).split(".");
|
||||
let current: unknown = json;
|
||||
|
||||
for (const seg of segments) {
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch) {
|
||||
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
|
||||
const idx = parseInt(bracketMatch[2]!, 10);
|
||||
if (!Array.isArray(current) || idx >= current.length) return undefined;
|
||||
current = current[idx];
|
||||
} else {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[seg];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
for (const [key, expected] of Object.entries(op)) {
|
||||
if (expected === undefined) continue;
|
||||
|
||||
switch (key) {
|
||||
case "equals":
|
||||
if (actual !== expected) return false;
|
||||
break;
|
||||
case "contains":
|
||||
if (!String(actual).includes(expected as string)) return false;
|
||||
break;
|
||||
case "match":
|
||||
if (!new RegExp(expected as string).test(String(actual))) return false;
|
||||
break;
|
||||
case "empty": {
|
||||
const isEmpty =
|
||||
actual === null ||
|
||||
actual === undefined ||
|
||||
actual === "" ||
|
||||
(Array.isArray(actual) && actual.length === 0) ||
|
||||
(typeof actual === "object" && Object.keys(actual as object).length === 0);
|
||||
if (expected !== isEmpty) return false;
|
||||
break;
|
||||
}
|
||||
case "exists":
|
||||
if (expected) {
|
||||
if (actual === undefined) return false;
|
||||
} else {
|
||||
if (actual !== undefined) return false;
|
||||
}
|
||||
break;
|
||||
case "gte":
|
||||
if (!(Number(actual) >= (expected as number))) return false;
|
||||
break;
|
||||
case "lte":
|
||||
if (!(Number(actual) <= (expected as number))) return false;
|
||||
break;
|
||||
case "gt":
|
||||
if (!(Number(actual) > (expected as number))) return false;
|
||||
break;
|
||||
case "lt":
|
||||
if (!(Number(actual) < (expected as number))) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||
if (isObject(expected)) {
|
||||
return applyOperator(actual, expected as ExpectOperator);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
||||
}
|
||||
|
||||
function checkJsonRule(
|
||||
body: string,
|
||||
rule: JsonRule,
|
||||
rulePath: string,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
const { path, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.json(${path})`;
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", fullPath, "body is not valid JSON"),
|
||||
};
|
||||
}
|
||||
|
||||
const actual = evaluateJsonPath(json, path);
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
if (actual === undefined) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "defined", actual, `path ${path} is undefined`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
const matched = applyOperator(actual, operators);
|
||||
if (!matched) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `json path ${path} mismatch`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkCssRule(
|
||||
body: string,
|
||||
rule: CssRule,
|
||||
rulePath: string,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
const { selector, attr, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
|
||||
|
||||
let $: cheerio.CheerioAPI;
|
||||
try {
|
||||
$ = cheerio.load(body);
|
||||
} catch {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", fullPath, "failed to parse HTML"),
|
||||
};
|
||||
}
|
||||
|
||||
const el = $(selector);
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
if (attr !== undefined) {
|
||||
if (el.attr(attr) === undefined) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
if (el.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
if (operators.exists === true) {
|
||||
if (el.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
if (operators.exists === false) {
|
||||
if (el.length > 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, false, true, `selector ${selector} exists`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
if (el.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
|
||||
};
|
||||
}
|
||||
|
||||
const actual = attr ? el.attr(attr) : el.text();
|
||||
const matched = applyOperator(actual ?? "", operators);
|
||||
if (!matched) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `css selector ${selector} mismatch`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkXpathRule(
|
||||
body: string,
|
||||
rule: XpathRule,
|
||||
rulePath: string,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
const { path, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.xpath(${path})`;
|
||||
|
||||
let doc: ReturnType<DOMParser["parseFromString"]>;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(body, "text/xml");
|
||||
} catch {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", fullPath, "failed to parse XML/HTML"),
|
||||
};
|
||||
}
|
||||
|
||||
const nodes = xpath.select(path, doc as unknown as Node);
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`),
|
||||
};
|
||||
}
|
||||
|
||||
const node = nodes[0]!;
|
||||
const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? "";
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
const matched = applyOperator(actual, operators);
|
||||
if (!matched) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `xpath ${path} mismatch`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkSingleBodyRule(
|
||||
body: string,
|
||||
rule: BodyRule,
|
||||
index: number,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
const rulePath = `body[${index}]`;
|
||||
|
||||
if ("contains" in rule) {
|
||||
const matched = body.includes(rule.contains);
|
||||
if (!matched) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", rulePath, rule.contains, body, `body does not contain "${rule.contains}"`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
if ("regex" in rule) {
|
||||
const matched = new RegExp(rule.regex).test(body);
|
||||
if (!matched) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", rulePath, `/${rule.regex}/`, body, `body does not match /${rule.regex}/`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
if ("json" in rule) {
|
||||
return checkJsonRule(body, rule.json, rulePath);
|
||||
}
|
||||
|
||||
if ("css" in rule) {
|
||||
return checkCssRule(body, rule.css, rulePath);
|
||||
}
|
||||
|
||||
if ("xpath" in rule) {
|
||||
return checkXpathRule(body, rule.xpath, rulePath);
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
export function checkBodyExpect(body: string, rules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!rules || rules.length === 0) return { matched: true, failure: null };
|
||||
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const result = checkSingleBodyRule(body, rules[i]!, i);
|
||||
if (!result.matched) return result;
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
91
src/server/checker/expect/command.ts
Normal file
91
src/server/checker/expect/command.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { CheckFailure, CommandExpectConfig, TextRule } from "../types";
|
||||
import { applyOperator } from "./body";
|
||||
import { mismatchFailure } from "./failure";
|
||||
|
||||
export interface CommandObservation {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function checkExitCode(obs: CommandObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!allowed.includes(obs.exitCode)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"exitCode",
|
||||
"exitCode",
|
||||
allowed,
|
||||
obs.exitCode,
|
||||
`exitCode ${obs.exitCode} not in [${allowed}]`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkDuration(
|
||||
obs: CommandObservation,
|
||||
maxDurationMs?: number,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
||||
if (obs.durationMs > maxDurationMs) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${maxDurationMs}ms`,
|
||||
obs.durationMs,
|
||||
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkTextRules(
|
||||
text: string,
|
||||
rules: TextRule[],
|
||||
phase: "stdout" | "stderr",
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]!;
|
||||
const path = `${phase}[${i}]`;
|
||||
if (!applyOperator(text, rule)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(phase, path, rule, text, `${phase} rule at index ${i} mismatch`),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
export function checkCommandExpect(
|
||||
obs: CommandObservation,
|
||||
expect?: CommandExpectConfig,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!expect) {
|
||||
return checkExitCode(obs, [0]);
|
||||
}
|
||||
|
||||
const exitCodeResult = checkExitCode(obs, expect.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) return exitCodeResult;
|
||||
|
||||
const durationResult = checkDuration(obs, expect.maxDurationMs);
|
||||
if (!durationResult.matched) return durationResult;
|
||||
|
||||
if (expect.stdout && expect.stdout.length > 0) {
|
||||
const stdoutResult = checkTextRules(obs.stdout, expect.stdout, "stdout");
|
||||
if (!stdoutResult.matched) return stdoutResult;
|
||||
}
|
||||
|
||||
if (expect.stderr && expect.stderr.length > 0) {
|
||||
const stderrResult = checkTextRules(obs.stderr, expect.stderr, "stderr");
|
||||
if (!stderrResult.matched) return stderrResult;
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
34
src/server/checker/expect/failure.ts
Normal file
34
src/server/checker/expect/failure.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { CheckFailure } from "../types";
|
||||
|
||||
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
||||
if (value === undefined || value === null) return value;
|
||||
const str = String(value);
|
||||
if (str.length <= maxLen) return value;
|
||||
return str.slice(0, maxLen) + "...";
|
||||
}
|
||||
|
||||
export function mismatchFailure(
|
||||
phase: CheckFailure["phase"],
|
||||
path: string,
|
||||
expected: unknown,
|
||||
actual: unknown,
|
||||
message: string,
|
||||
): CheckFailure {
|
||||
return {
|
||||
kind: "mismatch",
|
||||
phase,
|
||||
path,
|
||||
expected,
|
||||
actual: truncateActual(actual),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
|
||||
return {
|
||||
kind: "error",
|
||||
phase,
|
||||
path,
|
||||
message,
|
||||
};
|
||||
}
|
||||
122
src/server/checker/expect/http.ts
Normal file
122
src/server/checker/expect/http.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { BodyRule, CheckFailure, HeaderExpect, HttpExpectConfig } from "../types";
|
||||
import { checkBodyExpect } from "./body";
|
||||
import { applyOperator } from "./body";
|
||||
import { mismatchFailure, errorFailure } from "./failure";
|
||||
|
||||
export interface HttpObservation {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function checkStatus(obs: HttpObservation, allowed: number[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!allowed.includes(obs.statusCode)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"status",
|
||||
"status",
|
||||
allowed,
|
||||
obs.statusCode,
|
||||
`status ${obs.statusCode} not in [${allowed}]`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkDuration(
|
||||
obs: HttpObservation,
|
||||
maxDurationMs?: number,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
||||
if (obs.durationMs > maxDurationMs) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${maxDurationMs}ms`,
|
||||
obs.durationMs,
|
||||
`duration ${obs.durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkHeaders(
|
||||
obs: HttpObservation,
|
||||
headerExpects?: Record<string, HeaderExpect>,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!headerExpects) return { matched: true, failure: null };
|
||||
|
||||
for (const [key, expected] of Object.entries(headerExpects)) {
|
||||
const actualValue = obs.headers[key.toLowerCase()];
|
||||
const path = `headers.${key}`;
|
||||
|
||||
if (typeof expected === "string") {
|
||||
if (actualValue !== expected) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (actualValue === undefined) {
|
||||
if (expected.exists !== false) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, "defined", undefined, `header ${key} not found`),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!applyOperator(actualValue, expected)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
|
||||
function checkBody(obs: HttpObservation, bodyRules?: BodyRule[]): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!bodyRules || bodyRules.length === 0) return { matched: true, failure: null };
|
||||
|
||||
if (obs.body === null) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", "body", "body is null but body rules are configured"),
|
||||
};
|
||||
}
|
||||
|
||||
return checkBodyExpect(obs.body, bodyRules);
|
||||
}
|
||||
|
||||
export function checkHttpExpect(
|
||||
obs: HttpObservation,
|
||||
expect?: HttpExpectConfig,
|
||||
): { matched: boolean; failure: CheckFailure | null } {
|
||||
if (!expect) {
|
||||
return checkStatus(obs, [200]);
|
||||
}
|
||||
|
||||
const statusResult = checkStatus(obs, expect.status ?? [200]);
|
||||
if (!statusResult.matched) return statusResult;
|
||||
|
||||
const durationResult = checkDuration(obs, expect.maxDurationMs);
|
||||
if (!durationResult.matched) return durationResult;
|
||||
|
||||
const headersResult = checkHeaders(obs, expect.headers);
|
||||
if (!headersResult.matched) return headersResult;
|
||||
|
||||
const bodyResult = checkBody(obs, expect.body);
|
||||
if (!bodyResult.matched) return bodyResult;
|
||||
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
@@ -1,52 +1,6 @@
|
||||
import type { CheckResult, ExpectConfig, ResolvedTarget } from "./types";
|
||||
import { checkBodyExpect } from "./body-expect";
|
||||
|
||||
export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
|
||||
|
||||
try {
|
||||
const start = performance.now();
|
||||
|
||||
const response = await fetch(target.url, {
|
||||
method: target.method,
|
||||
headers: target.headers,
|
||||
body: target.method !== "GET" && target.method !== "HEAD" ? target.body : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const body = await response.text();
|
||||
const responseHeaders = headersToRecord(response.headers);
|
||||
|
||||
const matched = checkExpect(response.status, body, latencyMs, responseHeaders, target.expect);
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: true,
|
||||
statusCode: response.status,
|
||||
latencyMs,
|
||||
error: null,
|
||||
matched,
|
||||
};
|
||||
} catch (error) {
|
||||
const isTimeout = error instanceof DOMException && error.name === "AbortError";
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: false,
|
||||
statusCode: null,
|
||||
latencyMs: null,
|
||||
error: isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error),
|
||||
matched: false,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
import type { CheckResult, ResolvedHttpTarget } from "./types";
|
||||
import { checkHttpExpect } from "./expect/http";
|
||||
import { errorFailure } from "./expect/failure";
|
||||
|
||||
function headersToRecord(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
@@ -56,35 +10,95 @@ function headersToRecord(headers: Headers): Record<string, string> {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function checkExpect(
|
||||
statusCode: number,
|
||||
body: string,
|
||||
latencyMs: number,
|
||||
responseHeaders: Record<string, string>,
|
||||
expect?: ExpectConfig,
|
||||
): boolean {
|
||||
if (!expect) return true;
|
||||
export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), target.timeoutMs);
|
||||
|
||||
if (expect.status && !expect.status.includes(statusCode)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const start = performance.now();
|
||||
|
||||
if (expect.headers) {
|
||||
for (const [key, expectedValue] of Object.entries(expect.headers)) {
|
||||
const actualValue = responseHeaders[key.toLowerCase()];
|
||||
if (!actualValue || actualValue !== expectedValue) {
|
||||
return false;
|
||||
}
|
||||
const response = await fetch(target.http.url, {
|
||||
method: target.http.method,
|
||||
headers: target.http.headers,
|
||||
body: target.http.method !== "GET" && target.http.method !== "HEAD" ? target.http.body : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const statusCode = response.status;
|
||||
const responseHeaders = headersToRecord(response.headers);
|
||||
|
||||
const hasBodyRules = !!(target.expect?.body && target.expect.body.length > 0);
|
||||
|
||||
const preBodyExpect = target.expect
|
||||
? { status: target.expect.status, maxDurationMs: target.expect.maxDurationMs, headers: target.expect.headers }
|
||||
: undefined;
|
||||
|
||||
const preBodyObs = { statusCode, headers: responseHeaders, body: null as string | null, durationMs };
|
||||
const preBodyResult = checkHttpExpect(preBodyObs, preBodyExpect);
|
||||
|
||||
if (!hasBodyRules || !preBodyResult.matched) {
|
||||
clearTimeout(timeoutId);
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: preBodyResult.matched,
|
||||
matched: preBodyResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: preBodyResult.failure,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkBodyExpect(body, expect.body)) {
|
||||
return false;
|
||||
}
|
||||
const bodyBuffer = await response.arrayBuffer();
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (expect.maxLatencyMs !== undefined && latencyMs > expect.maxLatencyMs) {
|
||||
return false;
|
||||
}
|
||||
if (bodyBuffer.byteLength > target.http.maxBodyBytes) {
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: false,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: errorFailure(
|
||||
"body",
|
||||
"body",
|
||||
`响应体大小 ${bodyBuffer.byteLength} 超过限制 ${target.http.maxBodyBytes}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
const body = new TextDecoder().decode(bodyBuffer);
|
||||
const fullObs = { statusCode, headers: responseHeaders, body, durationMs };
|
||||
const fullResult = checkHttpExpect(fullObs, target.expect);
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: fullResult.matched,
|
||||
matched: fullResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: fullResult.failure,
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
const isTimeout = error instanceof DOMException && error.name === "AbortError";
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
timestamp,
|
||||
success: false,
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure: errorFailure(
|
||||
"status",
|
||||
"request",
|
||||
isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
22
src/server/checker/size.ts
Normal file
22
src/server/checker/size.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
|
||||
|
||||
export function parseSize(value: string | number): number {
|
||||
if (typeof value === "number") return value;
|
||||
|
||||
const match = SIZE_REGEX.exec(value);
|
||||
if (!match) {
|
||||
throw new Error(`无效的 size 格式: "${value}",支持格式如 "100MB"、"512KB"、"1GB"、"1024B"`);
|
||||
}
|
||||
|
||||
const num = parseFloat(match[1]!);
|
||||
const unit = match[2]!;
|
||||
|
||||
if (unit === "B") return num;
|
||||
if (unit === "KB") return num * 1024;
|
||||
if (unit === "MB") return num * 1024 * 1024;
|
||||
return num * 1024 * 1024 * 1024;
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_BODY_BYTES = parseSize("100MB");
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = parseSize("100MB");
|
||||
export const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
||||
@@ -1,16 +1,15 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdirSync as fsMkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
|
||||
import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
|
||||
|
||||
const CREATE_TARGETS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
url TEXT NOT NULL,
|
||||
method TEXT NOT NULL DEFAULT 'GET',
|
||||
headers TEXT NOT NULL DEFAULT '{}',
|
||||
body TEXT,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
interval_ms INTEGER NOT NULL,
|
||||
timeout_ms INTEGER NOT NULL,
|
||||
expect TEXT
|
||||
@@ -23,10 +22,10 @@ CREATE TABLE IF NOT EXISTS check_results (
|
||||
target_id INTEGER NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
status_code INTEGER,
|
||||
latency_ms REAL,
|
||||
error TEXT,
|
||||
matched INTEGER NOT NULL,
|
||||
duration_ms REAL,
|
||||
status_detail TEXT,
|
||||
failure TEXT,
|
||||
FOREIGN KEY (target_id) REFERENCES targets(id)
|
||||
)
|
||||
`;
|
||||
@@ -59,40 +58,24 @@ export class ProbeStore {
|
||||
const configNames = new Set(targets.map((t) => t.name));
|
||||
|
||||
const insertStmt = this.db.prepare(
|
||||
"INSERT INTO targets (name, url, method, headers, body, interval_ms, timeout_ms, expect) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
const updateStmt = this.db.prepare(
|
||||
"UPDATE targets SET url = ?, method = ?, headers = ?, body = ?, interval_ms = ?, timeout_ms = ?, expect = ? WHERE id = ?",
|
||||
"UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ? WHERE id = ?",
|
||||
);
|
||||
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
|
||||
|
||||
const tx = this.db.transaction(() => {
|
||||
for (const target of targets) {
|
||||
const headers = JSON.stringify(target.headers);
|
||||
const expect = target.expect ? JSON.stringify(target.expect) : null;
|
||||
for (const t of targets) {
|
||||
const type = t.type;
|
||||
const target = buildTargetDisplay(t);
|
||||
const config = buildTargetConfig(t);
|
||||
const expect = t.expect ? JSON.stringify(t.expect) : null;
|
||||
|
||||
if (existingMap.has(target.name)) {
|
||||
updateStmt.run(
|
||||
target.url,
|
||||
target.method,
|
||||
headers,
|
||||
target.body ?? null,
|
||||
target.intervalMs,
|
||||
target.timeoutMs,
|
||||
expect,
|
||||
existingMap.get(target.name)!,
|
||||
);
|
||||
if (existingMap.has(t.name)) {
|
||||
updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, existingMap.get(t.name)!);
|
||||
} else {
|
||||
insertStmt.run(
|
||||
target.name,
|
||||
target.url,
|
||||
target.method,
|
||||
headers,
|
||||
target.body ?? null,
|
||||
target.intervalMs,
|
||||
target.timeoutMs,
|
||||
expect,
|
||||
);
|
||||
insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,24 +103,24 @@ export class ProbeStore {
|
||||
targetId: number;
|
||||
timestamp: string;
|
||||
success: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
matched: boolean;
|
||||
durationMs: number | null;
|
||||
statusDetail: string | null;
|
||||
failure: CheckFailure | null;
|
||||
}): void {
|
||||
if (this.closed) return;
|
||||
this.db
|
||||
.prepare(
|
||||
"INSERT INTO check_results (target_id, timestamp, success, status_code, latency_ms, error, matched) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO check_results (target_id, timestamp, success, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
result.targetId,
|
||||
result.timestamp,
|
||||
result.success ? 1 : 0,
|
||||
result.statusCode,
|
||||
result.latencyMs,
|
||||
result.error,
|
||||
result.matched ? 1 : 0,
|
||||
result.durationMs,
|
||||
result.statusDetail,
|
||||
result.failure ? JSON.stringify(result.failure) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,30 +139,30 @@ export class ProbeStore {
|
||||
getTargetStats(targetId: number): {
|
||||
totalChecks: number;
|
||||
availability: number;
|
||||
avgLatencyMs: number | null;
|
||||
p99LatencyMs: number | null;
|
||||
avgDurationMs: number | null;
|
||||
p99DurationMs: number | null;
|
||||
} {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as totalChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount,
|
||||
AVG(CASE WHEN success = 1 THEN latency_ms END) as avgLatencyMs
|
||||
AVG(CASE WHEN success = 1 THEN duration_ms END) as avgDurationMs
|
||||
FROM check_results
|
||||
WHERE target_id = ?`,
|
||||
)
|
||||
.get(targetId) as { totalChecks: number; upCount: number; avgLatencyMs: number | null };
|
||||
.get(targetId) as { totalChecks: number; upCount: number; avgDurationMs: number | null };
|
||||
|
||||
const p99Row = this.db
|
||||
.prepare(
|
||||
`SELECT latency_ms as p99LatencyMs
|
||||
`SELECT duration_ms as p99DurationMs
|
||||
FROM check_results
|
||||
WHERE target_id = ? AND success = 1
|
||||
ORDER BY latency_ms DESC
|
||||
ORDER BY duration_ms DESC
|
||||
LIMIT 1
|
||||
OFFSET (SELECT COUNT(*) FROM check_results WHERE target_id = ? AND success = 1) * 99 / 100`,
|
||||
)
|
||||
.get(targetId, targetId) as { p99LatencyMs: number | null } | undefined;
|
||||
.get(targetId, targetId) as { p99DurationMs: number | null } | undefined;
|
||||
|
||||
const totalChecks = row.totalChecks;
|
||||
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
|
||||
@@ -187,8 +170,8 @@ export class ProbeStore {
|
||||
return {
|
||||
totalChecks,
|
||||
availability: Math.round(availability * 100) / 100,
|
||||
avgLatencyMs: row.avgLatencyMs !== null ? Math.round(row.avgLatencyMs * 100) / 100 : null,
|
||||
p99LatencyMs: p99Row?.p99LatencyMs ?? null,
|
||||
avgDurationMs: row.avgDurationMs !== null ? Math.round(row.avgDurationMs * 100) / 100 : null,
|
||||
p99DurationMs: p99Row?.p99DurationMs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,7 +180,7 @@ export class ProbeStore {
|
||||
hours = 24,
|
||||
): Array<{
|
||||
hour: string;
|
||||
avgLatencyMs: number | null;
|
||||
avgDurationMs: number | null;
|
||||
availability: number;
|
||||
totalChecks: number;
|
||||
}> {
|
||||
@@ -205,7 +188,7 @@ export class ProbeStore {
|
||||
.prepare(
|
||||
`SELECT
|
||||
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
|
||||
AVG(CASE WHEN success = 1 THEN latency_ms END) as avgLatencyMs,
|
||||
AVG(CASE WHEN success = 1 THEN duration_ms END) as avgDurationMs,
|
||||
CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability,
|
||||
COUNT(*) as totalChecks
|
||||
FROM check_results
|
||||
@@ -215,7 +198,7 @@ export class ProbeStore {
|
||||
)
|
||||
.all(targetId, hours) as Array<{
|
||||
hour: string;
|
||||
avgLatencyMs: number | null;
|
||||
avgDurationMs: number | null;
|
||||
availability: number;
|
||||
totalChecks: number;
|
||||
}>;
|
||||
@@ -225,14 +208,14 @@ export class ProbeStore {
|
||||
total: number;
|
||||
up: number;
|
||||
down: number;
|
||||
avgLatencyMs: number | null;
|
||||
avgDurationMs: number | null;
|
||||
lastCheckTime: string | null;
|
||||
} {
|
||||
const targets = this.getTargets();
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
let totalLatency = 0;
|
||||
let latencyCount = 0;
|
||||
let totalDuration = 0;
|
||||
let durationCount = 0;
|
||||
let lastCheckTime: string | null = null;
|
||||
|
||||
for (const target of targets) {
|
||||
@@ -245,9 +228,9 @@ export class ProbeStore {
|
||||
down++;
|
||||
}
|
||||
|
||||
if (latest.latency_ms !== null) {
|
||||
totalLatency += latest.latency_ms;
|
||||
latencyCount++;
|
||||
if (latest.duration_ms !== null) {
|
||||
totalDuration += latest.duration_ms;
|
||||
durationCount++;
|
||||
}
|
||||
|
||||
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
|
||||
@@ -262,7 +245,7 @@ export class ProbeStore {
|
||||
total: targets.length,
|
||||
up,
|
||||
down,
|
||||
avgLatencyMs: latencyCount > 0 ? Math.round((totalLatency / latencyCount) * 100) / 100 : null,
|
||||
avgDurationMs: durationCount > 0 ? Math.round((totalDuration / durationCount) * 100) / 100 : null,
|
||||
lastCheckTime,
|
||||
};
|
||||
}
|
||||
@@ -270,10 +253,10 @@ export class ProbeStore {
|
||||
getSparkline(targetId: number, limit = 20): number[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
"SELECT latency_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?",
|
||||
"SELECT duration_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?",
|
||||
)
|
||||
.all(targetId, limit) as Array<{ latency_ms: number }>;
|
||||
return rows.map((r) => r.latency_ms).reverse();
|
||||
.all(targetId, limit) as Array<{ duration_ms: number }>;
|
||||
return rows.map((r) => r.duration_ms).reverse();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
@@ -282,6 +265,33 @@ export class ProbeStore {
|
||||
}
|
||||
}
|
||||
|
||||
function buildTargetDisplay(t: ResolvedTarget): string {
|
||||
if (t.type === "http") {
|
||||
return t.http.url;
|
||||
}
|
||||
const parts = [t.command.exec, ...t.command.args];
|
||||
return `exec ${parts.join(" ")}`;
|
||||
}
|
||||
|
||||
function buildTargetConfig(t: ResolvedTarget): string {
|
||||
if (t.type === "http") {
|
||||
return JSON.stringify({
|
||||
url: t.http.url,
|
||||
method: t.http.method,
|
||||
headers: t.http.headers,
|
||||
body: t.http.body,
|
||||
maxBodyBytes: t.http.maxBodyBytes,
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
exec: t.command.exec,
|
||||
args: t.command.args,
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
maxOutputBytes: t.command.maxOutputBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
try {
|
||||
fsMkdirSync(dir, { recursive: true });
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export type TargetType = "http" | "command";
|
||||
|
||||
export interface ProbeConfig {
|
||||
server?: ServerConfig;
|
||||
runtime?: RuntimeConfig;
|
||||
defaults?: DefaultsConfig;
|
||||
targets: TargetConfig[];
|
||||
}
|
||||
@@ -10,19 +13,49 @@ export interface ServerConfig {
|
||||
dataDir?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
maxConcurrentChecks?: number;
|
||||
}
|
||||
|
||||
export interface HttpDefaultsConfig {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
maxBodyBytes?: string;
|
||||
}
|
||||
|
||||
export interface CommandDefaultsConfig {
|
||||
cwd?: string;
|
||||
maxOutputBytes?: string;
|
||||
}
|
||||
|
||||
export interface DefaultsConfig {
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
http?: HttpDefaultsConfig;
|
||||
command?: CommandDefaultsConfig;
|
||||
}
|
||||
|
||||
export interface TargetConfig {
|
||||
name: string;
|
||||
export interface HttpTargetConfig {
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
maxBodyBytes?: string;
|
||||
}
|
||||
|
||||
export interface CommandTargetConfig {
|
||||
exec: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
maxOutputBytes?: string;
|
||||
}
|
||||
|
||||
export type TargetConfig = BaseTargetConfig &
|
||||
({ type: "http"; http: HttpTargetConfig } | { type: "command"; command: CommandTargetConfig });
|
||||
|
||||
interface BaseTargetConfig {
|
||||
name: string;
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
expect?: ExpectConfig;
|
||||
@@ -42,51 +75,100 @@ export interface ExpectOperator {
|
||||
|
||||
export type ExpectValue = string | number | boolean | null | ExpectOperator;
|
||||
|
||||
export type CssExpect = ExpectValue | (ExpectOperator & { attr?: string });
|
||||
export type TextRule = ExpectOperator;
|
||||
|
||||
export interface BodyExpectConfig {
|
||||
contains?: string;
|
||||
regex?: string;
|
||||
json?: Record<string, ExpectValue>;
|
||||
css?: Record<string, CssExpect>;
|
||||
xpath?: Record<string, ExpectValue>;
|
||||
}
|
||||
export type JsonRule = { path: string } & ExpectOperator;
|
||||
|
||||
export interface ExpectConfig {
|
||||
export type CssRule = { selector: string; attr?: string } & ExpectOperator;
|
||||
|
||||
export type XpathRule = { path: string } & ExpectOperator;
|
||||
|
||||
export type BodyRule =
|
||||
| { contains: string }
|
||||
| { regex: string }
|
||||
| { json: JsonRule }
|
||||
| { css: CssRule }
|
||||
| { xpath: XpathRule };
|
||||
|
||||
export type HeaderExpect = string | ExpectOperator;
|
||||
|
||||
export interface HttpExpectConfig {
|
||||
status?: number[];
|
||||
maxLatencyMs?: number;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyExpectConfig;
|
||||
maxDurationMs?: number;
|
||||
headers?: Record<string, HeaderExpect>;
|
||||
body?: BodyRule[];
|
||||
}
|
||||
|
||||
export interface ResolvedTarget {
|
||||
export interface CommandExpectConfig {
|
||||
exitCode?: number[];
|
||||
maxDurationMs?: number;
|
||||
stdout?: TextRule[];
|
||||
stderr?: TextRule[];
|
||||
}
|
||||
|
||||
export type ExpectConfig = HttpExpectConfig | CommandExpectConfig;
|
||||
|
||||
export interface ResolvedHttpTarget {
|
||||
type: "http";
|
||||
name: string;
|
||||
http: ResolvedHttpConfig;
|
||||
intervalMs: number;
|
||||
timeoutMs: number;
|
||||
expect?: HttpExpectConfig;
|
||||
}
|
||||
|
||||
export interface ResolvedHttpConfig {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
maxBodyBytes: number;
|
||||
}
|
||||
|
||||
export interface ResolvedCommandTarget {
|
||||
type: "command";
|
||||
name: string;
|
||||
command: ResolvedCommandConfig;
|
||||
intervalMs: number;
|
||||
timeoutMs: number;
|
||||
expect?: ExpectConfig;
|
||||
expect?: CommandExpectConfig;
|
||||
}
|
||||
|
||||
export interface ResolvedCommandConfig {
|
||||
exec: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
maxOutputBytes: number;
|
||||
}
|
||||
|
||||
export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget;
|
||||
|
||||
export interface CheckFailure {
|
||||
kind: "error" | "mismatch";
|
||||
phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr";
|
||||
path: string;
|
||||
expected?: unknown;
|
||||
actual?: unknown;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
targetName: string;
|
||||
timestamp: string;
|
||||
success: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
matched: boolean;
|
||||
durationMs: number | null;
|
||||
statusDetail: string | null;
|
||||
failure: CheckFailure | null;
|
||||
}
|
||||
|
||||
export interface StoredTarget {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
method: string;
|
||||
headers: string;
|
||||
body: string | null;
|
||||
type: TargetType;
|
||||
target: string;
|
||||
config: string;
|
||||
interval_ms: number;
|
||||
timeout_ms: number;
|
||||
expect: string | null;
|
||||
@@ -97,8 +179,8 @@ export interface StoredCheckResult {
|
||||
target_id: number;
|
||||
timestamp: string;
|
||||
success: number;
|
||||
status_code: number | null;
|
||||
latency_ms: number | null;
|
||||
error: string | null;
|
||||
matched: number;
|
||||
duration_ms: number | null;
|
||||
status_detail: string | null;
|
||||
failure: string | null;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ async function main() {
|
||||
const store = new ProbeStore(`${config.dataDir}/probe.db`);
|
||||
store.syncTargets(config.targets);
|
||||
|
||||
const engine = new ProbeEngine(store, config.targets);
|
||||
const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks);
|
||||
engine.start();
|
||||
|
||||
startServer({
|
||||
|
||||
Reference in New Issue
Block a user