1
0

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:
2026-05-12 18:44:59 +08:00
parent ce8baae3d1
commit a5cf6065c2
83 changed files with 2654 additions and 1824 deletions

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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);
}
}
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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,
};
}
}

View File

@@ -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());

View File

@@ -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);
}
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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) + "...";
}

View File

@@ -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 });
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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,
});
}

View File

@@ -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 };
}

View File

@@ -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)) {

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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,
};
});

View File

@@ -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,
}));

View File

@@ -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}`);

View File

@@ -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);
}