fix: 安全性与代码质量加固(异常保护、外键级联、竞态修复、优雅关机)
This commit is contained in:
@@ -118,3 +118,10 @@
|
|||||||
#### Scenario: 保留 API 错误语义
|
#### Scenario: 保留 API 错误语义
|
||||||
- **WHEN** 客户端请求未知的 `/api/*` 路由
|
- **WHEN** 客户端请求未知的 `/api/*` 路由
|
||||||
- **THEN** Bun server MUST NOT 返回前端入口 HTML 文档
|
- **THEN** Bun server MUST NOT 返回前端入口 HTML 文档
|
||||||
|
|
||||||
|
### Requirement: 优雅关机
|
||||||
|
系统 SHALL 在收到终止信号时正确清理资源。
|
||||||
|
|
||||||
|
#### Scenario: SIGINT/SIGTERM 处理
|
||||||
|
- **WHEN** 开发模式进程收到 SIGINT 或 SIGTERM 信号
|
||||||
|
- **THEN** 系统 SHALL 调用 engine.stop() 停止调度、调用 store.close() 关闭数据库连接后退出进程
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
- **WHEN** 数据库文件已存在
|
- **WHEN** 数据库文件已存在
|
||||||
- **THEN** 系统 SHALL 直接打开数据库,不重新建表
|
- **THEN** 系统 SHALL 直接打开数据库,不重新建表
|
||||||
|
|
||||||
|
#### Scenario: 外键约束
|
||||||
|
- **THEN** 系统 SHALL 启用 `PRAGMA foreign_keys = ON`
|
||||||
|
|
||||||
|
#### Scenario: 级联删除
|
||||||
|
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
|
||||||
|
|
||||||
### Requirement: targets 表同步
|
### Requirement: targets 表同步
|
||||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置和分组信息。
|
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置和分组信息。
|
||||||
|
|
||||||
|
|||||||
@@ -218,7 +218,12 @@ function createTargetsResponse(store: ProbeStore): TargetStatus[] {
|
|||||||
function mapCheckResult(row: StoredCheckResult): CheckResult {
|
function mapCheckResult(row: StoredCheckResult): CheckResult {
|
||||||
let failure: CheckFailure | null = null;
|
let failure: CheckFailure | null = null;
|
||||||
if (row.failure) {
|
if (row.failure) {
|
||||||
failure = JSON.parse(row.failure) as CheckFailure;
|
try {
|
||||||
|
failure = JSON.parse(row.failure) as CheckFailure;
|
||||||
|
} catch {
|
||||||
|
console.warn(`无法解析 failure 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`);
|
||||||
|
failure = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
ResolvedCommandTarget,
|
ResolvedCommandTarget,
|
||||||
ResolvedHttpTarget,
|
ResolvedHttpTarget,
|
||||||
ResolvedTarget,
|
ResolvedTarget,
|
||||||
RuntimeConfig,
|
EngineRuntimeConfig,
|
||||||
TargetConfig,
|
TargetConfig,
|
||||||
TargetType,
|
TargetType,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -77,7 +77,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
|||||||
return { host, port, dataDir, configDir, maxConcurrentChecks, targets };
|
return { host, port, dataDir, configDir, maxConcurrentChecks, targets };
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRuntime(runtime: RuntimeConfig): number {
|
function validateRuntime(runtime: EngineRuntimeConfig): number {
|
||||||
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
|
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class ProbeEngine {
|
|||||||
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
|
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
this.targets = targets;
|
this.targets = targets;
|
||||||
this.maxConcurrentChecks = maxConcurrentChecks ?? 10;
|
this.maxConcurrentChecks = maxConcurrentChecks ?? 20;
|
||||||
this.refreshCache();
|
this.refreshCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +86,8 @@ export class ProbeEngine {
|
|||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === "fulfilled") {
|
if (result.status === "fulfilled") {
|
||||||
this.writeResult(result.value);
|
this.writeResult(result.value);
|
||||||
|
} else {
|
||||||
|
console.warn("探针执行失败:", result.reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,3 @@ export function parseSize(value: string | number): number {
|
|||||||
if (unit === "MB") return num * 1024 * 1024;
|
if (unit === "MB") return num * 1024 * 1024;
|
||||||
return num * 1024 * 1024 * 1024;
|
return num * 1024 * 1024 * 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_MAX_BODY_BYTES = parseSize("100MB");
|
|
||||||
export const DEFAULT_MAX_OUTPUT_BYTES = parseSize("100MB");
|
|
||||||
export const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS check_results (
|
|||||||
duration_ms REAL,
|
duration_ms REAL,
|
||||||
status_detail TEXT,
|
status_detail TEXT,
|
||||||
failure TEXT,
|
failure TEXT,
|
||||||
FOREIGN KEY (target_id) REFERENCES targets(id)
|
FOREIGN KEY (target_id) REFERENCES targets(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ export class ProbeStore {
|
|||||||
ensureDir(dirname(dbPath));
|
ensureDir(dirname(dbPath));
|
||||||
this.db = new Database(dbPath, { create: true });
|
this.db = new Database(dbPath, { create: true });
|
||||||
this.db.run("PRAGMA journal_mode = WAL");
|
this.db.run("PRAGMA journal_mode = WAL");
|
||||||
|
this.db.run("PRAGMA foreign_keys = ON");
|
||||||
this.db.run(CREATE_TARGETS_TABLE);
|
this.db.run(CREATE_TARGETS_TABLE);
|
||||||
this.db.run(CREATE_CHECK_RESULTS_TABLE);
|
this.db.run(CREATE_CHECK_RESULTS_TABLE);
|
||||||
this.db.run(CREATE_INDEX);
|
this.db.run(CREATE_INDEX);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type TargetType = "http" | "command";
|
|||||||
|
|
||||||
export interface ProbeConfig {
|
export interface ProbeConfig {
|
||||||
server?: ServerConfig;
|
server?: ServerConfig;
|
||||||
runtime?: RuntimeConfig;
|
runtime?: EngineRuntimeConfig;
|
||||||
defaults?: DefaultsConfig;
|
defaults?: DefaultsConfig;
|
||||||
targets: TargetConfig[];
|
targets: TargetConfig[];
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ export interface ServerConfig {
|
|||||||
dataDir?: string;
|
dataDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeConfig {
|
export interface EngineRuntimeConfig {
|
||||||
maxConcurrentChecks?: number;
|
maxConcurrentChecks?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ async function main() {
|
|||||||
const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks);
|
const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks);
|
||||||
engine.start();
|
engine.start();
|
||||||
|
|
||||||
|
const shutdown = () => {
|
||||||
|
engine.stop();
|
||||||
|
store.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
startServer({
|
startServer({
|
||||||
config: { host: config.host, port: config.port },
|
config: { host: config.host, port: config.port },
|
||||||
mode: "development",
|
mode: "development",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { subtractHours } from "../utils/time";
|
||||||
|
|
||||||
interface TimeRangePickerProps {
|
interface TimeRangePickerProps {
|
||||||
from: string;
|
from: string;
|
||||||
@@ -18,12 +19,6 @@ function toLocalDatetimeInput(date: Date): string {
|
|||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function subtractHours(date: Date, hours: number): Date {
|
|
||||||
const result = new Date(date);
|
|
||||||
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TimeRangePicker({ from, to, onChange }: TimeRangePickerProps) {
|
export function TimeRangePicker({ from, to, onChange }: TimeRangePickerProps) {
|
||||||
const [activeShortcut, setActiveShortcut] = useState<string | null>("24h");
|
const [activeShortcut, setActiveShortcut] = useState<string | null>("24h");
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import type { HistoryResponse } from "../../shared/api";
|
import type { HistoryResponse } from "../../shared/api";
|
||||||
|
|
||||||
export function useHistory(targetId: number | null) {
|
export function useHistory(targetId: number | null) {
|
||||||
const [data, setData] = useState<HistoryResponse>({ items: [], total: 0, page: 1, pageSize: 15 });
|
const [data, setData] = useState<HistoryResponse>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const fetchHistory = useCallback(
|
const fetchHistory = useCallback(
|
||||||
async (from: string, to: string, page = 1, pageSize = 15) => {
|
async (from: string, to: string, page = 1, pageSize = 20) => {
|
||||||
if (targetId === null) return;
|
if (targetId === null) return;
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/targets/${targetId}/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&page=${page}&pageSize=${pageSize}`,
|
`/api/targets/${targetId}/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&page=${page}&pageSize=${pageSize}`,
|
||||||
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -23,6 +29,7 @@ export function useHistory(targetId: number | null) {
|
|||||||
const result = (await response.json()) as HistoryResponse;
|
const result = (await response.json()) as HistoryResponse;
|
||||||
setData(result);
|
setData(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||||
setError(err instanceof Error ? err.message : "请求失败");
|
setError(err instanceof Error ? err.message : "请求失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import type { TargetStatus } from "../../shared/api";
|
import type { TargetStatus } from "../../shared/api";
|
||||||
import { useTrend } from "./useTrend";
|
import { useTrend } from "./useTrend";
|
||||||
import { useHistory } from "./useHistory";
|
import { useHistory } from "./useHistory";
|
||||||
|
import { subtractHours } from "../utils/time";
|
||||||
|
|
||||||
export function useTargetDetail() {
|
export function useTargetDetail() {
|
||||||
const [selectedTarget, setSelectedTarget] = useState<TargetStatus | null>(null);
|
const [selectedTarget, setSelectedTarget] = useState<TargetStatus | null>(null);
|
||||||
@@ -69,9 +70,3 @@ export function useTargetDetail() {
|
|||||||
handlePageChange,
|
handlePageChange,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function subtractHours(date: Date, hours: number): Date {
|
|
||||||
const result = new Date(date);
|
|
||||||
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import type { TrendPoint } from "../../shared/api";
|
import type { TrendPoint } from "../../shared/api";
|
||||||
|
|
||||||
export function useTrend(targetId: number | null) {
|
export function useTrend(targetId: number | null) {
|
||||||
const [data, setData] = useState<TrendPoint[]>([]);
|
const [data, setData] = useState<TrendPoint[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const fetchTrend = useCallback(
|
const fetchTrend = useCallback(
|
||||||
async (from: string, to: string) => {
|
async (from: string, to: string) => {
|
||||||
if (targetId === null) return;
|
if (targetId === null) return;
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/targets/${targetId}/trend?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
`/api/targets/${targetId}/trend?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
||||||
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -23,6 +29,7 @@ export function useTrend(targetId: number | null) {
|
|||||||
const result = (await response.json()) as TrendPoint[];
|
const result = (await response.json()) as TrendPoint[];
|
||||||
setData(result);
|
setData(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||||
setError(err instanceof Error ? err.message : "请求失败");
|
setError(err instanceof Error ? err.message : "请求失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
5
src/web/utils/time.ts
Normal file
5
src/web/utils/time.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function subtractHours(date: Date, hours: number): Date {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -254,4 +254,33 @@ describe("API 路由", () => {
|
|||||||
const asset = await fetchHandler(new Request("http://localhost/assets/app.js"));
|
const asset = await fetchHandler(new Request("http://localhost/assets/app.js"));
|
||||||
expect(asset.status).toBe(200);
|
expect(asset.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("损坏的 failure JSON 返回 null 而不崩溃", async () => {
|
||||||
|
const targets = store.getTargets();
|
||||||
|
const t1Id = targets[0]!.id;
|
||||||
|
|
||||||
|
store.insertCheckResult({
|
||||||
|
targetId: t1Id,
|
||||||
|
timestamp: "2025-06-01T00:00:00.000Z",
|
||||||
|
matched: false,
|
||||||
|
durationMs: 100,
|
||||||
|
statusDetail: "200 OK",
|
||||||
|
failure: { kind: "error", phase: "body", path: "$", message: "test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
(store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db
|
||||||
|
.prepare("UPDATE check_results SET failure = ? WHERE target_id = ? AND timestamp = ?")
|
||||||
|
.run("{invalid json!!!", t1Id, "2025-06-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
const from = "2025-06-01T00:00:00.000Z";
|
||||||
|
const to = "2025-06-01T23:59:59.999Z";
|
||||||
|
const response = await fetchHandler(
|
||||||
|
new Request(`http://localhost/api/targets/${t1Id}/history?from=${from}&to=${to}`),
|
||||||
|
);
|
||||||
|
const body = (await response.json()) as HistoryResponse;
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.items).toHaveLength(1);
|
||||||
|
expect(body.items[0]!.failure).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { parseSize, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_OUTPUT_BYTES } from "../../../src/server/checker/size";
|
import { parseSize } from "../../../src/server/checker/size";
|
||||||
|
|
||||||
describe("parseSize", () => {
|
describe("parseSize", () => {
|
||||||
test("解析 B", () => {
|
test("解析 B", () => {
|
||||||
@@ -35,9 +35,4 @@ describe("parseSize", () => {
|
|||||||
expect(() => parseSize("abc")).toThrow("无效的 size 格式");
|
expect(() => parseSize("abc")).toThrow("无效的 size 格式");
|
||||||
expect(() => parseSize("")).toThrow("无效的 size 格式");
|
expect(() => parseSize("")).toThrow("无效的 size 格式");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("默认值", () => {
|
|
||||||
expect(DEFAULT_MAX_BODY_BYTES).toBe(104857600);
|
|
||||||
expect(DEFAULT_MAX_OUTPUT_BYTES).toBe(104857600);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -256,4 +256,45 @@ describe("ProbeStore", () => {
|
|||||||
expect(closedStore.getTargets()).toHaveLength(0);
|
expect(closedStore.getTargets()).toHaveLength(0);
|
||||||
expect(closedStore.getTargetById(1)).toBeNull();
|
expect(closedStore.getTargetById(1)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("删除 target 级联删除 check_results", () => {
|
||||||
|
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
|
||||||
|
const cascadeTarget: ResolvedTarget = {
|
||||||
|
type: "http",
|
||||||
|
name: "cascade-test",
|
||||||
|
group: "default",
|
||||||
|
http: { url: "http://cascade.test", method: "GET", headers: {}, maxBodyBytes: 104857600 },
|
||||||
|
intervalMs: 30000,
|
||||||
|
timeoutMs: 10000,
|
||||||
|
};
|
||||||
|
|
||||||
|
cascadeStore.syncTargets([cascadeTarget]);
|
||||||
|
const t = cascadeStore.getTargets()[0]!;
|
||||||
|
|
||||||
|
cascadeStore.insertCheckResult({
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp: "2025-01-01T00:00:00.000Z",
|
||||||
|
matched: true,
|
||||||
|
durationMs: 100,
|
||||||
|
statusDetail: "200 OK",
|
||||||
|
failure: null,
|
||||||
|
});
|
||||||
|
cascadeStore.insertCheckResult({
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp: "2025-01-01T00:01:00.000Z",
|
||||||
|
matched: false,
|
||||||
|
durationMs: null,
|
||||||
|
statusDetail: null,
|
||||||
|
failure: { kind: "error", phase: "status", path: "$", message: "fail" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cascadeStore.getLatestCheck(t.id)).not.toBeNull();
|
||||||
|
|
||||||
|
cascadeStore.syncTargets([]);
|
||||||
|
|
||||||
|
expect(cascadeStore.getTargets()).toHaveLength(0);
|
||||||
|
expect(cascadeStore.getLatestCheck(t.id)).toBeNull();
|
||||||
|
|
||||||
|
cascadeStore.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user