fix(system): retired heartbeat rows no longer alarm on status page

- GET /system/health splits processes into current instances (hosts with
  a beat in the last 10 min, newest per host+kind, not stopped) and
  retiredProcesses[]; 45s stale and level only consider current ones
- heartbeat writer marks stopped_at on graceful exit (migration 0043),
  upsert clears it; pure api role now stops its writer on SIGTERM
- scheduled job system.heartbeat_cleanup (maintain, 1h) deletes rows
  without a beat for 24h and samples older than 7 days
- status page: per-role alarms ("io worker 45 秒没有心跳"), collapsible
  retired instances; glossary/runbook updated
- bump all members to 0.2.2, CHANGELOG [0.2.2]

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 23:31:21 +08:00
1 parent fa261eb6cd
commit b4c9b1ead2
32 files changed
+747 -91

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/testkit",
"version": "0.2.0",
"version": "0.2.2",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+43 -11
View File
@@ -4,14 +4,15 @@ import { deflateSync } from "node:zlib";
import type { MockCall, MockFault, MockHttp, MockReply } from "../tmdb/mock-http.ts";
/**
* mock camofox-browser(形状按 jo-inc/camofox-browser openapi.json 1.17.0,取值虚构,未对真实实例核验)。
* mock camofox-browser(形状按 jo-inc/camofox-browser v1.17.0,2026-09-28 与真实容器对照过请求/响应字段;取值虚构)。
* 路由:GET /health、GET /openapi.json、POST /tabs、GET /tabs、DELETE /tabs/{id}、POST /tabs/{id}/navigate、
* POST /tabs/{id}/{back,forward,refresh}、GET /tabs/{id}/snapshot、GET /tabs/{id}/screenshot(base64 PNG)、
* POST /tabs/{id}/{click,type,select,scroll,press,extract}、GET /tabs/{id}/downloads、DELETE /sessions/{userId}。
* POST /tabs/{id}/{click,type,select,scroll,press,extract,evaluate}、GET /tabs/{id}/downloads、DELETE /sessions/{userId}。
* 不给 site:快照为固定文本(含密码值/Cookie/passkey 供脱敏测试)。
* 给 site:模拟一个 NexusPHP 风格站点——未登录访问受保护页重定向登录页,登录页可带验证码(需人工),
* 点击/输入改变页面,点"下载"把 .torrent 放进该标签页的下载列表;登录状态按 userId(profile)保存。
* 坐标点击:页面元素按快照行排布,第 i 行(从 0 起)占 y ∈ [MOCK_ROW_Y0 + i·MOCK_ROW_H, +MOCK_ROW_H)。
* 坐标:页面元素按快照行排布,第 i 行(从 0 起)占 y ∈ [MOCK_ROW_Y0 + i·MOCK_ROW_H, +MOCK_ROW_H);与真实上游一样
* click 不接受 {x, y},适配器先 evaluate(elementFromPoint)打 data-umt-pt 标记再按选择器点击。
* accessKey 设置时模拟 CAMOFOX_ACCESS_KEY:除 /health 外要求 `Authorization: Bearer <key>`,否则 401。
* 故障注入(401/超时/断连)用 MockHttp.fault。
*/
@@ -58,6 +59,8 @@ export interface MockCamofoxTab {
/** 元素引用 → 已输入的值 */
fields: Map<string, string>;
focus: string | null;
/** evaluate(elementFromPoint)打的 data-umt-pt 标记 → 元素引用 */
marks: Map<string, string>;
downloads: MockCamofoxDownload[];
error: string | null;
}
@@ -121,6 +124,7 @@ const OPENAPI_PATHS: Record<string, string[]> = {
"/tabs/{tabId}/click": ["post"],
"/tabs/{tabId}/type": ["post"],
"/tabs/{tabId}/select": ["post"],
"/tabs/{tabId}/evaluate": ["post"],
"/tabs/{tabId}/extract": ["post"],
"/tabs/{tabId}/downloads": ["get"],
"/sessions/{userId}": ["delete"],
@@ -403,8 +407,11 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
const findLine = (t: MockCamofoxTab, b: Body): Line | undefined => {
const lines = pageLines(t);
if (typeof b.ref === "string") return lines.find((l) => l.ref === b.ref);
if (typeof b.y === "number") return lines[Math.floor((b.y - MOCK_ROW_Y0) / MOCK_ROW_H)];
if (typeof b.selector === "string") return lines.find((l) => l.role.startsWith("button"));
if (typeof b.selector === "string") {
const mark = /^\[data-umt-pt="([^"]+)"\]$/.exec(b.selector)?.[1];
if (mark) return lines.find((l) => l.ref === t.marks.get(mark));
return lines.find((l) => l.role.startsWith("button"));
}
return undefined;
};
@@ -473,6 +480,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
cursor: -1,
fields: new Map(),
focus: null,
marks: new Map(),
downloads: [],
error: null,
};
@@ -565,6 +573,9 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
[
/^\/tabs\/([^/]+)\/click$/,
action("click", (t, b) => {
// 1.17.0 实测:只认 ref / selector,坐标点击由适配器先 evaluate 打标记
if (typeof b.ref !== "string" && typeof b.selector !== "string")
return err(400, "ref or selector required");
const line = findLine(t, b);
if (!line) return typeof b.ref === "string" ? err(422, "Unknown ref") : undefined;
if (line.role.startsWith("textbox")) t.focus = line.ref;
@@ -576,6 +587,8 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
/^\/tabs\/([^/]+)\/type$/,
action("type", (t, b) => {
if (typeof b.text !== "string") return err(400, "text required");
if (typeof b.ref !== "string" && typeof b.selector !== "string" && b.mode !== "keyboard")
return err(400, "ref or selector required for mode=fill");
const ref = typeof b.ref === "string" ? b.ref : (findLine(t, b)?.ref ?? t.focus);
if (!ref) return err(422, "No focused element");
t.fields.set(ref, b.clear === false ? (t.fields.get(ref) ?? "") + b.text : b.text);
@@ -584,6 +597,21 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
return undefined;
}),
],
[
/^\/tabs\/([^/]+)\/evaluate$/,
action("evaluate", (t, b) => {
// 只模拟适配器的坐标定位表达式:elementFromPoint(x, y) + data-umt-pt 标记
const expr = typeof b.expression === "string" ? b.expression : "";
const pt = /elementFromPoint\((\d+), (\d+)\)/.exec(expr);
const nonce = /setAttribute\("data-umt-pt", "([^"]+)"\)/.exec(expr)?.[1];
if (!pt || !nonce) return { json: { ok: true, result: null } };
const line = pageLines(t)[Math.floor((Number(pt[2]) - MOCK_ROW_Y0) / MOCK_ROW_H)];
if (!line) return { json: { ok: true, result: false } };
t.marks.clear();
t.marks.set(nonce, line.ref);
return { json: { ok: true, result: true } };
}),
],
[/^\/tabs\/([^/]+)\/select$/, action("select", () => undefined)],
[/^\/tabs\/([^/]+)\/scroll$/, action("scroll", () => undefined)],
[
@@ -617,16 +645,20 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
const t = tabFor(m[1]!, q.userId);
if (!t) return err(404, "Tab not found");
const max = q.maxBytes ? Number(q.maxBytes) : Number.POSITIVE_INFINITY;
// 字段形状按 1.17.0 实测(lib/downloads.js getDownloadsList)
const list = t.downloads.map((d) => ({
id: d.id,
filename: d.filename,
url: d.url,
suggestedFilename: d.filename,
mimeType: d.mimeType,
size: d.bytes.byteLength,
status: "completed",
...(q.includeData === "true" && d.bytes.byteLength <= max
? { data: Buffer.from(d.bytes).toString("base64") }
: { truncated: d.bytes.byteLength > max }),
bytes: d.bytes.byteLength,
createdAt: Date.now(),
failure: null,
...(q.includeData === "true"
? d.bytes.byteLength <= max
? { dataBase64: Buffer.from(d.bytes).toString("base64") }
: { dataSkipped: "max_bytes_exceeded" }
: {}),
}));
if (q.consume === "true") t.downloads = [];
return { json: { downloads: list } };