chore: sync local changes to Gitea
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
frontend/dist
|
||||||
.wrangler/
|
.wrangler/
|
||||||
*.local
|
*.local
|
||||||
.dev.vars
|
.dev.vars
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
在 Cloudflare Workers 上运行的大模型 API **流式首字延迟**与**可用性**监控面板:React + Vite 前端(Static Assets CDN)+ D1 存储(默认保留 30 天探测记录)+ 每分钟 Cron 调度。
|
在 Cloudflare Workers 上运行的大模型 API **流式首字延迟**与**可用性**监控面板:React + Vite 前端(Static Assets CDN)+ D1 存储(默认保留 30 天探测记录)+ 每分钟 Cron 调度。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
- [`frontend/`](frontend/):React 前端(入口 [`frontend/index.html`](frontend/index.html)、源码 [`frontend/src/`](frontend/src/));根目录 [`vite.config.ts`](vite.config.ts) 将 `root` 指向此处,[`public/`](public/) 仍为静态资源目录。
|
||||||
|
- [`worker/`](worker/):Cloudflare Worker — Hono API、D1、加密与定时探测调度;入口见 [`wrangler.json`](wrangler.json) 的 `main` 字段。
|
||||||
|
|
||||||
## 功能概要
|
## 功能概要
|
||||||
|
|
||||||
- 管理端配置:API 根地址、API Key、模型名、协议(**OpenAI Chat Completions 经典** / **OpenAI Responses 新版** / Claude Anthropic)。
|
- 管理端配置:API 根地址、API Key、模型名、协议(**OpenAI Chat Completions 经典** / **OpenAI Responses 新版** / Claude Anthropic)。
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { defineConfig, globalIgnores } from 'eslint/config'
|
|||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['frontend/**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
@@ -19,4 +19,14 @@ export default defineConfig([
|
|||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['worker/**/*.ts'],
|
||||||
|
extends: [js.configs.recommended, tseslint.configs.recommended],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.worker,
|
||||||
|
Env: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
21
frontend/index.html
Normal file
@@ -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";
|
import { getAdminToken } from "./types";
|
||||||
|
|
||||||
export async function fetchMonitors(): Promise<MonitorDto[]> {
|
export async function fetchMonitors(): Promise<MonitorDto[]> {
|
||||||
const r = await fetch("/api/monitors");
|
const r = await fetch("/api/monitors", { cache: "no-store" });
|
||||||
if (!r.ok) throw new Error("failed_to_load");
|
if (!r.ok) throw new Error("failed_to_load");
|
||||||
return r.json() as Promise<MonitorDto[]>;
|
return r.json() as Promise<MonitorDto[]>;
|
||||||
}
|
}
|
||||||
@@ -24,12 +24,15 @@ export async function adminPing(token: string): Promise<AdminPingResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adminListMonitors(): Promise<AdminMonitorRow[]> {
|
export async function adminListMonitors(): Promise<MonitorDto[]> {
|
||||||
const t = getAdminToken();
|
const t = getAdminToken();
|
||||||
if (!t) throw new Error("no_token");
|
if (!t) throw new Error("no_token");
|
||||||
const r = await fetch("/api/admin/monitors", { headers: { Authorization: `Bearer ${t}` } });
|
const r = await fetch("/api/admin/monitors", {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Authorization: `Bearer ${t}` },
|
||||||
|
});
|
||||||
if (!r.ok) throw new Error("admin_list_failed");
|
if (!r.ok) throw new Error("admin_list_failed");
|
||||||
return r.json();
|
return r.json() as Promise<MonitorDto[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adminGetProbeSettings(): Promise<GlobalProbeSettings> {
|
export async function adminGetProbeSettings(): Promise<GlobalProbeSettings> {
|
||||||
@@ -70,6 +73,8 @@ type CreateBody = {
|
|||||||
protocol: MonitorProtocol;
|
protocol: MonitorProtocol;
|
||||||
category?: string;
|
category?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
probe_stream?: boolean;
|
||||||
|
show_on_dashboard?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function adminCreateMonitor(body: CreateBody): Promise<string> {
|
export async function adminCreateMonitor(body: CreateBody): Promise<string> {
|
||||||
|
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 {
|
:root {
|
||||||
font-family: system-ui, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
/* 主字体:LXGW WenKai Mono(等宽,由 Vite 打包子集 woff2);无则回退系统字体 */
|
||||||
|
--font-app: "LXGW WenKai Mono", "PingFang SC", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
|
||||||
|
--font-mono: "LXGW WenKai Mono", ui-monospace, "Cascadia Code", monospace;
|
||||||
|
/* 随机背景高斯模糊(CSS blur 无百分比,按「10%」诉求取 10px,可按需改) */
|
||||||
|
--rand-bg-blur: 10px;
|
||||||
|
--rand-bg-scale: 1.08;
|
||||||
|
font-family: var(--font-app);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #222;
|
color: #222;
|
||||||
@@ -21,10 +27,7 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background:
|
background-color: #dfe6eb;
|
||||||
linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(240, 253, 244, 0.85)),
|
|
||||||
radial-gradient(800px 400px at 20% 0%, rgba(46, 204, 113, 0.12), transparent),
|
|
||||||
radial-gradient(600px 360px at 90% 10%, rgba(52, 211, 153, 0.1), transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
@@ -41,11 +44,35 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* https://randbg.smyhub.com — 全站随机底图,轻微缩放避免模糊露边 */
|
||||||
|
.app-rand-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
filter: blur(var(--rand-bg-blur));
|
||||||
|
transform: scale(var(--rand-bg-scale));
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-rand-bg {
|
||||||
|
filter: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -93,16 +120,13 @@ a.brand:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
width: 36px;
|
width: 40px;
|
||||||
height: 36px;
|
height: 40px;
|
||||||
border-radius: 999px;
|
object-fit: contain;
|
||||||
display: grid;
|
display: block;
|
||||||
place-items: center;
|
flex-shrink: 0;
|
||||||
background: linear-gradient(145deg, var(--green), #58d68d);
|
touch-action: manipulation;
|
||||||
color: #fff;
|
cursor: pointer;
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-text {
|
.brand-text {
|
||||||
@@ -219,7 +243,7 @@ a.brand:focus-visible {
|
|||||||
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +280,67 @@ a.brand:focus-visible {
|
|||||||
.card-sub {
|
.card-sub {
|
||||||
margin: 0.15rem 0 0;
|
margin: 0.15rem 0 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--muted);
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub-row-model {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub-col-protocol {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.08rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub-protocol-path {
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub-meta-after,
|
||||||
|
.card-sub-paused-after {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub-model {
|
||||||
|
color: #1e3a5f;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub-protocol {
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub-meta {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub-paused {
|
||||||
|
color: #c2410c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-sub-sep {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-head-right {
|
.card-head-right {
|
||||||
@@ -405,7 +489,7 @@ a.brand:focus-visible {
|
|||||||
|
|
||||||
.probe-detail-pre {
|
.probe-detail-pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: ui-monospace, "Cascadia Code", monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -437,7 +521,7 @@ a.brand:focus-visible {
|
|||||||
|
|
||||||
.probe-detail-io-pre {
|
.probe-detail-io-pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: ui-monospace, "Cascadia Code", monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -456,8 +540,8 @@ a.brand:focus-visible {
|
|||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: stretch;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
padding: 4px 2px;
|
padding: 4px 2px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -467,24 +551,37 @@ a.brand:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timeline-empty {
|
.timeline-empty {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 0.25rem 0.35rem;
|
padding: 0.25rem 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tl-seg {
|
.tl-seg {
|
||||||
flex: 1 1 2px;
|
flex: 1 1 0;
|
||||||
min-width: 2px;
|
min-width: 0;
|
||||||
max-width: 4px;
|
border-radius: 1px;
|
||||||
border-radius: 2px;
|
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
background: rgba(46, 204, 113, 0.35);
|
background: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-seg.tl-none {
|
||||||
|
background: #e2e8f0;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tl-seg.tl-up {
|
.tl-seg.tl-up {
|
||||||
background: var(--green);
|
background: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tl-seg.tl-warn {
|
||||||
|
background: linear-gradient(180deg, #facc15, #eab308);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(180, 83, 9, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
.tl-seg.tl-down {
|
.tl-seg.tl-down {
|
||||||
background: var(--red);
|
background: var(--red);
|
||||||
}
|
}
|
||||||
@@ -494,50 +591,129 @@ a.brand:focus-visible {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-mid strong {
|
.card-mid-edge {
|
||||||
color: #111827;
|
color: #94a3b8;
|
||||||
font-size: 0.95rem;
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-mid-pct {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-foot {
|
.card-foot {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.35rem 0.5rem;
|
gap: 0.55rem 0.75rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-foot-probe-time {
|
.card-foot-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.12rem;
|
gap: 0.15rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-foot-probe-label {
|
.card-foot-label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.68rem;
|
||||||
color: var(--muted);
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-metric-value {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-metric-value-muted {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-http-ok {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-http-info {
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-http-client {
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-http-server {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-latency-good {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-latency-mid {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-latency-slow {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-pct-high {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-pct-mid {
|
||||||
|
color: #3f6212;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-pct-low {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-accent-pct-critical {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-foot-probe-time {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-foot-probe-value {
|
.card-foot-probe-value {
|
||||||
font-size: 0.76rem;
|
font-size: 0.78rem;
|
||||||
color: #374151;
|
font-weight: 650;
|
||||||
font-weight: 600;
|
line-height: 1.35;
|
||||||
line-height: 1.3;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
color: #3730a3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-foot-wide {
|
.card-foot-wide {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
font-size: 0.76rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-foot-wide strong.card-foot-count {
|
.card-foot-count-row {
|
||||||
font-weight: 700;
|
display: flex;
|
||||||
color: #1f2937;
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
border-top: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-foot-count-value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 750;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #6d28d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner {
|
.banner {
|
||||||
@@ -577,6 +753,30 @@ a.brand:focus-visible {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-token-backdrop {
|
||||||
|
z-index: 10002;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-token-modal {
|
||||||
|
width: min(300px, 100%);
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-token-title {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-token-input {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-token-modal .modal-actions {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-input {
|
.modal-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.55rem;
|
padding: 0.5rem 0.55rem;
|
||||||
@@ -682,30 +882,56 @@ a.brand:focus-visible {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-list {
|
.admin-monitor-grid {
|
||||||
list-style: none;
|
margin-top: 0.75rem;
|
||||||
padding: 0;
|
}
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
|
.admin-monitors-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitors-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor-dialog {
|
||||||
|
max-width: min(720px, 96vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor-dialog-inner {
|
||||||
|
max-height: min(88vh, 900px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-row {
|
.admin-form--modal {
|
||||||
display: flex;
|
overflow-y: auto;
|
||||||
flex-wrap: wrap;
|
padding-right: 0.15rem;
|
||||||
justify-content: space-between;
|
flex: 1;
|
||||||
gap: 0.5rem;
|
min-height: 0;
|
||||||
background: var(--card);
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-row-actions {
|
.card-admin-footer {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
padding-top: 0.65rem;
|
||||||
|
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-admin-footer-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem;
|
gap: 0.4rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-admin-fallback-msg {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-actions-bar {
|
.admin-actions-bar {
|
||||||
@@ -713,6 +939,37 @@ a.brand:focus-visible {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pwa-update-banner {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 1rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10001;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem 0.85rem;
|
||||||
|
max-width: min(520px, calc(100vw - 2rem));
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border: 1px solid rgba(46, 204, 113, 0.35);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #374151;
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-update-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.topbar {
|
.topbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
11
frontend/src/main.tsx
Normal file
@@ -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 { fetchMonitors } from "../api";
|
||||||
import { MonitorCard } from "../components/MonitorCard";
|
import { MonitorCard } from "../components/MonitorCard";
|
||||||
|
import { useAppBoot } from "../context/AppBootContext";
|
||||||
import type { MonitorDto } from "../types";
|
import type { MonitorDto } from "../types";
|
||||||
|
|
||||||
|
const REFRESH_MS = 60_000;
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
|
const { signalBootReady } = useAppBoot();
|
||||||
const [rows, setRows] = useState<MonitorDto[] | null>(null);
|
const [rows, setRows] = useState<MonitorDto[] | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [filterCat, setFilterCat] = useState<string>("__all__");
|
const [filterCat, setFilterCat] = useState<string>("__all__");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
const load = () => {
|
const load = useCallback(() => {
|
||||||
fetchMonitors()
|
fetchMonitors()
|
||||||
.then(setRows)
|
.then((data) => {
|
||||||
.catch(() => setErr("加载失败"));
|
setRows(data);
|
||||||
};
|
setErr(null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setRows([]);
|
||||||
|
setErr("加载失败");
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
const t = setInterval(load, 60_000);
|
const t = setInterval(load, REFRESH_MS);
|
||||||
return () => clearInterval(t);
|
const onVis = () => {
|
||||||
}, []);
|
if (document.visibilityState === "visible") load();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVis);
|
||||||
|
return () => {
|
||||||
|
clearInterval(t);
|
||||||
|
document.removeEventListener("visibilitychange", onVis);
|
||||||
|
};
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rows !== null) signalBootReady();
|
||||||
|
}, [rows, signalBootReady]);
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const s = new Set<string>();
|
const s = new Set<string>();
|
||||||
@@ -78,8 +99,6 @@ export function Dashboard() {
|
|||||||
|
|
||||||
{err ? <p className="banner error">{err}</p> : null}
|
{err ? <p className="banner error">{err}</p> : null}
|
||||||
|
|
||||||
{!rows ? <p className="muted">加载中…</p> : null}
|
|
||||||
|
|
||||||
{rows?.length === 0 ? <p className="muted">暂无监控项。请从管理入口添加。</p> : null}
|
{rows?.length === 0 ? <p className="muted">暂无监控项。请从管理入口添加。</p> : null}
|
||||||
|
|
||||||
<div className="card-grid">
|
<div className="card-grid">
|
||||||
225
frontend/src/splash.css
Normal file
@@ -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;
|
model: string;
|
||||||
protocol: MonitorProtocol;
|
protocol: MonitorProtocol;
|
||||||
interval_minutes: number;
|
interval_minutes: number;
|
||||||
|
/** 1=流式,0=非流式 */
|
||||||
|
probe_stream: number;
|
||||||
|
/** 1=首页展示卡片,0=仅后台可见 */
|
||||||
|
show_on_dashboard: number;
|
||||||
enabled: number;
|
enabled: number;
|
||||||
category: string;
|
category: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
@@ -24,24 +28,11 @@ export type MonitorDto = {
|
|||||||
probe_input: string | null;
|
probe_input: string | null;
|
||||||
probe_output: string | null;
|
probe_output: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
timelineDaily: Array<{ t: number; up: boolean; ratio: number }>;
|
timelineDaily: Array<{ t: number; up: boolean; ratio: number; hasData: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 管理后台列表(无统计字段;interval_minutes 为全站统一值) */
|
/** 管理后台列表与公开面板共用数据结构(后台接口含统计字段) */
|
||||||
export type AdminMonitorRow = Pick<
|
export type AdminMonitorRow = MonitorDto;
|
||||||
MonitorDto,
|
|
||||||
| "id"
|
|
||||||
| "display_name"
|
|
||||||
| "api_base_url"
|
|
||||||
| "model"
|
|
||||||
| "protocol"
|
|
||||||
| "interval_minutes"
|
|
||||||
| "enabled"
|
|
||||||
| "category"
|
|
||||||
| "created_at"
|
|
||||||
| "last_run_at"
|
|
||||||
| "next_run_at"
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** /api/admin/probe-settings 与后台表单 */
|
/** /api/admin/probe-settings 与后台表单 */
|
||||||
export type GlobalProbeSettings = {
|
export type GlobalProbeSettings = {
|
||||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"deploy": "npm run build && wrangler deploy",
|
"deploy": "npm run build && wrangler deploy --config wrangler.json",
|
||||||
"cf-typegen": "wrangler types",
|
"cf-typegen": "wrangler types",
|
||||||
"secret:admin": "wrangler secret put ADMIN_TOKEN",
|
"secret:admin": "wrangler secret put ADMIN_TOKEN",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hono": "^4.12.19",
|
"hono": "^4.12.19",
|
||||||
|
"lxgw-wenkai-webfont": "^1.7.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.15.1"
|
"react-router-dom": "^7.15.1"
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"wrangler": "^4.92.0"
|
"wrangler": "^4.92.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/favicon.ico
Normal file
|
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,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["frontend/src"]
|
||||||
"exclude": ["src/worker"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"types": ["@cloudflare/workers-types"],
|
"types": ["@cloudflare/workers-types"],
|
||||||
"moduleResolution": "bundler"
|
"moduleResolution": "bundler"
|
||||||
},
|
},
|
||||||
"include": ["src/worker/**/*.ts", "worker-configuration.d.ts"]
|
"include": ["worker/**/*.ts", "worker-configuration.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,61 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import { cloudflare } from '@cloudflare/vite-plugin'
|
import { cloudflare } from '@cloudflare/vite-plugin'
|
||||||
|
|
||||||
|
const projRoot = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), cloudflare()],
|
root: path.resolve(projRoot, 'frontend'),
|
||||||
|
publicDir: path.resolve(projRoot, 'public'),
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve(projRoot, 'dist/client'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'prompt',
|
||||||
|
includeAssets: ['favicon.ico', 'logo.png', 'logo192.png', 'logo512.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'ModelPing',
|
||||||
|
short_name: 'ModelPing',
|
||||||
|
description: '大模型 API 可用性与首字延迟监控',
|
||||||
|
theme_color: '#27ae60',
|
||||||
|
background_color: '#dfe6eb',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait-primary',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
lang: 'zh-CN',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'logo192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'logo512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'logo512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
|
navigateFallback: '/index.html',
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
cloudflare(),
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export type MonitorRow = {
|
|||||||
api_key_nonce: ArrayBuffer;
|
api_key_nonce: ArrayBuffer;
|
||||||
last_run_at: number | null;
|
last_run_at: number | null;
|
||||||
next_run_at: number;
|
next_run_at: number;
|
||||||
|
/** 1 = 流式;0 = 非流式 */
|
||||||
|
probe_stream: number;
|
||||||
|
/** 1 = 首页卡片展示;0 = 仅后台可见 */
|
||||||
|
show_on_dashboard: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 列表 API 在合并 interval_minutes 之前的行 */
|
/** 列表 API 在合并 interval_minutes 之前的行 */
|
||||||
@@ -69,14 +73,20 @@ export type MonitorPublic = {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
last_run_at: number | null;
|
last_run_at: number | null;
|
||||||
next_run_at: number;
|
next_run_at: number;
|
||||||
|
probe_stream: number;
|
||||||
|
show_on_dashboard: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listMonitorsPublic(db: D1Database): Promise<MonitorPublic[]> {
|
export async function listMonitorsPublic(
|
||||||
|
db: D1Database,
|
||||||
|
visibility: "all" | "dashboard" = "all"
|
||||||
|
): Promise<MonitorPublic[]> {
|
||||||
|
const where = visibility === "dashboard" ? " WHERE show_on_dashboard = 1" : "";
|
||||||
const r = await db
|
const r = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||||
created_at, last_run_at, next_run_at
|
created_at, last_run_at, next_run_at, probe_stream, show_on_dashboard
|
||||||
FROM monitors ORDER BY display_name ASC`
|
FROM monitors${where} ORDER BY display_name ASC`
|
||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
return (r.results ?? []) as unknown as MonitorPublic[];
|
return (r.results ?? []) as unknown as MonitorPublic[];
|
||||||
@@ -86,7 +96,8 @@ export async function listAllMonitors(db: D1Database): Promise<MonitorRow[]> {
|
|||||||
const r = await db
|
const r = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at
|
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||||
|
show_on_dashboard
|
||||||
FROM monitors ORDER BY display_name ASC`
|
FROM monitors ORDER BY display_name ASC`
|
||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
@@ -97,7 +108,8 @@ export async function listDueMonitors(db: D1Database, nowSec: number): Promise<M
|
|||||||
const r = await db
|
const r = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at
|
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||||
|
show_on_dashboard
|
||||||
FROM monitors WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC`
|
FROM monitors WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC`
|
||||||
)
|
)
|
||||||
.bind(nowSec)
|
.bind(nowSec)
|
||||||
@@ -109,7 +121,8 @@ export async function getMonitor(db: D1Database, id: string): Promise<MonitorRow
|
|||||||
const r = await db
|
const r = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at
|
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||||
|
show_on_dashboard
|
||||||
FROM monitors WHERE id = ?`
|
FROM monitors WHERE id = ?`
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -126,8 +139,9 @@ export async function insertMonitor(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO monitors (id, display_name, api_base_url, model, protocol, enabled, category,
|
`INSERT INTO monitors (id, display_name, api_base_url, model, protocol, enabled, category,
|
||||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at)
|
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
show_on_dashboard)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
row.id,
|
row.id,
|
||||||
@@ -141,7 +155,9 @@ export async function insertMonitor(
|
|||||||
ct,
|
ct,
|
||||||
nn,
|
nn,
|
||||||
row.last_run_at,
|
row.last_run_at,
|
||||||
row.next_run_at
|
row.next_run_at,
|
||||||
|
row.probe_stream,
|
||||||
|
row.show_on_dashboard
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
@@ -159,6 +175,8 @@ export async function updateMonitorMeta(
|
|||||||
api_key_ciphertext: ArrayBuffer;
|
api_key_ciphertext: ArrayBuffer;
|
||||||
api_key_nonce: ArrayBuffer;
|
api_key_nonce: ArrayBuffer;
|
||||||
next_run_at: number;
|
next_run_at: number;
|
||||||
|
probe_stream: number;
|
||||||
|
show_on_dashboard: number;
|
||||||
}>
|
}>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cur = await getMonitor(db, id);
|
const cur = await getMonitor(db, id);
|
||||||
@@ -169,7 +187,8 @@ export async function updateMonitorMeta(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE monitors SET display_name=?, api_base_url=?, model=?, protocol=?,
|
`UPDATE monitors SET display_name=?, api_base_url=?, model=?, protocol=?,
|
||||||
enabled=?, category=?, api_key_ciphertext=?, api_key_nonce=?, next_run_at=?
|
enabled=?, category=?, api_key_ciphertext=?, api_key_nonce=?, next_run_at=?, probe_stream=?,
|
||||||
|
show_on_dashboard=?
|
||||||
WHERE id=?`
|
WHERE id=?`
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
@@ -182,6 +201,8 @@ export async function updateMonitorMeta(
|
|||||||
ct,
|
ct,
|
||||||
nn,
|
nn,
|
||||||
next.next_run_at,
|
next.next_run_at,
|
||||||
|
next.probe_stream,
|
||||||
|
next.show_on_dashboard,
|
||||||
id
|
id
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
@@ -235,11 +256,7 @@ export async function updateMonitorRunTimes(
|
|||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-day bucket: dayStart unix sec at UTC midnight approximation (floor to day in UTC) */
|
export type AvailabilityTimelineStats = {
|
||||||
export async function availabilityAndTimeline(
|
|
||||||
db: D1Database,
|
|
||||||
monitorId: string
|
|
||||||
): Promise<{
|
|
||||||
availability24h: number | null;
|
availability24h: number | null;
|
||||||
availability30d: number | null;
|
availability30d: number | null;
|
||||||
probe_count: number;
|
probe_count: number;
|
||||||
@@ -253,57 +270,18 @@ export async function availabilityAndTimeline(
|
|||||||
probe_input: string | null;
|
probe_input: string | null;
|
||||||
probe_output: string | null;
|
probe_output: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}> {
|
};
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const start24 = now - 86400;
|
|
||||||
const start30 = now - THIRTY_DAYS_SEC;
|
|
||||||
|
|
||||||
const lastProbe = await db
|
type LastProbeRow = NonNullable<AvailabilityTimelineStats["lastProbe"]>;
|
||||||
.prepare(
|
|
||||||
`SELECT ts, ok, first_token_ms, http_status, error_message, probe_input, probe_output FROM probe_events WHERE monitor_id = ? ORDER BY ts DESC LIMIT 1`
|
|
||||||
)
|
|
||||||
.bind(monitorId)
|
|
||||||
.first<{
|
|
||||||
ts: number;
|
|
||||||
ok: number;
|
|
||||||
first_token_ms: number | null;
|
|
||||||
http_status: number | null;
|
|
||||||
error_message: string | null;
|
|
||||||
probe_input: string | null;
|
|
||||||
probe_output: string | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const stats24 = await db
|
function finalizeAvailabilityTimeline(
|
||||||
.prepare(
|
lastProbe: LastProbeRow | null | undefined,
|
||||||
`SELECT SUM(ok) AS okc, COUNT(*) AS total FROM probe_events WHERE monitor_id = ? AND ts >= ?`
|
stats24: { okc: number | null; total: number | null } | null | undefined,
|
||||||
)
|
stats30: { okc: number | null; total: number | null } | null | undefined,
|
||||||
.bind(monitorId, start24)
|
dailyRaw: unknown[],
|
||||||
.first<{ okc: number | null; total: number | null }>();
|
countRow: { c: number | null } | null | undefined
|
||||||
|
): AvailabilityTimelineStats {
|
||||||
const stats30 = await db
|
const daily = dailyRaw.map((r) => ({
|
||||||
.prepare(
|
|
||||||
`SELECT SUM(ok) AS okc, COUNT(*) AS total FROM probe_events WHERE monitor_id = ? AND ts >= ?`
|
|
||||||
)
|
|
||||||
.bind(monitorId, start30)
|
|
||||||
.first<{ okc: number | null; total: number | null }>();
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT (CAST(ts / 86400 AS INTEGER) * 86400) AS day_start,
|
|
||||||
SUM(ok) AS okc,
|
|
||||||
COUNT(*) AS cnt
|
|
||||||
FROM probe_events WHERE monitor_id = ? AND ts >= ?
|
|
||||||
GROUP BY day_start ORDER BY day_start ASC`
|
|
||||||
)
|
|
||||||
.bind(monitorId, start30)
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const countRow = await db
|
|
||||||
.prepare(`SELECT COUNT(*) AS c FROM probe_events WHERE monitor_id = ?`)
|
|
||||||
.bind(monitorId)
|
|
||||||
.first<{ c: number | null }>();
|
|
||||||
|
|
||||||
const daily = (rows.results ?? []).map((r) => ({
|
|
||||||
dayStart: Number((r as { day_start: number }).day_start),
|
dayStart: Number((r as { day_start: number }).day_start),
|
||||||
ok: Number((r as { okc: number | null }).okc ?? 0),
|
ok: Number((r as { okc: number | null }).okc ?? 0),
|
||||||
total: Number((r as { cnt: number | null }).cnt ?? 0),
|
total: Number((r as { cnt: number | null }).cnt ?? 0),
|
||||||
@@ -322,3 +300,71 @@ export async function availabilityAndTimeline(
|
|||||||
lastProbe: lastProbe ?? null,
|
lastProbe: lastProbe ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 与 SQL bind / Map 查找统一,避免首尾空白或类型不一致导致 stats 对不上 */
|
||||||
|
export function normMonitorId(id: unknown): string {
|
||||||
|
return String(id ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每个监控单独 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 { Hono } from "hono";
|
||||||
import { availabilityAndTimeline, getAppSettings, getMonitor, listMonitorsPublic, updateAppSettings } from "./db";
|
import {
|
||||||
|
availabilityAndTimeline,
|
||||||
|
availabilityAndTimelineBatch,
|
||||||
|
getAppSettings,
|
||||||
|
getMonitor,
|
||||||
|
listMonitorsPublic,
|
||||||
|
normMonitorId,
|
||||||
|
updateAppSettings,
|
||||||
|
} from "./db";
|
||||||
import {
|
import {
|
||||||
createMonitorFromPayload,
|
createMonitorFromPayload,
|
||||||
deleteMonitor,
|
deleteMonitor,
|
||||||
@@ -16,10 +24,32 @@ type MonitorCreateBody = {
|
|||||||
protocol: "openai" | "openai_responses" | "claude";
|
protocol: "openai" | "openai_responses" | "claude";
|
||||||
category?: string;
|
category?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** 未传时默认为流式 */
|
||||||
|
probe_stream?: boolean;
|
||||||
|
/** 未传时默认在首页展示 */
|
||||||
|
show_on_dashboard?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function parseOptionalBool01(o: Record<string, unknown>, key: string): boolean | undefined {
|
||||||
|
const v = o[key];
|
||||||
|
if (typeof v === "boolean") return v;
|
||||||
|
if (v === 0) return false;
|
||||||
|
if (v === 1) return true;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalProbeStream(o: Record<string, unknown>): boolean | undefined {
|
||||||
|
return parseOptionalBool01(o, "probe_stream");
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hono<{ Bindings: Env }>();
|
const app = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
|
/** 避免边缘/浏览器把动态 JSON 缓存成「半套字段」,尤其带 Authorization 的请求易被错误复用 */
|
||||||
|
app.use("/api/*", async (c, next) => {
|
||||||
|
c.header("Cache-Control", "private, no-store, max-age=0");
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
const ALLOWED = new Set([1, 5, 10, 30, 60, 360]);
|
const ALLOWED = new Set([1, 5, 10, 30, 60, 360]);
|
||||||
|
|
||||||
function checkAdmin(c: { req: { header: (k: string) => string | undefined }; env: Env }): Response | null {
|
function checkAdmin(c: { req: { header: (k: string) => string | undefined }; env: Env }): Response | null {
|
||||||
@@ -65,6 +95,8 @@ async function parseMonitorCreate(c: { req: { json: () => Promise<unknown> } }):
|
|||||||
}
|
}
|
||||||
const category = typeof o.category === "string" ? o.category : undefined;
|
const category = typeof o.category === "string" ? o.category : undefined;
|
||||||
const enabled = typeof o.enabled === "boolean" ? o.enabled : undefined;
|
const enabled = typeof o.enabled === "boolean" ? o.enabled : undefined;
|
||||||
|
const probe_stream = parseOptionalProbeStream(o);
|
||||||
|
const show_on_dashboard = parseOptionalBool01(o, "show_on_dashboard");
|
||||||
return {
|
return {
|
||||||
display_name,
|
display_name,
|
||||||
api_base_url,
|
api_base_url,
|
||||||
@@ -73,54 +105,89 @@ async function parseMonitorCreate(c: { req: { json: () => Promise<unknown> } }):
|
|||||||
protocol,
|
protocol,
|
||||||
category,
|
category,
|
||||||
enabled,
|
enabled,
|
||||||
|
probe_stream,
|
||||||
|
show_on_dashboard,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withIntervalForApi(
|
type MonitorRowWithInterval = Awaited<ReturnType<typeof listMonitorsPublic>>[number] & {
|
||||||
db: D1Database,
|
interval_minutes: number;
|
||||||
rows: Awaited<ReturnType<typeof listMonitorsPublic>>
|
};
|
||||||
): Promise<Array<(typeof rows)[number] & { interval_minutes: number }>> {
|
|
||||||
const s = await getAppSettings(db);
|
const TIMELINE_SHANGHAI_OFF = 8 * 3600;
|
||||||
const iv = s.probe_interval_minutes;
|
const TIMELINE_HALF_DAY_SEC = 12 * 60 * 60;
|
||||||
return rows.map((m) => ({ ...m, interval_minutes: iv }));
|
const TIMELINE_SLOTS = 60;
|
||||||
|
|
||||||
|
function currentTimelineSlotStart(nowSec: number): number {
|
||||||
|
return Math.floor((nowSec + TIMELINE_SHANGHAI_OFF) / TIMELINE_HALF_DAY_SEC) * TIMELINE_HALF_DAY_SEC - TIMELINE_SHANGHAI_OFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get("/api/monitors", async (c) => {
|
function timelineDailyFromBuckets(daily: Array<{ dayStart: number; ok: number; total: number }>) {
|
||||||
const db = c.env.DB;
|
const bySlot = new Map(daily.map((d) => [d.dayStart, d]));
|
||||||
const rows = await listMonitorsPublic(db);
|
const current = currentTimelineSlotStart(Math.floor(Date.now() / 1000));
|
||||||
const merged = await withIntervalForApi(db, rows);
|
const first = current - (TIMELINE_SLOTS - 1) * TIMELINE_HALF_DAY_SEC;
|
||||||
const out = [];
|
|
||||||
for (const m of merged) {
|
return Array.from({ length: TIMELINE_SLOTS }, (_, i) => {
|
||||||
const stats = await availabilityAndTimeline(db, m.id);
|
const t = first + i * TIMELINE_HALF_DAY_SEC;
|
||||||
const timelineDaily = stats.daily.map((d) => ({
|
const d = bySlot.get(t);
|
||||||
t: d.dayStart,
|
const total = d?.total ?? 0;
|
||||||
up: d.total > 0 && d.ok === d.total,
|
const ratio = total > 0 ? (d?.ok ?? 0) / total : 0;
|
||||||
ratio: d.total > 0 ? d.ok / d.total : 0,
|
return {
|
||||||
}));
|
t,
|
||||||
out.push({
|
up: total > 0 && (d?.ok ?? 0) === total,
|
||||||
|
ratio,
|
||||||
|
hasData: total > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function monitorsDtoList(db: D1Database, merged: MonitorRowWithInterval[]) {
|
||||||
|
const statsMap = await availabilityAndTimelineBatch(
|
||||||
|
db,
|
||||||
|
merged.map((m) => m.id)
|
||||||
|
);
|
||||||
|
return merged.map((m) => {
|
||||||
|
const stats = statsMap.get(normMonitorId(m.id));
|
||||||
|
if (!stats) {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
availability24h: null,
|
||||||
|
availability30d: null,
|
||||||
|
probe_count: 0,
|
||||||
|
lastProbe: null,
|
||||||
|
timelineDaily: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const timelineDaily = timelineDailyFromBuckets(stats.daily);
|
||||||
|
return {
|
||||||
...m,
|
...m,
|
||||||
availability24h: stats.availability24h,
|
availability24h: stats.availability24h,
|
||||||
availability30d: stats.availability30d,
|
availability30d: stats.availability30d,
|
||||||
probe_count: stats.probe_count,
|
probe_count: stats.probe_count,
|
||||||
lastProbe: stats.lastProbe,
|
lastProbe: stats.lastProbe,
|
||||||
timelineDaily,
|
timelineDaily,
|
||||||
});
|
};
|
||||||
}
|
});
|
||||||
return c.json(out);
|
}
|
||||||
|
|
||||||
|
app.get("/api/monitors", async (c) => {
|
||||||
|
const db = c.env.DB;
|
||||||
|
const [rows, s] = await Promise.all([listMonitorsPublic(db, "dashboard"), getAppSettings(db)]);
|
||||||
|
const merged: MonitorRowWithInterval[] = rows.map((m) => ({
|
||||||
|
...m,
|
||||||
|
interval_minutes: s.probe_interval_minutes,
|
||||||
|
}));
|
||||||
|
return c.json(await monitorsDtoList(db, merged));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/monitors/:id", async (c) => {
|
app.get("/api/monitors/:id", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const db = c.env.DB;
|
const db = c.env.DB;
|
||||||
const m = await getMonitor(db, id);
|
const [m, s] = await Promise.all([getMonitor(db, id), getAppSettings(db)]);
|
||||||
if (!m) return c.json({ error: "not_found" }, 404);
|
if (!m) return c.json({ error: "not_found" }, 404);
|
||||||
const s = await getAppSettings(db);
|
if (m.show_on_dashboard === 0) return c.json({ error: "not_found" }, 404);
|
||||||
const stats = await availabilityAndTimeline(db, id);
|
const stats = await availabilityAndTimeline(db, id);
|
||||||
const timelineDaily = stats.daily.map((d) => ({
|
const timelineDaily = timelineDailyFromBuckets(stats.daily);
|
||||||
t: d.dayStart,
|
|
||||||
up: d.total > 0 && d.ok === d.total,
|
|
||||||
ratio: d.total > 0 ? d.ok / d.total : 0,
|
|
||||||
}));
|
|
||||||
return c.json({
|
return c.json({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
display_name: m.display_name,
|
display_name: m.display_name,
|
||||||
@@ -128,6 +195,8 @@ app.get("/api/monitors/:id", async (c) => {
|
|||||||
model: m.model,
|
model: m.model,
|
||||||
protocol: m.protocol,
|
protocol: m.protocol,
|
||||||
interval_minutes: s.probe_interval_minutes,
|
interval_minutes: s.probe_interval_minutes,
|
||||||
|
probe_stream: m.probe_stream,
|
||||||
|
show_on_dashboard: m.show_on_dashboard,
|
||||||
enabled: m.enabled,
|
enabled: m.enabled,
|
||||||
category: m.category,
|
category: m.category,
|
||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
@@ -188,8 +257,12 @@ app.get("/api/admin/monitors", async (c) => {
|
|||||||
const err = checkAdmin(c);
|
const err = checkAdmin(c);
|
||||||
if (err) return err;
|
if (err) return err;
|
||||||
const db = c.env.DB;
|
const db = c.env.DB;
|
||||||
const rows = await listMonitorsPublic(db);
|
const [rows, s] = await Promise.all([listMonitorsPublic(db, "all"), getAppSettings(db)]);
|
||||||
return c.json(await withIntervalForApi(db, rows));
|
const merged: MonitorRowWithInterval[] = rows.map((m) => ({
|
||||||
|
...m,
|
||||||
|
interval_minutes: s.probe_interval_minutes,
|
||||||
|
}));
|
||||||
|
return c.json(await monitorsDtoList(db, merged));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/admin/monitors", async (c) => {
|
app.post("/api/admin/monitors", async (c) => {
|
||||||
@@ -220,6 +293,11 @@ app.put("/api/admin/monitors/:id", async (c) => {
|
|||||||
patch.protocol = body.protocol;
|
patch.protocol = body.protocol;
|
||||||
if (typeof body.category === "string") patch.category = body.category;
|
if (typeof body.category === "string") patch.category = body.category;
|
||||||
if (typeof body.enabled === "boolean") patch.enabled = body.enabled;
|
if (typeof body.enabled === "boolean") patch.enabled = body.enabled;
|
||||||
|
if (typeof body.probe_stream === "boolean") patch.probe_stream = body.probe_stream;
|
||||||
|
else if (body.probe_stream === 0 || body.probe_stream === 1) patch.probe_stream = body.probe_stream === 1;
|
||||||
|
if (typeof body.show_on_dashboard === "boolean") patch.show_on_dashboard = body.show_on_dashboard;
|
||||||
|
else if (body.show_on_dashboard === 0 || body.show_on_dashboard === 1)
|
||||||
|
patch.show_on_dashboard = body.show_on_dashboard === 1;
|
||||||
try {
|
try {
|
||||||
const ok = await updateMonitorFromPayload(c.env, c.env.DB, id, patch);
|
const ok = await updateMonitorFromPayload(c.env, c.env.DB, id, patch);
|
||||||
if (!ok) return c.json({ error: "not_found" }, 404);
|
if (!ok) return c.json({ error: "not_found" }, 404);
|
||||||
@@ -68,6 +68,33 @@ function claudeUrl(base: string): string {
|
|||||||
return `${b}/v1/messages`;
|
return `${b}/v1/messages`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat Completions 流式里 `choices[0].delta` 的正文抽取。
|
||||||
|
* 对齐 OpenAI 常见形态:delta.content 字符串;多段/多模态下为 part 数组;
|
||||||
|
* 部分国产/推理模型会在 delta.reasoning_content 等字段里先出字。
|
||||||
|
*/
|
||||||
|
function streamDeltaTextFromOpenAiChatChoice(delta: unknown): string {
|
||||||
|
if (delta == null || typeof delta !== "object") return "";
|
||||||
|
const d = delta as Record<string, unknown>;
|
||||||
|
const c = d.content;
|
||||||
|
if (typeof c === "string" && c !== "") return c;
|
||||||
|
if (Array.isArray(c)) {
|
||||||
|
let s = "";
|
||||||
|
for (const part of c) {
|
||||||
|
if (part && typeof part === "object") {
|
||||||
|
const p = part as { type?: string; text?: string };
|
||||||
|
if (typeof p.text === "string") s += p.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
for (const k of ["reasoning_content", "reasoning"] as const) {
|
||||||
|
const v = d[k];
|
||||||
|
if (typeof v === "string" && v !== "") return v;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
async function readStreamOpenAIChat(
|
async function readStreamOpenAIChat(
|
||||||
res: Response,
|
res: Response,
|
||||||
started: number
|
started: number
|
||||||
@@ -96,12 +123,13 @@ async function readStreamOpenAIChat(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(payload) as {
|
const obj = JSON.parse(payload) as {
|
||||||
choices?: Array<{ delta?: { content?: string } }>;
|
choices?: Array<{ delta?: unknown }>;
|
||||||
};
|
};
|
||||||
const c = obj.choices?.[0]?.delta?.content;
|
const delta = obj.choices?.[0]?.delta;
|
||||||
if (c != null && c !== "") {
|
const chunk = streamDeltaTextFromOpenAiChatChoice(delta);
|
||||||
|
if (chunk !== "") {
|
||||||
if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started);
|
if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started);
|
||||||
outputText = appendCap(outputText, c, STREAM_OUTPUT_CAP);
|
outputText = appendCap(outputText, chunk, STREAM_OUTPUT_CAP);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore bad json line */
|
/* ignore bad json line */
|
||||||
@@ -155,6 +183,101 @@ async function readStreamOpenAIResponses(
|
|||||||
return { firstTokenMs, httpStatus: res.status, outputText };
|
return { firstTokenMs, httpStatus: res.status, outputText };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAiChatMessageContentToText(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
let s = "";
|
||||||
|
for (const part of content) {
|
||||||
|
if (part && typeof part === "object") {
|
||||||
|
const p = part as { type?: string; text?: string };
|
||||||
|
if (typeof p.text === "string") s += p.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpenAiChatNonStream(
|
||||||
|
json: unknown,
|
||||||
|
started: number
|
||||||
|
): { firstTokenMs: number | null; outputText: string } {
|
||||||
|
const obj = json as { choices?: Array<{ message?: { content?: unknown } }> };
|
||||||
|
const raw = openAiChatMessageContentToText(obj.choices?.[0]?.message?.content);
|
||||||
|
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
|
||||||
|
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
|
||||||
|
return { firstTokenMs, outputText };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOpenAiResponsesText(data: unknown): string {
|
||||||
|
if (data == null || typeof data !== "object") return "";
|
||||||
|
const d = data as Record<string, unknown>;
|
||||||
|
if (typeof d.output_text === "string") return d.output_text;
|
||||||
|
|
||||||
|
const out = d.output;
|
||||||
|
if (!Array.isArray(out)) return "";
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
const walk = (node: unknown): void => {
|
||||||
|
if (node == null) return;
|
||||||
|
if (typeof node === "string") {
|
||||||
|
parts.push(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const x of node) walk(x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof node !== "object") return;
|
||||||
|
const o = node as Record<string, unknown>;
|
||||||
|
if (o.type === "output_text" && typeof o.text === "string") {
|
||||||
|
parts.push(o.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof o.text === "string" && typeof o.type === "string" && o.type.includes("text")) {
|
||||||
|
parts.push(o.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (o.content != null) walk(o.content);
|
||||||
|
if (o.output != null) walk(o.output);
|
||||||
|
};
|
||||||
|
for (const item of out) walk(item);
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpenAiResponsesNonStream(
|
||||||
|
json: unknown,
|
||||||
|
started: number
|
||||||
|
): { firstTokenMs: number | null; outputText: string } {
|
||||||
|
const raw = extractOpenAiResponsesText(json);
|
||||||
|
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
|
||||||
|
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
|
||||||
|
return { firstTokenMs, outputText };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractClaudeNonStreamText(json: unknown): string {
|
||||||
|
if (json == null || typeof json !== "object") return "";
|
||||||
|
const d = json as { content?: unknown };
|
||||||
|
const content = d.content;
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
let s = "";
|
||||||
|
for (const block of content) {
|
||||||
|
if (block && typeof block === "object") {
|
||||||
|
const b = block as { type?: string; text?: string };
|
||||||
|
if (b.type === "text" && typeof b.text === "string") s += b.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClaudeNonStream(
|
||||||
|
json: unknown,
|
||||||
|
started: number
|
||||||
|
): { firstTokenMs: number | null; outputText: string } {
|
||||||
|
const raw = extractClaudeNonStreamText(json);
|
||||||
|
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
|
||||||
|
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
|
||||||
|
return { firstTokenMs, outputText };
|
||||||
|
}
|
||||||
|
|
||||||
/** 首字可来自正文或 thinking;输出只累计 assistant 可见正文 delta.text */
|
/** 首字可来自正文或 thinking;输出只累计 assistant 可见正文 delta.text */
|
||||||
function claudeStreamDeltaParts(
|
function claudeStreamDeltaParts(
|
||||||
data: string,
|
data: string,
|
||||||
@@ -243,8 +366,11 @@ export async function runProbe(params: {
|
|||||||
model: string;
|
model: string;
|
||||||
protocol: Protocol;
|
protocol: Protocol;
|
||||||
userMessage: string;
|
userMessage: string;
|
||||||
|
/** 默认 true(流式);false 时使用非流式 JSON 响应 */
|
||||||
|
stream?: boolean;
|
||||||
}): Promise<ProbeResult> {
|
}): Promise<ProbeResult> {
|
||||||
const userMessage = params.userMessage.trim() || "ping";
|
const userMessage = params.userMessage.trim() || "ping";
|
||||||
|
const useStream = params.stream !== false;
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
const t = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
|
const t = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
@@ -262,7 +388,7 @@ export async function runProbe(params: {
|
|||||||
model: params.model,
|
model: params.model,
|
||||||
messages: [{ role: "user", content: userMessage }],
|
messages: [{ role: "user", content: userMessage }],
|
||||||
max_tokens: PROBE_MAX_TOKENS,
|
max_tokens: PROBE_MAX_TOKENS,
|
||||||
stream: true,
|
stream: useStream,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -276,13 +402,38 @@ export async function runProbe(params: {
|
|||||||
responseText: null,
|
responseText: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIChat(res, started);
|
if (useStream) {
|
||||||
|
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIChat(res, started);
|
||||||
|
const ok = firstTokenMs != null;
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
firstTokenMs,
|
||||||
|
httpStatus,
|
||||||
|
errorMessage: ok ? null : "no_stream_token",
|
||||||
|
requestMessage: userMessage,
|
||||||
|
responseText: outputText === "" ? null : outputText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = await res.json();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
firstTokenMs: null,
|
||||||
|
httpStatus: res.status,
|
||||||
|
errorMessage: "invalid_json_body",
|
||||||
|
requestMessage: userMessage,
|
||||||
|
responseText: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { firstTokenMs, outputText } = parseOpenAiChatNonStream(json, started);
|
||||||
const ok = firstTokenMs != null;
|
const ok = firstTokenMs != null;
|
||||||
return {
|
return {
|
||||||
ok,
|
ok,
|
||||||
firstTokenMs,
|
firstTokenMs,
|
||||||
httpStatus,
|
httpStatus: res.status,
|
||||||
errorMessage: ok ? null : "no_stream_token",
|
errorMessage: ok ? null : "no_response_content",
|
||||||
requestMessage: userMessage,
|
requestMessage: userMessage,
|
||||||
responseText: outputText === "" ? null : outputText,
|
responseText: outputText === "" ? null : outputText,
|
||||||
};
|
};
|
||||||
@@ -299,7 +450,7 @@ export async function runProbe(params: {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: params.model,
|
model: params.model,
|
||||||
input: userMessage,
|
input: userMessage,
|
||||||
stream: true,
|
stream: useStream,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -313,13 +464,38 @@ export async function runProbe(params: {
|
|||||||
responseText: null,
|
responseText: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIResponses(res, started);
|
if (useStream) {
|
||||||
|
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIResponses(res, started);
|
||||||
|
const ok = firstTokenMs != null;
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
firstTokenMs,
|
||||||
|
httpStatus,
|
||||||
|
errorMessage: ok ? null : "no_stream_token",
|
||||||
|
requestMessage: userMessage,
|
||||||
|
responseText: outputText === "" ? null : outputText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = await res.json();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
firstTokenMs: null,
|
||||||
|
httpStatus: res.status,
|
||||||
|
errorMessage: "invalid_json_body",
|
||||||
|
requestMessage: userMessage,
|
||||||
|
responseText: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { firstTokenMs, outputText } = parseOpenAiResponsesNonStream(json, started);
|
||||||
const ok = firstTokenMs != null;
|
const ok = firstTokenMs != null;
|
||||||
return {
|
return {
|
||||||
ok,
|
ok,
|
||||||
firstTokenMs,
|
firstTokenMs,
|
||||||
httpStatus,
|
httpStatus: res.status,
|
||||||
errorMessage: ok ? null : "no_stream_token",
|
errorMessage: ok ? null : "no_response_content",
|
||||||
requestMessage: userMessage,
|
requestMessage: userMessage,
|
||||||
responseText: outputText === "" ? null : outputText,
|
responseText: outputText === "" ? null : outputText,
|
||||||
};
|
};
|
||||||
@@ -337,7 +513,7 @@ export async function runProbe(params: {
|
|||||||
model: params.model,
|
model: params.model,
|
||||||
max_tokens: PROBE_MAX_TOKENS,
|
max_tokens: PROBE_MAX_TOKENS,
|
||||||
messages: [{ role: "user", content: userMessage }],
|
messages: [{ role: "user", content: userMessage }],
|
||||||
stream: true,
|
stream: useStream,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -351,13 +527,38 @@ export async function runProbe(params: {
|
|||||||
responseText: null,
|
responseText: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { firstTokenMs, httpStatus, outputText } = await readStreamClaude(res, started);
|
if (useStream) {
|
||||||
|
const { firstTokenMs, httpStatus, outputText } = await readStreamClaude(res, started);
|
||||||
|
const ok = firstTokenMs != null;
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
firstTokenMs,
|
||||||
|
httpStatus,
|
||||||
|
errorMessage: ok ? null : "no_stream_token",
|
||||||
|
requestMessage: userMessage,
|
||||||
|
responseText: outputText === "" ? null : outputText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = await res.json();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
firstTokenMs: null,
|
||||||
|
httpStatus: res.status,
|
||||||
|
errorMessage: "invalid_json_body",
|
||||||
|
requestMessage: userMessage,
|
||||||
|
responseText: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { firstTokenMs, outputText } = parseClaudeNonStream(json, started);
|
||||||
const ok = firstTokenMs != null;
|
const ok = firstTokenMs != null;
|
||||||
return {
|
return {
|
||||||
ok,
|
ok,
|
||||||
firstTokenMs,
|
firstTokenMs,
|
||||||
httpStatus,
|
httpStatus: res.status,
|
||||||
errorMessage: ok ? null : "no_stream_token",
|
errorMessage: ok ? null : "no_response_content",
|
||||||
requestMessage: userMessage,
|
requestMessage: userMessage,
|
||||||
responseText: outputText === "" ? null : outputText,
|
responseText: outputText === "" ? null : outputText,
|
||||||
};
|
};
|
||||||
@@ -119,6 +119,7 @@ export async function runSingleProbe(
|
|||||||
model: m.model,
|
model: m.model,
|
||||||
protocol: m.protocol,
|
protocol: m.protocol,
|
||||||
userMessage: userMsg,
|
userMessage: userMsg,
|
||||||
|
stream: m.probe_stream !== 0,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await insertProbeEvent(db, {
|
await insertProbeEvent(db, {
|
||||||
@@ -156,6 +157,10 @@ export async function createMonitorFromPayload(
|
|||||||
protocol: "openai" | "openai_responses" | "claude";
|
protocol: "openai" | "openai_responses" | "claude";
|
||||||
category?: string;
|
category?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** 未传时默认流式 */
|
||||||
|
probe_stream?: boolean;
|
||||||
|
/** 未传时默认在首页展示 */
|
||||||
|
show_on_dashboard?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
@@ -175,6 +180,8 @@ export async function createMonitorFromPayload(
|
|||||||
api_key_nonce: nonceCopy.buffer.slice(nonceCopy.byteOffset, nonceCopy.byteOffset + nonceCopy.byteLength),
|
api_key_nonce: nonceCopy.buffer.slice(nonceCopy.byteOffset, nonceCopy.byteOffset + nonceCopy.byteLength),
|
||||||
last_run_at: null,
|
last_run_at: null,
|
||||||
next_run_at: nowSec,
|
next_run_at: nowSec,
|
||||||
|
probe_stream: body.probe_stream === false ? 0 : 1,
|
||||||
|
show_on_dashboard: body.show_on_dashboard === false ? 0 : 1,
|
||||||
});
|
});
|
||||||
return { id };
|
return { id };
|
||||||
}
|
}
|
||||||
@@ -191,6 +198,8 @@ export async function updateMonitorFromPayload(
|
|||||||
protocol: "openai" | "openai_responses" | "claude";
|
protocol: "openai" | "openai_responses" | "claude";
|
||||||
category: string;
|
category: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
probe_stream: boolean;
|
||||||
|
show_on_dashboard: boolean;
|
||||||
}>
|
}>
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const cur = await getMonitor(db, id);
|
const cur = await getMonitor(db, id);
|
||||||
@@ -214,6 +223,12 @@ export async function updateMonitorFromPayload(
|
|||||||
api_key_ciphertext: ciphertext,
|
api_key_ciphertext: ciphertext,
|
||||||
api_key_nonce: nonceBuf,
|
api_key_nonce: nonceBuf,
|
||||||
next_run_at: nowSec,
|
next_run_at: nowSec,
|
||||||
|
probe_stream:
|
||||||
|
body.probe_stream !== undefined ? (body.probe_stream ? 1 : 0) : cur.probe_stream,
|
||||||
|
show_on_dashboard:
|
||||||
|
body.show_on_dashboard !== undefined
|
||||||
|
? (body.show_on_dashboard ? 1 : 0)
|
||||||
|
: cur.show_on_dashboard,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
"name": "modelping",
|
"name": "modelping",
|
||||||
"main": "./src/worker/index.ts",
|
"main": "./worker/index.ts",
|
||||||
"compatibility_date": "2025-10-08",
|
"compatibility_date": "2025-10-08",
|
||||||
"compatibility_flags": [
|
"compatibility_flags": [
|
||||||
"nodejs_compat"
|
"nodejs_compat"
|
||||||
|
|||||||