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:
@@ -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) {
|
||||
|
||||
188
src/server/checker/body-expect.ts
Normal file
188
src/server/checker/body-expect.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user