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
@@ -114,8 +114,13 @@ export interface RecoveryState {
|
||||
downloads?: { sha256: string; artifactId: string; sourceReleaseId: string | null; planId: string | null }[];
|
||||
}
|
||||
|
||||
/** 环境变量(BROWSER_PROVIDER_URL)登记的浏览器服务 */
|
||||
export const ENV_PROVIDER_NAME = "camofox-env";
|
||||
const ENV_MANAGED = "env";
|
||||
|
||||
export class BrowserService {
|
||||
private readonly providers = new Map<string, { sig: string; provider: BrowserProvider }>();
|
||||
private envSync: Promise<void> | undefined;
|
||||
readonly now: () => Date;
|
||||
|
||||
constructor(
|
||||
@@ -163,7 +168,11 @@ export class BrowserService {
|
||||
const { db, secrets } = this.ctx;
|
||||
return {
|
||||
resolve: async (req) => {
|
||||
if (req.kind === "provider_access_key") return row.secret_ref ? secrets.resolve(row.secret_ref) : null;
|
||||
if (req.kind === "provider_access_key") {
|
||||
if (row.secret_ref) return secrets.resolve(row.secret_ref);
|
||||
// 环境变量登记的服务:凭据只在进程内(BROWSER_ACCESS_KEY),不入库
|
||||
return obj(row.config).managedBy === ENV_MANAGED ? (this.ctx.config?.BROWSER_ACCESS_KEY ?? null) : null;
|
||||
}
|
||||
let q = db
|
||||
.selectFrom("site_account")
|
||||
.selectAll()
|
||||
@@ -210,7 +219,126 @@ export class BrowserService {
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED → 名为 camofox-env 的浏览器服务(每个进程同步一次)。
|
||||
* 地址与启用由环境变量决定;config 里的其它键(verified、超时等)保留。没设地址时已有的 camofox-env 置为停用。
|
||||
*/
|
||||
syncEnvProvider(): Promise<void> {
|
||||
this.envSync ??= this.doSyncEnvProvider().catch((e) => {
|
||||
this.envSync = undefined;
|
||||
this.ctx.logger.warn({ component: "browser", err: String(e) }, "登记环境变量浏览器服务失败");
|
||||
});
|
||||
return this.envSync;
|
||||
}
|
||||
|
||||
private async doSyncEnvProvider(): Promise<void> {
|
||||
const c = this.ctx.config;
|
||||
const url = c?.BROWSER_PROVIDER_URL;
|
||||
const enabled = !!url && c.BROWSER_ENABLED !== false;
|
||||
const db = this.ctx.db;
|
||||
const row = await db
|
||||
.selectFrom("browser_provider")
|
||||
.selectAll()
|
||||
.where("name", "=", ENV_PROVIDER_NAME)
|
||||
.executeTakeFirst();
|
||||
if (!url) {
|
||||
if (row?.enabled)
|
||||
await db.updateTable("browser_provider").set({ enabled: false }).where("id", "=", row.id).execute();
|
||||
return;
|
||||
}
|
||||
const config = JSON.stringify({ ...obj(row?.config), managedBy: ENV_MANAGED });
|
||||
if (row)
|
||||
await db
|
||||
.updateTable("browser_provider")
|
||||
.set({ base_url: url, enabled, kind: "camofox", secret_ref: null, config })
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
else
|
||||
await db
|
||||
.insertInto("browser_provider")
|
||||
.values({ name: ENV_PROVIDER_NAME, kind: "camofox", base_url: url, enabled, config })
|
||||
.onConflict((oc) => oc.column("name").doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** 设置页登记浏览器服务:访问凭据加密存 secret;名称 camofox-env 保留给环境变量 */
|
||||
async createProvider(body: {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
accessKey?: string | undefined;
|
||||
enabled?: boolean | undefined;
|
||||
}): Promise<BrowserProviderView> {
|
||||
if (body.name === ENV_PROVIDER_NAME)
|
||||
throw new BrowserServiceError("bad_request", `名称 ${ENV_PROVIDER_NAME} 保留给环境变量`);
|
||||
const exists = await this.ctx.db
|
||||
.selectFrom("browser_provider")
|
||||
.select("id")
|
||||
.where("name", "=", body.name)
|
||||
.executeTakeFirst();
|
||||
if (exists) throw new BrowserServiceError("conflict", `浏览器服务「${body.name}」已存在`);
|
||||
const secretRef = body.accessKey
|
||||
? await this.ctx.secrets.create({ purpose: "other", plaintext: body.accessKey, label: `browser:${body.name}` })
|
||||
: null;
|
||||
const row = await this.ctx.db
|
||||
.insertInto("browser_provider")
|
||||
.values({
|
||||
name: body.name,
|
||||
kind: "camofox",
|
||||
base_url: body.baseUrl,
|
||||
secret_ref: secretRef,
|
||||
enabled: body.enabled ?? true,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return this.providerView(row.id);
|
||||
}
|
||||
|
||||
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据。camofox-env 的地址与凭据归环境变量管,只能改启用 */
|
||||
async updateProvider(
|
||||
id: string,
|
||||
body: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
|
||||
): Promise<BrowserProviderView> {
|
||||
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
if (obj(row.config).managedBy === ENV_MANAGED && (body.baseUrl !== undefined || body.accessKey !== undefined))
|
||||
throw new BrowserServiceError(
|
||||
"conflict",
|
||||
"该浏览器服务由环境变量 BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY 管理,改部署配置后重启",
|
||||
);
|
||||
let secretRef = row.secret_ref;
|
||||
if (body.accessKey === null) secretRef = null;
|
||||
else if (body.accessKey) {
|
||||
if (secretRef) await this.ctx.secrets.replace(secretRef, body.accessKey);
|
||||
else
|
||||
secretRef = await this.ctx.secrets.create({
|
||||
purpose: "other",
|
||||
plaintext: body.accessKey,
|
||||
label: `browser:${row.name}`,
|
||||
});
|
||||
}
|
||||
await this.ctx.db
|
||||
.updateTable("browser_provider")
|
||||
.set({
|
||||
...(body.baseUrl !== undefined ? { base_url: body.baseUrl } : {}),
|
||||
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
||||
secret_ref: secretRef,
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
if (row.secret_ref && secretRef === null) await this.ctx.secrets.remove(row.secret_ref).catch(() => {});
|
||||
// 凭据换了但 secret_ref 不变:签名不变,显式丢弃缓存的适配器
|
||||
this.providers.delete(id);
|
||||
return this.providerView(id);
|
||||
}
|
||||
|
||||
private async providerView(id: string): Promise<BrowserProviderView> {
|
||||
const v = (await this.listProviders(false, id))[0];
|
||||
if (!v) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
return v;
|
||||
}
|
||||
|
||||
async providerRow(id?: string): Promise<BrowserProviderRow> {
|
||||
await this.syncEnvProvider();
|
||||
let q = this.ctx.db
|
||||
.selectFrom("browser_provider")
|
||||
.selectAll()
|
||||
@@ -223,12 +351,14 @@ export class BrowserService {
|
||||
return row;
|
||||
}
|
||||
|
||||
async listProviders(probe: boolean): Promise<BrowserProviderView[]> {
|
||||
const rows = await this.ctx.db.selectFrom("browser_provider").selectAll().orderBy("created_at").execute();
|
||||
async listProviders(probe: boolean, onlyId?: string): Promise<BrowserProviderView[]> {
|
||||
await this.syncEnvProvider();
|
||||
let rows = await this.ctx.db.selectFrom("browser_provider").selectAll().orderBy("created_at").execute();
|
||||
if (onlyId) rows = rows.filter((r) => r.id === onlyId);
|
||||
const out: BrowserProviderView[] = [];
|
||||
for (let row of rows) {
|
||||
let health: BrowserHealth | null = null;
|
||||
if (probe && row.enabled && row.kind !== "none") {
|
||||
if (probe && (row.enabled || onlyId) && row.kind !== "none") {
|
||||
const p = this.providerFor(row);
|
||||
health = await p.health();
|
||||
let caps: BrowserCapabilities | null = null;
|
||||
@@ -255,6 +385,9 @@ export class BrowserService {
|
||||
kind: row.kind as "camofox" | "none",
|
||||
baseUrl: row.base_url,
|
||||
secretRef: row.secret_ref,
|
||||
managedBy: obj(row.config).managedBy === ENV_MANAGED ? "env" : null,
|
||||
hasAccessKey:
|
||||
!!row.secret_ref || (obj(row.config).managedBy === ENV_MANAGED && !!this.ctx.config?.BROWSER_ACCESS_KEY),
|
||||
enabled: row.enabled,
|
||||
capabilities: Object.keys(caps).length ? (caps as unknown as BrowserCapabilities) : null,
|
||||
verifiedAt: iso(row.verified_at),
|
||||
|
||||
Reference in new issue
Block a user