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

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
frontend/dist
.wrangler/ .wrangler/
*.local *.local
.dev.vars .dev.vars

View File

@@ -2,6 +2,11 @@
在 Cloudflare Workers 上运行的大模型 API **流式首字延迟**与**可用性**监控面板React + Vite 前端Static Assets CDN+ D1 存储(默认保留 30 天探测记录)+ 每分钟 Cron 调度。 在 Cloudflare Workers 上运行的大模型 API **流式首字延迟**与**可用性**监控面板React + Vite 前端Static Assets CDN+ D1 存储(默认保留 30 天探测记录)+ 每分钟 Cron 调度。
## 目录结构
- [`frontend/`](frontend/)React 前端(入口 [`frontend/index.html`](frontend/index.html)、源码 [`frontend/src/`](frontend/src/));根目录 [`vite.config.ts`](vite.config.ts) 将 `root` 指向此处,[`public/`](public/) 仍为静态资源目录。
- [`worker/`](worker/)Cloudflare Worker — Hono API、D1、加密与定时探测调度入口见 [`wrangler.json`](wrangler.json) 的 `main` 字段。
## 功能概要 ## 功能概要
- 管理端配置API 根地址、API Key、模型名、协议**OpenAI Chat Completions 经典** / **OpenAI Responses 新版** / Claude Anthropic - 管理端配置API 根地址、API Key、模型名、协议**OpenAI Chat Completions 经典** / **OpenAI Responses 新版** / Claude Anthropic

View File

@@ -8,7 +8,7 @@ import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(['dist']),
{ {
files: ['**/*.{ts,tsx}'], files: ['frontend/**/*.{ts,tsx}'],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
@@ -19,4 +19,14 @@ export default defineConfig([
globals: globals.browser, globals: globals.browser,
}, },
}, },
{
files: ['worker/**/*.ts'],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
globals: {
...globals.worker,
Env: 'readonly',
},
},
},
]) ])

21
frontend/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="apple-touch-icon" href="/logo192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#27ae60" />
<meta name="description" content="大模型 API 可用性与首字延迟监控" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="ModelPing" />
<title>ModelPing</title>
<!-- 字体改由 Vite 从本地 node_modules 打包,避免 jsdelivr 被跟踪防护拦截、减少首屏外链请求 -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

23
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import { PwaUpdatePrompt } from "./components/PwaUpdatePrompt";
import { AppBootProvider } from "./context/AppBootContext";
import { Admin } from "./pages/Admin";
import { Dashboard } from "./pages/Dashboard";
export default function App() {
return (
<AppBootProvider>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="admin" element={<Admin />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
<PwaUpdatePrompt />
</BrowserRouter>
</AppBootProvider>
);
}

View File

@@ -1,8 +1,8 @@
import type { AdminMonitorRow, GlobalProbeSettings, MonitorDto, MonitorProtocol } from "./types"; import type { GlobalProbeSettings, MonitorDto, MonitorProtocol } from "./types";
import { getAdminToken } from "./types"; import { getAdminToken } from "./types";
export async function fetchMonitors(): Promise<MonitorDto[]> { export async function fetchMonitors(): Promise<MonitorDto[]> {
const r = await fetch("/api/monitors"); const r = await fetch("/api/monitors", { cache: "no-store" });
if (!r.ok) throw new Error("failed_to_load"); if (!r.ok) throw new Error("failed_to_load");
return r.json() as Promise<MonitorDto[]>; return r.json() as Promise<MonitorDto[]>;
} }
@@ -24,12 +24,15 @@ export async function adminPing(token: string): Promise<AdminPingResult> {
} }
} }
export async function adminListMonitors(): Promise<AdminMonitorRow[]> { export async function adminListMonitors(): Promise<MonitorDto[]> {
const t = getAdminToken(); const t = getAdminToken();
if (!t) throw new Error("no_token"); if (!t) throw new Error("no_token");
const r = await fetch("/api/admin/monitors", { headers: { Authorization: `Bearer ${t}` } }); const r = await fetch("/api/admin/monitors", {
cache: "no-store",
headers: { Authorization: `Bearer ${t}` },
});
if (!r.ok) throw new Error("admin_list_failed"); if (!r.ok) throw new Error("admin_list_failed");
return r.json(); return r.json() as Promise<MonitorDto[]>;
} }
export async function adminGetProbeSettings(): Promise<GlobalProbeSettings> { export async function adminGetProbeSettings(): Promise<GlobalProbeSettings> {
@@ -70,6 +73,8 @@ type CreateBody = {
protocol: MonitorProtocol; protocol: MonitorProtocol;
category?: string; category?: string;
enabled?: boolean; enabled?: boolean;
probe_stream?: boolean;
show_on_dashboard?: boolean;
}; };
export async function adminCreateMonitor(body: CreateBody): Promise<string> { export async function adminCreateMonitor(body: CreateBody): Promise<string> {

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,93 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { adminPing } from "../api";
import { setAdminToken } from "../types";
type Props = {
onClose: () => void;
onSuccess: () => void;
};
function pingErrorMessage(result: Exclude<Awaited<ReturnType<typeof adminPing>>, { ok: true }>): string {
if (result.ok) return "";
switch (result.reason) {
case "not_configured":
return "服务端未配置 ADMIN_TOKEN";
case "unauthorized":
return "Token 不正确";
case "network":
return "无法连接校验接口";
default:
return "校验失败";
}
}
export function AdminTokenDialog({ onClose, onSuccess }: Props) {
const [token, setToken] = useState("");
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const submit = async (e: FormEvent) => {
e.preventDefault();
const trimmed = token.trim();
if (!trimmed) {
setErr("请输入 Token");
return;
}
setBusy(true);
setErr(null);
const result = await adminPing(trimmed);
setBusy(false);
if (result.ok === true) {
setAdminToken(trimmed);
onSuccess();
return;
}
setErr(pingErrorMessage(result));
};
return (
<div
className="modal-backdrop admin-token-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<form className="modal admin-token-modal" onSubmit={(e) => void submit(e)} onClick={(e) => e.stopPropagation()}>
<p className="modal-title admin-token-title"> Token</p>
<input
ref={inputRef}
className="modal-input admin-token-input"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Token"
autoComplete="off"
disabled={busy}
/>
{err ? <p className="modal-err">{err}</p> : null}
<div className="modal-actions">
<button type="button" className="btn ghost small" onClick={onClose} disabled={busy}>
</button>
<button type="submit" className="btn primary small" disabled={busy}>
{busy ? "校验中…" : "进入"}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useRef, useState, type PointerEvent } from "react";
import { useNavigate } from "react-router-dom";
import { AdminTokenDialog } from "./AdminTokenDialog";
const LOGO_CLICKS = 5;
const LOGO_CLICK_WINDOW_MS = 2000;
export function BrandLogo() {
const navigate = useNavigate();
const [dialogOpen, setDialogOpen] = useState(false);
const clicks = useRef({ count: 0, lastAt: 0 });
const onLogoPointerDown = (e: PointerEvent<HTMLImageElement>) => {
e.preventDefault();
e.stopPropagation();
const now = Date.now();
if (now - clicks.current.lastAt > LOGO_CLICK_WINDOW_MS) {
clicks.current.count = 0;
}
clicks.current.lastAt = now;
clicks.current.count += 1;
if (clicks.current.count >= LOGO_CLICKS) {
clicks.current.count = 0;
setDialogOpen(true);
}
};
return (
<>
<img
className="brand-logo"
src="/logo.png"
alt=""
width={40}
height={40}
decoding="async"
onPointerDown={onLogoPointerDown}
/>
{dialogOpen ? (
<AdminTokenDialog
onClose={() => setDialogOpen(false)}
onSuccess={() => {
setDialogOpen(false);
window.dispatchEvent(new Event("modelping:admin-auth"));
void navigate("/admin", { replace: true });
}}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { Link, Outlet } from "react-router-dom";
import { BrandLogo } from "./BrandLogo";
const RANDBG_ORIGIN = "https://randbg.smyhub.com";
const RANDBG_RANDOM_JSON = `${RANDBG_ORIGIN}/api/random?format=json&mode=desktop`;
function resolveRandBgUrl(raw: string): string {
return /^https?:/i.test(raw) ? raw : new URL(raw, `${RANDBG_ORIGIN}/`).href;
}
export function Layout() {
const [bgUrl, setBgUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await fetch(RANDBG_RANDOM_JSON, { cache: "no-store" });
if (!r.ok) throw new Error("randbg_status");
const data = (await r.json()) as { url?: string };
const u = typeof data.url === "string" ? data.url.trim() : "";
if (!cancelled && u) {
setBgUrl(resolveRandBgUrl(u));
return;
}
} catch {
/* fallback: 302 直链仍可作为 img src */
}
if (!cancelled) {
setBgUrl(`${RANDBG_ORIGIN}/api/random?mode=desktop`);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div className="app-shell">
{bgUrl ? (
<img className="app-rand-bg" src={bgUrl} alt="" decoding="async" fetchPriority="low" />
) : null}
<header className="topbar">
<Link to="/" className="brand" title="ModelPing">
<BrandLogo />
<span className="brand-text">ModelPing</span>
</Link>
</header>
<main className="main-area">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,265 @@
import { useRef, type ReactNode } from "react";
import type { MonitorDto } from "../types";
function formatPct(v: number | null): string {
if (v == null) return "—";
return `${v.toFixed(2)}%`;
}
function protocolLabel(p: MonitorDto["protocol"]): string {
if (p === "claude") return "Anthropic/messages";
if (p === "openai_responses") return "OpenAI Responses/v1/responses";
return "OpenAI Chat Completions/v1/chat/completions";
}
/** 卡片上下两行:模型一行,协议一行 */
function protocolCardLines(p: MonitorDto["protocol"]): { name: string; path: string } {
if (p === "claude") return { name: "Anthropic", path: "/messages" };
if (p === "openai_responses") return { name: "OpenAI Responses", path: "/v1/responses" };
return { name: "OpenAI Chat Completions", path: "/v1/chat/completions" };
}
function httpStatusValueClass(st: number | undefined | null): string {
if (st == null) return "card-metric-value card-metric-value-muted";
if (st >= 200 && st < 300) return "card-metric-value card-accent-http-ok";
if (st >= 300 && st < 400) return "card-metric-value card-accent-http-info";
if (st >= 400 && st < 500) return "card-metric-value card-accent-http-client";
if (st >= 500) return "card-metric-value card-accent-http-server";
return "card-metric-value";
}
function latencyValueClass(ms: number | undefined | null): string {
if (ms == null) return "card-metric-value card-metric-value-muted";
if (ms < 500) return "card-metric-value card-accent-latency-good";
if (ms < 1500) return "card-metric-value card-accent-latency-mid";
return "card-metric-value card-accent-latency-slow";
}
/** 与「可用率」色带一致,用于 24h 与卡片中部大字 */
function pctAccentClass(pct: number | null): string {
if (pct == null) return "card-metric-value-muted";
if (pct >= 99) return "card-accent-pct-high";
if (pct >= 95) return "card-accent-pct-mid";
if (pct >= 90) return "card-accent-pct-low";
return "card-accent-pct-critical";
}
function availabilityValueClass(pct: number | null): string {
return `card-metric-value ${pctAccentClass(pct)}`;
}
/** 时间条桶为上海时区 0012 / 1224与 Worker 一致) */
const TIMELINE_SHANGHAI_OFF = 8 * 3600;
const HALF_DAY_SEC = 43200;
/** 当前时刻在上海时区属于上午还是下午(与时间条最右一格一致) */
function shanghaiAmPmNow(): "上午" | "下午" {
const nowSec = Math.floor(Date.now() / 1000);
const L = nowSec + TIMELINE_SHANGHAI_OFF;
const secInDay = ((L % 86400) + 86400) % 86400;
return secInDay < HALF_DAY_SEC ? "上午" : "下午";
}
function formatTimelineSlotLabel(t: number): string {
const half = (t + TIMELINE_SHANGHAI_OFF) % 86400 === 0 ? "上午" : "下午";
const dateStr = new Date(t * 1000).toLocaleDateString("zh-CN", {
timeZone: "Asia/Shanghai",
month: "numeric",
day: "numeric",
});
return `${dateStr} ${half}`;
}
/** 时间条:无数据灰、全成功绿、部分成功黄、全失败红 */
function timelineSegTone(d: MonitorDto["timelineDaily"][number]): "none" | "up" | "warn" | "down" {
if (d.hasData === false) return "none";
if (d.up) return "up";
if (d.ratio <= 0) return "down";
return "warn";
}
function timelineSegTitle(d: MonitorDto["timelineDaily"][number]): string {
const label = formatTimelineSlotLabel(d.t);
if (d.hasData === false) return `${label} · 无探测`;
return `${label} · ${(d.ratio * 100).toFixed(0)}%`;
}
/** 与详情弹窗一致风格2026年5月17日 19:42:45 */
function formatLastProbeTime(ts: number): string {
return new Date(ts * 1000).toLocaleString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
export function MonitorCard({ m, footerExtra }: { m: MonitorDto; footerExtra?: ReactNode }) {
const dlgRef = useRef<HTMLDialogElement>(null);
const timelineDaily = Array.isArray(m.timelineDaily) ? m.timelineDaily : [];
const ok = m.lastProbe?.ok === 1;
const ft = m.lastProbe?.first_token_ms;
const st = m.lastProbe?.http_status;
const pct30 = m.availability30d;
const probe = m.lastProbe;
const openDetail = () => dlgRef.current?.showModal();
const closeDetail = () => dlgRef.current?.close();
const protoLines = protocolCardLines(m.protocol);
return (
<article className="card">
<header className="card-head">
<div className="card-title-row">
<div>
<h2 className="card-name">{m.display_name}</h2>
<div className="card-sub card-sub-stack">
<div className="card-sub-row card-sub-row-model">
<span className="card-sub-model">{m.model}</span>
</div>
<div className="card-sub-col-protocol">
<span className="card-sub-protocol">{protoLines.name}</span>
<span className="card-sub-protocol-path">{protoLines.path}</span>
{m.category?.trim() ? (
<span className="card-sub-meta card-sub-meta-after">{m.category.trim()}</span>
) : null}
<span className="card-sub-meta card-sub-meta-after">
{m.probe_stream !== 0 ? "流式" : "非流式"}
</span>
{m.show_on_dashboard === 0 ? (
<span className="card-sub-meta card-sub-meta-after"></span>
) : null}
{m.enabled === 0 ? <span className="card-sub-paused card-sub-paused-after"></span> : null}
</div>
</div>
</div>
</div>
<div className="card-head-right">
<span className={`badge ${ok ? "badge-ok" : "badge-bad"}`}>
{ok ? "运行正常" : m.lastProbe ? "异常" : "尚无数据"}
</span>
<button type="button" className="btn-detail" onClick={openDetail}>
</button>
</div>
</header>
<div
className="timeline"
role="img"
aria-label={`约 30 天、上海时区每格半天012 点为上午1224 点为下午),右侧为当前${shanghaiAmPmNow()}`}
>
{timelineDaily.length === 0 ? (
<div className="timeline-empty"></div>
) : (
timelineDaily.map((d) => (
<span
key={d.t}
className={`tl-seg tl-${timelineSegTone(d)}`}
title={timelineSegTitle(d)}
/>
))
)}
</div>
<div className="card-mid">
<span className="card-mid-edge"> 30 </span>
<strong className={`card-mid-pct ${pctAccentClass(pct30)}`}>{formatPct(pct30)} </strong>
<span className="card-mid-edge">{shanghaiAmPmNow()}</span>
</div>
<footer className="card-foot">
<div className="card-foot-cell">
<span className="card-foot-label"></span>
<span className={httpStatusValueClass(st ?? null)}>{st != null ? st : "—"}</span>
</div>
<div className="card-foot-cell">
<span className="card-foot-label"></span>
<span className={latencyValueClass(ft ?? null)}>{ft != null ? `${ft} ms` : "—"}</span>
</div>
<div className="card-foot-cell">
<span className="card-foot-label">24h </span>
<span className={availabilityValueClass(m.availability24h)}>{formatPct(m.availability24h)}</span>
</div>
<div className="card-foot-cell card-foot-probe-time">
<span className="card-foot-label"></span>
<span
className={probe ? "card-foot-probe-value" : "card-foot-probe-value card-metric-value-muted"}
>
{probe ? formatLastProbeTime(probe.ts) : "—"}
</span>
</div>
<div className="card-foot-wide card-foot-count-row">
<span className="card-foot-label"></span>
<span className="card-foot-count-value">{m.probe_count}</span>
</div>
</footer>
{footerExtra ? <div className="card-admin-footer">{footerExtra}</div> : null}
<dialog ref={dlgRef} className="probe-detail-dialog">
<div className="probe-detail-inner">
<header className="probe-detail-head">
<h3 className="probe-detail-title"></h3>
<button type="button" className="probe-detail-close" aria-label="关闭" onClick={closeDetail}>
×
</button>
</header>
<div className="probe-detail-body">
<dl className="probe-detail-dl">
<dt></dt>
<dd>{m.display_name}</dd>
<dt></dt>
<dd>{protocolLabel(m.protocol)}</dd>
<dt></dt>
<dd>{m.model}</dd>
<dt>API </dt>
<dd>
<code className="probe-detail-code">{m.api_base_url}</code>
<div className="probe-detail-link-wrap">
<a href={m.api_base_url} target="_blank" rel="noreferrer">
</a>
</div>
</dd>
<dt></dt>
<dd>
{probe
? new Date(probe.ts * 1000).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "medium",
})
: "—"}
</dd>
<dt></dt>
<dd>{probe ? (probe.ok === 1 ? "成功" : "失败") : "尚无记录"}</dd>
<dt>HTTP </dt>
<dd>{probe?.http_status != null ? probe.http_status : "—"}</dd>
<dt></dt>
<dd>{probe?.first_token_ms != null ? `${probe.first_token_ms} ms` : "—"}</dd>
</dl>
{probe?.error_message ? (
<div className="probe-detail-error">
<strong></strong>
<pre className="probe-detail-pre">{probe.error_message}</pre>
</div>
) : null}
{probe ? (
<div className="probe-detail-io">
<strong></strong>
<pre className="probe-detail-io-pre">{probe.probe_input ?? "—"}</pre>
<strong></strong>
<pre className="probe-detail-io-pre probe-detail-io-out">
{probe.probe_output ?? "—"}
</pre>
</div>
) : null}
</div>
</div>
</dialog>
</article>
);
}

View File

@@ -0,0 +1,34 @@
import { useRegisterSW } from "virtual:pwa-register/react";
export function PwaUpdatePrompt() {
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegisteredSW(_swUrl, registration) {
if (registration) {
window.setInterval(() => void registration.update(), 60 * 60 * 1000);
}
},
});
if (!needRefresh) return null;
return (
<div className="pwa-update-banner" role="alert">
<p className="pwa-update-text">使</p>
<div className="pwa-update-actions">
<button type="button" className="btn ghost small" onClick={() => setNeedRefresh(false)}>
</button>
<button
type="button"
className="btn primary small"
onClick={() => void updateServiceWorker(true)}
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import "../splash.css";
const APP_NAME = "ModelPing";
export function SplashScreen() {
return (
<div className="splash-screen" role="status" aria-live="polite" aria-busy="true">
<div className="splash-bg-glow splash-bg-glow--a" aria-hidden />
<div className="splash-bg-glow splash-bg-glow--b" aria-hidden />
<div className="splash-content">
<div className="splash-logo-wrap">
<div className="splash-rings" aria-hidden>
<span className="splash-ring" />
<span className="splash-ring" />
<span className="splash-ring" />
</div>
<img
className="splash-logo"
src="/logo192.png"
alt=""
width={96}
height={96}
decoding="async"
fetchPriority="high"
/>
</div>
<h1 className="splash-title">{APP_NAME}</h1>
<p className="splash-subtitle"></p>
<div className="splash-dots" aria-hidden>
<span />
<span />
<span />
</div>
</div>
<span className="sr-only">{APP_NAME} </span>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { SplashScreen } from "../components/SplashScreen";
const MIN_SPLASH_MS = 900;
type AppBootContextValue = {
signalBootReady: () => void;
};
const AppBootContext = createContext<AppBootContextValue | null>(null);
export function AppBootProvider({ children }: { children: ReactNode }) {
const [showSplash, setShowSplash] = useState(true);
const bootReady = useRef(false);
const startedAt = useRef(Date.now());
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const signalBootReady = useCallback(() => {
if (bootReady.current) return;
bootReady.current = true;
const delay = Math.max(0, MIN_SPLASH_MS - (Date.now() - startedAt.current));
hideTimer.current = setTimeout(() => setShowSplash(false), delay);
}, []);
useEffect(
() => () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
},
[],
);
const value = useMemo(() => ({ signalBootReady }), [signalBootReady]);
return (
<AppBootContext.Provider value={value}>
{children}
{showSplash ? <SplashScreen /> : null}
</AppBootContext.Provider>
);
}
export function useAppBoot(): AppBootContextValue {
const ctx = useContext(AppBootContext);
if (!ctx) {
throw new Error("useAppBoot must be used within AppBootProvider");
}
return ctx;
}

View File

@@ -1,5 +1,11 @@
:root { :root {
font-family: system-ui, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; /* 主字体LXGW WenKai Mono等宽由 Vite 打包子集 woff2无则回退系统字体 */
--font-app: "LXGW WenKai Mono", "PingFang SC", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
--font-mono: "LXGW WenKai Mono", ui-monospace, "Cascadia Code", monospace;
/* 随机背景高斯模糊CSS blur 无百分比按「10%」诉求取 10px可按需改 */
--rand-bg-blur: 10px;
--rand-bg-scale: 1.08;
font-family: var(--font-app);
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color: #222; color: #222;
@@ -21,10 +27,7 @@
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
background: background-color: #dfe6eb;
linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(240, 253, 244, 0.85)),
radial-gradient(800px 400px at 20% 0%, rgba(46, 204, 113, 0.12), transparent),
radial-gradient(600px 360px at 90% 10%, rgba(52, 211, 153, 0.1), transparent);
} }
#root { #root {
@@ -41,11 +44,35 @@ a:hover {
} }
.app-shell { .app-shell {
position: relative;
isolation: isolate;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* https://randbg.smyhub.com — 全站随机底图,轻微缩放避免模糊露边 */
.app-rand-bg {
position: fixed;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
user-select: none;
filter: blur(var(--rand-bg-blur));
transform: scale(var(--rand-bg-scale));
transform-origin: center center;
}
@media (prefers-reduced-motion: reduce) {
.app-rand-bg {
filter: none;
transform: none;
}
}
.topbar { .topbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -93,16 +120,13 @@ a.brand:focus-visible {
} }
.brand-logo { .brand-logo {
width: 36px; width: 40px;
height: 36px; height: 40px;
border-radius: 999px; object-fit: contain;
display: grid; display: block;
place-items: center; flex-shrink: 0;
background: linear-gradient(145deg, var(--green), #58d68d); touch-action: manipulation;
color: #fff; cursor: pointer;
font-weight: 700;
font-size: 0.95rem;
box-shadow: var(--shadow);
} }
.brand-text { .brand-text {
@@ -219,7 +243,7 @@ a.brand:focus-visible {
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
gap: 1rem; gap: 1rem;
} }
@@ -256,7 +280,67 @@ a.brand:focus-visible {
.card-sub { .card-sub {
margin: 0.15rem 0 0; margin: 0.15rem 0 0;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--muted); line-height: 1.45;
}
.card-sub-stack {
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: flex-start;
}
.card-sub-row-model {
min-width: 0;
width: 100%;
}
.card-sub-col-protocol {
display: flex;
flex-direction: column;
gap: 0.08rem;
min-width: 0;
}
.card-sub-protocol-path {
color: #0f766e;
font-weight: 500;
font-size: 0.76rem;
line-height: 1.35;
word-break: break-word;
}
.card-sub-meta-after,
.card-sub-paused-after {
margin-top: 0.2rem;
font-size: 0.76rem;
line-height: 1.35;
}
.card-sub-model {
color: #1e3a5f;
font-weight: 600;
word-break: break-word;
}
.card-sub-protocol {
color: #0f766e;
font-weight: 500;
}
.card-sub-meta {
color: #64748b;
font-weight: 500;
}
.card-sub-paused {
color: #c2410c;
font-weight: 600;
}
.card-sub-sep {
color: #cbd5e1;
font-weight: 400;
} }
.card-head-right { .card-head-right {
@@ -405,7 +489,7 @@ a.brand:focus-visible {
.probe-detail-pre { .probe-detail-pre {
margin: 0; margin: 0;
font-family: ui-monospace, "Cascadia Code", monospace; font-family: var(--font-mono);
font-size: 0.78rem; font-size: 0.78rem;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
@@ -437,7 +521,7 @@ a.brand:focus-visible {
.probe-detail-io-pre { .probe-detail-io-pre {
margin: 0; margin: 0;
font-family: ui-monospace, "Cascadia Code", monospace; font-family: var(--font-mono);
font-size: 0.78rem; font-size: 0.78rem;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
@@ -456,8 +540,8 @@ a.brand:focus-visible {
.timeline { .timeline {
display: flex; display: flex;
align-items: flex-end; align-items: stretch;
gap: 2px; gap: 1px;
height: 42px; height: 42px;
padding: 4px 2px; padding: 4px 2px;
border-radius: 10px; border-radius: 10px;
@@ -467,24 +551,37 @@ a.brand:focus-visible {
} }
.timeline-empty { .timeline-empty {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--muted); color: var(--muted);
padding: 0.25rem 0.35rem; padding: 0.25rem 0.35rem;
} }
.tl-seg { .tl-seg {
flex: 1 1 2px; flex: 1 1 0;
min-width: 2px; min-width: 0;
max-width: 4px; border-radius: 1px;
border-radius: 2px;
align-self: stretch; align-self: stretch;
background: rgba(46, 204, 113, 0.35); background: #cbd5e1;
}
.tl-seg.tl-none {
background: #e2e8f0;
opacity: 0.9;
} }
.tl-seg.tl-up { .tl-seg.tl-up {
background: var(--green); background: var(--green);
} }
.tl-seg.tl-warn {
background: linear-gradient(180deg, #facc15, #eab308);
box-shadow: inset 0 0 0 1px rgba(180, 83, 9, 0.22);
}
.tl-seg.tl-down { .tl-seg.tl-down {
background: var(--red); background: var(--red);
} }
@@ -494,50 +591,129 @@ a.brand:focus-visible {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--muted);
} }
.card-mid strong { .card-mid-edge {
color: #111827; color: #94a3b8;
font-size: 0.95rem; font-size: 0.72rem;
}
.card-mid-pct {
font-size: 1.02rem;
font-weight: 750;
letter-spacing: -0.02em;
} }
.card-foot { .card-foot {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem 0.5rem; gap: 0.55rem 0.75rem;
font-size: 0.78rem; font-size: 0.78rem;
color: var(--muted);
} }
.card-foot-probe-time { .card-foot-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.12rem; gap: 0.15rem;
min-width: 0; min-width: 0;
} }
.card-foot-probe-label { .card-foot-label {
font-size: 0.72rem; font-size: 0.68rem;
color: var(--muted); font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.card-metric-value {
font-size: 0.82rem;
font-weight: 650;
line-height: 1.25;
color: #1e293b;
}
.card-metric-value-muted {
color: #94a3b8 !important;
font-weight: 500;
}
.card-accent-http-ok {
color: #15803d;
}
.card-accent-http-info {
color: #0369a1;
}
.card-accent-http-client {
color: #c2410c;
}
.card-accent-http-server {
color: #b91c1c;
}
.card-accent-latency-good {
color: #15803d;
}
.card-accent-latency-mid {
color: #b45309;
}
.card-accent-latency-slow {
color: #b91c1c;
}
.card-accent-pct-high {
color: #15803d;
}
.card-accent-pct-mid {
color: #3f6212;
}
.card-accent-pct-low {
color: #b45309;
}
.card-accent-pct-critical {
color: #b91c1c;
}
.card-foot-probe-time {
min-width: 0;
} }
.card-foot-probe-value { .card-foot-probe-value {
font-size: 0.76rem; font-size: 0.78rem;
color: #374151; font-weight: 650;
font-weight: 600; line-height: 1.35;
line-height: 1.3;
word-break: break-word; word-break: break-word;
color: #3730a3;
} }
.card-foot-wide { .card-foot-wide {
grid-column: 1 / -1; grid-column: 1 / -1;
font-size: 0.76rem;
} }
.card-foot-wide strong.card-foot-count { .card-foot-count-row {
font-weight: 700; display: flex;
color: #1f2937; flex-direction: row;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
padding-top: 0.35rem;
margin-top: 0.1rem;
border-top: 1px solid rgba(15, 23, 42, 0.06);
}
.card-foot-count-value {
font-size: 0.9rem;
font-weight: 750;
font-variant-numeric: tabular-nums;
color: #6d28d9;
} }
.banner { .banner {
@@ -577,6 +753,30 @@ a.brand:focus-visible {
font-size: 1rem; font-size: 1rem;
} }
.admin-token-backdrop {
z-index: 10002;
}
.admin-token-modal {
width: min(300px, 100%);
padding: 0.85rem 0.95rem;
}
.admin-token-title {
margin-bottom: 0.5rem;
font-size: 0.92rem;
font-weight: 600;
text-align: center;
}
.admin-token-input {
font-size: 0.9rem;
}
.admin-token-modal .modal-actions {
margin-top: 0.55rem;
}
.modal-input { .modal-input {
width: 100%; width: 100%;
padding: 0.5rem 0.55rem; padding: 0.5rem 0.55rem;
@@ -682,30 +882,56 @@ a.brand:focus-visible {
justify-content: flex-end; justify-content: flex-end;
} }
.admin-list { .admin-monitor-grid {
list-style: none; margin-top: 0.75rem;
padding: 0; }
margin: 0.5rem 0 0;
.admin-monitors-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.admin-monitors-head h2 {
margin: 0;
}
.admin-monitor-dialog {
max-width: min(720px, 96vw);
}
.admin-monitor-dialog-inner {
max-height: min(88vh, 900px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; padding-bottom: 1rem;
} }
.admin-row { .admin-form--modal {
display: flex; overflow-y: auto;
flex-wrap: wrap; padding-right: 0.15rem;
justify-content: space-between; flex: 1;
gap: 0.5rem; min-height: 0;
background: var(--card);
padding: 0.65rem 0.75rem;
border-radius: 12px;
box-shadow: var(--shadow);
} }
.admin-row-actions { .card-admin-footer {
margin-top: 0.15rem;
padding-top: 0.65rem;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.card-admin-footer-inner {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.35rem; gap: 0.4rem;
justify-content: flex-end;
align-items: center;
}
.card-admin-fallback-msg {
margin: 0;
} }
.admin-actions-bar { .admin-actions-bar {
@@ -713,6 +939,37 @@ a.brand:focus-visible {
justify-content: flex-end; justify-content: flex-end;
} }
.pwa-update-banner {
position: fixed;
left: 50%;
bottom: 1rem;
transform: translateX(-50%);
z-index: 10001;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.65rem 0.85rem;
max-width: min(520px, calc(100vw - 2rem));
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(46, 204, 113, 0.35);
border-radius: 12px;
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.18);
}
.pwa-update-text {
margin: 0;
font-size: 0.88rem;
color: #374151;
flex: 1 1 12rem;
}
.pwa-update-actions {
display: flex;
gap: 0.45rem;
flex-shrink: 0;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.topbar { .topbar {
flex-direction: column; flex-direction: column;

11
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "lxgw-wenkai-webfont/lxgwwenkaimono-regular.css";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,462 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
import {
adminCreateMonitor,
adminDeleteMonitor,
adminGetProbeSettings,
adminListMonitors,
adminPing,
adminRunMonitor,
adminSaveProbeSettings,
adminUpdateMonitor,
} from "../api";
import { MonitorCard } from "../components/MonitorCard";
import { useAppBoot } from "../context/AppBootContext";
import {
clearAdminToken,
getAdminToken,
setAdminToken,
type AdminMonitorRow,
type MonitorProtocol,
} from "../types";
const INTERVALS = [1, 5, 10, 30, 60, 360] as const;
/** 管理页与首页一致:拉取最新监控与统计数据 */
const ADMIN_REFRESH_MS = 60_000;
const EMPTY_FORM = {
display_name: "",
api_base_url: "",
api_key: "",
model: "",
protocol: "openai" as MonitorProtocol,
category: "",
enabled: true,
probe_stream: true,
show_on_dashboard: true,
};
type Row = AdminMonitorRow;
export function Admin() {
const { signalBootReady } = useAppBoot();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [hasToken, setHasToken] = useState(() => !!getAdminToken());
useEffect(() => {
const sync = () => setHasToken(!!getAdminToken());
window.addEventListener("modelping:admin-auth", sync);
return () => window.removeEventListener("modelping:admin-auth", sync);
}, []);
const [urlVerifying, setUrlVerifying] = useState(false);
const authAttempt = useRef(0);
const [rows, setRows] = useState<Row[] | null>(null);
const [globalForm, setGlobalForm] = useState({
probe_interval_minutes: 5 as (typeof INTERVALS)[number],
probe_prompts: "",
});
const [msg, setMsg] = useState<string | null>(null);
const [editing, setEditing] = useState<Row | null>(null);
const monitorFormDlgRef = useRef<HTMLDialogElement>(null);
const [form, setForm] = useState({ ...EMPTY_FORM });
const refreshAll = () => {
if (!getAdminToken()) {
setRows([]);
return;
}
void Promise.all([adminListMonitors(), adminGetProbeSettings()])
.then(([list, g]) => {
setRows(list);
setGlobalForm({
probe_interval_minutes: g.probe_interval_minutes as (typeof INTERVALS)[number],
probe_prompts: g.probe_prompts,
});
setMsg(null);
})
.catch(() => setMsg("加载失败,请重新使用带 token 的链接登录"));
};
const refreshAllRef = useRef(refreshAll);
refreshAllRef.current = refreshAll;
useEffect(() => {
if (!hasToken) return;
const tick = () => {
refreshAllRef.current();
};
const t = setInterval(tick, ADMIN_REFRESH_MS);
const onVis = () => {
if (document.visibilityState === "visible") tick();
};
document.addEventListener("visibilitychange", onVis);
return () => {
clearInterval(t);
document.removeEventListener("visibilitychange", onVis);
};
}, [hasToken]);
/** 从 /admin?token=xxx 登录:校验后写入 sessionStorage 并去掉地址栏参数 */
useEffect(() => {
const raw = searchParams.get("token");
const tokenFromUrl = typeof raw === "string" ? raw.trim() : "";
if (!tokenFromUrl) return;
const id = ++authAttempt.current;
queueMicrotask(() => {
if (authAttempt.current !== id) return;
setUrlVerifying(true);
setMsg(null);
});
void (async () => {
try {
const result = await adminPing(tokenFromUrl);
if (authAttempt.current !== id) return;
if (result.ok === true) {
setAdminToken(tokenFromUrl);
setHasToken(true);
} else if (result.ok === false) {
setMsg(null);
}
} catch {
if (authAttempt.current === id) setMsg(null);
} finally {
if (authAttempt.current === id) {
setUrlVerifying(false);
if (getAdminToken()) {
navigate("/admin", { replace: true });
} else {
navigate("/", { replace: true });
}
}
}
})();
}, [searchParams, navigate]);
useEffect(() => {
let cancelled = false;
const run = async () => {
if (!getAdminToken()) {
if (!cancelled) {
setRows([]);
}
return;
}
try {
const [list, g] = await Promise.all([adminListMonitors(), adminGetProbeSettings()]);
if (!cancelled) {
setRows(list);
setGlobalForm({
probe_interval_minutes: g.probe_interval_minutes as (typeof INTERVALS)[number],
probe_prompts: g.probe_prompts,
});
setMsg(null);
}
} catch {
if (!cancelled) {
setRows([]);
setMsg("加载失败,请重新使用带 token 的链接登录");
}
}
};
void run();
return () => {
cancelled = true;
};
}, [hasToken]);
useEffect(() => {
if (!urlVerifying && rows !== null) signalBootReady();
}, [urlVerifying, rows, signalBootReady]);
if (urlVerifying || !hasToken) {
if (urlVerifying) return null;
return <Navigate to="/" replace />;
}
const saveGlobal = async (e: FormEvent) => {
e.preventDefault();
setMsg(null);
try {
await adminSaveProbeSettings({
probe_interval_minutes: globalForm.probe_interval_minutes,
probe_prompts: globalForm.probe_prompts,
});
refreshAll();
setMsg("全局设置已保存(对所有监控生效)");
} catch (err) {
setMsg(err instanceof Error && err.message ? err.message : "全局设置保存失败");
}
};
const submitNew = async (e: FormEvent) => {
e.preventDefault();
setMsg(null);
try {
await adminCreateMonitor({
display_name: form.display_name,
api_base_url: form.api_base_url,
api_key: form.api_key,
model: form.model,
protocol: form.protocol,
category: form.category || undefined,
enabled: form.enabled,
probe_stream: form.probe_stream,
show_on_dashboard: form.show_on_dashboard,
});
setForm({ ...EMPTY_FORM });
monitorFormDlgRef.current?.close();
refreshAll();
setMsg("已创建");
} catch {
setMsg("创建失败");
}
};
const openNewMonitorModal = () => {
setEditing(null);
setForm({ ...EMPTY_FORM });
monitorFormDlgRef.current?.showModal();
};
const openEditMonitor = (r: Row) => {
setEditing(r);
setForm({
display_name: r.display_name,
api_base_url: r.api_base_url,
api_key: "",
model: r.model,
protocol: r.protocol,
category: r.category,
enabled: r.enabled !== 0,
probe_stream: r.probe_stream !== 0,
show_on_dashboard: r.show_on_dashboard !== 0,
});
monitorFormDlgRef.current?.showModal();
};
const onMonitorFormDialogClose = () => {
setEditing(null);
setForm({ ...EMPTY_FORM });
};
const saveEdit = async (e: FormEvent) => {
e.preventDefault();
if (!editing) return;
setMsg(null);
try {
const patch: Parameters<typeof adminUpdateMonitor>[1] = {
display_name: form.display_name,
api_base_url: form.api_base_url,
model: form.model,
protocol: form.protocol,
category: form.category,
enabled: form.enabled,
probe_stream: form.probe_stream,
show_on_dashboard: form.show_on_dashboard,
};
if (form.api_key.trim()) patch.api_key = form.api_key.trim();
await adminUpdateMonitor(editing.id, patch);
monitorFormDlgRef.current?.close();
refreshAll();
setMsg("已保存");
} catch (e) {
setMsg(e instanceof Error && e.message ? e.message : "保存失败");
}
};
const logout = () => {
clearAdminToken();
setHasToken(false);
setRows([]);
navigate("/admin", { replace: true });
};
return (
<div className="page admin">
<div className="admin-actions-bar">
<button type="button" className="btn ghost" onClick={logout}>
退
</button>
</div>
{msg ? <p className="banner">{msg}</p> : null}
<section className="admin-form-section">
<h2></h2>
<form className="admin-form" onSubmit={saveGlobal}>
<label>
<select
value={globalForm.probe_interval_minutes}
onChange={(e) =>
setGlobalForm((f) => ({
...f,
probe_interval_minutes: Number(e.target.value) as (typeof INTERVALS)[number],
}))
}
>
{INTERVALS.map((n) => (
<option key={n} value={n}>
{n === 60 ? "1小时" : n === 360 ? "6小时" : `${n}分钟`}
</option>
))}
</select>
</label>
<label>
<textarea
rows={4}
placeholder={"你好\nhello\nping"}
value={globalForm.probe_prompts}
onChange={(e) => setGlobalForm((f) => ({ ...f, probe_prompts: e.target.value }))}
/>
</label>
<div className="form-actions">
<button type="submit" className="btn primary">
</button>
</div>
</form>
</section>
<section className="admin-monitors-section">
<div className="admin-monitors-head">
<h2></h2>
<button type="button" className="btn primary" onClick={openNewMonitorModal}>
</button>
</div>
<div className="card-grid admin-monitor-grid">
{(rows ?? []).map((r) => {
const bar = (
<div className="card-admin-footer-inner">
<button type="button" className="btn small" onClick={() => void adminRunMonitor(r.id).then(refreshAll)}>
</button>
<button type="button" className="btn small" onClick={() => openEditMonitor(r)}>
</button>
<button
type="button"
className="btn small danger"
onClick={() => {
if (confirm("确定删除?")) void adminDeleteMonitor(r.id).then(refreshAll);
}}
>
</button>
</div>
);
return <MonitorCard key={r.id} m={r} footerExtra={bar} />;
})}
</div>
</section>
<dialog
ref={monitorFormDlgRef}
className="probe-detail-dialog admin-monitor-dialog"
onClose={onMonitorFormDialogClose}
>
<div className="probe-detail-inner admin-monitor-dialog-inner">
<header className="probe-detail-head">
<h3 className="probe-detail-title">{editing ? "编辑监控" : "新建监控"}</h3>
<button
type="button"
className="probe-detail-close"
aria-label="关闭"
onClick={() => monitorFormDlgRef.current?.close()}
>
×
</button>
</header>
<form className="admin-form admin-form--modal" onSubmit={editing ? saveEdit : submitNew}>
<label>
<input
required
value={form.display_name}
onChange={(e) => setForm((f) => ({ ...f, display_name: e.target.value }))}
/>
</label>
<label>
API https://api.openai.com/v1
<input
required
value={form.api_base_url}
onChange={(e) => setForm((f) => ({ ...f, api_base_url: e.target.value }))}
/>
</label>
<label>
API Key {editing ? "(留空则不变)" : null}
<input
required={!editing}
type="password"
autoComplete="off"
value={form.api_key}
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
/>
</label>
<label>
<input required value={form.model} onChange={(e) => setForm((f) => ({ ...f, model: e.target.value }))} />
</label>
<label>
<select
value={form.protocol}
onChange={(e) =>
setForm((f) => ({ ...f, protocol: e.target.value as MonitorProtocol }))
}
>
<option value="openai">OpenAI Chat Completions/v1/chat/completions</option>
<option value="openai_responses">OpenAI Responses/v1/responses</option>
<option value="claude">Anthropic/messages</option>
</select>
</label>
<label>
<input value={form.category} onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))} />
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={form.show_on_dashboard}
onChange={(e) => setForm((f) => ({ ...f, show_on_dashboard: e.target.checked }))}
/>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={form.probe_stream}
onChange={(e) => setForm((f) => ({ ...f, probe_stream: e.target.checked }))}
/>
使SSE JSON
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
</label>
<div className="form-actions">
<button type="button" className="btn ghost" onClick={() => monitorFormDlgRef.current?.close()}>
</button>
<button type="submit" className="btn primary">
{editing ? "保存" : "创建"}
</button>
</div>
</form>
</div>
</dialog>
</div>
);
}

View File

@@ -1,25 +1,46 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchMonitors } from "../api"; import { fetchMonitors } from "../api";
import { MonitorCard } from "../components/MonitorCard"; import { MonitorCard } from "../components/MonitorCard";
import { useAppBoot } from "../context/AppBootContext";
import type { MonitorDto } from "../types"; import type { MonitorDto } from "../types";
const REFRESH_MS = 60_000;
export function Dashboard() { export function Dashboard() {
const { signalBootReady } = useAppBoot();
const [rows, setRows] = useState<MonitorDto[] | null>(null); const [rows, setRows] = useState<MonitorDto[] | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [filterCat, setFilterCat] = useState<string>("__all__"); const [filterCat, setFilterCat] = useState<string>("__all__");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const load = () => { const load = useCallback(() => {
fetchMonitors() fetchMonitors()
.then(setRows) .then((data) => {
.catch(() => setErr("加载失败")); setRows(data);
}; setErr(null);
})
.catch(() => {
setRows([]);
setErr("加载失败");
});
}, []);
useEffect(() => { useEffect(() => {
load(); load();
const t = setInterval(load, 60_000); const t = setInterval(load, REFRESH_MS);
return () => clearInterval(t); const onVis = () => {
}, []); if (document.visibilityState === "visible") load();
};
document.addEventListener("visibilitychange", onVis);
return () => {
clearInterval(t);
document.removeEventListener("visibilitychange", onVis);
};
}, [load]);
useEffect(() => {
if (rows !== null) signalBootReady();
}, [rows, signalBootReady]);
const categories = useMemo(() => { const categories = useMemo(() => {
const s = new Set<string>(); const s = new Set<string>();
@@ -78,8 +99,6 @@ export function Dashboard() {
{err ? <p className="banner error">{err}</p> : null} {err ? <p className="banner error">{err}</p> : null}
{!rows ? <p className="muted"></p> : null}
{rows?.length === 0 ? <p className="muted"></p> : null} {rows?.length === 0 ? <p className="muted"></p> : null}
<div className="card-grid"> <div className="card-grid">

225
frontend/src/splash.css Normal file
View File

@@ -0,0 +1,225 @@
.splash-screen {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: linear-gradient(
145deg,
#dfe6eb 0%,
#e8f4ec 28%,
#d8efe3 55%,
#cfe8d8 78%,
#c5e2d2 100%
);
animation: splash-bg-pulse 5s ease-in-out infinite;
}
.splash-bg-glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
filter: blur(48px);
opacity: 0.55;
}
.splash-bg-glow--a {
width: min(72vw, 420px);
height: min(72vw, 420px);
top: 12%;
left: 50%;
transform: translateX(-50%);
background: radial-gradient(circle, rgba(46, 204, 113, 0.45) 0%, transparent 70%);
animation: splash-glow-drift 6s ease-in-out infinite;
}
.splash-bg-glow--b {
width: min(55vw, 320px);
height: min(55vw, 320px);
bottom: 8%;
right: 8%;
background: radial-gradient(circle, rgba(39, 174, 96, 0.35) 0%, transparent 72%);
animation: splash-glow-drift 7s ease-in-out infinite reverse;
}
.splash-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.65rem;
padding: 1.5rem;
text-align: center;
}
.splash-logo-wrap {
position: relative;
width: 120px;
height: 120px;
display: grid;
place-items: center;
margin-bottom: 0.35rem;
}
.splash-rings {
position: absolute;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
.splash-ring {
position: absolute;
width: 72px;
height: 72px;
border: 2px solid rgba(46, 204, 113, 0.55);
border-radius: 50%;
animation: splash-ring-expand 2.4s ease-out infinite;
}
.splash-ring:nth-child(2) {
animation-delay: 0.8s;
}
.splash-ring:nth-child(3) {
animation-delay: 1.6s;
}
.splash-logo {
position: relative;
z-index: 2;
width: 96px;
height: 96px;
object-fit: contain;
border-radius: 22px;
box-shadow:
0 12px 32px rgba(15, 23, 42, 0.18),
0 0 0 1px rgba(255, 255, 255, 0.65) inset;
animation: splash-logo-float 2.8s ease-in-out infinite;
}
.splash-title {
margin: 0;
font-size: clamp(1.65rem, 5vw, 2rem);
font-weight: 800;
letter-spacing: -0.02em;
color: #1e293b;
}
.splash-subtitle {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: #64748b;
}
.splash-dots {
display: flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
margin-top: 0.5rem;
}
.splash-dots span {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--green, #2ecc71);
animation: splash-dot-pulse 1.1s ease-in-out infinite;
}
.splash-dots span:nth-child(2) {
animation-delay: 0.18s;
}
.splash-dots span:nth-child(3) {
animation-delay: 0.36s;
}
@keyframes splash-bg-pulse {
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(1.04);
}
}
@keyframes splash-glow-drift {
0%,
100% {
transform: translateX(-50%) scale(1);
opacity: 0.5;
}
50% {
transform: translateX(-48%) scale(1.08);
opacity: 0.72;
}
}
.splash-bg-glow--b {
animation-name: splash-glow-drift-b;
}
@keyframes splash-glow-drift-b {
0%,
100% {
transform: scale(1);
opacity: 0.4;
}
50% {
transform: scale(1.1);
opacity: 0.65;
}
}
@keyframes splash-logo-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-7px);
}
}
@keyframes splash-ring-expand {
0% {
width: 72px;
height: 72px;
opacity: 0.75;
}
100% {
width: 128px;
height: 128px;
opacity: 0;
}
}
@keyframes splash-dot-pulse {
0%,
100% {
transform: scale(0.65);
opacity: 0.55;
}
50% {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.splash-screen,
.splash-bg-glow,
.splash-logo,
.splash-ring,
.splash-dots span {
animation: none !important;
}
}

View File

@@ -7,6 +7,10 @@ export type MonitorDto = {
model: string; model: string;
protocol: MonitorProtocol; protocol: MonitorProtocol;
interval_minutes: number; interval_minutes: number;
/** 1=流式0=非流式 */
probe_stream: number;
/** 1=首页展示卡片0=仅后台可见 */
show_on_dashboard: number;
enabled: number; enabled: number;
category: string; category: string;
created_at: number; created_at: number;
@@ -24,24 +28,11 @@ export type MonitorDto = {
probe_input: string | null; probe_input: string | null;
probe_output: string | null; probe_output: string | null;
} | null; } | null;
timelineDaily: Array<{ t: number; up: boolean; ratio: number }>; timelineDaily: Array<{ t: number; up: boolean; ratio: number; hasData: boolean }>;
}; };
/** 管理后台列表无统计字段interval_minutes 为全站统一值 */ /** 管理后台列表与公开面板共用数据结构(后台接口含统计字段 */
export type AdminMonitorRow = Pick< export type AdminMonitorRow = MonitorDto;
MonitorDto,
| "id"
| "display_name"
| "api_base_url"
| "model"
| "protocol"
| "interval_minutes"
| "enabled"
| "category"
| "created_at"
| "last_run_at"
| "next_run_at"
>;
/** /api/admin/probe-settings 与后台表单 */ /** /api/admin/probe-settings 与后台表单 */
export type GlobalProbeSettings = { export type GlobalProbeSettings = {

2
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ModelPing</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
-- 全局探测是否使用流式 API1=流式0=非流式)
ALTER TABLE app_settings ADD COLUMN probe_stream INTEGER NOT NULL DEFAULT 1 CHECK (probe_stream IN (0, 1));

View File

@@ -0,0 +1,3 @@
-- 每条监控单独配置是否流式1=流式0=非流式)。未应用过 0005 的数据库也可执行本迁移。
ALTER TABLE monitors ADD COLUMN probe_stream INTEGER NOT NULL DEFAULT 1 CHECK (probe_stream IN (0, 1));

View File

@@ -0,0 +1,3 @@
-- 是否在首页展示该监控卡片1=展示0=仅后台管理可见,仍参与探测)
ALTER TABLE monitors ADD COLUMN show_on_dashboard INTEGER NOT NULL DEFAULT 1 CHECK (show_on_dashboard IN (0, 1));

4630
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"deploy": "npm run build && wrangler deploy", "deploy": "npm run build && wrangler deploy --config wrangler.json",
"cf-typegen": "wrangler types", "cf-typegen": "wrangler types",
"secret:admin": "wrangler secret put ADMIN_TOKEN", "secret:admin": "wrangler secret put ADMIN_TOKEN",
"lint": "eslint .", "lint": "eslint .",
@@ -14,6 +14,7 @@
}, },
"dependencies": { "dependencies": {
"hono": "^4.12.19", "hono": "^4.12.19",
"lxgw-wenkai-webfont": "^1.7.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-router-dom": "^7.15.1" "react-router-dom": "^7.15.1"
@@ -33,6 +34,7 @@
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",
"vite": "^8.0.12", "vite": "^8.0.12",
"vite-plugin-pwa": "^1.3.0",
"wrangler": "^4.92.0" "wrangler": "^4.92.0"
} }
} }

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

View File

@@ -1,18 +0,0 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import { Admin } from "./pages/Admin";
import { Dashboard } from "./pages/Dashboard";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="admin" element={<Admin />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
);
}

View File

@@ -1,18 +0,0 @@
import { Link, Outlet } from "react-router-dom";
export function Layout() {
return (
<div className="app-shell">
<header className="topbar">
<Link to="/" className="brand" title="ModelPing">
<span className="brand-logo">M</span>
<span className="brand-text">ModelPing</span>
</Link>
</header>
<main className="main-area">
<Outlet />
</main>
</div>
);
}

View File

@@ -1,158 +0,0 @@
import { useRef } from "react";
import type { MonitorDto } from "../types";
function formatPct(v: number | null): string {
if (v == null) return "—";
return `${v.toFixed(2)}%`;
}
function protocolLabel(p: MonitorDto["protocol"]): string {
if (p === "claude") return "Anthropic/messages";
if (p === "openai_responses") return "OpenAI Responses/v1/responses";
return "OpenAI Chat Completions/v1/chat/completions";
}
/** 与详情弹窗一致风格2026年5月17日 19:42:45 */
function formatLastProbeTime(ts: number): string {
return new Date(ts * 1000).toLocaleString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
export function MonitorCard({ m }: { m: MonitorDto }) {
const dlgRef = useRef<HTMLDialogElement>(null);
const ok = m.lastProbe?.ok === 1;
const ft = m.lastProbe?.first_token_ms;
const st = m.lastProbe?.http_status;
const pct30 = m.availability30d;
const probe = m.lastProbe;
const openDetail = () => dlgRef.current?.showModal();
const closeDetail = () => dlgRef.current?.close();
return (
<article className="card">
<header className="card-head">
<div className="card-title-row">
<div>
<h2 className="card-name">{m.display_name}</h2>
<p className="card-sub">
{m.model} · {protocolLabel(m.protocol)}
{m.category ? ` · ${m.category}` : ""}
</p>
</div>
</div>
<div className="card-head-right">
<span className={`badge ${ok ? "badge-ok" : "badge-bad"}`}>
{ok ? "运行正常" : m.lastProbe ? "异常" : "尚无数据"}
</span>
<button type="button" className="btn-detail" onClick={openDetail}>
</button>
</div>
</header>
<div className="timeline" role="img" aria-label="最近30天每日可用简图">
{m.timelineDaily.length === 0 ? (
<div className="timeline-empty"></div>
) : (
m.timelineDaily.map((d) => (
<span
key={d.t}
className={`tl-seg ${d.up ? "tl-up" : "tl-down"}`}
title={`${new Date(d.t * 1000).toLocaleDateString()} · ${(d.ratio * 100).toFixed(0)}%`}
/>
))
)}
</div>
<div className="card-mid">
<span>30 </span>
<strong>{formatPct(pct30)} </strong>
<span></span>
</div>
<footer className="card-foot">
<span> {st != null ? st : "—"}</span>
<span> {ft != null ? `${ft} ms` : "—"}</span>
<span>24h {formatPct(m.availability24h)}</span>
<div className="card-foot-probe-time">
<span className="card-foot-probe-label"></span>
<span className="card-foot-probe-value">
{probe ? formatLastProbeTime(probe.ts) : "—"}
</span>
</div>
<span className="card-foot-wide">
<strong className="card-foot-count">{m.probe_count}</strong>
</span>
</footer>
<dialog ref={dlgRef} className="probe-detail-dialog">
<div className="probe-detail-inner">
<header className="probe-detail-head">
<h3 className="probe-detail-title"></h3>
<button type="button" className="probe-detail-close" aria-label="关闭" onClick={closeDetail}>
×
</button>
</header>
<div className="probe-detail-body">
<dl className="probe-detail-dl">
<dt></dt>
<dd>{m.display_name}</dd>
<dt></dt>
<dd>{protocolLabel(m.protocol)}</dd>
<dt></dt>
<dd>{m.model}</dd>
<dt>API </dt>
<dd>
<code className="probe-detail-code">{m.api_base_url}</code>
<div className="probe-detail-link-wrap">
<a href={m.api_base_url} target="_blank" rel="noreferrer">
</a>
</div>
</dd>
<dt></dt>
<dd>
{probe
? new Date(probe.ts * 1000).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "medium",
})
: "—"}
</dd>
<dt></dt>
<dd>{probe ? (probe.ok === 1 ? "成功" : "失败") : "尚无记录"}</dd>
<dt>HTTP </dt>
<dd>{probe?.http_status != null ? probe.http_status : "—"}</dd>
<dt></dt>
<dd>{probe?.first_token_ms != null ? `${probe.first_token_ms} ms` : "—"}</dd>
</dl>
{probe?.error_message ? (
<div className="probe-detail-error">
<strong></strong>
<pre className="probe-detail-pre">{probe.error_message}</pre>
</div>
) : null}
{probe ? (
<div className="probe-detail-io">
<strong></strong>
<pre className="probe-detail-io-pre">{probe.probe_input ?? "—"}</pre>
<strong></strong>
<pre className="probe-detail-io-pre probe-detail-io-out">
{probe.probe_output ?? "—"}
</pre>
</div>
) : null}
</div>
</div>
</dialog>
</article>
);
}

View File

@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -1,415 +0,0 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import {
adminCreateMonitor,
adminDeleteMonitor,
adminGetProbeSettings,
adminListMonitors,
adminPing,
adminRunMonitor,
adminSaveProbeSettings,
adminUpdateMonitor,
} from "../api";
import {
clearAdminToken,
getAdminToken,
setAdminToken,
type AdminMonitorRow,
type MonitorProtocol,
} from "../types";
const INTERVALS = [1, 5, 10, 30, 60, 360] as const;
type Row = AdminMonitorRow;
export function Admin() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [hasToken, setHasToken] = useState(() => !!getAdminToken());
const [urlVerifying, setUrlVerifying] = useState(false);
const authAttempt = useRef(0);
const [rows, setRows] = useState<Row[] | null>(null);
const [globalForm, setGlobalForm] = useState({
probe_interval_minutes: 5 as (typeof INTERVALS)[number],
probe_prompts: "",
});
const [msg, setMsg] = useState<string | null>(null);
const [editing, setEditing] = useState<Row | null>(null);
const [form, setForm] = useState({
display_name: "",
api_base_url: "",
api_key: "",
model: "",
protocol: "openai" as MonitorProtocol,
category: "",
enabled: true,
});
const refreshAll = () => {
if (!getAdminToken()) {
setRows([]);
return;
}
void Promise.all([adminListMonitors(), adminGetProbeSettings()])
.then(([list, g]) => {
setRows(list);
setGlobalForm({
probe_interval_minutes: g.probe_interval_minutes as (typeof INTERVALS)[number],
probe_prompts: g.probe_prompts,
});
setMsg(null);
})
.catch(() => setMsg("加载失败,请重新使用带 token 的链接登录"));
};
/** 从 /admin?token=xxx 登录:校验后写入 sessionStorage 并去掉地址栏参数 */
useEffect(() => {
const raw = searchParams.get("token");
const tokenFromUrl = typeof raw === "string" ? raw.trim() : "";
if (!tokenFromUrl) return;
const id = ++authAttempt.current;
queueMicrotask(() => {
if (authAttempt.current !== id) return;
setUrlVerifying(true);
setMsg(null);
});
void (async () => {
try {
const result = await adminPing(tokenFromUrl);
if (authAttempt.current !== id) return;
if (result.ok) {
setAdminToken(tokenFromUrl);
setHasToken(true);
} else {
const copy: Record<typeof result.reason, string> = {
not_configured:
"服务端未配置 ADMIN_TOKEN本地检查 .dev.vars 是否生效并已重启 dev线上请执行 npx wrangler secret put ADMIN_TOKEN。",
unauthorized:
"Token 校验失败:请确认 URL 中的 token 与 ADMIN_TOKEN 完全一致(线上 .dev.vars 不会自动同步,必须单独设 Secret。",
network:
"无法访问 /api/admin/ping请用 npm run dev 启动(需 Cloudflare Vite 插件带起 Worker或确认部署站点与 API 同域。",
};
setMsg(copy[result.reason]);
}
} catch {
if (authAttempt.current === id) {
setMsg("无法校验 token请确认已用 npm run dev 或已部署 Worker");
}
} finally {
if (authAttempt.current === id) {
setUrlVerifying(false);
navigate("/admin", { replace: true });
}
}
})();
}, [searchParams, navigate]);
useEffect(() => {
let cancelled = false;
const run = async () => {
if (!getAdminToken()) {
if (!cancelled) setRows([]);
return;
}
try {
const [list, g] = await Promise.all([adminListMonitors(), adminGetProbeSettings()]);
if (!cancelled) {
setRows(list);
setGlobalForm({
probe_interval_minutes: g.probe_interval_minutes as (typeof INTERVALS)[number],
probe_prompts: g.probe_prompts,
});
setMsg(null);
}
} catch {
if (!cancelled) setMsg("加载失败,请重新使用带 token 的链接登录");
}
};
void run();
return () => {
cancelled = true;
};
}, [hasToken]);
if (urlVerifying) {
return (
<div className="page admin">
<p className="muted"> token</p>
</div>
);
}
if (!hasToken) {
return (
<div className="page admin">
{msg ? <p className="banner error">{msg}</p> : null}
<p className="muted"> <code>token</code> </p>
<p>
<code className="admin-code-sample">/admin?token=ADMIN_TOKEN</code>
</p>
<p className="muted small">
<code>.dev.vars</code> <code>ADMIN_TOKEN</code> <code>shumengya520</code>
token token
</p>
<Link to="/"></Link>
</div>
);
}
const saveGlobal = async (e: FormEvent) => {
e.preventDefault();
setMsg(null);
try {
await adminSaveProbeSettings({
probe_interval_minutes: globalForm.probe_interval_minutes,
probe_prompts: globalForm.probe_prompts,
});
refreshAll();
setMsg("全局设置已保存(对所有监控生效)");
} catch (err) {
setMsg(err instanceof Error && err.message ? err.message : "全局设置保存失败");
}
};
const submitNew = async (e: FormEvent) => {
e.preventDefault();
setMsg(null);
try {
await adminCreateMonitor({
display_name: form.display_name,
api_base_url: form.api_base_url,
api_key: form.api_key,
model: form.model,
protocol: form.protocol,
category: form.category || undefined,
enabled: form.enabled,
});
setForm({
display_name: "",
api_base_url: "",
api_key: "",
model: "",
protocol: "openai",
category: "",
enabled: true,
});
refreshAll();
setMsg("已创建");
} catch {
setMsg("创建失败");
}
};
const startEdit = (r: Row) => {
setEditing(r);
setForm({
display_name: r.display_name,
api_base_url: r.api_base_url,
api_key: "",
model: r.model,
protocol: r.protocol,
category: r.category,
enabled: r.enabled !== 0,
});
window.scrollTo({ top: 0, behavior: "smooth" });
};
const saveEdit = async (e: FormEvent) => {
e.preventDefault();
if (!editing) return;
setMsg(null);
try {
const patch: Parameters<typeof adminUpdateMonitor>[1] = {
display_name: form.display_name,
api_base_url: form.api_base_url,
model: form.model,
protocol: form.protocol,
category: form.category,
enabled: form.enabled,
};
if (form.api_key.trim()) patch.api_key = form.api_key.trim();
await adminUpdateMonitor(editing.id, patch);
setEditing(null);
setForm({
display_name: "",
api_base_url: "",
api_key: "",
model: "",
protocol: "openai",
category: "",
enabled: true,
});
refreshAll();
setMsg("已保存");
} catch (e) {
setMsg(e instanceof Error && e.message ? e.message : "保存失败");
}
};
const logout = () => {
clearAdminToken();
setHasToken(false);
setRows([]);
navigate("/admin", { replace: true });
};
return (
<div className="page admin">
<div className="admin-actions-bar">
<button type="button" className="btn ghost" onClick={logout}>
退
</button>
</div>
{msg ? <p className="banner">{msg}</p> : null}
<section className="admin-form-section">
<h2></h2>
<form className="admin-form" onSubmit={saveGlobal}>
<label>
<select
value={globalForm.probe_interval_minutes}
onChange={(e) =>
setGlobalForm((f) => ({
...f,
probe_interval_minutes: Number(e.target.value) as (typeof INTERVALS)[number],
}))
}
>
{INTERVALS.map((n) => (
<option key={n} value={n}>
{n === 60 ? "1小时" : n === 360 ? "6小时" : `${n}分钟`}
</option>
))}
</select>
</label>
<label>
<textarea
rows={4}
placeholder={"你好\nhello\nping"}
value={globalForm.probe_prompts}
onChange={(e) => setGlobalForm((f) => ({ ...f, probe_prompts: e.target.value }))}
/>
</label>
<div className="form-actions">
<button type="submit" className="btn primary">
</button>
</div>
</form>
</section>
<section className="admin-form-section">
<h2>{editing ? "编辑监控" : "新建监控"}</h2>
<form className="admin-form" onSubmit={editing ? saveEdit : submitNew}>
<label>
<input
required
value={form.display_name}
onChange={(e) => setForm((f) => ({ ...f, display_name: e.target.value }))}
/>
</label>
<label>
API https://api.openai.com/v1
<input
required
value={form.api_base_url}
onChange={(e) => setForm((f) => ({ ...f, api_base_url: e.target.value }))}
/>
</label>
<label>
API Key {editing ? "(留空则不变)" : null}
<input
required={!editing}
type="password"
autoComplete="off"
value={form.api_key}
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
/>
</label>
<label>
<input required value={form.model} onChange={(e) => setForm((f) => ({ ...f, model: e.target.value }))} />
</label>
<label>
<select
value={form.protocol}
onChange={(e) =>
setForm((f) => ({ ...f, protocol: e.target.value as MonitorProtocol }))
}
>
<option value="openai">OpenAI Chat Completions/v1/chat/completions</option>
<option value="openai_responses">OpenAI Responses/v1/responses</option>
<option value="claude">Anthropic/messages</option>
</select>
</label>
<label>
<input value={form.category} onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))} />
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
</label>
<div className="form-actions">
{editing ? (
<button type="button" className="btn ghost" onClick={() => setEditing(null)}>
</button>
) : null}
<button type="submit" className="btn primary">
{editing ? "保存" : "创建"}
</button>
</div>
</form>
</section>
<section>
<h2></h2>
{!rows ? <p className="muted"></p> : null}
<ul className="admin-list">
{(rows ?? []).map((r) => (
<li key={r.id} className="admin-row">
<div>
<strong>{r.display_name}</strong>
<span className="muted small">
{" "}
· {r.model} · {r.protocol} · {r.interval_minutes} · {r.enabled ? "开" : "停"}
</span>
</div>
<div className="admin-row-actions">
<button type="button" className="btn small" onClick={() => void adminRunMonitor(r.id).then(refreshAll)}>
</button>
<button type="button" className="btn small" onClick={() => startEdit(r)}>
</button>
<button
type="button"
className="btn small danger"
onClick={() => {
if (confirm("确定删除?")) void adminDeleteMonitor(r.id).then(refreshAll);
}}
>
</button>
</div>
</li>
))}
</ul>
</section>
</div>
);
}

View File

@@ -21,6 +21,5 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"], "include": ["frontend/src"]
"exclude": ["src/worker"]
} }

View File

@@ -6,5 +6,5 @@
"types": ["@cloudflare/workers-types"], "types": ["@cloudflare/workers-types"],
"moduleResolution": "bundler" "moduleResolution": "bundler"
}, },
"include": ["src/worker/**/*.ts", "worker-configuration.d.ts"] "include": ["worker/**/*.ts", "worker-configuration.d.ts"]
} }

View File

@@ -1,8 +1,61 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import { cloudflare } from '@cloudflare/vite-plugin' import { cloudflare } from '@cloudflare/vite-plugin'
const projRoot = path.dirname(fileURLToPath(import.meta.url))
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), cloudflare()], root: path.resolve(projRoot, 'frontend'),
publicDir: path.resolve(projRoot, 'public'),
build: {
outDir: path.resolve(projRoot, 'dist/client'),
emptyOutDir: true,
},
plugins: [
react(),
VitePWA({
registerType: 'prompt',
includeAssets: ['favicon.ico', 'logo.png', 'logo192.png', 'logo512.png'],
manifest: {
name: 'ModelPing',
short_name: 'ModelPing',
description: '大模型 API 可用性与首字延迟监控',
theme_color: '#27ae60',
background_color: '#dfe6eb',
display: 'standalone',
orientation: 'portrait-primary',
start_url: '/',
scope: '/',
lang: 'zh-CN',
icons: [
{
src: 'logo192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'logo512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: 'logo512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
navigateFallback: '/index.html',
cleanupOutdatedCaches: true,
},
}),
cloudflare(),
],
}) })

View File

@@ -55,6 +55,10 @@ export type MonitorRow = {
api_key_nonce: ArrayBuffer; api_key_nonce: ArrayBuffer;
last_run_at: number | null; last_run_at: number | null;
next_run_at: number; next_run_at: number;
/** 1 = 流式0 = 非流式 */
probe_stream: number;
/** 1 = 首页卡片展示0 = 仅后台可见 */
show_on_dashboard: number;
}; };
/** 列表 API 在合并 interval_minutes 之前的行 */ /** 列表 API 在合并 interval_minutes 之前的行 */
@@ -69,14 +73,20 @@ export type MonitorPublic = {
created_at: number; created_at: number;
last_run_at: number | null; last_run_at: number | null;
next_run_at: number; next_run_at: number;
probe_stream: number;
show_on_dashboard: number;
}; };
export async function listMonitorsPublic(db: D1Database): Promise<MonitorPublic[]> { 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 const r = await db
.prepare( .prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category, `SELECT id, display_name, api_base_url, model, protocol, enabled, category,
created_at, last_run_at, next_run_at created_at, last_run_at, next_run_at, probe_stream, show_on_dashboard
FROM monitors ORDER BY display_name ASC` FROM monitors${where} ORDER BY display_name ASC`
) )
.all(); .all();
return (r.results ?? []) as unknown as MonitorPublic[]; return (r.results ?? []) as unknown as MonitorPublic[];
@@ -86,7 +96,8 @@ export async function listAllMonitors(db: D1Database): Promise<MonitorRow[]> {
const r = await db const r = await db
.prepare( .prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category, `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 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` FROM monitors ORDER BY display_name ASC`
) )
.all(); .all();
@@ -97,7 +108,8 @@ export async function listDueMonitors(db: D1Database, nowSec: number): Promise<M
const r = await db const r = await db
.prepare( .prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category, `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 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` FROM monitors WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC`
) )
.bind(nowSec) .bind(nowSec)
@@ -109,7 +121,8 @@ export async function getMonitor(db: D1Database, id: string): Promise<MonitorRow
const r = await db const r = await db
.prepare( .prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category, `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 created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
show_on_dashboard
FROM monitors WHERE id = ?` FROM monitors WHERE id = ?`
) )
.bind(id) .bind(id)
@@ -126,8 +139,9 @@ export async function insertMonitor(
await db await db
.prepare( .prepare(
`INSERT INTO monitors (id, display_name, api_base_url, model, protocol, enabled, category, `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) created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` show_on_dashboard)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
.bind( .bind(
row.id, row.id,
@@ -141,7 +155,9 @@ export async function insertMonitor(
ct, ct,
nn, nn,
row.last_run_at, row.last_run_at,
row.next_run_at row.next_run_at,
row.probe_stream,
row.show_on_dashboard
) )
.run(); .run();
} }
@@ -159,6 +175,8 @@ export async function updateMonitorMeta(
api_key_ciphertext: ArrayBuffer; api_key_ciphertext: ArrayBuffer;
api_key_nonce: ArrayBuffer; api_key_nonce: ArrayBuffer;
next_run_at: number; next_run_at: number;
probe_stream: number;
show_on_dashboard: number;
}> }>
): Promise<void> { ): Promise<void> {
const cur = await getMonitor(db, id); const cur = await getMonitor(db, id);
@@ -169,7 +187,8 @@ export async function updateMonitorMeta(
await db await db
.prepare( .prepare(
`UPDATE monitors SET display_name=?, api_base_url=?, model=?, protocol=?, `UPDATE monitors SET display_name=?, api_base_url=?, model=?, protocol=?,
enabled=?, category=?, api_key_ciphertext=?, api_key_nonce=?, next_run_at=? enabled=?, category=?, api_key_ciphertext=?, api_key_nonce=?, next_run_at=?, probe_stream=?,
show_on_dashboard=?
WHERE id=?` WHERE id=?`
) )
.bind( .bind(
@@ -182,6 +201,8 @@ export async function updateMonitorMeta(
ct, ct,
nn, nn,
next.next_run_at, next.next_run_at,
next.probe_stream,
next.show_on_dashboard,
id id
) )
.run(); .run();
@@ -235,11 +256,7 @@ export async function updateMonitorRunTimes(
.run(); .run();
} }
/** Per-day bucket: dayStart unix sec at UTC midnight approximation (floor to day in UTC) */ export type AvailabilityTimelineStats = {
export async function availabilityAndTimeline(
db: D1Database,
monitorId: string
): Promise<{
availability24h: number | null; availability24h: number | null;
availability30d: number | null; availability30d: number | null;
probe_count: number; probe_count: number;
@@ -253,57 +270,18 @@ export async function availabilityAndTimeline(
probe_input: string | null; probe_input: string | null;
probe_output: string | null; probe_output: string | null;
} | null; } | null;
}> { };
const now = Math.floor(Date.now() / 1000);
const start24 = now - 86400;
const start30 = now - THIRTY_DAYS_SEC;
const lastProbe = await db type LastProbeRow = NonNullable<AvailabilityTimelineStats["lastProbe"]>;
.prepare(
`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`
)
.bind(monitorId)
.first<{
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;
}>();
const stats24 = await db function finalizeAvailabilityTimeline(
.prepare( lastProbe: LastProbeRow | null | undefined,
`SELECT SUM(ok) AS okc, COUNT(*) AS total FROM probe_events WHERE monitor_id = ? AND ts >= ?` stats24: { okc: number | null; total: number | null } | null | undefined,
) stats30: { okc: number | null; total: number | null } | null | undefined,
.bind(monitorId, start24) dailyRaw: unknown[],
.first<{ okc: number | null; total: number | null }>(); countRow: { c: number | null } | null | undefined
): AvailabilityTimelineStats {
const stats30 = await db const daily = dailyRaw.map((r) => ({
.prepare(
`SELECT SUM(ok) AS okc, COUNT(*) AS total FROM probe_events WHERE monitor_id = ? AND ts >= ?`
)
.bind(monitorId, start30)
.first<{ okc: number | null; total: number | null }>();
const rows = await db
.prepare(
`SELECT (CAST(ts / 86400 AS INTEGER) * 86400) 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`
)
.bind(monitorId, start30)
.all();
const countRow = await db
.prepare(`SELECT COUNT(*) AS c FROM probe_events WHERE monitor_id = ?`)
.bind(monitorId)
.first<{ c: number | null }>();
const daily = (rows.results ?? []).map((r) => ({
dayStart: Number((r as { day_start: number }).day_start), dayStart: Number((r as { day_start: number }).day_start),
ok: Number((r as { okc: number | null }).okc ?? 0), ok: Number((r as { okc: number | null }).okc ?? 0),
total: Number((r as { cnt: number | null }).cnt ?? 0), total: Number((r as { cnt: number | null }).cnt ?? 0),
@@ -322,3 +300,71 @@ export async function availabilityAndTimeline(
lastProbe: lastProbe ?? null, 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);
}

View File

@@ -1,5 +1,13 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { availabilityAndTimeline, getAppSettings, getMonitor, listMonitorsPublic, updateAppSettings } from "./db"; import {
availabilityAndTimeline,
availabilityAndTimelineBatch,
getAppSettings,
getMonitor,
listMonitorsPublic,
normMonitorId,
updateAppSettings,
} from "./db";
import { import {
createMonitorFromPayload, createMonitorFromPayload,
deleteMonitor, deleteMonitor,
@@ -16,10 +24,32 @@ type MonitorCreateBody = {
protocol: "openai" | "openai_responses" | "claude"; protocol: "openai" | "openai_responses" | "claude";
category?: string; category?: string;
enabled?: boolean; 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 }>(); 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]); const ALLOWED = new Set([1, 5, 10, 30, 60, 360]);
function checkAdmin(c: { req: { header: (k: string) => string | undefined }; env: Env }): Response | null { function checkAdmin(c: { req: { header: (k: string) => string | undefined }; env: Env }): Response | null {
@@ -65,6 +95,8 @@ async function parseMonitorCreate(c: { req: { json: () => Promise<unknown> } }):
} }
const category = typeof o.category === "string" ? o.category : undefined; const category = typeof o.category === "string" ? o.category : undefined;
const enabled = typeof o.enabled === "boolean" ? o.enabled : 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 { return {
display_name, display_name,
api_base_url, api_base_url,
@@ -73,54 +105,89 @@ async function parseMonitorCreate(c: { req: { json: () => Promise<unknown> } }):
protocol, protocol,
category, category,
enabled, enabled,
probe_stream,
show_on_dashboard,
}; };
} }
async function withIntervalForApi( type MonitorRowWithInterval = Awaited<ReturnType<typeof listMonitorsPublic>>[number] & {
db: D1Database, interval_minutes: number;
rows: Awaited<ReturnType<typeof listMonitorsPublic>> };
): Promise<Array<(typeof rows)[number] & { interval_minutes: number }>> {
const s = await getAppSettings(db); const TIMELINE_SHANGHAI_OFF = 8 * 3600;
const iv = s.probe_interval_minutes; const TIMELINE_HALF_DAY_SEC = 12 * 60 * 60;
return rows.map((m) => ({ ...m, interval_minutes: iv })); 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;
} }
app.get("/api/monitors", async (c) => { function timelineDailyFromBuckets(daily: Array<{ dayStart: number; ok: number; total: number }>) {
const db = c.env.DB; const bySlot = new Map(daily.map((d) => [d.dayStart, d]));
const rows = await listMonitorsPublic(db); const current = currentTimelineSlotStart(Math.floor(Date.now() / 1000));
const merged = await withIntervalForApi(db, rows); const first = current - (TIMELINE_SLOTS - 1) * TIMELINE_HALF_DAY_SEC;
const out = [];
for (const m of merged) { return Array.from({ length: TIMELINE_SLOTS }, (_, i) => {
const stats = await availabilityAndTimeline(db, m.id); const t = first + i * TIMELINE_HALF_DAY_SEC;
const timelineDaily = stats.daily.map((d) => ({ const d = bySlot.get(t);
t: d.dayStart, const total = d?.total ?? 0;
up: d.total > 0 && d.ok === d.total, const ratio = total > 0 ? (d?.ok ?? 0) / total : 0;
ratio: d.total > 0 ? d.ok / d.total : 0, return {
})); t,
out.push({ 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, ...m,
availability24h: stats.availability24h, availability24h: stats.availability24h,
availability30d: stats.availability30d, availability30d: stats.availability30d,
probe_count: stats.probe_count, probe_count: stats.probe_count,
lastProbe: stats.lastProbe, lastProbe: stats.lastProbe,
timelineDaily, timelineDaily,
}); };
} });
return c.json(out); }
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) => { app.get("/api/monitors/:id", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
const db = c.env.DB; const db = c.env.DB;
const m = await getMonitor(db, id); const [m, s] = await Promise.all([getMonitor(db, id), getAppSettings(db)]);
if (!m) return c.json({ error: "not_found" }, 404); if (!m) return c.json({ error: "not_found" }, 404);
const s = await getAppSettings(db); if (m.show_on_dashboard === 0) return c.json({ error: "not_found" }, 404);
const stats = await availabilityAndTimeline(db, id); const stats = await availabilityAndTimeline(db, id);
const timelineDaily = stats.daily.map((d) => ({ const timelineDaily = timelineDailyFromBuckets(stats.daily);
t: d.dayStart,
up: d.total > 0 && d.ok === d.total,
ratio: d.total > 0 ? d.ok / d.total : 0,
}));
return c.json({ return c.json({
id: m.id, id: m.id,
display_name: m.display_name, display_name: m.display_name,
@@ -128,6 +195,8 @@ app.get("/api/monitors/:id", async (c) => {
model: m.model, model: m.model,
protocol: m.protocol, protocol: m.protocol,
interval_minutes: s.probe_interval_minutes, interval_minutes: s.probe_interval_minutes,
probe_stream: m.probe_stream,
show_on_dashboard: m.show_on_dashboard,
enabled: m.enabled, enabled: m.enabled,
category: m.category, category: m.category,
created_at: m.created_at, created_at: m.created_at,
@@ -188,8 +257,12 @@ app.get("/api/admin/monitors", async (c) => {
const err = checkAdmin(c); const err = checkAdmin(c);
if (err) return err; if (err) return err;
const db = c.env.DB; const db = c.env.DB;
const rows = await listMonitorsPublic(db); const [rows, s] = await Promise.all([listMonitorsPublic(db, "all"), getAppSettings(db)]);
return c.json(await withIntervalForApi(db, rows)); 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) => { app.post("/api/admin/monitors", async (c) => {
@@ -220,6 +293,11 @@ app.put("/api/admin/monitors/:id", async (c) => {
patch.protocol = body.protocol; patch.protocol = body.protocol;
if (typeof body.category === "string") patch.category = body.category; if (typeof body.category === "string") patch.category = body.category;
if (typeof body.enabled === "boolean") patch.enabled = body.enabled; 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 { try {
const ok = await updateMonitorFromPayload(c.env, c.env.DB, id, patch); const ok = await updateMonitorFromPayload(c.env, c.env.DB, id, patch);
if (!ok) return c.json({ error: "not_found" }, 404); if (!ok) return c.json({ error: "not_found" }, 404);

View File

@@ -68,6 +68,33 @@ function claudeUrl(base: string): string {
return `${b}/v1/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( async function readStreamOpenAIChat(
res: Response, res: Response,
started: number started: number
@@ -96,12 +123,13 @@ async function readStreamOpenAIChat(
} }
try { try {
const obj = JSON.parse(payload) as { const obj = JSON.parse(payload) as {
choices?: Array<{ delta?: { content?: string } }>; choices?: Array<{ delta?: unknown }>;
}; };
const c = obj.choices?.[0]?.delta?.content; const delta = obj.choices?.[0]?.delta;
if (c != null && c !== "") { const chunk = streamDeltaTextFromOpenAiChatChoice(delta);
if (chunk !== "") {
if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started); if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started);
outputText = appendCap(outputText, c, STREAM_OUTPUT_CAP); outputText = appendCap(outputText, chunk, STREAM_OUTPUT_CAP);
} }
} catch { } catch {
/* ignore bad json line */ /* ignore bad json line */
@@ -155,6 +183,101 @@ async function readStreamOpenAIResponses(
return { firstTokenMs, httpStatus: res.status, outputText }; 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 */ /** 首字可来自正文或 thinking输出只累计 assistant 可见正文 delta.text */
function claudeStreamDeltaParts( function claudeStreamDeltaParts(
data: string, data: string,
@@ -243,8 +366,11 @@ export async function runProbe(params: {
model: string; model: string;
protocol: Protocol; protocol: Protocol;
userMessage: string; userMessage: string;
/** 默认 true流式false 时使用非流式 JSON 响应 */
stream?: boolean;
}): Promise<ProbeResult> { }): Promise<ProbeResult> {
const userMessage = params.userMessage.trim() || "ping"; const userMessage = params.userMessage.trim() || "ping";
const useStream = params.stream !== false;
const ac = new AbortController(); const ac = new AbortController();
const t = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS); const t = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
const started = Date.now(); const started = Date.now();
@@ -262,7 +388,7 @@ export async function runProbe(params: {
model: params.model, model: params.model,
messages: [{ role: "user", content: userMessage }], messages: [{ role: "user", content: userMessage }],
max_tokens: PROBE_MAX_TOKENS, max_tokens: PROBE_MAX_TOKENS,
stream: true, stream: useStream,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -276,13 +402,38 @@ export async function runProbe(params: {
responseText: null, responseText: null,
}; };
} }
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIChat(res, started); 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; const ok = firstTokenMs != null;
return { return {
ok, ok,
firstTokenMs, firstTokenMs,
httpStatus, httpStatus: res.status,
errorMessage: ok ? null : "no_stream_token", errorMessage: ok ? null : "no_response_content",
requestMessage: userMessage, requestMessage: userMessage,
responseText: outputText === "" ? null : outputText, responseText: outputText === "" ? null : outputText,
}; };
@@ -299,7 +450,7 @@ export async function runProbe(params: {
body: JSON.stringify({ body: JSON.stringify({
model: params.model, model: params.model,
input: userMessage, input: userMessage,
stream: true, stream: useStream,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -313,13 +464,38 @@ export async function runProbe(params: {
responseText: null, responseText: null,
}; };
} }
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIResponses(res, started); 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; const ok = firstTokenMs != null;
return { return {
ok, ok,
firstTokenMs, firstTokenMs,
httpStatus, httpStatus: res.status,
errorMessage: ok ? null : "no_stream_token", errorMessage: ok ? null : "no_response_content",
requestMessage: userMessage, requestMessage: userMessage,
responseText: outputText === "" ? null : outputText, responseText: outputText === "" ? null : outputText,
}; };
@@ -337,7 +513,7 @@ export async function runProbe(params: {
model: params.model, model: params.model,
max_tokens: PROBE_MAX_TOKENS, max_tokens: PROBE_MAX_TOKENS,
messages: [{ role: "user", content: userMessage }], messages: [{ role: "user", content: userMessage }],
stream: true, stream: useStream,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -351,13 +527,38 @@ export async function runProbe(params: {
responseText: null, responseText: null,
}; };
} }
const { firstTokenMs, httpStatus, outputText } = await readStreamClaude(res, started); 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; const ok = firstTokenMs != null;
return { return {
ok, ok,
firstTokenMs, firstTokenMs,
httpStatus, httpStatus: res.status,
errorMessage: ok ? null : "no_stream_token", errorMessage: ok ? null : "no_response_content",
requestMessage: userMessage, requestMessage: userMessage,
responseText: outputText === "" ? null : outputText, responseText: outputText === "" ? null : outputText,
}; };

View File

@@ -119,6 +119,7 @@ export async function runSingleProbe(
model: m.model, model: m.model,
protocol: m.protocol, protocol: m.protocol,
userMessage: userMsg, userMessage: userMsg,
stream: m.probe_stream !== 0,
}); });
try { try {
await insertProbeEvent(db, { await insertProbeEvent(db, {
@@ -156,6 +157,10 @@ export async function createMonitorFromPayload(
protocol: "openai" | "openai_responses" | "claude"; protocol: "openai" | "openai_responses" | "claude";
category?: string; category?: string;
enabled?: boolean; enabled?: boolean;
/** 未传时默认流式 */
probe_stream?: boolean;
/** 未传时默认在首页展示 */
show_on_dashboard?: boolean;
} }
): Promise<{ id: string }> { ): Promise<{ id: string }> {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
@@ -175,6 +180,8 @@ export async function createMonitorFromPayload(
api_key_nonce: nonceCopy.buffer.slice(nonceCopy.byteOffset, nonceCopy.byteOffset + nonceCopy.byteLength), api_key_nonce: nonceCopy.buffer.slice(nonceCopy.byteOffset, nonceCopy.byteOffset + nonceCopy.byteLength),
last_run_at: null, last_run_at: null,
next_run_at: nowSec, next_run_at: nowSec,
probe_stream: body.probe_stream === false ? 0 : 1,
show_on_dashboard: body.show_on_dashboard === false ? 0 : 1,
}); });
return { id }; return { id };
} }
@@ -191,6 +198,8 @@ export async function updateMonitorFromPayload(
protocol: "openai" | "openai_responses" | "claude"; protocol: "openai" | "openai_responses" | "claude";
category: string; category: string;
enabled: boolean; enabled: boolean;
probe_stream: boolean;
show_on_dashboard: boolean;
}> }>
): Promise<boolean> { ): Promise<boolean> {
const cur = await getMonitor(db, id); const cur = await getMonitor(db, id);
@@ -214,6 +223,12 @@ export async function updateMonitorFromPayload(
api_key_ciphertext: ciphertext, api_key_ciphertext: ciphertext,
api_key_nonce: nonceBuf, api_key_nonce: nonceBuf,
next_run_at: nowSec, 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; return true;
} }

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "node_modules/wrangler/config-schema.json", "$schema": "node_modules/wrangler/config-schema.json",
"name": "modelping", "name": "modelping",
"main": "./src/worker/index.ts", "main": "./worker/index.ts",
"compatibility_date": "2025-10-08", "compatibility_date": "2025-10-08",
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat" "nodejs_compat"

View File

@@ -0,0 +1,15 @@
你好啊
I love you
你是什么模型
测试一下
还在吗?
how are you
hi
hello
(。・ω・。)/🩷
(。・ω・。)?
(=^・ω・^=) 喵~
(=・ω・=)
ヘ(=^・ω・^=)ノ
|・ω・`)
你喜欢人类吗,只能回复喜欢和不喜欢