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

371 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}