Files
modelping/worker/index.ts
2026-06-24 22:10:25 +08:00

341 lines
11 KiB
TypeScript

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));
},
};