chore: sync local changes to Gitea
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
frontend/dist
|
||||
.wrangler/
|
||||
*.local
|
||||
.dev.vars
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
在 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)。
|
||||
|
||||
@@ -8,7 +8,7 @@ import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ['frontend/**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
@@ -19,4 +19,14 @@ export default defineConfig([
|
||||
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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AdminMonitorRow, GlobalProbeSettings, MonitorDto, MonitorProtocol } from "./types";
|
||||
import type { GlobalProbeSettings, MonitorDto, MonitorProtocol } from "./types";
|
||||
import { getAdminToken } from "./types";
|
||||
|
||||
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");
|
||||
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();
|
||||
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");
|
||||
return r.json();
|
||||
return r.json() as Promise<MonitorDto[]>;
|
||||
}
|
||||
|
||||
export async function adminGetProbeSettings(): Promise<GlobalProbeSettings> {
|
||||
@@ -70,6 +73,8 @@ type CreateBody = {
|
||||
protocol: MonitorProtocol;
|
||||
category?: string;
|
||||
enabled?: boolean;
|
||||
probe_stream?: boolean;
|
||||
show_on_dashboard?: boolean;
|
||||
};
|
||||
|
||||
export async function adminCreateMonitor(body: CreateBody): Promise<string> {
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
93
frontend/src/components/AdminTokenDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/BrandLogo.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
265
frontend/src/components/MonitorCard.tsx
Normal 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)}`;
|
||||
}
|
||||
|
||||
/** 时间条桶为上海时区 00–12 / 12–24(与 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 天、上海时区每格半天(0–12 点为上午,12–24 点为下午),右侧为当前${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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/PwaUpdatePrompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/SplashScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/context/AppBootContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
: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;
|
||||
font-weight: 400;
|
||||
color: #222;
|
||||
@@ -21,10 +27,7 @@
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
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);
|
||||
background-color: #dfe6eb;
|
||||
}
|
||||
|
||||
#root {
|
||||
@@ -41,11 +44,35 @@ a:hover {
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -93,16 +120,13 @@ a.brand:focus-visible {
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(145deg, var(--green), #58d68d);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
box-shadow: var(--shadow);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
@@ -219,7 +243,7 @@ a.brand:focus-visible {
|
||||
|
||||
.card-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;
|
||||
}
|
||||
|
||||
@@ -256,7 +280,67 @@ a.brand:focus-visible {
|
||||
.card-sub {
|
||||
margin: 0.15rem 0 0;
|
||||
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 {
|
||||
@@ -405,7 +489,7 @@ a.brand:focus-visible {
|
||||
|
||||
.probe-detail-pre {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, "Cascadia Code", monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -437,7 +521,7 @@ a.brand:focus-visible {
|
||||
|
||||
.probe-detail-io-pre {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, "Cascadia Code", monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -456,8 +540,8 @@ a.brand:focus-visible {
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
height: 42px;
|
||||
padding: 4px 2px;
|
||||
border-radius: 10px;
|
||||
@@ -467,24 +551,37 @@ a.brand:focus-visible {
|
||||
}
|
||||
|
||||
.timeline-empty {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
padding: 0.25rem 0.35rem;
|
||||
}
|
||||
|
||||
.tl-seg {
|
||||
flex: 1 1 2px;
|
||||
min-width: 2px;
|
||||
max-width: 4px;
|
||||
border-radius: 2px;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
border-radius: 1px;
|
||||
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 {
|
||||
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 {
|
||||
background: var(--red);
|
||||
}
|
||||
@@ -494,50 +591,129 @@ a.brand:focus-visible {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.card-mid strong {
|
||||
color: #111827;
|
||||
font-size: 0.95rem;
|
||||
.card-mid-edge {
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.card-mid-pct {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.card-foot {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.35rem 0.5rem;
|
||||
gap: 0.55rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.card-foot-probe-time {
|
||||
.card-foot-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-foot-probe-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
.card-foot-label {
|
||||
font-size: 0.68rem;
|
||||
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 {
|
||||
font-size: 0.76rem;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.card-foot-wide {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.card-foot-wide strong.card-foot-count {
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
.card-foot-count-row {
|
||||
display: flex;
|
||||
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 {
|
||||
@@ -577,6 +753,30 @@ a.brand:focus-visible {
|
||||
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 {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.55rem;
|
||||
@@ -682,30 +882,56 @@ a.brand:focus-visible {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0 0;
|
||||
.admin-monitor-grid {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
background: var(--card);
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
.admin-form--modal {
|
||||
overflow-y: auto;
|
||||
padding-right: 0.15rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
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 {
|
||||
@@ -713,6 +939,37 @@ a.brand:focus-visible {
|
||||
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) {
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
11
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
462
frontend/src/pages/Admin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,46 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { fetchMonitors } from "../api";
|
||||
import { MonitorCard } from "../components/MonitorCard";
|
||||
import { useAppBoot } from "../context/AppBootContext";
|
||||
import type { MonitorDto } from "../types";
|
||||
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
export function Dashboard() {
|
||||
const { signalBootReady } = useAppBoot();
|
||||
const [rows, setRows] = useState<MonitorDto[] | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [filterCat, setFilterCat] = useState<string>("__all__");
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const load = () => {
|
||||
const load = useCallback(() => {
|
||||
fetchMonitors()
|
||||
.then(setRows)
|
||||
.catch(() => setErr("加载失败"));
|
||||
};
|
||||
.then((data) => {
|
||||
setRows(data);
|
||||
setErr(null);
|
||||
})
|
||||
.catch(() => {
|
||||
setRows([]);
|
||||
setErr("加载失败");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 60_000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
const t = setInterval(load, REFRESH_MS);
|
||||
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 s = new Set<string>();
|
||||
@@ -78,8 +99,6 @@ export function Dashboard() {
|
||||
|
||||
{err ? <p className="banner error">{err}</p> : null}
|
||||
|
||||
{!rows ? <p className="muted">加载中…</p> : null}
|
||||
|
||||
{rows?.length === 0 ? <p className="muted">暂无监控项。请从管理入口添加。</p> : null}
|
||||
|
||||
<div className="card-grid">
|
||||
225
frontend/src/splash.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ export type MonitorDto = {
|
||||
model: string;
|
||||
protocol: MonitorProtocol;
|
||||
interval_minutes: number;
|
||||
/** 1=流式,0=非流式 */
|
||||
probe_stream: number;
|
||||
/** 1=首页展示卡片,0=仅后台可见 */
|
||||
show_on_dashboard: number;
|
||||
enabled: number;
|
||||
category: string;
|
||||
created_at: number;
|
||||
@@ -24,24 +28,11 @@ export type MonitorDto = {
|
||||
probe_input: string | null;
|
||||
probe_output: string | 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<
|
||||
MonitorDto,
|
||||
| "id"
|
||||
| "display_name"
|
||||
| "api_base_url"
|
||||
| "model"
|
||||
| "protocol"
|
||||
| "interval_minutes"
|
||||
| "enabled"
|
||||
| "category"
|
||||
| "created_at"
|
||||
| "last_run_at"
|
||||
| "next_run_at"
|
||||
>;
|
||||
/** 管理后台列表与公开面板共用数据结构(后台接口含统计字段) */
|
||||
export type AdminMonitorRow = MonitorDto;
|
||||
|
||||
/** /api/admin/probe-settings 与后台表单 */
|
||||
export type GlobalProbeSettings = {
|
||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
13
index.html
@@ -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>
|
||||
3
migrations/0005_probe_stream.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 全局探测是否使用流式 API(1=流式,0=非流式)
|
||||
|
||||
ALTER TABLE app_settings ADD COLUMN probe_stream INTEGER NOT NULL DEFAULT 1 CHECK (probe_stream IN (0, 1));
|
||||
3
migrations/0006_probe_stream_per_monitor.sql
Normal 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));
|
||||
3
migrations/0007_show_on_dashboard.sql
Normal 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
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"deploy": "npm run build && wrangler deploy",
|
||||
"deploy": "npm run build && wrangler deploy --config wrangler.json",
|
||||
"cf-typegen": "wrangler types",
|
||||
"secret:admin": "wrangler secret put ADMIN_TOKEN",
|
||||
"lint": "eslint .",
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.12.19",
|
||||
"lxgw-wenkai-webfont": "^1.7.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.1"
|
||||
@@ -33,6 +34,7 @@
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"wrangler": "^4.92.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -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
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/logo192.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/logo512.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
18
src/App.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
10
src/main.tsx
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,5 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/worker"]
|
||||
"include": ["frontend/src"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src/worker/**/*.ts", "worker-configuration.d.ts"]
|
||||
"include": ["worker/**/*.ts", "worker-configuration.d.ts"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,61 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { cloudflare } from '@cloudflare/vite-plugin'
|
||||
|
||||
const projRoot = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// https://vite.dev/config/
|
||||
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(),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -55,6 +55,10 @@ export type MonitorRow = {
|
||||
api_key_nonce: ArrayBuffer;
|
||||
last_run_at: number | null;
|
||||
next_run_at: number;
|
||||
/** 1 = 流式;0 = 非流式 */
|
||||
probe_stream: number;
|
||||
/** 1 = 首页卡片展示;0 = 仅后台可见 */
|
||||
show_on_dashboard: number;
|
||||
};
|
||||
|
||||
/** 列表 API 在合并 interval_minutes 之前的行 */
|
||||
@@ -69,14 +73,20 @@ export type MonitorPublic = {
|
||||
created_at: number;
|
||||
last_run_at: number | null;
|
||||
next_run_at: number;
|
||||
probe_stream: number;
|
||||
show_on_dashboard: number;
|
||||
};
|
||||
|
||||
export async function listMonitorsPublic(db: D1Database): 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
|
||||
.prepare(
|
||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||
created_at, last_run_at, next_run_at
|
||||
FROM monitors ORDER BY display_name ASC`
|
||||
created_at, last_run_at, next_run_at, probe_stream, show_on_dashboard
|
||||
FROM monitors${where} ORDER BY display_name ASC`
|
||||
)
|
||||
.all();
|
||||
return (r.results ?? []) as unknown as MonitorPublic[];
|
||||
@@ -86,7 +96,8 @@ export async function listAllMonitors(db: D1Database): Promise<MonitorRow[]> {
|
||||
const r = await db
|
||||
.prepare(
|
||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||
show_on_dashboard
|
||||
FROM monitors ORDER BY display_name ASC`
|
||||
)
|
||||
.all();
|
||||
@@ -97,7 +108,8 @@ export async function listDueMonitors(db: D1Database, nowSec: number): Promise<M
|
||||
const r = await db
|
||||
.prepare(
|
||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||
show_on_dashboard
|
||||
FROM monitors WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC`
|
||||
)
|
||||
.bind(nowSec)
|
||||
@@ -109,7 +121,8 @@ export async function getMonitor(db: D1Database, id: string): Promise<MonitorRow
|
||||
const r = await db
|
||||
.prepare(
|
||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||
show_on_dashboard
|
||||
FROM monitors WHERE id = ?`
|
||||
)
|
||||
.bind(id)
|
||||
@@ -126,8 +139,9 @@ export async function insertMonitor(
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO monitors (id, display_name, api_base_url, model, protocol, enabled, category,
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||
show_on_dashboard)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(
|
||||
row.id,
|
||||
@@ -141,7 +155,9 @@ export async function insertMonitor(
|
||||
ct,
|
||||
nn,
|
||||
row.last_run_at,
|
||||
row.next_run_at
|
||||
row.next_run_at,
|
||||
row.probe_stream,
|
||||
row.show_on_dashboard
|
||||
)
|
||||
.run();
|
||||
}
|
||||
@@ -159,6 +175,8 @@ export async function updateMonitorMeta(
|
||||
api_key_ciphertext: ArrayBuffer;
|
||||
api_key_nonce: ArrayBuffer;
|
||||
next_run_at: number;
|
||||
probe_stream: number;
|
||||
show_on_dashboard: number;
|
||||
}>
|
||||
): Promise<void> {
|
||||
const cur = await getMonitor(db, id);
|
||||
@@ -169,7 +187,8 @@ export async function updateMonitorMeta(
|
||||
await db
|
||||
.prepare(
|
||||
`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=?`
|
||||
)
|
||||
.bind(
|
||||
@@ -182,6 +201,8 @@ export async function updateMonitorMeta(
|
||||
ct,
|
||||
nn,
|
||||
next.next_run_at,
|
||||
next.probe_stream,
|
||||
next.show_on_dashboard,
|
||||
id
|
||||
)
|
||||
.run();
|
||||
@@ -235,11 +256,7 @@ export async function updateMonitorRunTimes(
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Per-day bucket: dayStart unix sec at UTC midnight approximation (floor to day in UTC) */
|
||||
export async function availabilityAndTimeline(
|
||||
db: D1Database,
|
||||
monitorId: string
|
||||
): Promise<{
|
||||
export type AvailabilityTimelineStats = {
|
||||
availability24h: number | null;
|
||||
availability30d: number | null;
|
||||
probe_count: number;
|
||||
@@ -253,57 +270,18 @@ export async function availabilityAndTimeline(
|
||||
probe_input: string | null;
|
||||
probe_output: string | null;
|
||||
} | null;
|
||||
}> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const start24 = now - 86400;
|
||||
const start30 = now - THIRTY_DAYS_SEC;
|
||||
};
|
||||
|
||||
const lastProbe = await db
|
||||
.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;
|
||||
}>();
|
||||
type LastProbeRow = NonNullable<AvailabilityTimelineStats["lastProbe"]>;
|
||||
|
||||
const stats24 = await db
|
||||
.prepare(
|
||||
`SELECT SUM(ok) AS okc, COUNT(*) AS total FROM probe_events WHERE monitor_id = ? AND ts >= ?`
|
||||
)
|
||||
.bind(monitorId, start24)
|
||||
.first<{ okc: number | null; total: number | null }>();
|
||||
|
||||
const stats30 = await db
|
||||
.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) => ({
|
||||
function finalizeAvailabilityTimeline(
|
||||
lastProbe: LastProbeRow | null | undefined,
|
||||
stats24: { okc: number | null; total: number | null } | null | undefined,
|
||||
stats30: { okc: number | null; total: number | null } | null | undefined,
|
||||
dailyRaw: unknown[],
|
||||
countRow: { c: number | null } | null | undefined
|
||||
): AvailabilityTimelineStats {
|
||||
const daily = dailyRaw.map((r) => ({
|
||||
dayStart: Number((r as { day_start: number }).day_start),
|
||||
ok: Number((r as { okc: number | null }).okc ?? 0),
|
||||
total: Number((r as { cnt: number | null }).cnt ?? 0),
|
||||
@@ -322,3 +300,71 @@ export async function availabilityAndTimeline(
|
||||
lastProbe: lastProbe ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 与 SQL bind / Map 查找统一,避免首尾空白或类型不一致导致 stats 对不上 */
|
||||
export function normMonitorId(id: unknown): string {
|
||||
return String(id ?? "").trim();
|
||||
}
|
||||
|
||||
/** 每个监控单独 batch(5 条语句),多监控并发;避免单次 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);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { availabilityAndTimeline, getAppSettings, getMonitor, listMonitorsPublic, updateAppSettings } from "./db";
|
||||
import {
|
||||
availabilityAndTimeline,
|
||||
availabilityAndTimelineBatch,
|
||||
getAppSettings,
|
||||
getMonitor,
|
||||
listMonitorsPublic,
|
||||
normMonitorId,
|
||||
updateAppSettings,
|
||||
} from "./db";
|
||||
import {
|
||||
createMonitorFromPayload,
|
||||
deleteMonitor,
|
||||
@@ -16,10 +24,32 @@ type MonitorCreateBody = {
|
||||
protocol: "openai" | "openai_responses" | "claude";
|
||||
category?: string;
|
||||
enabled?: boolean;
|
||||
/** 未传时默认为流式 */
|
||||
probe_stream?: boolean;
|
||||
/** 未传时默认在首页展示 */
|
||||
show_on_dashboard?: boolean;
|
||||
};
|
||||
|
||||
function parseOptionalBool01(o: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const v = o[key];
|
||||
if (typeof v === "boolean") return v;
|
||||
if (v === 0) return false;
|
||||
if (v === 1) return true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseOptionalProbeStream(o: Record<string, unknown>): boolean | undefined {
|
||||
return parseOptionalBool01(o, "probe_stream");
|
||||
}
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
/** 避免边缘/浏览器把动态 JSON 缓存成「半套字段」,尤其带 Authorization 的请求易被错误复用 */
|
||||
app.use("/api/*", async (c, next) => {
|
||||
c.header("Cache-Control", "private, no-store, max-age=0");
|
||||
await next();
|
||||
});
|
||||
|
||||
const ALLOWED = new Set([1, 5, 10, 30, 60, 360]);
|
||||
|
||||
function checkAdmin(c: { req: { header: (k: string) => string | undefined }; env: Env }): Response | null {
|
||||
@@ -65,6 +95,8 @@ async function parseMonitorCreate(c: { req: { json: () => Promise<unknown> } }):
|
||||
}
|
||||
const category = typeof o.category === "string" ? o.category : undefined;
|
||||
const enabled = typeof o.enabled === "boolean" ? o.enabled : undefined;
|
||||
const probe_stream = parseOptionalProbeStream(o);
|
||||
const show_on_dashboard = parseOptionalBool01(o, "show_on_dashboard");
|
||||
return {
|
||||
display_name,
|
||||
api_base_url,
|
||||
@@ -73,54 +105,89 @@ async function parseMonitorCreate(c: { req: { json: () => Promise<unknown> } }):
|
||||
protocol,
|
||||
category,
|
||||
enabled,
|
||||
probe_stream,
|
||||
show_on_dashboard,
|
||||
};
|
||||
}
|
||||
|
||||
async function withIntervalForApi(
|
||||
db: D1Database,
|
||||
rows: Awaited<ReturnType<typeof listMonitorsPublic>>
|
||||
): Promise<Array<(typeof rows)[number] & { interval_minutes: number }>> {
|
||||
const s = await getAppSettings(db);
|
||||
const iv = s.probe_interval_minutes;
|
||||
return rows.map((m) => ({ ...m, interval_minutes: iv }));
|
||||
type MonitorRowWithInterval = Awaited<ReturnType<typeof listMonitorsPublic>>[number] & {
|
||||
interval_minutes: number;
|
||||
};
|
||||
|
||||
const TIMELINE_SHANGHAI_OFF = 8 * 3600;
|
||||
const TIMELINE_HALF_DAY_SEC = 12 * 60 * 60;
|
||||
const TIMELINE_SLOTS = 60;
|
||||
|
||||
function currentTimelineSlotStart(nowSec: number): number {
|
||||
return Math.floor((nowSec + TIMELINE_SHANGHAI_OFF) / TIMELINE_HALF_DAY_SEC) * TIMELINE_HALF_DAY_SEC - TIMELINE_SHANGHAI_OFF;
|
||||
}
|
||||
|
||||
app.get("/api/monitors", async (c) => {
|
||||
const db = c.env.DB;
|
||||
const rows = await listMonitorsPublic(db);
|
||||
const merged = await withIntervalForApi(db, rows);
|
||||
const out = [];
|
||||
for (const m of merged) {
|
||||
const stats = await availabilityAndTimeline(db, m.id);
|
||||
const timelineDaily = stats.daily.map((d) => ({
|
||||
t: d.dayStart,
|
||||
up: d.total > 0 && d.ok === d.total,
|
||||
ratio: d.total > 0 ? d.ok / d.total : 0,
|
||||
}));
|
||||
out.push({
|
||||
function timelineDailyFromBuckets(daily: Array<{ dayStart: number; ok: number; total: number }>) {
|
||||
const bySlot = new Map(daily.map((d) => [d.dayStart, d]));
|
||||
const current = currentTimelineSlotStart(Math.floor(Date.now() / 1000));
|
||||
const first = current - (TIMELINE_SLOTS - 1) * TIMELINE_HALF_DAY_SEC;
|
||||
|
||||
return Array.from({ length: TIMELINE_SLOTS }, (_, i) => {
|
||||
const t = first + i * TIMELINE_HALF_DAY_SEC;
|
||||
const d = bySlot.get(t);
|
||||
const total = d?.total ?? 0;
|
||||
const ratio = total > 0 ? (d?.ok ?? 0) / total : 0;
|
||||
return {
|
||||
t,
|
||||
up: total > 0 && (d?.ok ?? 0) === total,
|
||||
ratio,
|
||||
hasData: total > 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function monitorsDtoList(db: D1Database, merged: MonitorRowWithInterval[]) {
|
||||
const statsMap = await availabilityAndTimelineBatch(
|
||||
db,
|
||||
merged.map((m) => m.id)
|
||||
);
|
||||
return merged.map((m) => {
|
||||
const stats = statsMap.get(normMonitorId(m.id));
|
||||
if (!stats) {
|
||||
return {
|
||||
...m,
|
||||
availability24h: null,
|
||||
availability30d: null,
|
||||
probe_count: 0,
|
||||
lastProbe: null,
|
||||
timelineDaily: [],
|
||||
};
|
||||
}
|
||||
const timelineDaily = timelineDailyFromBuckets(stats.daily);
|
||||
return {
|
||||
...m,
|
||||
availability24h: stats.availability24h,
|
||||
availability30d: stats.availability30d,
|
||||
probe_count: stats.probe_count,
|
||||
lastProbe: stats.lastProbe,
|
||||
timelineDaily,
|
||||
});
|
||||
}
|
||||
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) => {
|
||||
const id = c.req.param("id");
|
||||
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);
|
||||
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 timelineDaily = stats.daily.map((d) => ({
|
||||
t: d.dayStart,
|
||||
up: d.total > 0 && d.ok === d.total,
|
||||
ratio: d.total > 0 ? d.ok / d.total : 0,
|
||||
}));
|
||||
const timelineDaily = timelineDailyFromBuckets(stats.daily);
|
||||
return c.json({
|
||||
id: m.id,
|
||||
display_name: m.display_name,
|
||||
@@ -128,6 +195,8 @@ app.get("/api/monitors/:id", async (c) => {
|
||||
model: m.model,
|
||||
protocol: m.protocol,
|
||||
interval_minutes: s.probe_interval_minutes,
|
||||
probe_stream: m.probe_stream,
|
||||
show_on_dashboard: m.show_on_dashboard,
|
||||
enabled: m.enabled,
|
||||
category: m.category,
|
||||
created_at: m.created_at,
|
||||
@@ -188,8 +257,12 @@ app.get("/api/admin/monitors", async (c) => {
|
||||
const err = checkAdmin(c);
|
||||
if (err) return err;
|
||||
const db = c.env.DB;
|
||||
const rows = await listMonitorsPublic(db);
|
||||
return c.json(await withIntervalForApi(db, rows));
|
||||
const [rows, s] = await Promise.all([listMonitorsPublic(db, "all"), getAppSettings(db)]);
|
||||
const merged: MonitorRowWithInterval[] = rows.map((m) => ({
|
||||
...m,
|
||||
interval_minutes: s.probe_interval_minutes,
|
||||
}));
|
||||
return c.json(await monitorsDtoList(db, merged));
|
||||
});
|
||||
|
||||
app.post("/api/admin/monitors", async (c) => {
|
||||
@@ -220,6 +293,11 @@ app.put("/api/admin/monitors/:id", async (c) => {
|
||||
patch.protocol = body.protocol;
|
||||
if (typeof body.category === "string") patch.category = body.category;
|
||||
if (typeof body.enabled === "boolean") patch.enabled = body.enabled;
|
||||
if (typeof body.probe_stream === "boolean") patch.probe_stream = body.probe_stream;
|
||||
else if (body.probe_stream === 0 || body.probe_stream === 1) patch.probe_stream = body.probe_stream === 1;
|
||||
if (typeof body.show_on_dashboard === "boolean") patch.show_on_dashboard = body.show_on_dashboard;
|
||||
else if (body.show_on_dashboard === 0 || body.show_on_dashboard === 1)
|
||||
patch.show_on_dashboard = body.show_on_dashboard === 1;
|
||||
try {
|
||||
const ok = await updateMonitorFromPayload(c.env, c.env.DB, id, patch);
|
||||
if (!ok) return c.json({ error: "not_found" }, 404);
|
||||
@@ -68,6 +68,33 @@ function claudeUrl(base: string): string {
|
||||
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(
|
||||
res: Response,
|
||||
started: number
|
||||
@@ -96,12 +123,13 @@ async function readStreamOpenAIChat(
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(payload) as {
|
||||
choices?: Array<{ delta?: { content?: string } }>;
|
||||
choices?: Array<{ delta?: unknown }>;
|
||||
};
|
||||
const c = obj.choices?.[0]?.delta?.content;
|
||||
if (c != null && c !== "") {
|
||||
const delta = obj.choices?.[0]?.delta;
|
||||
const chunk = streamDeltaTextFromOpenAiChatChoice(delta);
|
||||
if (chunk !== "") {
|
||||
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 {
|
||||
/* ignore bad json line */
|
||||
@@ -155,6 +183,101 @@ async function readStreamOpenAIResponses(
|
||||
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 */
|
||||
function claudeStreamDeltaParts(
|
||||
data: string,
|
||||
@@ -243,8 +366,11 @@ export async function runProbe(params: {
|
||||
model: string;
|
||||
protocol: Protocol;
|
||||
userMessage: string;
|
||||
/** 默认 true(流式);false 时使用非流式 JSON 响应 */
|
||||
stream?: boolean;
|
||||
}): Promise<ProbeResult> {
|
||||
const userMessage = params.userMessage.trim() || "ping";
|
||||
const useStream = params.stream !== false;
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
|
||||
const started = Date.now();
|
||||
@@ -262,7 +388,7 @@ export async function runProbe(params: {
|
||||
model: params.model,
|
||||
messages: [{ role: "user", content: userMessage }],
|
||||
max_tokens: PROBE_MAX_TOKENS,
|
||||
stream: true,
|
||||
stream: useStream,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -276,13 +402,38 @@ export async function runProbe(params: {
|
||||
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;
|
||||
return {
|
||||
ok,
|
||||
firstTokenMs,
|
||||
httpStatus,
|
||||
errorMessage: ok ? null : "no_stream_token",
|
||||
httpStatus: res.status,
|
||||
errorMessage: ok ? null : "no_response_content",
|
||||
requestMessage: userMessage,
|
||||
responseText: outputText === "" ? null : outputText,
|
||||
};
|
||||
@@ -299,7 +450,7 @@ export async function runProbe(params: {
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
input: userMessage,
|
||||
stream: true,
|
||||
stream: useStream,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -313,13 +464,38 @@ export async function runProbe(params: {
|
||||
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;
|
||||
return {
|
||||
ok,
|
||||
firstTokenMs,
|
||||
httpStatus,
|
||||
errorMessage: ok ? null : "no_stream_token",
|
||||
httpStatus: res.status,
|
||||
errorMessage: ok ? null : "no_response_content",
|
||||
requestMessage: userMessage,
|
||||
responseText: outputText === "" ? null : outputText,
|
||||
};
|
||||
@@ -337,7 +513,7 @@ export async function runProbe(params: {
|
||||
model: params.model,
|
||||
max_tokens: PROBE_MAX_TOKENS,
|
||||
messages: [{ role: "user", content: userMessage }],
|
||||
stream: true,
|
||||
stream: useStream,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -351,13 +527,38 @@ export async function runProbe(params: {
|
||||
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;
|
||||
return {
|
||||
ok,
|
||||
firstTokenMs,
|
||||
httpStatus,
|
||||
errorMessage: ok ? null : "no_stream_token",
|
||||
httpStatus: res.status,
|
||||
errorMessage: ok ? null : "no_response_content",
|
||||
requestMessage: userMessage,
|
||||
responseText: outputText === "" ? null : outputText,
|
||||
};
|
||||
@@ -119,6 +119,7 @@ export async function runSingleProbe(
|
||||
model: m.model,
|
||||
protocol: m.protocol,
|
||||
userMessage: userMsg,
|
||||
stream: m.probe_stream !== 0,
|
||||
});
|
||||
try {
|
||||
await insertProbeEvent(db, {
|
||||
@@ -156,6 +157,10 @@ export async function createMonitorFromPayload(
|
||||
protocol: "openai" | "openai_responses" | "claude";
|
||||
category?: string;
|
||||
enabled?: boolean;
|
||||
/** 未传时默认流式 */
|
||||
probe_stream?: boolean;
|
||||
/** 未传时默认在首页展示 */
|
||||
show_on_dashboard?: boolean;
|
||||
}
|
||||
): Promise<{ id: string }> {
|
||||
const id = crypto.randomUUID();
|
||||
@@ -175,6 +180,8 @@ export async function createMonitorFromPayload(
|
||||
api_key_nonce: nonceCopy.buffer.slice(nonceCopy.byteOffset, nonceCopy.byteOffset + nonceCopy.byteLength),
|
||||
last_run_at: null,
|
||||
next_run_at: nowSec,
|
||||
probe_stream: body.probe_stream === false ? 0 : 1,
|
||||
show_on_dashboard: body.show_on_dashboard === false ? 0 : 1,
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
@@ -191,6 +198,8 @@ export async function updateMonitorFromPayload(
|
||||
protocol: "openai" | "openai_responses" | "claude";
|
||||
category: string;
|
||||
enabled: boolean;
|
||||
probe_stream: boolean;
|
||||
show_on_dashboard: boolean;
|
||||
}>
|
||||
): Promise<boolean> {
|
||||
const cur = await getMonitor(db, id);
|
||||
@@ -214,6 +223,12 @@ export async function updateMonitorFromPayload(
|
||||
api_key_ciphertext: ciphertext,
|
||||
api_key_nonce: nonceBuf,
|
||||
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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "modelping",
|
||||
"main": "./src/worker/index.ts",
|
||||
"main": "./worker/index.ts",
|
||||
"compatibility_date": "2025-10-08",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
|
||||