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, 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): 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 } }): Promise { let body: unknown; try { body = await c.req.json(); } catch { return null; } if (!body || typeof body !== "object") return null; const o = body as Record; 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>[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; try { body = (await c.req.json()) as Record; } 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; try { body = (await c.req.json()) as Record; } catch { return c.json({ error: "invalid_json" }, 400); } const patch: Parameters[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)); }, };