1
0

feat: 增强 expect 规则系统,支持多种 body 校验方法和操作符

- 新增 body 分组校验:contains、regex、json(JSONPath)、css(CSS选择器)、xpath
- 新增操作符系统:equals、contains、match、empty、exists、gte、lte、gt、lt
- 新增 headers 响应头校验
- 引入 cheerio、xpath、@xmldom/xmldom 依赖
- BREAKING: expect.bodyContains 迁移至 expect.body.contains
This commit is contained in:
2026-05-10 00:10:42 +08:00
parent 57d3a5cfb4
commit 599d973cbd
22 changed files with 923 additions and 80 deletions

View File

@@ -84,13 +84,7 @@ function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: Run
return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 });
}
function handleHistory(
idStr: string,
url: URL,
method: string,
store: ProbeStore,
mode: RuntimeMode,
): Response {
function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
const id = Number(idStr);
if (!Number.isInteger(id) || id <= 0) {
@@ -118,13 +112,7 @@ function handleHistory(
return jsonResponse(results, { method, mode });
}
function handleTrend(
idStr: string,
url: URL,
method: string,
store: ProbeStore,
mode: RuntimeMode,
): Response {
function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
const id = Number(idStr);
if (!Number.isInteger(id) || id <= 0) {

View File

@@ -0,0 +1,188 @@
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;
}

View File

@@ -1,4 +1,5 @@
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();
@@ -17,8 +18,9 @@ export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult>
const latencyMs = Math.round(performance.now() - start);
const body = await response.text();
const responseHeaders = headersToRecord(response.headers);
const matched = checkExpect(response.status, body, latencyMs, target.expect);
const matched = checkExpect(response.status, body, latencyMs, responseHeaders, target.expect);
return {
targetName: target.name,
@@ -38,7 +40,7 @@ export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult>
success: false,
statusCode: null,
latencyMs: null,
error: isTimeout ? `请求超时 (${target.timeoutMs}ms)` : (error instanceof Error ? error.message : String(error)),
error: isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error),
matched: false,
};
} finally {
@@ -46,14 +48,37 @@ export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult>
}
}
export function checkExpect(statusCode: number, body: string, latencyMs: number, expect?: ExpectConfig): boolean {
function headersToRecord(headers: Headers): Record<string, string> {
const result: Record<string, string> = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
export function checkExpect(
statusCode: number,
body: string,
latencyMs: number,
responseHeaders: Record<string, string>,
expect?: ExpectConfig,
): boolean {
if (!expect) return true;
if (expect.status && !expect.status.includes(statusCode)) {
return false;
}
if (expect.bodyContains && !body.includes(expect.bodyContains)) {
if (expect.headers) {
for (const [key, expectedValue] of Object.entries(expect.headers)) {
const actualValue = responseHeaders[key.toLowerCase()];
if (!actualValue || actualValue !== expectedValue) {
return false;
}
}
}
if (!checkBodyExpect(body, expect.body)) {
return false;
}

View File

@@ -83,7 +83,16 @@ export class ProbeStore {
existingMap.get(target.name)!,
);
} else {
insertStmt.run(target.name, target.url, target.method, headers, target.body ?? null, target.intervalMs, target.timeoutMs, expect);
insertStmt.run(
target.name,
target.url,
target.method,
headers,
target.body ?? null,
target.intervalMs,
target.timeoutMs,
expect,
);
}
}
@@ -133,9 +142,9 @@ export class ProbeStore {
}
getLatestCheck(targetId: number): StoredCheckResult | null {
return this.db.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1").get(targetId) as
| StoredCheckResult
| null;
return this.db
.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1")
.get(targetId) as StoredCheckResult | null;
}
getHistory(targetId: number, limit = 20): StoredCheckResult[] {
@@ -183,7 +192,10 @@ export class ProbeStore {
};
}
getTrend(targetId: number, hours = 24): Array<{
getTrend(
targetId: number,
hours = 24,
): Array<{
hour: string;
avgLatencyMs: number | null;
availability: number;
@@ -257,7 +269,9 @@ 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 ?")
.prepare(
"SELECT latency_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();
}

View File

@@ -28,10 +28,35 @@ export interface TargetConfig {
expect?: ExpectConfig;
}
export interface ExpectOperator {
equals?: string | number | boolean | null;
contains?: string;
match?: string;
empty?: boolean;
exists?: boolean;
gte?: number;
lte?: number;
gt?: number;
lt?: number;
}
export type ExpectValue = string | number | boolean | null | ExpectOperator;
export type CssExpect = ExpectValue | (ExpectOperator & { attr?: string });
export interface BodyExpectConfig {
contains?: string;
regex?: string;
json?: Record<string, ExpectValue>;
css?: Record<string, CssExpect>;
xpath?: Record<string, ExpectValue>;
}
export interface ExpectConfig {
status?: number[];
bodyContains?: string;
maxLatencyMs?: number;
headers?: Record<string, string>;
body?: BodyExpectConfig;
}
export interface ResolvedTarget {

View File

@@ -16,11 +16,7 @@ export function App() {
<p className="dashboard-subtitle">HTTP </p>
</header>
{error && (
<div className="error-banner">
: {error}
</div>
)}
{error && <div className="error-banner">: {error}</div>}
<SummaryCards summary={summary} loading={summaryLoading} />
<TargetTable targets={targets} loading={targetsLoading} />

View File

@@ -40,9 +40,7 @@ export function TargetDetail({ target }: TargetDetailProps) {
<div className="detail-stats">
<div className="detail-stat">
<span className="detail-stat-label"></span>
<span className={`detail-stat-value ${isUp ? "text-up" : "text-down"}`}>
{isUp ? "UP" : "DOWN"}
</span>
<span className={`detail-stat-value ${isUp ? "text-up" : "text-down"}`}>{isUp ? "UP" : "DOWN"}</span>
</div>
<div className="detail-stat">
<span className="detail-stat-label"></span>
@@ -80,18 +78,10 @@ export function TargetDetail({ target }: TargetDetailProps) {
<span className={`history-status ${item.success && item.matched ? "text-up" : "text-down"}`}>
{item.success && item.matched ? "UP" : "DOWN"}
</span>
<span className="history-time">
{new Date(item.timestamp).toLocaleString("zh-CN")}
</span>
{item.statusCode && (
<span className="history-code">{item.statusCode}</span>
)}
{item.latencyMs !== null && (
<span className="history-latency">{Math.round(item.latencyMs)}ms</span>
)}
{item.error && (
<span className="history-error">{item.error}</span>
)}
<span className="history-time">{new Date(item.timestamp).toLocaleString("zh-CN")}</span>
{item.statusCode && <span className="history-code">{item.statusCode}</span>}
{item.latencyMs !== null && <span className="history-latency">{Math.round(item.latencyMs)}ms</span>}
{item.error && <span className="history-error">{item.error}</span>}
</div>
))}
</div>

View File

@@ -26,8 +26,20 @@ export function TrendChart({ data, loading }: TrendChartProps) {
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="#94a3b8" />
<YAxis yAxisId="latency" tick={{ fontSize: 12 }} stroke="#94a3b8" label={{ value: "ms", position: "insideTopRight", fontSize: 11 }} />
<YAxis yAxisId="availability" orientation="right" domain={[0, 100]} tick={{ fontSize: 12 }} stroke="#94a3b8" label={{ value: "%", position: "insideTopLeft", fontSize: 11 }} />
<YAxis
yAxisId="latency"
tick={{ fontSize: 12 }}
stroke="#94a3b8"
label={{ value: "ms", position: "insideTopRight", fontSize: 11 }}
/>
<YAxis
yAxisId="availability"
orientation="right"
domain={[0, 100]}
tick={{ fontSize: 12 }}
stroke="#94a3b8"
label={{ value: "%", position: "insideTopLeft", fontSize: 11 }}
/>
<Tooltip
formatter={(value: unknown, name: unknown) => {
const num = Number(value);
@@ -37,8 +49,24 @@ export function TrendChart({ data, loading }: TrendChartProps) {
return [String(value), nameStr];
}}
/>
<Line yAxisId="latency" type="monotone" dataKey="avgLatencyMs" stroke="#356dd2" strokeWidth={2} dot={false} name="avgLatencyMs" />
<Line yAxisId="availability" type="monotone" dataKey="availability" stroke="#1fbf75" strokeWidth={2} dot={false} name="availability" />
<Line
yAxisId="latency"
type="monotone"
dataKey="avgLatencyMs"
stroke="#356dd2"
strokeWidth={2}
dot={false}
name="avgLatencyMs"
/>
<Line
yAxisId="availability"
type="monotone"
dataKey="availability"
stroke="#1fbf75"
strokeWidth={2}
dot={false}
name="availability"
/>
</LineChart>
</ResponsiveContainer>
</div>