diff --git a/openspec/specs/fullstack-app-runtime/spec.md b/openspec/specs/fullstack-app-runtime/spec.md index 192a9a6..cc94d30 100644 --- a/openspec/specs/fullstack-app-runtime/spec.md +++ b/openspec/specs/fullstack-app-runtime/spec.md @@ -118,3 +118,10 @@ #### Scenario: 保留 API 错误语义 - **WHEN** 客户端请求未知的 `/api/*` 路由 - **THEN** Bun server MUST NOT 返回前端入口 HTML 文档 + +### Requirement: 优雅关机 +系统 SHALL 在收到终止信号时正确清理资源。 + +#### Scenario: SIGINT/SIGTERM 处理 +- **WHEN** 开发模式进程收到 SIGINT 或 SIGTERM 信号 +- **THEN** 系统 SHALL 调用 engine.stop() 停止调度、调用 store.close() 关闭数据库连接后退出进程 diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md index 8a5f7b0..e4af086 100644 --- a/openspec/specs/probe-data-store/spec.md +++ b/openspec/specs/probe-data-store/spec.md @@ -19,6 +19,12 @@ - **WHEN** 数据库文件已存在 - **THEN** 系统 SHALL 直接打开数据库,不重新建表 +#### Scenario: 外键约束 +- **THEN** 系统 SHALL 启用 `PRAGMA foreign_keys = ON` + +#### Scenario: 级联删除 +- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录 + ### Requirement: targets 表同步 系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置和分组信息。 diff --git a/src/server/app.ts b/src/server/app.ts index 385f3c3..e36e5a6 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -218,7 +218,12 @@ function createTargetsResponse(store: ProbeStore): TargetStatus[] { function mapCheckResult(row: StoredCheckResult): CheckResult { let failure: CheckFailure | null = null; 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 { diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index 78f4735..9ab2fcc 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -9,7 +9,7 @@ import type { ResolvedCommandTarget, ResolvedHttpTarget, ResolvedTarget, - RuntimeConfig, + EngineRuntimeConfig, TargetConfig, TargetType, } from "./types"; @@ -77,7 +77,7 @@ export async function loadConfig(configPath: string): Promise { 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 ( diff --git a/src/server/checker/engine.ts b/src/server/checker/engine.ts index c556fb0..2d2ba71 100644 --- a/src/server/checker/engine.ts +++ b/src/server/checker/engine.ts @@ -15,7 +15,7 @@ export class ProbeEngine { constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) { this.store = store; this.targets = targets; - this.maxConcurrentChecks = maxConcurrentChecks ?? 10; + this.maxConcurrentChecks = maxConcurrentChecks ?? 20; this.refreshCache(); } @@ -86,6 +86,8 @@ export class ProbeEngine { for (const result of results) { if (result.status === "fulfilled") { this.writeResult(result.value); + } else { + console.warn("探针执行失败:", result.reason); } } } diff --git a/src/server/checker/size.ts b/src/server/checker/size.ts index 9033353..7b3a8ae 100644 --- a/src/server/checker/size.ts +++ b/src/server/checker/size.ts @@ -16,7 +16,3 @@ export function parseSize(value: string | number): number { if (unit === "MB") return num * 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; diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index 5528354..32c58fe 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS check_results ( duration_ms REAL, status_detail 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)); this.db = new Database(dbPath, { create: true }); this.db.run("PRAGMA journal_mode = WAL"); + this.db.run("PRAGMA foreign_keys = ON"); this.db.run(CREATE_TARGETS_TABLE); this.db.run(CREATE_CHECK_RESULTS_TABLE); this.db.run(CREATE_INDEX); diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index c9e8b68..af98d09 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -2,7 +2,7 @@ export type TargetType = "http" | "command"; export interface ProbeConfig { server?: ServerConfig; - runtime?: RuntimeConfig; + runtime?: EngineRuntimeConfig; defaults?: DefaultsConfig; targets: TargetConfig[]; } @@ -13,7 +13,7 @@ export interface ServerConfig { dataDir?: string; } -export interface RuntimeConfig { +export interface EngineRuntimeConfig { maxConcurrentChecks?: number; } diff --git a/src/server/dev.ts b/src/server/dev.ts index 845fe6f..40372b2 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -14,6 +14,14 @@ async function main() { const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks); engine.start(); + const shutdown = () => { + engine.stop(); + store.close(); + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + startServer({ config: { host: config.host, port: config.port }, mode: "development", diff --git a/src/web/components/TimeRangePicker.tsx b/src/web/components/TimeRangePicker.tsx index 1ca53d6..59602b2 100644 --- a/src/web/components/TimeRangePicker.tsx +++ b/src/web/components/TimeRangePicker.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { subtractHours } from "../utils/time"; interface TimeRangePickerProps { 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())}`; } -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) { const [activeShortcut, setActiveShortcut] = useState("24h"); diff --git a/src/web/hooks/useHistory.ts b/src/web/hooks/useHistory.ts index a68f6ef..8fe9f70 100644 --- a/src/web/hooks/useHistory.ts +++ b/src/web/hooks/useHistory.ts @@ -1,21 +1,27 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import type { HistoryResponse } from "../../shared/api"; export function useHistory(targetId: number | null) { - const [data, setData] = useState({ items: [], total: 0, page: 1, pageSize: 15 }); + const [data, setData] = useState({ items: [], total: 0, page: 1, pageSize: 20 }); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const abortRef = useRef(null); 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; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setLoading(true); setError(null); try { const response = await fetch( `/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}`); @@ -23,6 +29,7 @@ export function useHistory(targetId: number | null) { const result = (await response.json()) as HistoryResponse; setData(result); } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; setError(err instanceof Error ? err.message : "请求失败"); } finally { setLoading(false); diff --git a/src/web/hooks/useTargetDetail.ts b/src/web/hooks/useTargetDetail.ts index bfb6960..4b78b01 100644 --- a/src/web/hooks/useTargetDetail.ts +++ b/src/web/hooks/useTargetDetail.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { TargetStatus } from "../../shared/api"; import { useTrend } from "./useTrend"; import { useHistory } from "./useHistory"; +import { subtractHours } from "../utils/time"; export function useTargetDetail() { const [selectedTarget, setSelectedTarget] = useState(null); @@ -69,9 +70,3 @@ export function useTargetDetail() { handlePageChange, }; } - -function subtractHours(date: Date, hours: number): Date { - const result = new Date(date); - result.setTime(result.getTime() - hours * 60 * 60 * 1000); - return result; -} diff --git a/src/web/hooks/useTrend.ts b/src/web/hooks/useTrend.ts index 694e793..2db73c1 100644 --- a/src/web/hooks/useTrend.ts +++ b/src/web/hooks/useTrend.ts @@ -1,21 +1,27 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import type { TrendPoint } from "../../shared/api"; export function useTrend(targetId: number | null) { const [data, setData] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const abortRef = useRef(null); const fetchTrend = useCallback( async (from: string, to: string) => { if (targetId === null) return; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setLoading(true); setError(null); try { const response = await fetch( `/api/targets/${targetId}/trend?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, + { signal: controller.signal }, ); 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[]; setData(result); } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; setError(err instanceof Error ? err.message : "请求失败"); } finally { setLoading(false); diff --git a/src/web/utils/time.ts b/src/web/utils/time.ts new file mode 100644 index 0000000..7edd903 --- /dev/null +++ b/src/web/utils/time.ts @@ -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; +} diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 46e93a3..65f777f 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -254,4 +254,33 @@ describe("API 路由", () => { const asset = await fetchHandler(new Request("http://localhost/assets/app.js")); 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(); + }); }); diff --git a/tests/server/checker/size.test.ts b/tests/server/checker/size.test.ts index b57255c..34a8c9e 100644 --- a/tests/server/checker/size.test.ts +++ b/tests/server/checker/size.test.ts @@ -1,5 +1,5 @@ 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", () => { test("解析 B", () => { @@ -35,9 +35,4 @@ describe("parseSize", () => { expect(() => parseSize("abc")).toThrow("无效的 size 格式"); expect(() => parseSize("")).toThrow("无效的 size 格式"); }); - - test("默认值", () => { - expect(DEFAULT_MAX_BODY_BYTES).toBe(104857600); - expect(DEFAULT_MAX_OUTPUT_BYTES).toBe(104857600); - }); }); diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index dc52521..ac0051c 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -256,4 +256,45 @@ describe("ProbeStore", () => { expect(closedStore.getTargets()).toHaveLength(0); 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(); + }); });