chore: sync local changes to Gitea

This commit is contained in:
shumengya
2026-06-24 22:10:25 +08:00
parent ea07531243
commit 5c44179c2c
50 changed files with 6892 additions and 869 deletions

68
worker/crypto.ts Normal file
View File

@@ -0,0 +1,68 @@
/** AES-GCM encrypt/decrypt for API keys at rest. */
function encEncoder(): TextEncoder {
return new TextEncoder();
}
async function getCryptoKey(env: Env): Promise<CryptoKey> {
const raw = await resolveRawKey(env);
return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
}
async function resolveRawKey(env: Env): Promise<ArrayBuffer> {
if (env.ENCRYPTION_KEY?.trim()) {
const s = env.ENCRYPTION_KEY.trim();
try {
const buf = Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
if (buf.byteLength === 32) return buf.buffer;
} catch {
/* fall through */
}
const digest = await crypto.subtle.digest("SHA-256", encEncoder().encode(s));
return digest;
}
const digest = await crypto.subtle.digest("SHA-256", encEncoder().encode(env.ADMIN_TOKEN));
return digest;
}
export async function encryptSecret(plain: string, env: Env): Promise<{ ciphertext: ArrayBuffer; nonce: Uint8Array }> {
const key = await getCryptoKey(env);
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, encEncoder().encode(plain));
return { ciphertext, nonce };
}
/** Normalize D1 BLOB columns: cloud/local may return ArrayBuffer, Uint8Array, or number[]. */
export function d1BlobToUint8Array(v: unknown): Uint8Array {
if (v == null) throw new TypeError("blob is null or undefined");
if (v instanceof Uint8Array) return v;
if (v instanceof ArrayBuffer) return new Uint8Array(v);
if (Array.isArray(v)) return new Uint8Array(v as number[]);
if (typeof v === "object" && "buffer" in v && (v as ArrayBufferView).buffer instanceof ArrayBuffer) {
const t = v as ArrayBufferView;
return new Uint8Array(t.buffer.slice(t.byteOffset, t.byteOffset + t.byteLength));
}
throw new TypeError("unsupported BLOB shape from D1");
}
export async function decryptSecret(ciphertext: unknown, nonce: unknown, env: Env): Promise<string> {
const ct = d1BlobToUint8Array(ciphertext);
const iv = d1BlobToUint8Array(nonce);
const key = await getCryptoKey(env);
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, key, ct);
return new TextDecoder().decode(plain);
}
export function toB64(buf: ArrayBuffer): string {
const u8 = new Uint8Array(buf);
let s = "";
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]!);
return btoa(s);
}
export function fromB64(s: string): ArrayBuffer {
const bin = atob(s);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return u8.buffer;
}

370
worker/db.ts Normal file
View File

@@ -0,0 +1,370 @@
import { d1BlobToUint8Array } from "./crypto";
import type { Protocol } from "./probe";
const THIRTY_DAYS_SEC = 30 * 24 * 60 * 60;
export async function pruneProbeEvents(db: D1Database): Promise<void> {
const cutoff = Math.floor(Date.now() / 1000) - THIRTY_DAYS_SEC;
await db.prepare("DELETE FROM probe_events WHERE ts < ?").bind(cutoff).run();
}
export type AppSettingsRow = {
id: number;
probe_prompts: string;
probe_interval_minutes: number;
};
export async function getAppSettings(db: D1Database): Promise<AppSettingsRow> {
const r = await db
.prepare("SELECT id, probe_prompts, probe_interval_minutes FROM app_settings WHERE id = 1")
.first<AppSettingsRow>();
if (r) return r;
await db
.prepare("INSERT INTO app_settings (id, probe_prompts, probe_interval_minutes) VALUES (1, '', 5)")
.run();
return { id: 1, probe_prompts: "", probe_interval_minutes: 5 };
}
export async function updateAppSettings(
db: D1Database,
patch: Partial<Pick<AppSettingsRow, "probe_prompts" | "probe_interval_minutes">>
): Promise<void> {
const cur = await getAppSettings(db);
const next = {
probe_prompts: patch.probe_prompts !== undefined ? patch.probe_prompts : cur.probe_prompts,
probe_interval_minutes:
patch.probe_interval_minutes !== undefined ? patch.probe_interval_minutes : cur.probe_interval_minutes,
};
await db
.prepare("UPDATE app_settings SET probe_prompts = ?, probe_interval_minutes = ? WHERE id = 1")
.bind(next.probe_prompts, next.probe_interval_minutes)
.run();
}
/** 库中存证的监控行(不含全站探测间隔,间隔在 app_settings */
export type MonitorRow = {
id: string;
display_name: string;
api_base_url: string;
model: string;
protocol: Protocol;
enabled: number;
category: string;
created_at: number;
api_key_ciphertext: ArrayBuffer;
api_key_nonce: ArrayBuffer;
last_run_at: number | null;
next_run_at: number;
/** 1 = 流式0 = 非流式 */
probe_stream: number;
/** 1 = 首页卡片展示0 = 仅后台可见 */
show_on_dashboard: number;
};
/** 列表 API 在合并 interval_minutes 之前的行 */
export type MonitorPublic = {
id: string;
display_name: string;
api_base_url: string;
model: string;
protocol: Protocol;
enabled: number;
category: string;
created_at: number;
last_run_at: number | null;
next_run_at: number;
probe_stream: number;
show_on_dashboard: number;
};
export async function listMonitorsPublic(
db: D1Database,
visibility: "all" | "dashboard" = "all"
): Promise<MonitorPublic[]> {
const where = visibility === "dashboard" ? " WHERE show_on_dashboard = 1" : "";
const r = await db
.prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
created_at, last_run_at, next_run_at, probe_stream, show_on_dashboard
FROM monitors${where} ORDER BY display_name ASC`
)
.all();
return (r.results ?? []) as unknown as MonitorPublic[];
}
export async function listAllMonitors(db: D1Database): Promise<MonitorRow[]> {
const r = await db
.prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
show_on_dashboard
FROM monitors ORDER BY display_name ASC`
)
.all();
return (r.results ?? []) as unknown as MonitorRow[];
}
export async function listDueMonitors(db: D1Database, nowSec: number): Promise<MonitorRow[]> {
const r = await db
.prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
show_on_dashboard
FROM monitors WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC`
)
.bind(nowSec)
.all();
return (r.results ?? []) as unknown as MonitorRow[];
}
export async function getMonitor(db: D1Database, id: string): Promise<MonitorRow | null> {
const r = await db
.prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
show_on_dashboard
FROM monitors WHERE id = ?`
)
.bind(id)
.first();
return (r as unknown as MonitorRow) ?? null;
}
export async function insertMonitor(
db: D1Database,
row: Omit<MonitorRow, "last_run_at"> & { last_run_at: number | null }
): Promise<void> {
const ct = d1BlobToUint8Array(row.api_key_ciphertext as unknown);
const nn = d1BlobToUint8Array(row.api_key_nonce as unknown);
await db
.prepare(
`INSERT INTO monitors (id, display_name, api_base_url, model, protocol, enabled, category,
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
show_on_dashboard)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
row.id,
row.display_name,
row.api_base_url,
row.model,
row.protocol,
row.enabled,
row.category,
row.created_at,
ct,
nn,
row.last_run_at,
row.next_run_at,
row.probe_stream,
row.show_on_dashboard
)
.run();
}
export async function updateMonitorMeta(
db: D1Database,
id: string,
patch: Partial<{
display_name: string;
api_base_url: string;
model: string;
protocol: Protocol;
category: string;
enabled: number;
api_key_ciphertext: ArrayBuffer;
api_key_nonce: ArrayBuffer;
next_run_at: number;
probe_stream: number;
show_on_dashboard: number;
}>
): Promise<void> {
const cur = await getMonitor(db, id);
if (!cur) return;
const next = { ...cur, ...patch };
const ct = d1BlobToUint8Array(next.api_key_ciphertext as unknown);
const nn = d1BlobToUint8Array(next.api_key_nonce as unknown);
await db
.prepare(
`UPDATE monitors SET display_name=?, api_base_url=?, model=?, protocol=?,
enabled=?, category=?, api_key_ciphertext=?, api_key_nonce=?, next_run_at=?, probe_stream=?,
show_on_dashboard=?
WHERE id=?`
)
.bind(
next.display_name,
next.api_base_url,
next.model,
next.protocol,
next.enabled,
next.category,
ct,
nn,
next.next_run_at,
next.probe_stream,
next.show_on_dashboard,
id
)
.run();
}
export async function deleteMonitor(db: D1Database, id: string): Promise<void> {
await db.prepare("DELETE FROM probe_events WHERE monitor_id = ?").bind(id).run();
await db.prepare("DELETE FROM monitors WHERE id = ?").bind(id).run();
}
export async function insertProbeEvent(
db: D1Database,
e: {
monitor_id: string;
ts: number;
ok: number;
first_token_ms: number | null;
http_status: number | null;
error_message: string | null;
probe_input: string | null;
probe_output: string | null;
}
): Promise<void> {
await db
.prepare(
`INSERT INTO probe_events (monitor_id, ts, ok, first_token_ms, http_status, error_message, probe_input, probe_output)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
e.monitor_id,
e.ts,
e.ok,
e.first_token_ms,
e.http_status,
e.error_message,
e.probe_input,
e.probe_output
)
.run();
}
export async function updateMonitorRunTimes(
db: D1Database,
id: string,
last_run_at: number,
next_run_at: number
): Promise<void> {
await db
.prepare("UPDATE monitors SET last_run_at = ?, next_run_at = ? WHERE id = ?")
.bind(last_run_at, next_run_at, id)
.run();
}
export type AvailabilityTimelineStats = {
availability24h: number | null;
availability30d: number | null;
probe_count: number;
daily: Array<{ dayStart: number; ok: number; total: number }>;
lastProbe: {
ts: number;
ok: number;
first_token_ms: number | null;
http_status: number | null;
error_message: string | null;
probe_input: string | null;
probe_output: string | null;
} | null;
};
type LastProbeRow = NonNullable<AvailabilityTimelineStats["lastProbe"]>;
function finalizeAvailabilityTimeline(
lastProbe: LastProbeRow | null | undefined,
stats24: { okc: number | null; total: number | null } | null | undefined,
stats30: { okc: number | null; total: number | null } | null | undefined,
dailyRaw: unknown[],
countRow: { c: number | null } | null | undefined
): AvailabilityTimelineStats {
const daily = dailyRaw.map((r) => ({
dayStart: Number((r as { day_start: number }).day_start),
ok: Number((r as { okc: number | null }).okc ?? 0),
total: Number((r as { cnt: number | null }).cnt ?? 0),
}));
const availability24h =
stats24?.total && stats24.total > 0 ? (Number(stats24.okc ?? 0) / Number(stats24.total)) * 100 : null;
const availability30d =
stats30?.total && stats30.total > 0 ? (Number(stats30.okc ?? 0) / Number(stats30.total)) * 100 : null;
return {
availability24h,
availability30d,
probe_count: Number(countRow?.c ?? 0),
daily,
lastProbe: lastProbe ?? null,
};
}
/** 与 SQL bind / Map 查找统一,避免首尾空白或类型不一致导致 stats 对不上 */
export function normMonitorId(id: unknown): string {
return String(id ?? "").trim();
}
/** 每个监控单独 batch5 条语句),多监控并发;避免单次 mega-batch 下结果与语句顺序偶发错位 */
const AV_STATS_PARALLEL_MONITORS = 12;
/**
* 批量拉取多个监控的可用率与时间线(每位监控 1 次 `db.batch`,含 5 条 SELECT
*/
export async function availabilityAndTimelineBatch(
db: D1Database,
monitorIds: string[]
): Promise<Map<string, AvailabilityTimelineStats>> {
const out = new Map<string, AvailabilityTimelineStats>();
const unique = [...new Set(monitorIds.map(normMonitorId).filter((id) => id.length > 0))];
if (unique.length === 0) return out;
const now = Math.floor(Date.now() / 1000);
const start24 = now - 86400;
const start30 = now - THIRTY_DAYS_SEC;
const sqlLast = `SELECT ts, ok, first_token_ms, http_status, error_message, probe_input, probe_output FROM probe_events WHERE monitor_id = ? ORDER BY ts DESC LIMIT 1`;
const sqlRange = `SELECT SUM(ok) AS okc, COUNT(*) AS total FROM probe_events WHERE monitor_id = ? AND ts >= ?`;
const sqlDaily = `SELECT (CAST((ts + 28800) / 43200 AS INTEGER) * 43200 - 28800) AS day_start,
SUM(ok) AS okc,
COUNT(*) AS cnt
FROM probe_events WHERE monitor_id = ? AND ts >= ?
GROUP BY day_start ORDER BY day_start ASC`;
const sqlCount = `SELECT COUNT(*) AS c FROM probe_events WHERE monitor_id = ?`;
async function fetchOne(monitorId: string): Promise<void> {
const batchResults = await db.batch([
db.prepare(sqlLast).bind(monitorId),
db.prepare(sqlRange).bind(monitorId, start24),
db.prepare(sqlRange).bind(monitorId, start30),
db.prepare(sqlDaily).bind(monitorId, start30),
db.prepare(sqlCount).bind(monitorId),
]);
const lastProbe = batchResults[0].results?.[0] as LastProbeRow | undefined;
const stats24 = batchResults[1].results?.[0] as { okc: number | null; total: number | null } | undefined;
const stats30 = batchResults[2].results?.[0] as { okc: number | null; total: number | null } | undefined;
const dailyRaw = batchResults[3].results ?? [];
const countRow = batchResults[4].results?.[0] as { c: number | null } | undefined;
out.set(monitorId, finalizeAvailabilityTimeline(lastProbe, stats24, stats30, dailyRaw, countRow));
}
for (let off = 0; off < unique.length; off += AV_STATS_PARALLEL_MONITORS) {
const slice = unique.slice(off, off + AV_STATS_PARALLEL_MONITORS);
await Promise.all(slice.map((id) => fetchOne(id)));
}
return out;
}
/** Timeline bucket: half-day slots aligned to Asia/Shanghai 00:00 and 12:00. */
export async function availabilityAndTimeline(
db: D1Database,
monitorId: string
): Promise<AvailabilityTimelineStats> {
const id = normMonitorId(monitorId);
const m = await availabilityAndTimelineBatch(db, [id]);
return m.get(id) ?? finalizeAvailabilityTimeline(null, null, null, [], null);
}

340
worker/index.ts Normal file
View File

@@ -0,0 +1,340 @@
import { Hono } from "hono";
import {
availabilityAndTimeline,
availabilityAndTimelineBatch,
getAppSettings,
getMonitor,
listMonitorsPublic,
normMonitorId,
updateAppSettings,
} from "./db";
import {
createMonitorFromPayload,
deleteMonitor,
runScheduled,
runSingleProbe,
updateMonitorFromPayload,
} from "./scheduler";
type MonitorCreateBody = {
display_name: string;
api_base_url: string;
api_key: string;
model: string;
protocol: "openai" | "openai_responses" | "claude";
category?: string;
enabled?: boolean;
/** 未传时默认为流式 */
probe_stream?: boolean;
/** 未传时默认在首页展示 */
show_on_dashboard?: boolean;
};
function parseOptionalBool01(o: Record<string, unknown>, key: string): boolean | undefined {
const v = o[key];
if (typeof v === "boolean") return v;
if (v === 0) return false;
if (v === 1) return true;
return undefined;
}
function parseOptionalProbeStream(o: Record<string, unknown>): boolean | undefined {
return parseOptionalBool01(o, "probe_stream");
}
const app = new Hono<{ Bindings: Env }>();
/** 避免边缘/浏览器把动态 JSON 缓存成「半套字段」,尤其带 Authorization 的请求易被错误复用 */
app.use("/api/*", async (c, next) => {
c.header("Cache-Control", "private, no-store, max-age=0");
await next();
});
const ALLOWED = new Set([1, 5, 10, 30, 60, 360]);
function checkAdmin(c: { req: { header: (k: string) => string | undefined }; env: Env }): Response | null {
const configured = (c.env.ADMIN_TOKEN ?? "").trim();
if (!configured) {
return new Response(
JSON.stringify({
error: "admin_token_not_configured",
message: "Configure ADMIN_TOKEN in .dev.vars (local) or wrangler secret put ADMIN_TOKEN (production).",
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
const h = c.req.header("Authorization") ?? "";
const m = /^Bearer\s+(.+)$/i.exec(h);
const bearer = (m?.[1] ?? "").trim();
if (bearer !== configured) {
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
return null;
}
async function parseMonitorCreate(c: { req: { json: () => Promise<unknown> } }): Promise<MonitorCreateBody | null> {
let body: unknown;
try {
body = await c.req.json();
} catch {
return null;
}
if (!body || typeof body !== "object") return null;
const o = body as Record<string, unknown>;
const display_name = typeof o.display_name === "string" ? o.display_name : "";
const api_base_url = typeof o.api_base_url === "string" ? o.api_base_url : "";
const api_key = typeof o.api_key === "string" ? o.api_key : "";
const model = typeof o.model === "string" ? o.model : "";
const protocol =
o.protocol === "openai" || o.protocol === "openai_responses" || o.protocol === "claude" ? o.protocol : null;
if (!display_name.trim() || !api_base_url.trim() || !api_key.trim() || !model.trim() || !protocol) {
return null;
}
const category = typeof o.category === "string" ? o.category : undefined;
const enabled = typeof o.enabled === "boolean" ? o.enabled : undefined;
const probe_stream = parseOptionalProbeStream(o);
const show_on_dashboard = parseOptionalBool01(o, "show_on_dashboard");
return {
display_name,
api_base_url,
api_key,
model,
protocol,
category,
enabled,
probe_stream,
show_on_dashboard,
};
}
type MonitorRowWithInterval = Awaited<ReturnType<typeof listMonitorsPublic>>[number] & {
interval_minutes: number;
};
const TIMELINE_SHANGHAI_OFF = 8 * 3600;
const TIMELINE_HALF_DAY_SEC = 12 * 60 * 60;
const TIMELINE_SLOTS = 60;
function currentTimelineSlotStart(nowSec: number): number {
return Math.floor((nowSec + TIMELINE_SHANGHAI_OFF) / TIMELINE_HALF_DAY_SEC) * TIMELINE_HALF_DAY_SEC - TIMELINE_SHANGHAI_OFF;
}
function timelineDailyFromBuckets(daily: Array<{ dayStart: number; ok: number; total: number }>) {
const bySlot = new Map(daily.map((d) => [d.dayStart, d]));
const current = currentTimelineSlotStart(Math.floor(Date.now() / 1000));
const first = current - (TIMELINE_SLOTS - 1) * TIMELINE_HALF_DAY_SEC;
return Array.from({ length: TIMELINE_SLOTS }, (_, i) => {
const t = first + i * TIMELINE_HALF_DAY_SEC;
const d = bySlot.get(t);
const total = d?.total ?? 0;
const ratio = total > 0 ? (d?.ok ?? 0) / total : 0;
return {
t,
up: total > 0 && (d?.ok ?? 0) === total,
ratio,
hasData: total > 0,
};
});
}
async function monitorsDtoList(db: D1Database, merged: MonitorRowWithInterval[]) {
const statsMap = await availabilityAndTimelineBatch(
db,
merged.map((m) => m.id)
);
return merged.map((m) => {
const stats = statsMap.get(normMonitorId(m.id));
if (!stats) {
return {
...m,
availability24h: null,
availability30d: null,
probe_count: 0,
lastProbe: null,
timelineDaily: [],
};
}
const timelineDaily = timelineDailyFromBuckets(stats.daily);
return {
...m,
availability24h: stats.availability24h,
availability30d: stats.availability30d,
probe_count: stats.probe_count,
lastProbe: stats.lastProbe,
timelineDaily,
};
});
}
app.get("/api/monitors", async (c) => {
const db = c.env.DB;
const [rows, s] = await Promise.all([listMonitorsPublic(db, "dashboard"), getAppSettings(db)]);
const merged: MonitorRowWithInterval[] = rows.map((m) => ({
...m,
interval_minutes: s.probe_interval_minutes,
}));
return c.json(await monitorsDtoList(db, merged));
});
app.get("/api/monitors/:id", async (c) => {
const id = c.req.param("id");
const db = c.env.DB;
const [m, s] = await Promise.all([getMonitor(db, id), getAppSettings(db)]);
if (!m) return c.json({ error: "not_found" }, 404);
if (m.show_on_dashboard === 0) return c.json({ error: "not_found" }, 404);
const stats = await availabilityAndTimeline(db, id);
const timelineDaily = timelineDailyFromBuckets(stats.daily);
return c.json({
id: m.id,
display_name: m.display_name,
api_base_url: m.api_base_url,
model: m.model,
protocol: m.protocol,
interval_minutes: s.probe_interval_minutes,
probe_stream: m.probe_stream,
show_on_dashboard: m.show_on_dashboard,
enabled: m.enabled,
category: m.category,
created_at: m.created_at,
last_run_at: m.last_run_at,
next_run_at: m.next_run_at,
availability24h: stats.availability24h,
availability30d: stats.availability30d,
probe_count: stats.probe_count,
lastProbe: stats.lastProbe,
timelineDaily,
});
});
app.get("/api/admin/ping", (c) => {
const err = checkAdmin(c);
if (err) return err;
return c.json({ ok: true });
});
app.get("/api/admin/probe-settings", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const s = await getAppSettings(c.env.DB);
return c.json({
probe_prompts: s.probe_prompts,
probe_interval_minutes: s.probe_interval_minutes,
});
});
app.put("/api/admin/probe-settings", async (c) => {
const err = checkAdmin(c);
if (err) return err;
let body: Record<string, unknown>;
try {
body = (await c.req.json()) as Record<string, unknown>;
} catch {
return c.json({ error: "invalid_json" }, 400);
}
const patch: Partial<{ probe_prompts: string; probe_interval_minutes: number }> = {};
if (typeof body.probe_prompts === "string") patch.probe_prompts = body.probe_prompts;
if (typeof body.probe_interval_minutes === "number") {
if (!ALLOWED.has(body.probe_interval_minutes)) {
return c.json({ error: "invalid_interval" }, 400);
}
patch.probe_interval_minutes = body.probe_interval_minutes as 1 | 5 | 10 | 30 | 60 | 360;
}
if (Object.keys(patch).length === 0) return c.json({ error: "invalid_body" }, 400);
try {
await updateAppSettings(c.env.DB, patch);
return c.json({ ok: true });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return c.json({ error: "update_failed", message }, 500);
}
});
app.get("/api/admin/monitors", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const db = c.env.DB;
const [rows, s] = await Promise.all([listMonitorsPublic(db, "all"), getAppSettings(db)]);
const merged: MonitorRowWithInterval[] = rows.map((m) => ({
...m,
interval_minutes: s.probe_interval_minutes,
}));
return c.json(await monitorsDtoList(db, merged));
});
app.post("/api/admin/monitors", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const parsed = await parseMonitorCreate(c);
if (!parsed) return c.json({ error: "invalid_body" }, 400);
const { id } = await createMonitorFromPayload(c.env, c.env.DB, parsed);
return c.json({ id }, 201);
});
app.put("/api/admin/monitors/:id", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const id = c.req.param("id");
let body: Record<string, unknown>;
try {
body = (await c.req.json()) as Record<string, unknown>;
} catch {
return c.json({ error: "invalid_json" }, 400);
}
const patch: Parameters<typeof updateMonitorFromPayload>[3] = {};
if (typeof body.display_name === "string") patch.display_name = body.display_name;
if (typeof body.api_base_url === "string") patch.api_base_url = body.api_base_url;
if (typeof body.api_key === "string") patch.api_key = body.api_key;
if (typeof body.model === "string") patch.model = body.model;
if (body.protocol === "openai" || body.protocol === "openai_responses" || body.protocol === "claude")
patch.protocol = body.protocol;
if (typeof body.category === "string") patch.category = body.category;
if (typeof body.enabled === "boolean") patch.enabled = body.enabled;
if (typeof body.probe_stream === "boolean") patch.probe_stream = body.probe_stream;
else if (body.probe_stream === 0 || body.probe_stream === 1) patch.probe_stream = body.probe_stream === 1;
if (typeof body.show_on_dashboard === "boolean") patch.show_on_dashboard = body.show_on_dashboard;
else if (body.show_on_dashboard === 0 || body.show_on_dashboard === 1)
patch.show_on_dashboard = body.show_on_dashboard === 1;
try {
const ok = await updateMonitorFromPayload(c.env, c.env.DB, id, patch);
if (!ok) return c.json({ error: "not_found" }, 404);
return c.json({ ok: true });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return c.json({ error: "update_failed", message }, 500);
}
});
app.delete("/api/admin/monitors/:id", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const id = c.req.param("id");
const m = await getMonitor(c.env.DB, id);
if (!m) return c.json({ error: "not_found" }, 404);
await deleteMonitor(c.env.DB, id);
return c.json({ ok: true });
});
app.post("/api/admin/monitors/:id/run", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const id = c.req.param("id");
const m = await getMonitor(c.env.DB, id);
if (!m) return c.json({ error: "not_found" }, 404);
const nowSec = Math.floor(Date.now() / 1000);
const outcome = await runSingleProbe(c.env, id, c.env.DB, nowSec);
if (!outcome.success) {
return c.json({ ok: false, error: outcome.error }, 200);
}
return c.json({ ok: true });
});
export default {
fetch: app.fetch,
scheduled: (_event: ScheduledEvent, env: Env, ctx: ExecutionContext) => {
ctx.waitUntil(runScheduled(env));
},
};

583
worker/probe.ts Normal file
View File

@@ -0,0 +1,583 @@
export type Protocol = "openai" | "openai_responses" | "claude";
const PROBE_TIMEOUT_MS = 45_000;
const PROBE_MAX_TOKENS = 256;
/** 存入 D1 前在 Worker 侧截断的流式输出上限 */
const STREAM_OUTPUT_CAP = 4096;
/** 非 2xx 时读取响应体截断长度scheduler 还会再截断入库) */
const HTTP_ERROR_BODY_MAX = 6000;
function tryFormatJsonBody(raw: string): string {
const t = raw.trim();
if (!t.startsWith("{") && !t.startsWith("[")) return raw;
try {
return JSON.stringify(JSON.parse(t), null, 2);
} catch {
return raw;
}
}
async function readHttpErrorBody(res: Response): Promise<string> {
try {
const raw = (await res.text()).trim();
if (!raw) return "";
const formatted = tryFormatJsonBody(raw);
if (formatted.length <= HTTP_ERROR_BODY_MAX) return formatted;
return formatted.slice(0, HTTP_ERROR_BODY_MAX) + "…";
} catch {
return "";
}
}
function buildHttpErrorMessage(res: Response, body: string): string {
const line = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
if (!body) return `${line}\n响应体为空常见原因网关未返回 JSON 错误详情)`;
return `${line}\n\n${body}`;
}
function normalizeBase(url: string): string {
return url.replace(/\/+$/, "");
}
function appendCap(base: string, add: string, max: number): string {
if (base.length >= max) return base;
const room = max - base.length;
return base + (add.length <= room ? add : add.slice(0, room));
}
/** 经典 Chat Completions/v1/chat/completions兼容绝大多数 OpenAI 兼容网关 */
function openAiChatUrl(base: string): string {
const b = normalizeBase(base);
if (b.endsWith("/chat/completions")) return b;
if (b.endsWith("/v1")) return `${b}/chat/completions`;
return `${b}/chat/completions`;
}
/** OpenAI Responses API/v1/responses */
function openAiResponsesUrl(base: string): string {
const b = normalizeBase(base);
if (b.endsWith("/responses")) return b;
if (b.endsWith("/v1")) return `${b}/responses`;
return `${b}/v1/responses`;
}
function claudeUrl(base: string): string {
const b = normalizeBase(base);
if (b.endsWith("/messages")) return b;
if (b.endsWith("/v1")) return `${b}/messages`;
return `${b}/v1/messages`;
}
/**
* Chat Completions 流式里 `choices[0].delta` 的正文抽取。
* 对齐 OpenAI 常见形态delta.content 字符串;多段/多模态下为 part 数组;
* 部分国产/推理模型会在 delta.reasoning_content 等字段里先出字。
*/
function streamDeltaTextFromOpenAiChatChoice(delta: unknown): string {
if (delta == null || typeof delta !== "object") return "";
const d = delta as Record<string, unknown>;
const c = d.content;
if (typeof c === "string" && c !== "") return c;
if (Array.isArray(c)) {
let s = "";
for (const part of c) {
if (part && typeof part === "object") {
const p = part as { type?: string; text?: string };
if (typeof p.text === "string") s += p.text;
}
}
return s;
}
for (const k of ["reasoning_content", "reasoning"] as const) {
const v = d[k];
if (typeof v === "string" && v !== "") return v;
}
return "";
}
async function readStreamOpenAIChat(
res: Response,
started: number
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
if (!res.ok || !res.body) {
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let firstTokenMs: number | null = null;
let outputText = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const t = line.trim();
if (!t.startsWith("data:")) continue;
const payload = t.slice(5).trim();
if (payload === "[DONE]") {
return { firstTokenMs, httpStatus: res.status, outputText };
}
try {
const obj = JSON.parse(payload) as {
choices?: Array<{ delta?: unknown }>;
};
const delta = obj.choices?.[0]?.delta;
const chunk = streamDeltaTextFromOpenAiChatChoice(delta);
if (chunk !== "") {
if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started);
outputText = appendCap(outputText, chunk, STREAM_OUTPUT_CAP);
}
} catch {
/* ignore bad json line */
}
}
}
} finally {
reader.releaseLock();
}
return { firstTokenMs, httpStatus: res.status, outputText };
}
async function readStreamOpenAIResponses(
res: Response,
started: number
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
if (!res.ok || !res.body) {
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let firstTokenMs: number | null = null;
let outputText = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const t = line.trim();
if (!t.startsWith("data:")) continue;
const payload = t.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const obj = JSON.parse(payload) as { type?: string; delta?: string };
if (obj.type === "response.output_text.delta" && obj.delta != null && obj.delta !== "") {
if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started);
outputText = appendCap(outputText, obj.delta, STREAM_OUTPUT_CAP);
}
} catch {
/* ignore */
}
}
}
} finally {
reader.releaseLock();
}
return { firstTokenMs, httpStatus: res.status, outputText };
}
function openAiChatMessageContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
let s = "";
for (const part of content) {
if (part && typeof part === "object") {
const p = part as { type?: string; text?: string };
if (typeof p.text === "string") s += p.text;
}
}
return s;
}
function parseOpenAiChatNonStream(
json: unknown,
started: number
): { firstTokenMs: number | null; outputText: string } {
const obj = json as { choices?: Array<{ message?: { content?: unknown } }> };
const raw = openAiChatMessageContentToText(obj.choices?.[0]?.message?.content);
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
return { firstTokenMs, outputText };
}
function extractOpenAiResponsesText(data: unknown): string {
if (data == null || typeof data !== "object") return "";
const d = data as Record<string, unknown>;
if (typeof d.output_text === "string") return d.output_text;
const out = d.output;
if (!Array.isArray(out)) return "";
const parts: string[] = [];
const walk = (node: unknown): void => {
if (node == null) return;
if (typeof node === "string") {
parts.push(node);
return;
}
if (Array.isArray(node)) {
for (const x of node) walk(x);
return;
}
if (typeof node !== "object") return;
const o = node as Record<string, unknown>;
if (o.type === "output_text" && typeof o.text === "string") {
parts.push(o.text);
return;
}
if (typeof o.text === "string" && typeof o.type === "string" && o.type.includes("text")) {
parts.push(o.text);
return;
}
if (o.content != null) walk(o.content);
if (o.output != null) walk(o.output);
};
for (const item of out) walk(item);
return parts.join("");
}
function parseOpenAiResponsesNonStream(
json: unknown,
started: number
): { firstTokenMs: number | null; outputText: string } {
const raw = extractOpenAiResponsesText(json);
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
return { firstTokenMs, outputText };
}
function extractClaudeNonStreamText(json: unknown): string {
if (json == null || typeof json !== "object") return "";
const d = json as { content?: unknown };
const content = d.content;
if (!Array.isArray(content)) return "";
let s = "";
for (const block of content) {
if (block && typeof block === "object") {
const b = block as { type?: string; text?: string };
if (b.type === "text" && typeof b.text === "string") s += b.text;
}
}
return s;
}
function parseClaudeNonStream(
json: unknown,
started: number
): { firstTokenMs: number | null; outputText: string } {
const raw = extractClaudeNonStreamText(json);
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
return { firstTokenMs, outputText };
}
/** 首字可来自正文或 thinking输出只累计 assistant 可见正文 delta.text */
function claudeStreamDeltaParts(
data: string,
currentEvent: string
): { outputChunk: string | null; marksFirstToken: boolean } {
try {
const obj = JSON.parse(data) as {
type?: string;
delta?: { type?: string; text?: string; thinking?: string };
};
const match = currentEvent === "content_block_delta" || obj.type === "content_block_delta";
if (!match) return { outputChunk: null, marksFirstToken: false };
const d = obj.delta;
if (!d) return { outputChunk: null, marksFirstToken: false };
const hasThinking = typeof d.thinking === "string" && d.thinking !== "";
const hasText = typeof d.text === "string" && d.text !== "";
return {
outputChunk: hasText ? (d.text as string) : null,
marksFirstToken: hasThinking || hasText,
};
} catch {
return { outputChunk: null, marksFirstToken: false };
}
}
async function readStreamClaude(
res: Response,
started: number
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
if (!res.ok || !res.body) {
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let currentEvent = "";
let firstTokenMs: number | null = null;
let outputText = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf("\n")) >= 0) {
let line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (line.endsWith("\r")) line = line.slice(0, -1);
if (line === "") {
currentEvent = "";
continue;
}
if (line.startsWith("event:")) {
currentEvent = line.slice(6).trim();
continue;
}
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
const parts = claudeStreamDeltaParts(data, currentEvent);
if (parts.marksFirstToken && firstTokenMs == null) {
firstTokenMs = Math.max(0, Date.now() - started);
}
if (parts.outputChunk) {
outputText = appendCap(outputText, parts.outputChunk, STREAM_OUTPUT_CAP);
}
}
}
} finally {
reader.releaseLock();
}
return { firstTokenMs, httpStatus: res.status, outputText };
}
export type ProbeResult = {
ok: boolean;
firstTokenMs: number | null;
httpStatus: number | null;
errorMessage: string | null;
requestMessage: string;
responseText: string | null;
};
export async function runProbe(params: {
apiBaseUrl: string;
apiKey: string;
model: string;
protocol: Protocol;
userMessage: string;
/** 默认 true流式false 时使用非流式 JSON 响应 */
stream?: boolean;
}): Promise<ProbeResult> {
const userMessage = params.userMessage.trim() || "ping";
const useStream = params.stream !== false;
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
const started = Date.now();
try {
if (params.protocol === "openai") {
const url = openAiChatUrl(params.apiBaseUrl);
const res = await fetch(url, {
method: "POST",
signal: ac.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: params.model,
messages: [{ role: "user", content: userMessage }],
max_tokens: PROBE_MAX_TOKENS,
stream: useStream,
}),
});
if (!res.ok) {
const errBody = await readHttpErrorBody(res);
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: buildHttpErrorMessage(res, errBody),
requestMessage: userMessage,
responseText: null,
};
}
if (useStream) {
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIChat(res, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus,
errorMessage: ok ? null : "no_stream_token",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
}
let json: unknown;
try {
json = await res.json();
} catch {
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: "invalid_json_body",
requestMessage: userMessage,
responseText: null,
};
}
const { firstTokenMs, outputText } = parseOpenAiChatNonStream(json, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus: res.status,
errorMessage: ok ? null : "no_response_content",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
}
if (params.protocol === "openai_responses") {
const url = openAiResponsesUrl(params.apiBaseUrl);
const res = await fetch(url, {
method: "POST",
signal: ac.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: params.model,
input: userMessage,
stream: useStream,
}),
});
if (!res.ok) {
const errBody = await readHttpErrorBody(res);
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: buildHttpErrorMessage(res, errBody),
requestMessage: userMessage,
responseText: null,
};
}
if (useStream) {
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIResponses(res, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus,
errorMessage: ok ? null : "no_stream_token",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
}
let json: unknown;
try {
json = await res.json();
} catch {
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: "invalid_json_body",
requestMessage: userMessage,
responseText: null,
};
}
const { firstTokenMs, outputText } = parseOpenAiResponsesNonStream(json, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus: res.status,
errorMessage: ok ? null : "no_response_content",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
}
const url = claudeUrl(params.apiBaseUrl);
const res = await fetch(url, {
method: "POST",
signal: ac.signal,
headers: {
"Content-Type": "application/json",
"x-api-key": params.apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: params.model,
max_tokens: PROBE_MAX_TOKENS,
messages: [{ role: "user", content: userMessage }],
stream: useStream,
}),
});
if (!res.ok) {
const errBody = await readHttpErrorBody(res);
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: buildHttpErrorMessage(res, errBody),
requestMessage: userMessage,
responseText: null,
};
}
if (useStream) {
const { firstTokenMs, httpStatus, outputText } = await readStreamClaude(res, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus,
errorMessage: ok ? null : "no_stream_token",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
}
let json: unknown;
try {
json = await res.json();
} catch {
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: "invalid_json_body",
requestMessage: userMessage,
responseText: null,
};
}
const { firstTokenMs, outputText } = parseClaudeNonStream(json, started);
const ok = firstTokenMs != null;
return {
ok,
firstTokenMs,
httpStatus: res.status,
errorMessage: ok ? null : "no_response_content",
requestMessage: userMessage,
responseText: outputText === "" ? null : outputText,
};
} catch (e) {
const msg = e instanceof Error ? (e.name === "AbortError" ? "timeout" : e.message) : "unknown_error";
return {
ok: false,
firstTokenMs: null,
httpStatus: null,
errorMessage: truncateErr(msg),
requestMessage: userMessage,
responseText: null,
};
} finally {
clearTimeout(t);
}
}
function truncateErr(s: string, max = 120): string {
if (s.length <= max) return s;
return s.slice(0, max);
}

236
worker/scheduler.ts Normal file
View File

@@ -0,0 +1,236 @@
import { decryptSecret, encryptSecret } from "./crypto";
import {
deleteMonitor,
getAppSettings,
getMonitor,
insertMonitor,
insertProbeEvent,
listDueMonitors,
pruneProbeEvents,
updateMonitorMeta,
updateMonitorRunTimes,
} from "./db";
import { runProbe } from "./probe";
const PROBE_ERROR_MAX = 8000;
const PROBE_IO_FIELD_MAX = 6000;
const DEFAULT_PROBE_PROMPTS = ["你好", "hello", "ping", "Hi", "测试一下"];
function parseProbePromptsConfig(raw: string): string[] {
const lines = raw.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0);
return lines.length > 0 ? lines : DEFAULT_PROBE_PROMPTS;
}
function pickProbeUserMessage(raw: string): string {
const list = parseProbePromptsConfig(raw);
const u = new Uint32Array(1);
crypto.getRandomValues(u);
return list[u[0]! % list.length]!;
}
function truncateProbeError(s: string): string {
if (s.length <= PROBE_ERROR_MAX) return s;
return s.slice(0, PROBE_ERROR_MAX);
}
function truncateIo(s: string | null): string | null {
if (s == null) return null;
if (s.length <= PROBE_IO_FIELD_MAX) return s;
return s.slice(0, PROBE_IO_FIELD_MAX) + "…";
}
export type RunSingleProbeOutcome = { success: true } | { success: false; error: string };
export async function runScheduled(env: Env): Promise<void> {
const db = env.DB;
try {
await pruneProbeEvents(db);
} catch {
/* ignore */
}
const nowSec = Math.floor(Date.now() / 1000);
let due: Awaited<ReturnType<typeof listDueMonitors>>;
try {
due = await listDueMonitors(db, nowSec);
} catch {
return;
}
for (const m of due) {
try {
await runSingleProbe(env, m.id, db, nowSec);
} catch {
/* runSingleProbe should not throw; defensive for unexpected runtime errors */
}
}
}
export async function runSingleProbe(
env: Env,
monitorId: string,
db: D1Database,
nowSec: number
): Promise<RunSingleProbeOutcome> {
const m = await getMonitor(db, monitorId);
if (!m || !m.enabled) return { success: true };
let settings: Awaited<ReturnType<typeof getAppSettings>>;
try {
settings = await getAppSettings(db);
} catch {
return { success: false, error: "app_settings_unavailable" };
}
const userMsg = pickProbeUserMessage(settings.probe_prompts ?? "");
const intervalMin = settings.probe_interval_minutes;
const advanceSchedule = async () => {
try {
const nextRun = nowSec + intervalMin * 60;
await updateMonitorRunTimes(db, m.id, nowSec, nextRun);
} catch {
/* ignore */
}
};
const recordInfrastructureFailure = async (errorMessage: string) => {
try {
await insertProbeEvent(db, {
monitor_id: m.id,
ts: nowSec,
ok: 0,
first_token_ms: null,
http_status: null,
error_message: truncateProbeError(errorMessage),
probe_input: truncateIo(userMsg),
probe_output: null,
});
} catch {
/* ignore */
}
await advanceSchedule();
};
try {
const apiKey = await decryptSecret(m.api_key_ciphertext, m.api_key_nonce, env);
const result = await runProbe({
apiBaseUrl: m.api_base_url,
apiKey,
model: m.model,
protocol: m.protocol,
userMessage: userMsg,
stream: m.probe_stream !== 0,
});
try {
await insertProbeEvent(db, {
monitor_id: m.id,
ts: nowSec,
ok: result.ok ? 1 : 0,
first_token_ms: result.firstTokenMs,
http_status: result.httpStatus,
error_message: result.errorMessage ? truncateProbeError(result.errorMessage) : null,
probe_input: truncateIo(result.requestMessage),
probe_output: truncateIo(result.responseText),
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
await advanceSchedule();
return { success: false, error: msg };
}
await advanceSchedule();
return { success: true };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
await recordInfrastructureFailure(msg);
return { success: false, error: msg };
}
}
export async function createMonitorFromPayload(
env: Env,
db: D1Database,
body: {
display_name: string;
api_base_url: string;
api_key: string;
model: string;
protocol: "openai" | "openai_responses" | "claude";
category?: string;
enabled?: boolean;
/** 未传时默认流式 */
probe_stream?: boolean;
/** 未传时默认在首页展示 */
show_on_dashboard?: boolean;
}
): Promise<{ id: string }> {
const id = crypto.randomUUID();
const nowSec = Math.floor(Date.now() / 1000);
const { ciphertext, nonce } = await encryptSecret(body.api_key, env);
const nonceCopy = new Uint8Array(nonce);
await insertMonitor(db, {
id,
display_name: body.display_name.trim(),
api_base_url: body.api_base_url.trim(),
model: body.model.trim(),
protocol: body.protocol,
enabled: body.enabled === false ? 0 : 1,
category: (body.category ?? "").trim(),
created_at: nowSec,
api_key_ciphertext: ciphertext,
api_key_nonce: nonceCopy.buffer.slice(nonceCopy.byteOffset, nonceCopy.byteOffset + nonceCopy.byteLength),
last_run_at: null,
next_run_at: nowSec,
probe_stream: body.probe_stream === false ? 0 : 1,
show_on_dashboard: body.show_on_dashboard === false ? 0 : 1,
});
return { id };
}
export async function updateMonitorFromPayload(
env: Env,
db: D1Database,
id: string,
body: Partial<{
display_name: string;
api_base_url: string;
api_key: string;
model: string;
protocol: "openai" | "openai_responses" | "claude";
category: string;
enabled: boolean;
probe_stream: boolean;
show_on_dashboard: boolean;
}>
): Promise<boolean> {
const cur = await getMonitor(db, id);
if (!cur) return false;
let ciphertext = cur.api_key_ciphertext as ArrayBuffer;
let nonceBuf = cur.api_key_nonce as ArrayBuffer;
if (body.api_key != null && body.api_key !== "") {
const enc = await encryptSecret(body.api_key, env);
const nc = new Uint8Array(enc.nonce);
ciphertext = enc.ciphertext;
nonceBuf = nc.buffer.slice(nc.byteOffset, nc.byteOffset + nc.byteLength);
}
const nowSec = Math.floor(Date.now() / 1000);
await updateMonitorMeta(db, id, {
display_name: body.display_name?.trim() ?? cur.display_name,
api_base_url: body.api_base_url?.trim() ?? cur.api_base_url,
model: body.model?.trim() ?? cur.model,
protocol: body.protocol ?? cur.protocol,
category: body.category !== undefined ? body.category.trim() : cur.category,
enabled: body.enabled != null ? (body.enabled ? 1 : 0) : cur.enabled,
api_key_ciphertext: ciphertext,
api_key_nonce: nonceBuf,
next_run_at: nowSec,
probe_stream:
body.probe_stream !== undefined ? (body.probe_stream ? 1 : 0) : cur.probe_stream,
show_on_dashboard:
body.show_on_dashboard !== undefined
? (body.show_on_dashboard ? 1 : 0)
: cur.show_on_dashboard,
});
return true;
}
export { deleteMonitor };