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:
1 parent
fa261eb6cd
commit
b4c9b1ead2
32 files changed
+747
-91
No files matched your search
@@ -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 } };
|
||||
|
||||
Reference in new issue
Block a user