341 lines
11 KiB
TypeScript
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));
|
|
},
|
|
};
|