chore: 强化代码质量与风格检查体系
ESLint 升级到 recommended-type-checked + stylistic-type-checked, 引入 perfectionist 导入排序和 import 插件导入验证。 Prettier 显式声明全部格式化参数,消除跨环境差异。 TypeScript 启用 noUnusedLocals 和 noPropertyAccessFromIndexSignature。 完善 ignore 列表,排除 .agents/、bun.lock、data/ 等。 引入 husky + lint-staged(pre-commit)+ commitlint(commit-msg)。 更新 DEVELOPMENT.md 代码质量章节。 修复所有新增规则检测到的类型和风格违规。
This commit is contained in:
@@ -1,18 +1,14 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { ProbeStore } from "./checker/store";
|
||||
import { jsonResponse, createApiError } from "./helpers";
|
||||
|
||||
import { createApiError, jsonResponse } from "./helpers";
|
||||
import { guardGetHead } from "./middleware";
|
||||
import { serveStaticAsset } from "./static";
|
||||
import { handleHealth } from "./routes/health";
|
||||
import { handleHistory } from "./routes/history";
|
||||
import { handleSummary } from "./routes/summary";
|
||||
import { handleTargets } from "./routes/targets";
|
||||
import { handleHistory } from "./routes/history";
|
||||
import { handleTrend } from "./routes/trend";
|
||||
|
||||
export interface StaticAssets {
|
||||
indexHtml: Blob;
|
||||
files: Record<string, Blob>;
|
||||
}
|
||||
import { serveStaticAsset } from "./static";
|
||||
|
||||
export interface AppOptions {
|
||||
mode: RuntimeMode;
|
||||
@@ -20,6 +16,11 @@ export interface AppOptions {
|
||||
store?: ProbeStore;
|
||||
}
|
||||
|
||||
export interface StaticAssets {
|
||||
files: Record<string, Blob>;
|
||||
indexHtml: Blob;
|
||||
}
|
||||
|
||||
export function createFetchHandler(options: AppOptions) {
|
||||
return (request: Request): Response => {
|
||||
const url = new URL(request.url);
|
||||
@@ -45,8 +46,8 @@ export function createFetchHandler(options: AppOptions) {
|
||||
}
|
||||
|
||||
return new Response("开发期请通过 Vite 前端地址访问页面。", {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
status: 404,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -65,12 +66,12 @@ function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: Run
|
||||
return handleTargets(store, method, mode);
|
||||
}
|
||||
|
||||
const historyMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/history$/);
|
||||
const historyMatch = /^\/api\/targets\/([^/]+)\/history$/.exec(url.pathname);
|
||||
if (historyMatch) {
|
||||
return handleHistory(historyMatch[1]!, url, method, store, mode);
|
||||
}
|
||||
|
||||
const trendMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/trend$/);
|
||||
const trendMatch = /^\/api\/targets\/([^/]+)\/trend$/.exec(url.pathname);
|
||||
if (trendMatch) {
|
||||
return handleTrend(trendMatch[1]!, url, method, store, mode);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type {
|
||||
DefaultsConfig,
|
||||
ProbeConfig,
|
||||
ResolvedTarget,
|
||||
EngineRuntimeConfig,
|
||||
TargetConfig,
|
||||
} from "./types";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import type { DefaultsConfig, EngineRuntimeConfig, ProbeConfig, ResolvedTarget, TargetConfig } from "./types";
|
||||
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
@@ -16,11 +12,11 @@ const DEFAULT_TIMEOUT = "10s";
|
||||
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
||||
|
||||
export interface ResolvedConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
dataDir: string;
|
||||
configDir: string;
|
||||
dataDir: string;
|
||||
host: string;
|
||||
maxConcurrentChecks: number;
|
||||
port: number;
|
||||
targets: ResolvedTarget[];
|
||||
}
|
||||
|
||||
@@ -32,7 +28,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
}
|
||||
|
||||
const content = await file.text();
|
||||
const raw = Bun.YAML.parse(content) as ProbeConfig | null;
|
||||
const raw = Bun.YAML.parse(content) as null | ProbeConfig;
|
||||
|
||||
if (!raw) {
|
||||
throw new Error("配置文件内容为空或格式无效");
|
||||
@@ -61,21 +57,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
|
||||
);
|
||||
|
||||
return { host, port, dataDir, configDir, maxConcurrentChecks, targets };
|
||||
}
|
||||
|
||||
function validateRuntime(runtime: EngineRuntimeConfig): 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;
|
||||
return { configDir, dataDir, host, maxConcurrentChecks, port, targets };
|
||||
}
|
||||
|
||||
function resolveTarget(
|
||||
@@ -89,7 +71,7 @@ function resolveTarget(
|
||||
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
|
||||
|
||||
const checker = checkerRegistry.get(target.type);
|
||||
const result = checker.resolve(target, { defaults, configDir, defaultIntervalMs, defaultTimeoutMs });
|
||||
const result = checker.resolve(target, { configDir, defaultIntervalMs, defaults, defaultTimeoutMs });
|
||||
|
||||
result.intervalMs = intervalMs;
|
||||
result.timeoutMs = timeoutMs;
|
||||
@@ -109,7 +91,7 @@ function validateConfig(config: ProbeConfig): void {
|
||||
const raw = config.targets[i] as unknown as Record<string, unknown>;
|
||||
|
||||
const name = raw["name"];
|
||||
if (!name || typeof name !== "string" || (name as string).trim() === "") {
|
||||
if (!name || typeof name !== "string" || name.trim() === "") {
|
||||
throw new Error(`第 ${i + 1} 个 target 缺少 name 字段`);
|
||||
}
|
||||
|
||||
@@ -127,14 +109,28 @@ function validateConfig(config: ProbeConfig): void {
|
||||
throw new Error(`target "${name}" 的 group 字段必须为字符串`);
|
||||
}
|
||||
|
||||
if (names.has(name as string)) {
|
||||
if (names.has(name)) {
|
||||
throw new Error(`target name 重复: "${name}"`);
|
||||
}
|
||||
|
||||
names.add(name as string);
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntime(runtime: EngineRuntimeConfig): 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;
|
||||
}
|
||||
|
||||
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
|
||||
|
||||
export function parseDuration(value: string): number {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { CheckResult, ResolvedTarget } from "./types";
|
||||
import type { ProbeStore } from "./store";
|
||||
import { checkerRegistry } from "./runner";
|
||||
import { groupBy, Semaphore } from "es-toolkit";
|
||||
|
||||
import type { ProbeStore } from "./store";
|
||||
import type { CheckResult, ResolvedTarget } from "./types";
|
||||
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
export class ProbeEngine {
|
||||
private timers: ReturnType<typeof setInterval>[] = [];
|
||||
private store: ProbeStore;
|
||||
private targets: ResolvedTarget[];
|
||||
private targetNameToId: Map<string, number> = new Map();
|
||||
private semaphore: Semaphore;
|
||||
private store: ProbeStore;
|
||||
private targetNameToId = new Map<string, number>();
|
||||
private targets: ResolvedTarget[];
|
||||
private timers: Array<ReturnType<typeof setInterval>> = [];
|
||||
|
||||
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
|
||||
this.store = store;
|
||||
@@ -59,6 +61,13 @@ export class ProbeEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private refreshCache(): void {
|
||||
this.targetNameToId.clear();
|
||||
for (const target of this.store.getTargets()) {
|
||||
this.targetNameToId.set(target.name, target.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async runCheck(target: ResolvedTarget): Promise<CheckResult> {
|
||||
const checker = checkerRegistry.get(target.type);
|
||||
const controller = new AbortController();
|
||||
@@ -76,19 +85,12 @@ export class ProbeEngine {
|
||||
if (!targetId) return;
|
||||
|
||||
this.store.insertCheckResult({
|
||||
durationMs: result.durationMs,
|
||||
failure: result.failure,
|
||||
matched: result.matched,
|
||||
statusDetail: result.statusDetail,
|
||||
targetId,
|
||||
timestamp: result.timestamp,
|
||||
matched: result.matched,
|
||||
durationMs: result.durationMs,
|
||||
statusDetail: result.statusDetail,
|
||||
failure: result.failure,
|
||||
});
|
||||
}
|
||||
|
||||
private refreshCache(): void {
|
||||
this.targetNameToId.clear();
|
||||
for (const target of this.store.getTargets()) {
|
||||
this.targetNameToId.set(target.name, target.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { mismatchFailure } from "../shared/failure";
|
||||
import type { ExpectResult } from "../shared/duration";
|
||||
|
||||
import { mismatchFailure } from "../shared/failure";
|
||||
|
||||
export function checkExitCode(exitCode: number, allowed: number[]): ExpectResult {
|
||||
if (!allowed.includes(exitCode)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"exitCode",
|
||||
"exitCode",
|
||||
allowed,
|
||||
exitCode,
|
||||
`exitCode ${exitCode} not in [${allowed}]`,
|
||||
`exitCode ${exitCode} not in [${allowed.join(", ")}]`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
@@ -1,26 +1,227 @@
|
||||
import { isError } from "es-toolkit";
|
||||
import type { CheckResult } from "../../types";
|
||||
import type { Checker, CheckerContext, ResolveContext } from "../types";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type {
|
||||
CommandExpectConfig,
|
||||
CheckResult,
|
||||
CommandTargetConfig,
|
||||
ResolvedCommandTarget,
|
||||
ResolvedTarget,
|
||||
TargetConfig,
|
||||
} from "../../types";
|
||||
import type { Checker, CheckerContext, ResolveContext } from "../types";
|
||||
|
||||
import { parseSize } from "../../size";
|
||||
import { checkExitCode } from "./expect";
|
||||
import { checkDuration } from "../shared/duration";
|
||||
import { checkTextRules } from "../shared/text";
|
||||
import { errorFailure } from "../shared/failure";
|
||||
import { resolve } from "node:path";
|
||||
import { checkTextRules } from "../shared/text";
|
||||
import { checkExitCode } from "./expect";
|
||||
|
||||
export class CommandChecker implements Checker {
|
||||
readonly type = "command";
|
||||
|
||||
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const t = target as ResolvedCommandTarget;
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
let proc: ReturnType<typeof Bun.spawn>;
|
||||
|
||||
try {
|
||||
proc = Bun.spawn([t.command.exec, ...t.command.args], {
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
stderr: "pipe",
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
ctx.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
let outputResult: { exceeded: boolean; stderr: string; stdout: string };
|
||||
|
||||
try {
|
||||
outputResult = await readOutput(
|
||||
proc.stdout as ReadableStream<Uint8Array>,
|
||||
proc.stderr as ReadableStream<Uint8Array>,
|
||||
() => proc.kill(),
|
||||
t.command.maxOutputBytes,
|
||||
);
|
||||
} catch {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.command.maxOutputBytes} 字节`),
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: exitCodeResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: durationResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (t.expect?.stdout && t.expect.stdout.length > 0) {
|
||||
const stdoutResult = checkTextRules(outputResult.stdout, t.expect.stdout, "stdout");
|
||||
if (!stdoutResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: stdoutResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (t.expect?.stderr && t.expect.stderr.length > 0) {
|
||||
const stderrResult = checkTextRules(outputResult.stderr, t.expect.stderr, "stderr");
|
||||
if (!stderrResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: stderrResult.failure,
|
||||
matched: false,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
durationMs,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
|
||||
const t = target as TargetConfig & { command: CommandTargetConfig; type: "command" };
|
||||
const commandDefaults = context.defaults.command;
|
||||
|
||||
if (!t.command.exec || t.command.exec.trim() === "") {
|
||||
throw new Error(`target "${t.name}" 缺少 command.exec 字段`);
|
||||
}
|
||||
|
||||
const cwd = t.command.cwd ?? commandDefaults?.cwd ?? ".";
|
||||
const resolvedCwd = resolve(context.configDir, cwd);
|
||||
|
||||
const maxOutputBytes = parseSize(t.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? "100MB");
|
||||
|
||||
const env = { ...process.env, ...(t.command.env ?? {}) } as Record<string, string>;
|
||||
|
||||
return {
|
||||
command: {
|
||||
args: t.command.args ?? [],
|
||||
cwd: resolvedCwd,
|
||||
env,
|
||||
exec: t.command.exec,
|
||||
maxOutputBytes,
|
||||
},
|
||||
expect: target.expect,
|
||||
group: target.group ?? "default",
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "command",
|
||||
} satisfies ResolvedCommandTarget;
|
||||
}
|
||||
|
||||
serialize(target: ResolvedTarget): { config: string; target: string } {
|
||||
const t = target as ResolvedCommandTarget;
|
||||
const parts = [t.command.exec, ...t.command.args];
|
||||
return {
|
||||
config: JSON.stringify({
|
||||
args: t.command.args,
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
exec: t.command.exec,
|
||||
maxOutputBytes: t.command.maxOutputBytes,
|
||||
}),
|
||||
target: `exec ${parts.join(" ")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function readOutput(
|
||||
stdout: ReadableStream<Uint8Array>,
|
||||
stderr: ReadableStream<Uint8Array>,
|
||||
kill: () => void,
|
||||
maxBytes: number,
|
||||
): Promise<{ stdout: string; stderr: string; exceeded: boolean }> {
|
||||
): Promise<{ exceeded: boolean; stderr: string; stdout: string }> {
|
||||
let totalBytes = 0;
|
||||
let exceeded = false;
|
||||
let killed = false;
|
||||
@@ -61,203 +262,5 @@ async function readOutput(
|
||||
|
||||
const [out, err] = await Promise.all([readStream(stdout), readStream(stderr)]);
|
||||
|
||||
return { stdout: out, stderr: err, exceeded };
|
||||
}
|
||||
|
||||
export class CommandChecker implements Checker {
|
||||
readonly type = "command";
|
||||
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
|
||||
const t = target as TargetConfig & { type: "command"; command: CommandTargetConfig };
|
||||
const commandDefaults = context.defaults.command;
|
||||
|
||||
if (!t.command.exec || t.command.exec.trim() === "") {
|
||||
throw new Error(`target "${t.name}" 缺少 command.exec 字段`);
|
||||
}
|
||||
|
||||
const cwd = t.command.cwd ?? commandDefaults?.cwd ?? ".";
|
||||
const resolvedCwd = resolve(context.configDir, cwd);
|
||||
|
||||
const maxOutputBytes = parseSize(
|
||||
t.command.maxOutputBytes ?? commandDefaults?.maxOutputBytes ?? "100MB",
|
||||
);
|
||||
|
||||
const env = { ...process.env, ...(t.command.env ?? {}) } as Record<string, string>;
|
||||
|
||||
return {
|
||||
type: "command",
|
||||
name: t.name,
|
||||
group: target.group ?? "default",
|
||||
command: {
|
||||
exec: t.command.exec,
|
||||
args: t.command.args ?? [],
|
||||
cwd: resolvedCwd,
|
||||
env,
|
||||
maxOutputBytes,
|
||||
},
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
expect: target.expect as CommandExpectConfig | undefined,
|
||||
} satisfies ResolvedCommandTarget;
|
||||
}
|
||||
|
||||
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const t = target as ResolvedCommandTarget;
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
let proc: ReturnType<typeof Bun.spawn>;
|
||||
|
||||
try {
|
||||
proc = Bun.spawn([t.command.exec, ...t.command.args], {
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||
};
|
||||
}
|
||||
|
||||
ctx.signal.addEventListener("abort", () => {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* best-effort kill */
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
let outputResult: { stdout: string; stderr: string; exceeded: boolean };
|
||||
|
||||
try {
|
||||
outputResult = await readOutput(
|
||||
proc.stdout as ReadableStream<Uint8Array>,
|
||||
proc.stderr as ReadableStream<Uint8Array>,
|
||||
() => proc.kill(),
|
||||
t.command.maxOutputBytes,
|
||||
);
|
||||
} catch {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "execution", "输出读取失败"),
|
||||
};
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const exitCode = proc.exitCode ?? 1;
|
||||
|
||||
if (outputResult.exceeded) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.command.maxOutputBytes} 字节`),
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: null,
|
||||
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
|
||||
};
|
||||
}
|
||||
|
||||
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
|
||||
if (!exitCodeResult.matched) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: exitCodeResult.failure,
|
||||
};
|
||||
}
|
||||
|
||||
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
|
||||
if (!durationResult.matched) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: durationResult.failure,
|
||||
};
|
||||
}
|
||||
|
||||
if (t.expect?.stdout && t.expect.stdout.length > 0) {
|
||||
const stdoutResult = checkTextRules(outputResult.stdout, t.expect.stdout, "stdout");
|
||||
if (!stdoutResult.matched) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: stdoutResult.failure,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (t.expect?.stderr && t.expect.stderr.length > 0) {
|
||||
const stderrResult = checkTextRules(outputResult.stderr, t.expect.stderr, "stderr");
|
||||
if (!stderrResult.matched) {
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: stderrResult.failure,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: true,
|
||||
durationMs,
|
||||
statusDetail: `exitCode=${exitCode}`,
|
||||
failure: null,
|
||||
};
|
||||
}
|
||||
|
||||
serialize(target: ResolvedTarget): { target: string; config: string } {
|
||||
const t = target as ResolvedCommandTarget;
|
||||
const parts = [t.command.exec, ...t.command.args];
|
||||
return {
|
||||
target: `exec ${parts.join(" ")}`,
|
||||
config: JSON.stringify({
|
||||
exec: t.command.exec,
|
||||
args: t.command.args,
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
maxOutputBytes: t.command.maxOutputBytes,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { exceeded, stderr: err, stdout: out };
|
||||
}
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
import type { HeaderExpect, HttpExpectConfig } from "../../types";
|
||||
import { mismatchFailure, errorFailure } from "../shared/failure";
|
||||
import { applyOperator } from "../shared/operator";
|
||||
import { checkDuration } from "../shared/duration";
|
||||
import { checkBodyExpect } from "../shared/body";
|
||||
import type { ExpectResult } from "../shared/duration";
|
||||
|
||||
export function checkStatus(statusCode: number, allowed: number[]): ExpectResult {
|
||||
if (!allowed.includes(statusCode)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"status",
|
||||
"status",
|
||||
allowed,
|
||||
statusCode,
|
||||
`status ${statusCode} not in [${allowed}]`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
}
|
||||
import { checkBodyExpect } from "../shared/body";
|
||||
import { checkDuration } from "../shared/duration";
|
||||
import { errorFailure, mismatchFailure } from "../shared/failure";
|
||||
import { applyOperator } from "../shared/operator";
|
||||
|
||||
export function checkHeaders(
|
||||
headers: Record<string, string>,
|
||||
headerExpects?: Record<string, HeaderExpect>,
|
||||
): ExpectResult {
|
||||
if (!headerExpects) return { matched: true, failure: null };
|
||||
if (!headerExpects) return { failure: null, matched: true };
|
||||
|
||||
for (const [key, expected] of Object.entries(headerExpects)) {
|
||||
const actualValue = headers[key.toLowerCase()];
|
||||
@@ -34,36 +19,36 @@ export function checkHeaders(
|
||||
if (typeof expected === "string") {
|
||||
if (actualValue !== expected) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (actualValue === undefined) {
|
||||
if (expected.exists !== false) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, "defined", undefined, `header ${key} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!applyOperator(actualValue, expected)) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function checkHttpExpect(
|
||||
statusCode: number,
|
||||
headers: Record<string, string>,
|
||||
body: string | null,
|
||||
body: null | string,
|
||||
durationMs: number,
|
||||
expect?: HttpExpectConfig,
|
||||
): ExpectResult {
|
||||
@@ -83,13 +68,29 @@ export function checkHttpExpect(
|
||||
if (expect.body && expect.body.length > 0) {
|
||||
if (body === null) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", "body", "body is null but body rules are configured"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
const bodyResult = checkBodyExpect(body, expect.body);
|
||||
if (!bodyResult.matched) return bodyResult;
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function checkStatus(statusCode: number, allowed: number[]): ExpectResult {
|
||||
if (!allowed.includes(statusCode)) {
|
||||
return {
|
||||
failure: mismatchFailure(
|
||||
"status",
|
||||
"status",
|
||||
allowed,
|
||||
statusCode,
|
||||
`status ${statusCode} not in [${allowed.join(", ")}]`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
@@ -1,51 +1,15 @@
|
||||
import type { CheckResult } from "../../types";
|
||||
import { isError } from "es-toolkit";
|
||||
import type {
|
||||
Checker,
|
||||
CheckerContext,
|
||||
ResolveContext,
|
||||
} from "../types";
|
||||
import type {
|
||||
HttpExpectConfig,
|
||||
HttpTargetConfig,
|
||||
ResolvedHttpTarget,
|
||||
ResolvedTarget,
|
||||
TargetConfig,
|
||||
} from "../../types";
|
||||
|
||||
import type { CheckResult, HttpTargetConfig, ResolvedHttpTarget, ResolvedTarget, TargetConfig } from "../../types";
|
||||
import type { Checker, CheckerContext, ResolveContext } from "../types";
|
||||
|
||||
import { parseSize } from "../../size";
|
||||
import { checkHttpExpect } from "./expect";
|
||||
import { errorFailure } from "../shared/failure";
|
||||
import { checkHttpExpect } from "./expect";
|
||||
|
||||
export class HttpChecker implements Checker {
|
||||
readonly type = "http";
|
||||
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
|
||||
const t = target as TargetConfig & { type: "http"; http: HttpTargetConfig };
|
||||
const httpDefaults = context.defaults.http;
|
||||
|
||||
if (!t.http.url || t.http.url.trim() === "") {
|
||||
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
|
||||
}
|
||||
|
||||
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
|
||||
|
||||
return {
|
||||
type: "http",
|
||||
name: t.name,
|
||||
group: target.group ?? "default",
|
||||
http: {
|
||||
url: t.http.url,
|
||||
method: t.http.method ?? httpDefaults?.method ?? "GET",
|
||||
headers: { ...(httpDefaults?.headers ?? {}), ...(t.http.headers ?? {}) },
|
||||
body: t.http.body,
|
||||
maxBodyBytes,
|
||||
},
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
expect: target.expect as HttpExpectConfig | undefined,
|
||||
} satisfies ResolvedHttpTarget;
|
||||
}
|
||||
|
||||
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const t = target as ResolvedHttpTarget;
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -54,9 +18,9 @@ export class HttpChecker implements Checker {
|
||||
const start = performance.now();
|
||||
|
||||
const response = await fetch(t.http.url, {
|
||||
method: t.http.method,
|
||||
headers: t.http.headers,
|
||||
body: t.http.method !== "GET" && t.http.method !== "HEAD" ? t.http.body : undefined,
|
||||
headers: t.http.headers,
|
||||
method: t.http.method,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
|
||||
@@ -67,19 +31,19 @@ export class HttpChecker implements Checker {
|
||||
const hasBodyRules = !!(t.expect?.body && t.expect.body.length > 0);
|
||||
|
||||
const preBodyExpect = t.expect
|
||||
? { status: t.expect.status, maxDurationMs: t.expect.maxDurationMs, headers: t.expect.headers }
|
||||
? { headers: t.expect.headers, maxDurationMs: t.expect.maxDurationMs, status: t.expect.status }
|
||||
: undefined;
|
||||
|
||||
const preBodyResult = checkHttpExpect(statusCode, responseHeaders, null, durationMs, preBodyExpect);
|
||||
|
||||
if (!hasBodyRules || !preBodyResult.matched) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: preBodyResult.failure,
|
||||
matched: preBodyResult.matched,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: preBodyResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: preBodyResult.failure,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,16 +51,12 @@ export class HttpChecker implements Checker {
|
||||
|
||||
if (bodyBuffer.byteLength > t.http.maxBodyBytes) {
|
||||
return {
|
||||
durationMs,
|
||||
failure: errorFailure("body", "body", `响应体大小 ${bodyBuffer.byteLength} 超过限制 ${t.http.maxBodyBytes}`),
|
||||
matched: false,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: errorFailure(
|
||||
"body",
|
||||
"body",
|
||||
`响应体大小 ${bodyBuffer.byteLength} 超过限制 ${t.http.maxBodyBytes}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,42 +64,69 @@ export class HttpChecker implements Checker {
|
||||
const fullResult = checkHttpExpect(statusCode, responseHeaders, body, durationMs, t.expect);
|
||||
|
||||
return {
|
||||
durationMs,
|
||||
failure: fullResult.failure,
|
||||
matched: fullResult.matched,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: fullResult.matched,
|
||||
durationMs,
|
||||
statusDetail: `HTTP ${statusCode}`,
|
||||
failure: fullResult.failure,
|
||||
};
|
||||
} catch (error) {
|
||||
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
|
||||
|
||||
return {
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
matched: false,
|
||||
durationMs: null,
|
||||
statusDetail: null,
|
||||
failure: errorFailure(
|
||||
"status",
|
||||
"request",
|
||||
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||
),
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetName: t.name,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
serialize(target: ResolvedTarget): { target: string; config: string } {
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget {
|
||||
const t = target as TargetConfig & { http: HttpTargetConfig; type: "http" };
|
||||
const httpDefaults = context.defaults.http;
|
||||
|
||||
if (!t.http.url || t.http.url.trim() === "") {
|
||||
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
|
||||
}
|
||||
|
||||
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
|
||||
|
||||
return {
|
||||
expect: target.expect,
|
||||
group: target.group ?? "default",
|
||||
http: {
|
||||
body: t.http.body,
|
||||
headers: { ...(httpDefaults?.headers ?? {}), ...(t.http.headers ?? {}) },
|
||||
maxBodyBytes,
|
||||
method: t.http.method ?? httpDefaults?.method ?? "GET",
|
||||
url: t.http.url,
|
||||
},
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "http",
|
||||
} satisfies ResolvedHttpTarget;
|
||||
}
|
||||
|
||||
serialize(target: ResolvedTarget): { config: string; target: string } {
|
||||
const t = target as ResolvedHttpTarget;
|
||||
return {
|
||||
target: t.http.url,
|
||||
config: JSON.stringify({
|
||||
url: t.http.url,
|
||||
method: t.http.method,
|
||||
headers: t.http.headers,
|
||||
body: t.http.body,
|
||||
headers: t.http.headers,
|
||||
maxBodyBytes: t.http.maxBodyBytes,
|
||||
method: t.http.method,
|
||||
url: t.http.url,
|
||||
}),
|
||||
target: t.http.url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { checkerRegistry } from "./registry";
|
||||
import { HttpChecker } from "./http/runner";
|
||||
import { CommandChecker } from "./command/runner";
|
||||
import { HttpChecker } from "./http/runner";
|
||||
import { checkerRegistry } from "./registry";
|
||||
|
||||
export function registerCheckers(): void {
|
||||
checkerRegistry.register(new HttpChecker());
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { Checker } from "./types";
|
||||
|
||||
export class CheckerRegistry {
|
||||
private checkers = new Map<string, Checker>();
|
||||
|
||||
register(checker: Checker): void {
|
||||
if (this.checkers.has(checker.type)) {
|
||||
throw new Error(`Checker type "${checker.type}" 已注册`);
|
||||
}
|
||||
this.checkers.set(checker.type, checker);
|
||||
get supportedTypes(): string[] {
|
||||
return [...this.checkers.keys()];
|
||||
}
|
||||
|
||||
private checkers = new Map<string, Checker>();
|
||||
|
||||
get(type: string): Checker {
|
||||
const checker = this.checkers.get(type);
|
||||
if (!checker) {
|
||||
@@ -18,8 +15,11 @@ export class CheckerRegistry {
|
||||
return checker;
|
||||
}
|
||||
|
||||
get supportedTypes(): string[] {
|
||||
return [...this.checkers.keys()];
|
||||
register(checker: Checker): void {
|
||||
if (this.checkers.has(checker.type)) {
|
||||
throw new Error(`Checker type "${checker.type}" 已注册`);
|
||||
}
|
||||
this.checkers.set(checker.type, checker);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +1,26 @@
|
||||
import type { BodyRule, CssRule, JsonRule, XpathRule } from "../../types";
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import * as cheerio from "cheerio";
|
||||
import * as xpath from "xpath";
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import { mismatchFailure, errorFailure } from "./failure";
|
||||
import { applyOperator, evaluateJsonPath } from "./operator";
|
||||
|
||||
import type { BodyRule, CssRule, JsonRule, XpathRule } from "../../types";
|
||||
import type { ExpectResult } from "./duration";
|
||||
|
||||
function checkJsonRule(
|
||||
body: string,
|
||||
rule: JsonRule,
|
||||
rulePath: string,
|
||||
): ExpectResult {
|
||||
const { path, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.json(${path})`;
|
||||
import { errorFailure, mismatchFailure } from "./failure";
|
||||
import { applyOperator, evaluateJsonPath } from "./operator";
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", fullPath, "body is not valid JSON"),
|
||||
};
|
||||
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
|
||||
if (!rules || rules.length === 0) return { failure: null, matched: true };
|
||||
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const result = checkSingleBodyRule(body, rules[i]!, i);
|
||||
if (!result.matched) return result;
|
||||
}
|
||||
|
||||
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 };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkCssRule(
|
||||
body: string,
|
||||
rule: CssRule,
|
||||
rulePath: string,
|
||||
): ExpectResult {
|
||||
const { selector, attr, ...operators } = rule;
|
||||
function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResult {
|
||||
const { attr, selector, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.css(${selector}${attr ? `@${attr}` : ""})`;
|
||||
|
||||
let $: cheerio.CheerioAPI;
|
||||
@@ -60,8 +28,8 @@ function checkCssRule(
|
||||
$ = cheerio.load(body);
|
||||
} catch {
|
||||
return {
|
||||
matched: false,
|
||||
failure: errorFailure("body", fullPath, "failed to parse HTML"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,44 +40,44 @@ function checkCssRule(
|
||||
if (attr !== undefined) {
|
||||
if (el.attr(attr) === undefined) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
if (el.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
if (operators.exists === true) {
|
||||
if (el.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
if (operators.exists === false) {
|
||||
if (el.length > 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, false, true, `selector ${selector} exists`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
if (el.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,84 +85,73 @@ function checkCssRule(
|
||||
const matched = applyOperator(actual ?? "", operators);
|
||||
if (!matched) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `css selector ${selector} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkXpathRule(
|
||||
body: string,
|
||||
rule: XpathRule,
|
||||
rulePath: string,
|
||||
): ExpectResult {
|
||||
function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectResult {
|
||||
const { path, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.xpath(${path})`;
|
||||
const fullPath = `${rulePath}.json(${path})`;
|
||||
|
||||
let doc: ReturnType<DOMParser["parseFromString"]>;
|
||||
let json: unknown;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(body, "text/xml");
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
return {
|
||||
failure: errorFailure("body", fullPath, "body is not valid JSON"),
|
||||
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 actual = evaluateJsonPath(json, path);
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
return { matched: true, failure: null };
|
||||
if (actual === undefined) {
|
||||
return {
|
||||
failure: mismatchFailure("body", fullPath, "defined", actual, `path ${path} is undefined`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
const matched = applyOperator(actual, operators);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `json path ${path} mismatch`),
|
||||
matched: false,
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `xpath ${path} mismatch`),
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
function checkSingleBodyRule(
|
||||
body: string,
|
||||
rule: BodyRule,
|
||||
index: number,
|
||||
): ExpectResult {
|
||||
function checkSingleBodyRule(body: string, rule: BodyRule, index: number): ExpectResult {
|
||||
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}"`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
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}/`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
if ("json" in rule) {
|
||||
@@ -209,16 +166,45 @@ function checkSingleBodyRule(
|
||||
return checkXpathRule(body, rule.xpath, rulePath);
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
|
||||
if (!rules || rules.length === 0) return { matched: true, failure: null };
|
||||
function checkXpathRule(body: string, rule: XpathRule, rulePath: string): ExpectResult {
|
||||
const { path, ...operators } = rule;
|
||||
const fullPath = `${rulePath}.xpath(${path})`;
|
||||
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const result = checkSingleBodyRule(body, rules[i]!, i);
|
||||
if (!result.matched) return result;
|
||||
let doc: ReturnType<DOMParser["parseFromString"]>;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(body, "text/xml");
|
||||
} catch {
|
||||
return {
|
||||
failure: errorFailure("body", fullPath, "failed to parse XML/HTML"),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { matched: true, failure: null };
|
||||
const nodes = xpath.select(path, doc as unknown as Node);
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
|
||||
return {
|
||||
failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
const node = nodes[0]!;
|
||||
const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? "";
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
const matched = applyOperator(actual, operators);
|
||||
if (!matched) {
|
||||
return {
|
||||
failure: mismatchFailure("body", fullPath, operators, actual, `xpath ${path} mismatch`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { CheckFailure } from "../../types";
|
||||
|
||||
import { mismatchFailure } from "./failure";
|
||||
|
||||
export interface ExpectResult {
|
||||
matched: boolean;
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult {
|
||||
if (maxDurationMs === undefined) return { matched: true, failure: null };
|
||||
if (maxDurationMs === undefined) return { failure: null, matched: true };
|
||||
if (durationMs > maxDurationMs) {
|
||||
return {
|
||||
matched: false,
|
||||
failure: mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
@@ -18,7 +18,8 @@ export function checkDuration(durationMs: number, maxDurationMs?: number): Expec
|
||||
durationMs,
|
||||
`duration ${durationMs}ms > ${maxDurationMs}ms`,
|
||||
),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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 errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
|
||||
return {
|
||||
kind: "error",
|
||||
message,
|
||||
path,
|
||||
phase,
|
||||
};
|
||||
}
|
||||
|
||||
export function mismatchFailure(
|
||||
@@ -15,20 +17,18 @@ export function mismatchFailure(
|
||||
message: string,
|
||||
): CheckFailure {
|
||||
return {
|
||||
kind: "mismatch",
|
||||
phase,
|
||||
path,
|
||||
expected,
|
||||
actual: truncateActual(actual),
|
||||
expected,
|
||||
kind: "mismatch",
|
||||
message,
|
||||
path,
|
||||
phase,
|
||||
};
|
||||
}
|
||||
|
||||
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
|
||||
return {
|
||||
kind: "error",
|
||||
phase,
|
||||
path,
|
||||
message,
|
||||
};
|
||||
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
||||
if (value === undefined || value === null) return value;
|
||||
const str = typeof value === "string" ? value : JSON.stringify(value);
|
||||
if (str.length <= maxLen) return value;
|
||||
return str.slice(0, maxLen) + "...";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,59 @@
|
||||
import { isNil, isEmptyObject, isEqual, isPlainObject } from "es-toolkit";
|
||||
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
|
||||
|
||||
import type { ExpectOperator, ExpectValue } from "../../types";
|
||||
|
||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
for (const [key, expected] of Object.entries(op)) {
|
||||
if (expected === undefined) continue;
|
||||
|
||||
switch (key) {
|
||||
case "contains":
|
||||
if (!String(actual).includes(expected as string)) return false;
|
||||
break;
|
||||
case "empty": {
|
||||
const isEmpty =
|
||||
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
|
||||
if (expected !== isEmpty) return false;
|
||||
break;
|
||||
}
|
||||
case "equals":
|
||||
if (!isEqual(actual, expected)) return false;
|
||||
break;
|
||||
case "exists":
|
||||
if (expected) {
|
||||
if (actual === undefined) return false;
|
||||
} else {
|
||||
if (actual !== undefined) return false;
|
||||
}
|
||||
break;
|
||||
case "gt":
|
||||
if (!(Number(actual) > (expected as number))) return false;
|
||||
break;
|
||||
case "gte":
|
||||
if (!(Number(actual) >= (expected as number))) return false;
|
||||
break;
|
||||
case "lt":
|
||||
if (!(Number(actual) < (expected as number))) return false;
|
||||
break;
|
||||
case "lte":
|
||||
if (!(Number(actual) <= (expected as number))) return false;
|
||||
break;
|
||||
case "match":
|
||||
if (!new RegExp(expected as string).test(String(actual))) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||
if (isPlainObject(expected)) {
|
||||
return applyOperator(actual, expected);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected });
|
||||
}
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
|
||||
@@ -22,55 +75,3 @@ export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
|
||||
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 (!isEqual(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 =
|
||||
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
|
||||
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 (isPlainObject(expected)) {
|
||||
return applyOperator(actual, expected as ExpectOperator);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import type { TextRule } from "../../types";
|
||||
import { applyOperator } from "./operator";
|
||||
import { mismatchFailure } from "./failure";
|
||||
import type { ExpectResult } from "./duration";
|
||||
|
||||
import { mismatchFailure } from "./failure";
|
||||
import { applyOperator } from "./operator";
|
||||
|
||||
export function checkTextRules(text: string, rules: TextRule[], phase: string): ExpectResult {
|
||||
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`),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { matched: true, failure: null };
|
||||
return { failure: null, matched: true };
|
||||
}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import type { CheckResult } from "../types";
|
||||
import type { DefaultsConfig, ResolvedTarget, TargetConfig } from "../types";
|
||||
import type { CheckResult, DefaultsConfig, ResolvedTarget, TargetConfig } from "../types";
|
||||
|
||||
export interface Checker {
|
||||
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>;
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget;
|
||||
serialize(target: ResolvedTarget): { config: string; target: string };
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
export interface CheckerContext {
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ResolveContext {
|
||||
defaults: DefaultsConfig;
|
||||
configDir: string;
|
||||
defaultIntervalMs: number;
|
||||
defaults: DefaultsConfig;
|
||||
defaultTimeoutMs: number;
|
||||
}
|
||||
|
||||
export interface Checker {
|
||||
readonly type: string;
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget;
|
||||
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>;
|
||||
serialize(target: ResolvedTarget): { target: string; config: string };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
|
||||
|
||||
export function parseSize(value: string | number): number {
|
||||
export function parseSize(value: number | string): number {
|
||||
if (typeof value === "number") return value;
|
||||
|
||||
const match = SIZE_REGEX.exec(value);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdirSync as fsMkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types";
|
||||
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
const CREATE_TARGETS_TABLE = `
|
||||
@@ -37,8 +39,8 @@ ON check_results (target_id, timestamp)
|
||||
`;
|
||||
|
||||
export class ProbeStore {
|
||||
private db: Database;
|
||||
private closed = false;
|
||||
private db: Database;
|
||||
|
||||
constructor(dbPath: string) {
|
||||
ensureDir(dirname(dbPath));
|
||||
@@ -50,6 +52,211 @@ export class ProbeStore {
|
||||
this.db.run(CREATE_INDEX);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
getAllTargetStats(): Map<number, { availability: number; totalChecks: number }> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`SELECT target_id, COUNT(*) as totalChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
||||
FROM check_results
|
||||
GROUP BY target_id`,
|
||||
)
|
||||
.all() as Array<{ target_id: number; totalChecks: number; upCount: number }>;
|
||||
|
||||
const result = new Map<number, { availability: number; totalChecks: number }>();
|
||||
for (const row of rows) {
|
||||
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 10000) / 100 : 0;
|
||||
result.set(row.target_id, { availability, totalChecks: row.totalChecks });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getHistory(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): { items: StoredCheckResult[]; page: number; pageSize: number; total: number } {
|
||||
const countRow = this.db
|
||||
.query("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
|
||||
.get(targetId, from, to) as { total: number };
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const items = this.db
|
||||
.query(
|
||||
"SELECT * FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.all(targetId, from, to, pageSize, offset) as StoredCheckResult[];
|
||||
|
||||
return { items, page, pageSize, total: countRow.total };
|
||||
}
|
||||
|
||||
getLatestCheck(targetId: number): null | StoredCheckResult {
|
||||
return this.db
|
||||
.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1")
|
||||
.get(targetId) as null | StoredCheckResult;
|
||||
}
|
||||
|
||||
getLatestChecksMap(): Map<number, StoredCheckResult> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`SELECT cr.* FROM check_results cr
|
||||
INNER JOIN (
|
||||
SELECT target_id, MAX(timestamp) as max_ts
|
||||
FROM check_results
|
||||
GROUP BY target_id
|
||||
) latest ON cr.target_id = latest.target_id AND cr.timestamp = latest.max_ts`,
|
||||
)
|
||||
.all() as StoredCheckResult[];
|
||||
return new Map(rows.map((r) => [r.target_id, r]));
|
||||
}
|
||||
|
||||
getRecentSamples(
|
||||
targetId: number,
|
||||
limit: number,
|
||||
): Array<{ duration_ms: null | number; matched: number; timestamp: string }> {
|
||||
return this.db
|
||||
.query(
|
||||
"SELECT timestamp, duration_ms, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?",
|
||||
)
|
||||
.all(targetId, limit) as Array<{
|
||||
duration_ms: null | number;
|
||||
matched: number;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
getSummary(): {
|
||||
down: number;
|
||||
lastCheckTime: null | string;
|
||||
total: number;
|
||||
up: number;
|
||||
} {
|
||||
const targets = this.getTargets();
|
||||
const latestChecksMap = this.getLatestChecksMap();
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
let lastCheckTime: null | string = null;
|
||||
|
||||
for (const target of targets) {
|
||||
const latest = latestChecksMap.get(target.id);
|
||||
|
||||
if (latest) {
|
||||
if (latest.matched) {
|
||||
up++;
|
||||
} else {
|
||||
down++;
|
||||
}
|
||||
|
||||
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
|
||||
lastCheckTime = latest.timestamp;
|
||||
}
|
||||
} else {
|
||||
down++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
down,
|
||||
lastCheckTime,
|
||||
total: targets.length,
|
||||
up,
|
||||
};
|
||||
}
|
||||
|
||||
getTargetById(id: number): null | StoredTarget {
|
||||
if (this.closed) return null;
|
||||
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as null | StoredTarget;
|
||||
}
|
||||
|
||||
getTargets(): StoredTarget[] {
|
||||
if (this.closed) return [];
|
||||
return this.db
|
||||
.query("SELECT * FROM targets ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id")
|
||||
.all() as StoredTarget[];
|
||||
}
|
||||
|
||||
getTargetStats(targetId: number): {
|
||||
availability: number;
|
||||
totalChecks: number;
|
||||
} {
|
||||
const row = this.db
|
||||
.query(
|
||||
`SELECT
|
||||
COUNT(*) as totalChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
||||
FROM check_results
|
||||
WHERE target_id = ?`,
|
||||
)
|
||||
.get(targetId) as { totalChecks: number; upCount: number };
|
||||
|
||||
const totalChecks = row.totalChecks;
|
||||
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
|
||||
|
||||
return {
|
||||
availability: Math.round(availability * 100) / 100,
|
||||
totalChecks,
|
||||
};
|
||||
}
|
||||
|
||||
getTrend(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
): Array<{
|
||||
availability: number;
|
||||
avgDurationMs: null | number;
|
||||
hour: string;
|
||||
totalChecks: number;
|
||||
}> {
|
||||
return this.db
|
||||
.query(
|
||||
`SELECT
|
||||
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
|
||||
AVG(CASE WHEN matched = 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
|
||||
WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?
|
||||
GROUP BY hour
|
||||
ORDER BY hour`,
|
||||
)
|
||||
.all(targetId, from, to) as Array<{
|
||||
availability: number;
|
||||
avgDurationMs: null | number;
|
||||
hour: string;
|
||||
totalChecks: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
insertCheckResult(result: {
|
||||
durationMs: null | number;
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
statusDetail: null | string;
|
||||
targetId: number;
|
||||
timestamp: string;
|
||||
}): void {
|
||||
if (this.closed) return;
|
||||
this.db
|
||||
.query(
|
||||
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
result.targetId,
|
||||
result.timestamp,
|
||||
result.matched ? 1 : 0,
|
||||
result.durationMs,
|
||||
result.statusDetail,
|
||||
result.failure ? JSON.stringify(result.failure) : null,
|
||||
);
|
||||
}
|
||||
|
||||
syncTargets(targets: ResolvedTarget[]): void {
|
||||
if (this.closed) return;
|
||||
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{
|
||||
@@ -90,221 +297,16 @@ export class ProbeStore {
|
||||
|
||||
tx();
|
||||
}
|
||||
|
||||
getTargets(): StoredTarget[] {
|
||||
if (this.closed) return [];
|
||||
return this.db
|
||||
.query("SELECT * FROM targets ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id")
|
||||
.all() as StoredTarget[];
|
||||
}
|
||||
|
||||
getTargetById(id: number): StoredTarget | null {
|
||||
if (this.closed) return null;
|
||||
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as StoredTarget | null;
|
||||
}
|
||||
|
||||
insertCheckResult(result: {
|
||||
targetId: number;
|
||||
timestamp: string;
|
||||
matched: boolean;
|
||||
durationMs: number | null;
|
||||
statusDetail: string | null;
|
||||
failure: CheckFailure | null;
|
||||
}): void {
|
||||
if (this.closed) return;
|
||||
this.db
|
||||
.query(
|
||||
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
result.targetId,
|
||||
result.timestamp,
|
||||
result.matched ? 1 : 0,
|
||||
result.durationMs,
|
||||
result.statusDetail,
|
||||
result.failure ? JSON.stringify(result.failure) : null,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
getHistory(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): { items: StoredCheckResult[]; total: number; page: number; pageSize: number } {
|
||||
const countRow = this.db
|
||||
.query("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
|
||||
.get(targetId, from, to) as { total: number };
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const items = this.db
|
||||
.query(
|
||||
"SELECT * FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.all(targetId, from, to, pageSize, offset) as StoredCheckResult[];
|
||||
|
||||
return { items, total: countRow.total, page, pageSize };
|
||||
}
|
||||
|
||||
getTargetStats(targetId: number): {
|
||||
totalChecks: number;
|
||||
availability: number;
|
||||
} {
|
||||
const row = this.db
|
||||
.query(
|
||||
`SELECT
|
||||
COUNT(*) as totalChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
||||
FROM check_results
|
||||
WHERE target_id = ?`,
|
||||
)
|
||||
.get(targetId) as { totalChecks: number; upCount: number };
|
||||
|
||||
const totalChecks = row.totalChecks;
|
||||
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalChecks,
|
||||
availability: Math.round(availability * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
getTrend(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
): Array<{
|
||||
hour: string;
|
||||
avgDurationMs: number | null;
|
||||
availability: number;
|
||||
totalChecks: number;
|
||||
}> {
|
||||
return this.db
|
||||
.query(
|
||||
`SELECT
|
||||
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
|
||||
AVG(CASE WHEN matched = 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
|
||||
WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?
|
||||
GROUP BY hour
|
||||
ORDER BY hour`,
|
||||
)
|
||||
.all(targetId, from, to) as Array<{
|
||||
hour: string;
|
||||
avgDurationMs: number | null;
|
||||
availability: number;
|
||||
totalChecks: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
getSummary(): {
|
||||
total: number;
|
||||
up: number;
|
||||
down: number;
|
||||
lastCheckTime: string | null;
|
||||
} {
|
||||
const targets = this.getTargets();
|
||||
const latestChecksMap = this.getLatestChecksMap();
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
let lastCheckTime: string | null = null;
|
||||
|
||||
for (const target of targets) {
|
||||
const latest = latestChecksMap.get(target.id);
|
||||
|
||||
if (latest) {
|
||||
if (latest.matched) {
|
||||
up++;
|
||||
} else {
|
||||
down++;
|
||||
}
|
||||
|
||||
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
|
||||
lastCheckTime = latest.timestamp;
|
||||
}
|
||||
} else {
|
||||
down++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: targets.length,
|
||||
up,
|
||||
down,
|
||||
lastCheckTime,
|
||||
};
|
||||
}
|
||||
|
||||
getRecentSamples(
|
||||
targetId: number,
|
||||
limit: number,
|
||||
): Array<{ timestamp: string; duration_ms: number | null; matched: number }> {
|
||||
return this.db
|
||||
.query(
|
||||
"SELECT timestamp, duration_ms, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?",
|
||||
)
|
||||
.all(targetId, limit) as Array<{
|
||||
timestamp: string;
|
||||
duration_ms: number | null;
|
||||
matched: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
getLatestChecksMap(): Map<number, StoredCheckResult> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`SELECT cr.* FROM check_results cr
|
||||
INNER JOIN (
|
||||
SELECT target_id, MAX(timestamp) as max_ts
|
||||
FROM check_results
|
||||
GROUP BY target_id
|
||||
) latest ON cr.target_id = latest.target_id AND cr.timestamp = latest.max_ts`,
|
||||
)
|
||||
.all() as StoredCheckResult[];
|
||||
return new Map(rows.map((r) => [r.target_id, r]));
|
||||
}
|
||||
|
||||
getAllTargetStats(): Map<number, { totalChecks: number; availability: number }> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`SELECT target_id, COUNT(*) as totalChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
||||
FROM check_results
|
||||
GROUP BY target_id`,
|
||||
)
|
||||
.all() as Array<{ target_id: number; totalChecks: number; upCount: number }>;
|
||||
|
||||
const result = new Map<number, { totalChecks: number; availability: number }>();
|
||||
for (const row of rows) {
|
||||
const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 10000) / 100 : 0;
|
||||
result.set(row.target_id, { totalChecks: row.totalChecks, availability });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function buildTargetDisplay(t: ResolvedTarget): string {
|
||||
return checkerRegistry.get(t.type).serialize(t).target;
|
||||
}
|
||||
|
||||
function buildTargetConfig(t: ResolvedTarget): string {
|
||||
return checkerRegistry.get(t.type).serialize(t).config;
|
||||
}
|
||||
|
||||
function buildTargetDisplay(t: ResolvedTarget): string {
|
||||
return checkerRegistry.get(t.type).serialize(t).target;
|
||||
}
|
||||
|
||||
function ensureDir(dir: string): void {
|
||||
try {
|
||||
fsMkdirSync(dir, { recursive: true });
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api";
|
||||
|
||||
export type TargetType = "http" | "command";
|
||||
export type BodyRule =
|
||||
| { contains: string }
|
||||
| { css: CssRule }
|
||||
| { json: JsonRule }
|
||||
| { regex: string }
|
||||
| { xpath: XpathRule };
|
||||
|
||||
export interface ProbeConfig {
|
||||
server?: ServerConfig;
|
||||
runtime?: EngineRuntimeConfig;
|
||||
defaults?: DefaultsConfig;
|
||||
targets: TargetConfig[];
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
dataDir?: string;
|
||||
}
|
||||
|
||||
export interface EngineRuntimeConfig {
|
||||
maxConcurrentChecks?: number;
|
||||
}
|
||||
|
||||
export interface HttpDefaultsConfig {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
maxBodyBytes?: string;
|
||||
export interface CheckResult extends ApiCheckResult {
|
||||
targetName: string;
|
||||
}
|
||||
|
||||
export interface CommandDefaultsConfig {
|
||||
@@ -30,148 +16,162 @@ export interface CommandDefaultsConfig {
|
||||
maxOutputBytes?: string;
|
||||
}
|
||||
|
||||
export interface DefaultsConfig {
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
http?: HttpDefaultsConfig;
|
||||
command?: CommandDefaultsConfig;
|
||||
}
|
||||
|
||||
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;
|
||||
group?: string;
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
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 TextRule = ExpectOperator;
|
||||
|
||||
export type JsonRule = { path: string } & ExpectOperator;
|
||||
|
||||
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[];
|
||||
maxDurationMs?: number;
|
||||
headers?: Record<string, HeaderExpect>;
|
||||
body?: BodyRule[];
|
||||
}
|
||||
|
||||
export interface CommandExpectConfig {
|
||||
exitCode?: number[];
|
||||
maxDurationMs?: number;
|
||||
stdout?: TextRule[];
|
||||
stderr?: TextRule[];
|
||||
stdout?: TextRule[];
|
||||
}
|
||||
|
||||
export type ExpectConfig = HttpExpectConfig | CommandExpectConfig;
|
||||
|
||||
export interface ResolvedHttpTarget {
|
||||
type: "http";
|
||||
name: string;
|
||||
group: string;
|
||||
http: ResolvedHttpConfig;
|
||||
intervalMs: number;
|
||||
timeoutMs: number;
|
||||
expect?: HttpExpectConfig;
|
||||
export interface CommandTargetConfig {
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
exec: string;
|
||||
maxOutputBytes?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedHttpConfig {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
export type CssRule = ExpectOperator & { attr?: string; selector: string };
|
||||
|
||||
export interface DefaultsConfig {
|
||||
command?: CommandDefaultsConfig;
|
||||
http?: HttpDefaultsConfig;
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
}
|
||||
|
||||
export interface EngineRuntimeConfig {
|
||||
maxConcurrentChecks?: number;
|
||||
}
|
||||
|
||||
export type ExpectConfig = CommandExpectConfig | HttpExpectConfig;
|
||||
|
||||
export interface ExpectOperator {
|
||||
contains?: string;
|
||||
empty?: boolean;
|
||||
equals?: boolean | null | number | string;
|
||||
exists?: boolean;
|
||||
gt?: number;
|
||||
gte?: number;
|
||||
lt?: number;
|
||||
lte?: number;
|
||||
match?: string;
|
||||
}
|
||||
|
||||
export type ExpectValue = boolean | ExpectOperator | null | number | string;
|
||||
|
||||
export type HeaderExpect = ExpectOperator | string;
|
||||
|
||||
export interface HttpDefaultsConfig {
|
||||
headers?: Record<string, string>;
|
||||
maxBodyBytes?: string;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export interface HttpExpectConfig {
|
||||
body?: BodyRule[];
|
||||
headers?: Record<string, HeaderExpect>;
|
||||
maxDurationMs?: number;
|
||||
status?: number[];
|
||||
}
|
||||
|
||||
export interface HttpTargetConfig {
|
||||
body?: string;
|
||||
maxBodyBytes: number;
|
||||
headers?: Record<string, string>;
|
||||
maxBodyBytes?: string;
|
||||
method?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ResolvedCommandTarget {
|
||||
type: "command";
|
||||
name: string;
|
||||
group: string;
|
||||
command: ResolvedCommandConfig;
|
||||
intervalMs: number;
|
||||
timeoutMs: number;
|
||||
expect?: CommandExpectConfig;
|
||||
export type JsonRule = ExpectOperator & { path: string };
|
||||
|
||||
export interface ProbeConfig {
|
||||
defaults?: DefaultsConfig;
|
||||
runtime?: EngineRuntimeConfig;
|
||||
server?: ServerConfig;
|
||||
targets: TargetConfig[];
|
||||
}
|
||||
|
||||
export interface ResolvedCommandConfig {
|
||||
exec: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
exec: string;
|
||||
maxOutputBytes: number;
|
||||
}
|
||||
|
||||
export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget;
|
||||
|
||||
export type { CheckFailure };
|
||||
export interface CheckResult extends ApiCheckResult {
|
||||
targetName: string;
|
||||
export interface ResolvedCommandTarget {
|
||||
command: ResolvedCommandConfig;
|
||||
expect?: CommandExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: string;
|
||||
timeoutMs: number;
|
||||
type: "command";
|
||||
}
|
||||
|
||||
export interface StoredTarget {
|
||||
id: number;
|
||||
export interface ResolvedHttpConfig {
|
||||
body?: string;
|
||||
headers: Record<string, string>;
|
||||
maxBodyBytes: number;
|
||||
method: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ResolvedHttpTarget {
|
||||
expect?: HttpExpectConfig;
|
||||
group: string;
|
||||
http: ResolvedHttpConfig;
|
||||
intervalMs: number;
|
||||
name: string;
|
||||
type: TargetType;
|
||||
target: string;
|
||||
config: string;
|
||||
interval_ms: number;
|
||||
timeout_ms: number;
|
||||
expect: string | null;
|
||||
grp: string;
|
||||
timeoutMs: number;
|
||||
type: "http";
|
||||
}
|
||||
|
||||
export type ResolvedTarget = ResolvedCommandTarget | ResolvedHttpTarget;
|
||||
|
||||
export interface ServerConfig {
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface StoredCheckResult {
|
||||
duration_ms: null | number;
|
||||
failure: null | string;
|
||||
id: number;
|
||||
matched: number;
|
||||
status_detail: null | string;
|
||||
target_id: number;
|
||||
timestamp: string;
|
||||
matched: number;
|
||||
duration_ms: number | null;
|
||||
status_detail: string | null;
|
||||
failure: string | null;
|
||||
}
|
||||
|
||||
export interface StoredTarget {
|
||||
config: string;
|
||||
expect: null | string;
|
||||
grp: string;
|
||||
id: number;
|
||||
interval_ms: number;
|
||||
name: string;
|
||||
target: string;
|
||||
timeout_ms: number;
|
||||
type: TargetType;
|
||||
}
|
||||
|
||||
export type TargetConfig = BaseTargetConfig &
|
||||
({ command: CommandTargetConfig; type: "command" } | { http: HttpTargetConfig; type: "http" });
|
||||
|
||||
export type TargetType = "command" | "http";
|
||||
|
||||
export type { CheckFailure };
|
||||
export type TextRule = ExpectOperator;
|
||||
|
||||
export type XpathRule = ExpectOperator & { path: string };
|
||||
|
||||
interface BaseTargetConfig {
|
||||
expect?: ExpectConfig;
|
||||
group?: string;
|
||||
interval?: string;
|
||||
name: string;
|
||||
timeout?: string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export interface RuntimeConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function readRuntimeConfig(argv: string[] = process.argv.slice(2)): { configPath: string } {
|
||||
if (argv.length === 0) {
|
||||
throw new Error("需要指定 YAML 配置文件路径\n用法: dial-server <config.yaml>");
|
||||
@@ -5,8 +10,3 @@ export function readRuntimeConfig(argv: string[] = process.argv.slice(2)): { con
|
||||
|
||||
return { configPath: argv[0]! };
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { loadConfig } from "./checker/config-loader";
|
||||
import { ProbeStore } from "./checker/store";
|
||||
import { ProbeEngine } from "./checker/engine";
|
||||
import { startServer } from "./server";
|
||||
import { readRuntimeConfig } from "./config";
|
||||
import { registerCheckers } from "./checker/runner";
|
||||
import { ProbeStore } from "./checker/store";
|
||||
import { readRuntimeConfig } from "./config";
|
||||
import { startServer } from "./server";
|
||||
|
||||
async function main() {
|
||||
registerCheckers();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
|
||||
import type { StoredCheckResult } from "./checker/types";
|
||||
|
||||
export function allowsGetHead(method: string): boolean {
|
||||
return method === "GET" || method === "HEAD";
|
||||
}
|
||||
|
||||
export function createApiError(error: string, status: number): ApiErrorResponse {
|
||||
return { error, status };
|
||||
}
|
||||
@@ -16,9 +20,23 @@ export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers {
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function createHealthResponse(): HealthResponse {
|
||||
return {
|
||||
ok: true,
|
||||
service: "dial-server",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`;
|
||||
if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`;
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
export function jsonResponse(
|
||||
body: unknown,
|
||||
options: { method?: string; mode: RuntimeMode; status?: number; headers?: HeadersInit },
|
||||
options: { headers?: HeadersInit; method?: string; mode: RuntimeMode; status?: number },
|
||||
): Response {
|
||||
const headers = createHeaders(options.mode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
@@ -27,29 +45,11 @@ export function jsonResponse(
|
||||
const responseBody = options.method === "HEAD" ? null : JSON.stringify(body);
|
||||
|
||||
return new Response(responseBody, {
|
||||
status: options.status,
|
||||
headers,
|
||||
status: options.status,
|
||||
});
|
||||
}
|
||||
|
||||
export function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response {
|
||||
return jsonResponse(createApiError("Method not allowed", 405), {
|
||||
mode,
|
||||
status: 405,
|
||||
headers: { Allow: allow.join(", ") },
|
||||
});
|
||||
}
|
||||
|
||||
export function allowsGetHead(method: string): boolean {
|
||||
return method === "GET" || method === "HEAD";
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`;
|
||||
if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`;
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
export function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
let failure: CheckFailure | null = null;
|
||||
if (row.failure) {
|
||||
@@ -62,18 +62,18 @@ export function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: row.timestamp,
|
||||
matched: row.matched === 1,
|
||||
durationMs: row.duration_ms,
|
||||
statusDetail: row.status_detail,
|
||||
failure,
|
||||
matched: row.matched === 1,
|
||||
statusDetail: row.status_detail,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function createHealthResponse(): HealthResponse {
|
||||
return {
|
||||
ok: true,
|
||||
service: "dial-server",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
export function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response {
|
||||
return jsonResponse(createApiError("Method not allowed", 405), {
|
||||
headers: { Allow: allow.join(", ") },
|
||||
mode,
|
||||
status: 405,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
|
||||
import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers";
|
||||
|
||||
export function guardGetHead(method: string, mode: RuntimeMode): Response | null {
|
||||
export function guardGetHead(method: string, mode: RuntimeMode): null | Response {
|
||||
if (!allowsGetHead(method)) {
|
||||
return methodNotAllowedResponse(["GET", "HEAD"], mode);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateTargetId(idStr: string, mode: RuntimeMode): { id: number } | Response {
|
||||
const id = Number(idStr);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return jsonResponse(createApiError("Invalid target ID", 400), { mode, status: 400 });
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
|
||||
export function validateTimeRange(
|
||||
from: string | null,
|
||||
to: string | null,
|
||||
mode: RuntimeMode,
|
||||
): { from: string; to: string } | Response {
|
||||
if (!from || !to) {
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (isNaN(new Date(from).getTime()) || isNaN(new Date(to).getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
export function validatePagination(
|
||||
pageParam: string | null,
|
||||
pageSizeParam: string | null,
|
||||
pageParam: null | string,
|
||||
pageSizeParam: null | string,
|
||||
mode: RuntimeMode,
|
||||
): { page: number; pageSize: number } | Response {
|
||||
): Response | { page: number; pageSize: number } {
|
||||
let page = 1;
|
||||
let pageSize = 20;
|
||||
|
||||
@@ -56,3 +33,27 @@ export function validatePagination(
|
||||
|
||||
return { page, pageSize };
|
||||
}
|
||||
|
||||
export function validateTargetId(idStr: string, mode: RuntimeMode): Response | { id: number } {
|
||||
const id = Number(idStr);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return jsonResponse(createApiError("Invalid target ID", 400), { mode, status: 400 });
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
|
||||
export function validateTimeRange(
|
||||
from: null | string,
|
||||
to: null | string,
|
||||
mode: RuntimeMode,
|
||||
): Response | { from: string; to: string } {
|
||||
if (!from || !to) {
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (isNaN(new Date(from).getTime()) || isNaN(new Date(to).getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeMode } from "../../shared/api";
|
||||
import { createHealthResponse, jsonResponse, allowsGetHead, methodNotAllowedResponse } from "../helpers";
|
||||
|
||||
import { allowsGetHead, createHealthResponse, jsonResponse, methodNotAllowedResponse } from "../helpers";
|
||||
|
||||
export function handleHealth(method: string, mode: RuntimeMode): Response {
|
||||
if (!allowsGetHead(method)) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RuntimeMode, HistoryResponse } from "../../shared/api";
|
||||
import type { HistoryResponse, RuntimeMode } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { jsonResponse, mapCheckResult } from "../helpers";
|
||||
import { validateTargetId, validateTimeRange, validatePagination } from "../middleware";
|
||||
import { validatePagination, validateTargetId, validateTimeRange } from "../middleware";
|
||||
|
||||
export function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const idResult = validateTargetId(idStr, mode);
|
||||
@@ -21,9 +22,9 @@ export function handleHistory(idStr: string, url: URL, method: string, store: Pr
|
||||
const result = store.getHistory(idResult.id, timeResult.from, timeResult.to, pageResult.page, pageResult.pageSize);
|
||||
const response: HistoryResponse = {
|
||||
items: result.items.map(mapCheckResult),
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
};
|
||||
|
||||
return jsonResponse(response, { method, mode });
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { RuntimeMode, SummaryResponse } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { jsonResponse } from "../helpers";
|
||||
|
||||
export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMode): Response {
|
||||
const summary = store.getSummary();
|
||||
const response: SummaryResponse = {
|
||||
total: summary.total,
|
||||
up: summary.up,
|
||||
down: summary.down,
|
||||
lastCheckTime: summary.lastCheckTime,
|
||||
total: summary.total,
|
||||
up: summary.up,
|
||||
};
|
||||
|
||||
return jsonResponse(response, { method, mode });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeMode, TargetStatus } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { formatDuration, jsonResponse, mapCheckResult } from "../helpers";
|
||||
|
||||
export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMode): Response {
|
||||
@@ -9,26 +10,26 @@ export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMo
|
||||
|
||||
const result: TargetStatus[] = targets.map((target) => {
|
||||
const latest = latestChecksMap.get(target.id) ?? null;
|
||||
const stats = allStats.get(target.id) ?? { totalChecks: 0, availability: 0 };
|
||||
const stats = allStats.get(target.id) ?? { availability: 0, totalChecks: 0 };
|
||||
const recentSamples = store.getRecentSamples(target.id, 30);
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
target: target.target,
|
||||
group: target.grp,
|
||||
id: target.id,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
name: target.name,
|
||||
recentSamples: recentSamples.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
durationMs: s.duration_ms,
|
||||
timestamp: s.timestamp,
|
||||
up: s.matched === 1,
|
||||
})),
|
||||
stats: {
|
||||
totalChecks: stats.totalChecks,
|
||||
availability: stats.availability,
|
||||
totalChecks: stats.totalChecks,
|
||||
},
|
||||
target: target.target,
|
||||
type: target.type,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeMode, TrendPoint } from "../../shared/api";
|
||||
import type { ProbeStore } from "../checker/store";
|
||||
|
||||
import { jsonResponse } from "../helpers";
|
||||
import { validateTargetId, validateTimeRange } from "../middleware";
|
||||
|
||||
@@ -16,9 +17,9 @@ export function handleTrend(idStr: string, url: URL, method: string, store: Prob
|
||||
if (timeResult instanceof Response) return timeResult;
|
||||
|
||||
const trend: TrendPoint[] = store.getTrend(idResult.id, timeResult.from, timeResult.to).map((row) => ({
|
||||
hour: row.hour,
|
||||
avgDurationMs: row.avgDurationMs,
|
||||
availability: Math.round(row.availability * 100) / 100,
|
||||
avgDurationMs: row.avgDurationMs,
|
||||
hour: row.hour,
|
||||
totalChecks: row.totalChecks,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { StaticAssets } from "./app";
|
||||
import type { ProbeStore } from "./checker/store";
|
||||
import { createFetchHandler } from "./app";
|
||||
import type { RuntimeConfig } from "./config";
|
||||
|
||||
import { createFetchHandler } from "./app";
|
||||
|
||||
export interface StartServerOptions {
|
||||
config: RuntimeConfig;
|
||||
mode: RuntimeMode;
|
||||
@@ -14,13 +15,13 @@ export interface StartServerOptions {
|
||||
export function startServer(options: StartServerOptions) {
|
||||
const { config, mode, staticAssets, store } = options;
|
||||
const server = Bun.serve({
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
fetch: createFetchHandler({
|
||||
mode,
|
||||
staticAssets,
|
||||
store,
|
||||
}),
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
});
|
||||
|
||||
console.log(`DiAL listening on ${server.url}`);
|
||||
|
||||
@@ -1,45 +1,7 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import { createHeaders } from "./helpers";
|
||||
import type { StaticAssets } from "./app";
|
||||
|
||||
export function serveStaticAsset(pathname: string, staticAssets: StaticAssets, mode: RuntimeMode): Response {
|
||||
if (pathname === "/") {
|
||||
return htmlResponse(staticAssets.indexHtml, mode);
|
||||
}
|
||||
|
||||
const asset = staticAssets.files[pathname];
|
||||
|
||||
if (asset) {
|
||||
return new Response(asset, {
|
||||
headers: createHeaders(mode, {
|
||||
"Content-Type": contentTypeFor(pathname),
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) {
|
||||
return new Response("Not Found", {
|
||||
status: 404,
|
||||
headers: createHeaders(mode, { "Content-Type": "text/plain; charset=utf-8" }),
|
||||
});
|
||||
}
|
||||
|
||||
return htmlResponse(staticAssets.indexHtml, mode);
|
||||
}
|
||||
|
||||
export function htmlResponse(indexHtml: Blob, mode: RuntimeMode): Response {
|
||||
return new Response(indexHtml, {
|
||||
headers: createHeaders(mode, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function hasFileExtension(pathname: string): boolean {
|
||||
return /\/[^/]+\.[^/]+$/.test(pathname);
|
||||
}
|
||||
import { createHeaders } from "./helpers";
|
||||
|
||||
export function contentTypeFor(pathname: string): string {
|
||||
if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "text/javascript; charset=utf-8";
|
||||
@@ -52,3 +14,42 @@ export function contentTypeFor(pathname: string): string {
|
||||
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
export function hasFileExtension(pathname: string): boolean {
|
||||
return /\/[^/]+\.[^/]+$/.test(pathname);
|
||||
}
|
||||
|
||||
export function htmlResponse(indexHtml: Blob, mode: RuntimeMode): Response {
|
||||
return new Response(indexHtml, {
|
||||
headers: createHeaders(mode, {
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function serveStaticAsset(pathname: string, staticAssets: StaticAssets, mode: RuntimeMode): Response {
|
||||
if (pathname === "/") {
|
||||
return htmlResponse(staticAssets.indexHtml, mode);
|
||||
}
|
||||
|
||||
const asset = staticAssets.files[pathname];
|
||||
|
||||
if (asset) {
|
||||
return new Response(asset, {
|
||||
headers: createHeaders(mode, {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Type": contentTypeFor(pathname),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) {
|
||||
return new Response("Not Found", {
|
||||
headers: createHeaders(mode, { "Content-Type": "text/plain; charset=utf-8" }),
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return htmlResponse(staticAssets.indexHtml, mode);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
export type RuntimeMode = "development" | "production" | "test";
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface CheckFailure {
|
||||
actual?: unknown;
|
||||
expected?: unknown;
|
||||
kind: "error" | "mismatch";
|
||||
message: string;
|
||||
path: string;
|
||||
phase: string;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
durationMs: null | number;
|
||||
failure: CheckFailure | null;
|
||||
matched: boolean;
|
||||
statusDetail: null | string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
ok: true;
|
||||
@@ -6,68 +26,48 @@ export interface HealthResponse {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface SummaryResponse {
|
||||
export interface HistoryResponse {
|
||||
items: CheckResult[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
up: number;
|
||||
down: number;
|
||||
lastCheckTime: string | null;
|
||||
}
|
||||
|
||||
export interface RecentSample {
|
||||
durationMs: null | number;
|
||||
timestamp: string;
|
||||
durationMs: number | null;
|
||||
up: boolean;
|
||||
}
|
||||
|
||||
export interface TargetStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
target: string;
|
||||
group: string;
|
||||
interval: string;
|
||||
latestCheck: CheckResult | null;
|
||||
stats: TargetStats;
|
||||
recentSamples: RecentSample[];
|
||||
export type RuntimeMode = "development" | "production" | "test";
|
||||
|
||||
export interface SummaryResponse {
|
||||
down: number;
|
||||
lastCheckTime: null | string;
|
||||
total: number;
|
||||
up: number;
|
||||
}
|
||||
|
||||
export interface TargetStats {
|
||||
totalChecks: number;
|
||||
availability: number;
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
items: CheckResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
timestamp: string;
|
||||
matched: boolean;
|
||||
durationMs: number | null;
|
||||
statusDetail: string | null;
|
||||
failure: CheckFailure | null;
|
||||
}
|
||||
|
||||
export interface CheckFailure {
|
||||
kind: "error" | "mismatch";
|
||||
phase: string;
|
||||
path: string;
|
||||
expected?: unknown;
|
||||
actual?: unknown;
|
||||
message: string;
|
||||
export interface TargetStatus {
|
||||
group: string;
|
||||
id: number;
|
||||
interval: string;
|
||||
latestCheck: CheckResult | null;
|
||||
name: string;
|
||||
recentSamples: RecentSample[];
|
||||
stats: TargetStats;
|
||||
target: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
hour: string;
|
||||
avgDurationMs: number | null;
|
||||
availability: number;
|
||||
avgDurationMs: null | number;
|
||||
hour: string;
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { Alert, Loading, Typography } from "tdesign-react";
|
||||
import { useSummary, useTargets, useTargetDetail } from "./hooks/useTargetDetail";
|
||||
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TargetBoard } from "./components/TargetBoard";
|
||||
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
||||
import { useSummary, useTargetDetail, useTargets } from "./hooks/useTargetDetail";
|
||||
|
||||
export function App() {
|
||||
const { data: summary, isLoading: summaryLoading, error: summaryError } = useSummary();
|
||||
const { data: targets, isLoading: targetsLoading, error: targetsError } = useTargets();
|
||||
const { data: summary, error: summaryError, isLoading: summaryLoading } = useSummary();
|
||||
const { data: targets, error: targetsError, isLoading: targetsLoading } = useTargets();
|
||||
const {
|
||||
selectedTarget,
|
||||
trendData,
|
||||
trendLoading,
|
||||
closeDrawer,
|
||||
handlePageChange,
|
||||
handleTimeChange,
|
||||
historyData,
|
||||
historyLoading,
|
||||
openDrawer,
|
||||
selectedTarget,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
handleTimeChange,
|
||||
handlePageChange,
|
||||
trendData,
|
||||
trendLoading,
|
||||
} = useTargetDetail();
|
||||
|
||||
const error = summaryError || targetsError;
|
||||
const error = summaryError ?? targetsError;
|
||||
|
||||
return (
|
||||
<main className="dashboard">
|
||||
@@ -30,29 +31,29 @@ export function App() {
|
||||
<Typography.Text theme="secondary">统一拨测平台</Typography.Text>
|
||||
</header>
|
||||
|
||||
{error && <Alert theme="error" message={`请求失败: ${error.message}`} closeBtn />}
|
||||
{error && <Alert closeBtn message={`请求失败: ${error.message}`} theme="error" />}
|
||||
|
||||
{summaryLoading && targetsLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<SummaryCards summary={summary ?? null} />
|
||||
<TargetBoard targets={targets ?? []} onTargetClick={openDrawer} />
|
||||
<TargetBoard onTargetClick={openDrawer} targets={targets ?? []} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TargetDetailDrawer
|
||||
key={selectedTarget?.id}
|
||||
target={selectedTarget}
|
||||
trendData={trendData}
|
||||
trendLoading={trendLoading}
|
||||
historyData={historyData}
|
||||
historyLoading={historyLoading}
|
||||
key={selectedTarget?.id}
|
||||
onClose={closeDrawer}
|
||||
onPageChange={handlePageChange}
|
||||
onTimeChange={handleTimeChange}
|
||||
target={selectedTarget}
|
||||
timeFrom={timeFrom}
|
||||
timeTo={timeTo}
|
||||
onTimeChange={handleTimeChange}
|
||||
onPageChange={handlePageChange}
|
||||
onClose={closeDrawer}
|
||||
trendData={trendData}
|
||||
trendLoading={trendLoading}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Tag, Typography } from "tdesign-react";
|
||||
|
||||
interface GroupHeaderProps {
|
||||
down: number;
|
||||
name: string;
|
||||
total: number;
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
|
||||
export function GroupHeader({ down, name, total, up }: GroupHeaderProps) {
|
||||
const displayName = name === "default" ? "默认分组" : name;
|
||||
|
||||
return (
|
||||
<div className="group-header">
|
||||
<Typography.Title level="h4">{displayName}</Typography.Title>
|
||||
<Tag theme="primary" variant="light" title="总数">
|
||||
<Tag theme="primary" title="总数" variant="light">
|
||||
{total}
|
||||
</Tag>
|
||||
<Tag theme="success" variant="light" title="正常">
|
||||
<Tag theme="success" title="正常" variant="light">
|
||||
{up}
|
||||
</Tag>
|
||||
<Tag theme="danger" variant="light" title="异常">
|
||||
<Tag theme="danger" title="异常" variant="light">
|
||||
{down}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
@@ -9,12 +9,12 @@ export function StatusBar({ samples }: StatusBarProps) {
|
||||
if (sample) {
|
||||
blocks.push(
|
||||
<span
|
||||
key={i}
|
||||
className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`}
|
||||
key={i}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
blocks.push(<span key={i} className="status-bar-block status-bar-block--empty" />);
|
||||
blocks.push(<span className="status-bar-block status-bar-block--empty" key={i} />);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface StatusDonutProps {
|
||||
up: number;
|
||||
down: number;
|
||||
up: number;
|
||||
}
|
||||
|
||||
const UP_COLOR = "var(--td-success-color)";
|
||||
const DOWN_COLOR = "var(--td-error-color)";
|
||||
const EMPTY_COLOR = "var(--td-bg-color-component-disabled)";
|
||||
|
||||
export function StatusDonut({ up, down }: StatusDonutProps) {
|
||||
export function StatusDonut({ down, up }: StatusDonutProps) {
|
||||
const total = up + down;
|
||||
const availability = total > 0 ? ((up / total) * 100).toFixed(1) : "-";
|
||||
|
||||
@@ -25,11 +25,11 @@ export function StatusDonut({ up, down }: StatusDonutProps) {
|
||||
|
||||
return (
|
||||
<div className="status-donut">
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<ResponsiveContainer height={180} width="100%">
|
||||
<PieChart>
|
||||
<Pie data={data} cx="50%" cy="50%" innerRadius={50} outerRadius={70} dataKey="value" stroke="none">
|
||||
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
|
||||
{data.map((_, index) => (
|
||||
<Cell key={index} fill={colors[index % colors.length]!} />
|
||||
<Cell fill={colors[index % colors.length]} key={index} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { Row, Col, Card, Statistic } from "tdesign-react";
|
||||
import { Card, Col, Row, Statistic } from "tdesign-react";
|
||||
|
||||
import type { SummaryResponse } from "../../shared/api";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: SummaryResponse | null;
|
||||
summary: null | SummaryResponse;
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary }: SummaryCardsProps) {
|
||||
if (!summary) return null;
|
||||
|
||||
const cards = [
|
||||
{ label: "全部目标", value: summary.total, color: "blue" as const },
|
||||
{ label: "正常", value: summary.up, color: "green" as const },
|
||||
{ label: "异常", value: summary.down, color: "red" as const },
|
||||
{ color: "blue" as const, label: "全部目标", value: summary.total },
|
||||
{ color: "green" as const, label: "正常", value: summary.up },
|
||||
{ color: "red" as const, label: "异常", value: summary.down },
|
||||
];
|
||||
|
||||
return (
|
||||
<Row gutter={16} className="summary-cards-row">
|
||||
<Row className="summary-cards-row" gutter={16}>
|
||||
{cards.map((card) => (
|
||||
<Col key={card.label} span={4}>
|
||||
<Card bordered>
|
||||
<Statistic title={card.label} value={card.value} color={card.color} />
|
||||
<Statistic color={card.color} title={card.label} value={card.value} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Space } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { TargetGroup } from "./TargetGroup";
|
||||
|
||||
interface TargetBoardProps {
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
targets: TargetStatus[];
|
||||
}
|
||||
|
||||
export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
|
||||
export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
||||
const groups = new Map<string, TargetStatus[]>();
|
||||
for (const target of targets) {
|
||||
const group = target.group;
|
||||
@@ -25,9 +27,9 @@ export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={32} className="full-width">
|
||||
<Space className="full-width" direction="vertical" size={32}>
|
||||
{sortedGroups.map(([name, groupTargets]) => (
|
||||
<TargetGroup key={name} name={name} targets={groupTargets} onTargetClick={onTargetClick} />
|
||||
<TargetGroup key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
|
||||
@@ -1,96 +1,99 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { TabValue } from "tdesign-react";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Drawer,
|
||||
Tabs,
|
||||
RadioGroup,
|
||||
DateRangePicker,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
DateRangePicker,
|
||||
Descriptions,
|
||||
Skeleton,
|
||||
PrimaryTable,
|
||||
Divider,
|
||||
Drawer,
|
||||
PrimaryTable,
|
||||
RadioGroup,
|
||||
Row,
|
||||
Skeleton,
|
||||
Space,
|
||||
Statistic,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "tdesign-react";
|
||||
import type { TabValue } from "tdesign-react";
|
||||
import type { CheckResult, TargetStatus, TrendPoint, HistoryResponse } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
import type { CheckResult, HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
import { subtractHours } from "../utils/time";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface TargetDetailDrawerProps {
|
||||
target: TargetStatus | null;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
historyData: HistoryResponse;
|
||||
historyLoading: boolean;
|
||||
onClose: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onTimeChange: (from: string, to: string) => void;
|
||||
target: null | TargetStatus;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
onTimeChange: (from: string, to: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onClose: () => void;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
}
|
||||
|
||||
const TIME_SHORTCUTS = [
|
||||
{ label: "1小时", hours: 1, value: "1h" },
|
||||
{ label: "6小时", hours: 6, value: "6h" },
|
||||
{ label: "24小时", hours: 24, value: "24h" },
|
||||
{ label: "7天", hours: 168, value: "7d" },
|
||||
{ hours: 1, label: "1小时", value: "1h" },
|
||||
{ hours: 6, label: "6小时", value: "6h" },
|
||||
{ hours: 24, label: "24小时", value: "24h" },
|
||||
{ hours: 168, label: "7天", value: "7d" },
|
||||
] as const;
|
||||
|
||||
const HISTORY_COLUMNS = [
|
||||
{
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => (
|
||||
<StatusDot up={!!row.matched} />
|
||||
),
|
||||
colKey: "matched",
|
||||
title: "#",
|
||||
width: 40,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => (
|
||||
<StatusDot up={!!row.matched} />
|
||||
),
|
||||
},
|
||||
{
|
||||
colKey: "timestamp",
|
||||
title: "时间",
|
||||
width: 180,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => {
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => {
|
||||
const d = new Date(row.timestamp);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
},
|
||||
colKey: "timestamp",
|
||||
title: "时间",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
align: "center" as const,
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) =>
|
||||
row.durationMs !== null ? Math.round(row.durationMs) : "-",
|
||||
colKey: "durationMs",
|
||||
title: "耗时(ms)",
|
||||
width: 96,
|
||||
align: "center" as const,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
|
||||
row.durationMs !== null ? Math.round(row.durationMs) : "-",
|
||||
},
|
||||
{
|
||||
colKey: "statusDetail",
|
||||
title: "详情",
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => {
|
||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => {
|
||||
const parts = [row.statusDetail, row.failure?.message].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(":") : "-";
|
||||
},
|
||||
colKey: "statusDetail",
|
||||
title: "详情",
|
||||
},
|
||||
];
|
||||
|
||||
export function TargetDetailDrawer({
|
||||
target,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
onClose,
|
||||
onPageChange,
|
||||
onTimeChange,
|
||||
target,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
onTimeChange,
|
||||
onPageChange,
|
||||
onClose,
|
||||
trendData,
|
||||
trendLoading,
|
||||
}: TargetDetailDrawerProps) {
|
||||
const [activeShortcut, setActiveShortcut] = useState<string>("24h");
|
||||
const [activeTab, setActiveTab] = useState<TabValue>("overview");
|
||||
@@ -108,8 +111,8 @@ export function TargetDetailDrawer({
|
||||
);
|
||||
|
||||
const handleDateRangeChange = useCallback(
|
||||
(value: Array<string | number | Date>) => {
|
||||
if (value && value.length === 2) {
|
||||
(value: Array<Date | number | string>) => {
|
||||
if (value?.length === 2) {
|
||||
onTimeChange(new Date(value[0]!).toISOString(), new Date(value[1]!).toISOString());
|
||||
setActiveShortcut("");
|
||||
}
|
||||
@@ -126,11 +129,7 @@ export function TargetDetailDrawer({
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
visible={!!target}
|
||||
placement="right"
|
||||
size="60%"
|
||||
footer={false}
|
||||
onClose={onClose}
|
||||
header={
|
||||
<Space align="center" size={8}>
|
||||
<StatusDot up={!!isUp} />
|
||||
@@ -140,44 +139,48 @@ export function TargetDetailDrawer({
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
onClose={onClose}
|
||||
placement="right"
|
||||
size="60%"
|
||||
visible={!!target}
|
||||
>
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<RadioGroup
|
||||
theme="button"
|
||||
variant="default-filled"
|
||||
value={activeShortcut}
|
||||
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
|
||||
onChange={handleShortcut}
|
||||
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
|
||||
theme="button"
|
||||
value={activeShortcut}
|
||||
variant="default-filled"
|
||||
/>
|
||||
<DateRangePicker
|
||||
mode="date"
|
||||
className="full-width"
|
||||
defaultTime={["00:00:00", "23:59:00"]}
|
||||
enableTimePicker
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
valueType="YYYY-MM-DD HH:mm"
|
||||
defaultTime={["00:00:00", "23:59:00"]}
|
||||
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
|
||||
className="full-width"
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
mode="date"
|
||||
onChange={handleDateRangeChange}
|
||||
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
valueType="YYYY-MM-DD HH:mm"
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Tabs value={activeTab} onChange={(val: TabValue) => setActiveTab(val)}>
|
||||
<Tabs.TabPanel value="overview" label="概览" className="tab-panel-padded">
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Divider align="left">统计</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic title="总检查" value={totalChecks} color="blue" />
|
||||
<Statistic color="blue" title="总检查" value={totalChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="正常" value={upChecks} color="green" />
|
||||
<Statistic color="green" title="正常" value={upChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="异常" value={downChecks} color="red" />
|
||||
<Statistic color="red" title="异常" value={downChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="可用率" value={target.stats?.availability ?? 0} color="green" suffix="%" />
|
||||
<Statistic color="green" suffix="%" title="可用率" value={target.stats?.availability ?? 0} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -185,38 +188,38 @@ export function TargetDetailDrawer({
|
||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} loading={false} />}
|
||||
|
||||
<Divider align="left">状态分布</Divider>
|
||||
<StatusDonut up={upChecks} down={downChecks} />
|
||||
<StatusDonut down={downChecks} up={upChecks} />
|
||||
|
||||
<Divider align="left">基本信息</Divider>
|
||||
<Descriptions
|
||||
items={[
|
||||
{ label: "目标地址", content: target.target },
|
||||
{ label: "检查间隔", content: target.interval },
|
||||
{ content: target.target, label: "目标地址" },
|
||||
{ content: target.interval, label: "检查间隔" },
|
||||
{
|
||||
label: "最新检查时间",
|
||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||
label: "最新检查时间",
|
||||
},
|
||||
{ label: "状态详情", content: target.latestCheck?.statusDetail ?? "-" },
|
||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel value="history" label="记录" className="tab-panel-padded">
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
|
||||
<PrimaryTable
|
||||
columns={HISTORY_COLUMNS}
|
||||
data={historyData.items}
|
||||
rowKey="timestamp"
|
||||
loading={historyLoading}
|
||||
disableDataPage
|
||||
loading={historyLoading}
|
||||
onPageChange={({ current }) => {
|
||||
if (current) onPageChange(current);
|
||||
}}
|
||||
pagination={{
|
||||
current: historyData.page,
|
||||
pageSize: historyData.pageSize,
|
||||
total: historyData.total,
|
||||
}}
|
||||
onPageChange={({ current }) => {
|
||||
if (current) onPageChange(current);
|
||||
}}
|
||||
rowKey="timestamp"
|
||||
/>
|
||||
</Tabs.TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,36 +1,38 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
import { PrimaryTable } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { TARGET_TABLE_COLUMNS } from "../constants/target-table-columns";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
|
||||
interface TargetGroupProps {
|
||||
name: string;
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
targets: TargetStatus[];
|
||||
}
|
||||
|
||||
export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) {
|
||||
export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps) {
|
||||
const up = targets.filter((t) => t.latestCheck?.matched).length;
|
||||
const down = targets.length - up;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GroupHeader name={name} total={targets.length} up={up} down={down} />
|
||||
<GroupHeader down={down} name={name} total={targets.length} up={up} />
|
||||
<PrimaryTable
|
||||
bordered
|
||||
className="clickable-table"
|
||||
columns={TARGET_TABLE_COLUMNS}
|
||||
data={targets}
|
||||
defaultSort={[{ descending: true, sortBy: "latestCheck.matched" }]}
|
||||
hover
|
||||
onRowClick={({ row }) => onTargetClick(row)}
|
||||
rowClassName={({ row }) => {
|
||||
const target = row;
|
||||
return target.latestCheck?.matched === false ? "row-down" : "";
|
||||
}}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
stripe
|
||||
hover
|
||||
bordered
|
||||
defaultSort={[{ sortBy: "latestCheck.matched", descending: true }]}
|
||||
onRowClick={({ row }) => onTargetClick(row as TargetStatus)}
|
||||
rowClassName={({ row }) => {
|
||||
const target = row as TargetStatus;
|
||||
return target.latestCheck?.matched === false ? "row-down" : "";
|
||||
}}
|
||||
className="clickable-table"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Line, LineChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts";
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
import type { TrendPoint } from "../../shared/api";
|
||||
|
||||
interface TrendChartProps {
|
||||
@@ -22,23 +23,23 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
|
||||
return (
|
||||
<div className="trend-chart">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<ResponsiveContainer height={240} width="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--td-border-level-2-color)" />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="var(--td-text-color-secondary)" />
|
||||
<CartesianGrid stroke="var(--td-border-level-2-color)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="hour" stroke="var(--td-text-color-secondary)" tick={{ fontSize: 12 }} />
|
||||
<YAxis
|
||||
yAxisId="duration"
|
||||
tick={{ fontSize: 12 }}
|
||||
label={{ fontSize: 11, position: "insideTopRight", value: "ms" }}
|
||||
stroke="var(--td-text-color-secondary)"
|
||||
label={{ value: "ms", position: "insideTopRight", fontSize: 11 }}
|
||||
tick={{ fontSize: 12 }}
|
||||
yAxisId="duration"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="availability"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12 }}
|
||||
label={{ fontSize: 11, position: "insideTopLeft", value: "%" }}
|
||||
orientation="right"
|
||||
stroke="var(--td-text-color-secondary)"
|
||||
label={{ value: "%", position: "insideTopLeft", fontSize: 11 }}
|
||||
tick={{ fontSize: 12 }}
|
||||
yAxisId="availability"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: unknown, name: unknown) => {
|
||||
@@ -50,22 +51,22 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="duration"
|
||||
type="monotone"
|
||||
dataKey="avgDurationMs"
|
||||
stroke="var(--td-brand-color)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="avgDurationMs"
|
||||
stroke="var(--td-brand-color)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="duration"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="availability"
|
||||
type="monotone"
|
||||
dataKey="availability"
|
||||
stroke="var(--td-success-color)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="availability"
|
||||
stroke="var(--td-success-color)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
yAxisId="availability"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -1,89 +1,92 @@
|
||||
import type { PrimaryTableCol, PrimaryTableCellParams } from "tdesign-react";
|
||||
import { Tag, Progress } from "tdesign-react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "../components/StatusDot";
|
||||
import { StatusBar } from "../components/StatusBar";
|
||||
import { getTargetTypeDisplay } from "./target-type-display";
|
||||
import { getAvailabilityProgressColor } from "./color-threshold";
|
||||
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
||||
import { statusFilter, typeFilter } from "./target-table-filters";
|
||||
import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
export const TARGET_TABLE_COLUMNS: PrimaryTableCol<TargetStatus>[] = [
|
||||
import { Progress, Tag } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { StatusBar } from "../components/StatusBar";
|
||||
import { StatusDot } from "../components/StatusDot";
|
||||
import { getAvailabilityProgressColor } from "./color-threshold";
|
||||
import { statusFilter, typeFilter } from "./target-table-filters";
|
||||
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
||||
import { getTargetTypeDisplay } from "./target-type-display";
|
||||
|
||||
export const TARGET_TABLE_COLUMNS: Array<PrimaryTableCol<TargetStatus>> = [
|
||||
{
|
||||
align: "center",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
|
||||
colKey: "latestCheck.matched",
|
||||
filter: statusFilter,
|
||||
fixed: "left",
|
||||
title: "#",
|
||||
width: 60,
|
||||
fixed: "left",
|
||||
align: "center",
|
||||
filter: statusFilter,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
|
||||
},
|
||||
{
|
||||
colKey: "name",
|
||||
title: "名称",
|
||||
ellipsis: true,
|
||||
sorter: nameSorter,
|
||||
sortType: "all",
|
||||
title: "名称",
|
||||
},
|
||||
{
|
||||
colKey: "type",
|
||||
title: "类型",
|
||||
width: 80,
|
||||
filter: typeFilter,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => (
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{getTargetTypeDisplay(row.type)}
|
||||
</Tag>
|
||||
),
|
||||
colKey: "type",
|
||||
filter: typeFilter,
|
||||
title: "类型",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
colKey: "stats.availability",
|
||||
title: "可用率",
|
||||
width: 160,
|
||||
sorter: availabilitySorter,
|
||||
sortType: "all",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const availability = row.stats?.availability;
|
||||
if (availability === undefined || availability === null) return "-";
|
||||
const color = getAvailabilityProgressColor(availability);
|
||||
return (
|
||||
<Progress
|
||||
theme="line"
|
||||
size="small"
|
||||
percentage={availability}
|
||||
color={color}
|
||||
label={`${availability.toFixed(1)}%`}
|
||||
percentage={availability}
|
||||
size="small"
|
||||
theme="line"
|
||||
/>
|
||||
);
|
||||
},
|
||||
colKey: "stats.availability",
|
||||
sorter: availabilitySorter,
|
||||
sortType: "all",
|
||||
title: "可用率",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusBar samples={row.recentSamples} />,
|
||||
colKey: "recentSamples",
|
||||
title: "最近状态",
|
||||
width: 220,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusBar samples={row.recentSamples} />,
|
||||
},
|
||||
{
|
||||
colKey: "latestCheck.durationMs",
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
align: "right",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const ms = row.latestCheck?.durationMs;
|
||||
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
|
||||
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
|
||||
return <span className={`${colorClass} tabular-nums`}>{Math.round(ms)}ms</span>;
|
||||
},
|
||||
colKey: "latestCheck.durationMs",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
colKey: "interval",
|
||||
title: "间隔",
|
||||
width: 72,
|
||||
align: "center",
|
||||
},
|
||||
];
|
||||
|
||||
export { statusSorter, availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
||||
export { statusFilter, typeFilter } from "./target-table-filters";
|
||||
export { availabilitySorter, latencySorter, nameSorter, statusSorter } from "./target-table-sorters";
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
export const statusFilter: PrimaryTableCol["filter"] = {
|
||||
type: "single",
|
||||
list: [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "UP", value: "up" },
|
||||
{ label: "DOWN", value: "down" },
|
||||
],
|
||||
type: "single",
|
||||
};
|
||||
|
||||
export const typeFilter: PrimaryTableCol["filter"] = {
|
||||
type: "single",
|
||||
list: [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
{ label: "CMD", value: "command" },
|
||||
],
|
||||
type: "single",
|
||||
};
|
||||
|
||||
@@ -5,15 +5,6 @@ const STATUS_ORDER: Record<string, number> = {
|
||||
up: 1,
|
||||
};
|
||||
|
||||
function getStatusRank(target: TargetStatus): number {
|
||||
if (!target.latestCheck) return 2;
|
||||
return target.latestCheck.matched ? STATUS_ORDER["up"]! : STATUS_ORDER["down"]!;
|
||||
}
|
||||
|
||||
export function statusSorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return getStatusRank(a) - getStatusRank(b);
|
||||
}
|
||||
|
||||
export function availabilitySorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return (a.stats?.availability ?? 0) - (b.stats?.availability ?? 0);
|
||||
}
|
||||
@@ -25,3 +16,12 @@ export function latencySorter(a: TargetStatus, b: TargetStatus): number {
|
||||
export function nameSorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return a.name.localeCompare(b.name, "zh-CN");
|
||||
}
|
||||
|
||||
export function statusSorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return getStatusRank(a) - getStatusRank(b);
|
||||
}
|
||||
|
||||
function getStatusRank(target: TargetStatus): number {
|
||||
if (!target.latestCheck) return 2;
|
||||
return target.latestCheck.matched ? STATUS_ORDER["up"]! : STATUS_ORDER["down"]!;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const TARGET_TYPE_DISPLAY = {
|
||||
http: "HTTP",
|
||||
command: "CMD",
|
||||
http: "HTTP",
|
||||
} as const;
|
||||
|
||||
export type TargetType = keyof typeof TARGET_TYPE_DISPLAY;
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { HistoryResponse, SummaryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
|
||||
import { subtractHours } from "../utils/time";
|
||||
|
||||
const queryKeys = {
|
||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||
summary: () => ["summary"] as const,
|
||||
targets: () => ["targets"] as const,
|
||||
trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const,
|
||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||
};
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function useSummary() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.summary(),
|
||||
queryFn: () => fetchJson<SummaryResponse>("/api/summary"),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargets() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.targets(),
|
||||
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
|
||||
queryKey: queryKeys.summary(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
@@ -36,7 +23,7 @@ export function useTargets() {
|
||||
|
||||
export function useTargetDetail() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null);
|
||||
const [timeFrom, setTimeFrom] = useState("");
|
||||
const [timeTo, setTimeTo] = useState("");
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
@@ -47,27 +34,27 @@ export function useTargetDetail() {
|
||||
selectedTargetId !== null ? (targetsData?.find((t) => t.id === selectedTargetId) ?? null) : null;
|
||||
|
||||
const trend = useQuery({
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.trend(selectedTargetId, timeFrom, timeTo)
|
||||
: ["trend", "disabled"],
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
queryFn: () =>
|
||||
fetchJson<TrendPoint[]>(
|
||||
`/api/targets/${selectedTargetId}/trend?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}`,
|
||||
),
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.trend(selectedTargetId, timeFrom, timeTo)
|
||||
: ["trend", "disabled"],
|
||||
});
|
||||
|
||||
const history = useQuery({
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
|
||||
: ["history", "disabled"],
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
queryFn: () =>
|
||||
fetchJson<HistoryResponse>(
|
||||
`/api/targets/${selectedTargetId}/history?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}&page=${historyPage}&pageSize=20`,
|
||||
),
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
|
||||
: ["history", "disabled"],
|
||||
});
|
||||
|
||||
const openDrawer = useCallback((target: TargetStatus) => {
|
||||
@@ -96,16 +83,31 @@ export function useTargetDetail() {
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedTarget,
|
||||
trendData: trend.data ?? [],
|
||||
trendLoading: trend.isLoading,
|
||||
historyData: history.data ?? { items: [], total: 0, page: 1, pageSize: 20 },
|
||||
closeDrawer,
|
||||
handlePageChange,
|
||||
handleTimeChange,
|
||||
historyData: history.data ?? { items: [], page: 1, pageSize: 20, total: 0 },
|
||||
historyLoading: history.isLoading,
|
||||
openDrawer,
|
||||
selectedTarget,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
handleTimeChange,
|
||||
handlePageChange,
|
||||
trendData: trend.data ?? [],
|
||||
trendLoading: trend.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTargets() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
|
||||
queryKey: queryKeys.targets(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./app";
|
||||
import "tdesign-react/dist/reset.css";
|
||||
import "tdesign-react/dist/tdesign.min.css";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
staleTime: 5000,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user