From 5c44179c2cd8617f476d8f75e5de851218db915e Mon Sep 17 00:00:00 2001 From: shumengya Date: Wed, 24 Jun 2026 22:10:25 +0800 Subject: [PATCH] chore: sync local changes to Gitea --- .gitignore | 1 + README.md | 5 + eslint.config.js | 12 +- frontend/index.html | 21 + frontend/src/App.tsx | 23 + {src => frontend/src}/api.ts | 15 +- {src => frontend/src}/assets/hero.png | Bin {src => frontend/src}/assets/react.svg | 0 {src => frontend/src}/assets/vite.svg | 0 frontend/src/components/AdminTokenDialog.tsx | 93 + frontend/src/components/BrandLogo.tsx | 53 + frontend/src/components/Layout.tsx | 56 + frontend/src/components/MonitorCard.tsx | 265 + frontend/src/components/PwaUpdatePrompt.tsx | 34 + frontend/src/components/SplashScreen.tsx | 42 + frontend/src/context/AppBootContext.tsx | 57 + {src => frontend/src}/index.css | 379 +- frontend/src/main.tsx | 11 + frontend/src/pages/Admin.tsx | 462 ++ {src => frontend/src}/pages/Dashboard.tsx | 39 +- frontend/src/splash.css | 225 + {src => frontend/src}/types.ts | 23 +- frontend/src/vite-env.d.ts | 2 + index.html | 13 - migrations/0005_probe_stream.sql | 3 + migrations/0006_probe_stream_per_monitor.sql | 3 + migrations/0007_show_on_dashboard.sql | 3 + package-lock.json | 4630 ++++++++++++++++++ package.json | 4 +- public/favicon.ico | Bin 0 -> 90905 bytes public/favicon.svg | 1 - public/icons.svg | 24 - public/logo.png | Bin 0 -> 1523799 bytes public/logo192.png | Bin 0 -> 37180 bytes public/logo512.png | Bin 0 -> 224361 bytes src/App.tsx | 18 - src/components/Layout.tsx | 18 - src/components/MonitorCard.tsx | 158 - src/main.tsx | 10 - src/pages/Admin.tsx | 415 -- tsconfig.app.json | 3 +- tsconfig.worker.json | 2 +- vite.config.ts | 55 +- {src/worker => worker}/crypto.ts | 0 {src/worker => worker}/db.ts | 174 +- {src/worker => worker}/index.ts | 144 +- {src/worker => worker}/probe.ts | 233 +- {src/worker => worker}/scheduler.ts | 15 + wrangler.json | 2 +- 测试短语.txt | 15 + 50 files changed, 6892 insertions(+), 869 deletions(-) create mode 100644 frontend/index.html create mode 100644 frontend/src/App.tsx rename {src => frontend/src}/api.ts (91%) rename {src => frontend/src}/assets/hero.png (100%) rename {src => frontend/src}/assets/react.svg (100%) rename {src => frontend/src}/assets/vite.svg (100%) create mode 100644 frontend/src/components/AdminTokenDialog.tsx create mode 100644 frontend/src/components/BrandLogo.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/MonitorCard.tsx create mode 100644 frontend/src/components/PwaUpdatePrompt.tsx create mode 100644 frontend/src/components/SplashScreen.tsx create mode 100644 frontend/src/context/AppBootContext.tsx rename {src => frontend/src}/index.css (65%) create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Admin.tsx rename {src => frontend/src}/pages/Dashboard.tsx (76%) create mode 100644 frontend/src/splash.css rename {src => frontend/src}/types.ts (75%) create mode 100644 frontend/src/vite-env.d.ts delete mode 100644 index.html create mode 100644 migrations/0005_probe_stream.sql create mode 100644 migrations/0006_probe_stream_per_monitor.sql create mode 100644 migrations/0007_show_on_dashboard.sql create mode 100644 public/favicon.ico delete mode 100644 public/favicon.svg delete mode 100644 public/icons.svg create mode 100644 public/logo.png create mode 100644 public/logo192.png create mode 100644 public/logo512.png delete mode 100644 src/App.tsx delete mode 100644 src/components/Layout.tsx delete mode 100644 src/components/MonitorCard.tsx delete mode 100644 src/main.tsx delete mode 100644 src/pages/Admin.tsx rename {src/worker => worker}/crypto.ts (100%) rename {src/worker => worker}/db.ts (64%) rename {src/worker => worker}/index.ts (65%) rename {src/worker => worker}/probe.ts (61%) rename {src/worker => worker}/scheduler.ts (91%) diff --git a/.gitignore b/.gitignore index 5b959d7..7b0a665 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +frontend/dist .wrangler/ *.local .dev.vars diff --git a/README.md b/README.md index e45fe5a..52d2234 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ 在 Cloudflare Workers 上运行的大模型 API **流式首字延迟**与**可用性**监控面板:React + Vite 前端(Static Assets CDN)+ D1 存储(默认保留 30 天探测记录)+ 每分钟 Cron 调度。 +## 目录结构 + +- [`frontend/`](frontend/):React 前端(入口 [`frontend/index.html`](frontend/index.html)、源码 [`frontend/src/`](frontend/src/));根目录 [`vite.config.ts`](vite.config.ts) 将 `root` 指向此处,[`public/`](public/) 仍为静态资源目录。 +- [`worker/`](worker/):Cloudflare Worker — Hono API、D1、加密与定时探测调度;入口见 [`wrangler.json`](wrangler.json) 的 `main` 字段。 + ## 功能概要 - 管理端配置:API 根地址、API Key、模型名、协议(**OpenAI Chat Completions 经典** / **OpenAI Responses 新版** / Claude Anthropic)。 diff --git a/eslint.config.js b/eslint.config.js index ef614d2..79440e9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,7 +8,7 @@ import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ globalIgnores(['dist']), { - files: ['**/*.{ts,tsx}'], + files: ['frontend/**/*.{ts,tsx}'], extends: [ js.configs.recommended, tseslint.configs.recommended, @@ -19,4 +19,14 @@ export default defineConfig([ globals: globals.browser, }, }, + { + files: ['worker/**/*.ts'], + extends: [js.configs.recommended, tseslint.configs.recommended], + languageOptions: { + globals: { + ...globals.worker, + Env: 'readonly', + }, + }, + }, ]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b5ae089 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + ModelPing + + + +
+ + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..6d2e16a --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + + + }> + } /> + } /> + } /> + + + + + + ); +} diff --git a/src/api.ts b/frontend/src/api.ts similarity index 91% rename from src/api.ts rename to frontend/src/api.ts index 48cd6cb..b0b73d9 100644 --- a/src/api.ts +++ b/frontend/src/api.ts @@ -1,8 +1,8 @@ -import type { AdminMonitorRow, GlobalProbeSettings, MonitorDto, MonitorProtocol } from "./types"; +import type { GlobalProbeSettings, MonitorDto, MonitorProtocol } from "./types"; import { getAdminToken } from "./types"; export async function fetchMonitors(): Promise { - const r = await fetch("/api/monitors"); + const r = await fetch("/api/monitors", { cache: "no-store" }); if (!r.ok) throw new Error("failed_to_load"); return r.json() as Promise; } @@ -24,12 +24,15 @@ export async function adminPing(token: string): Promise { } } -export async function adminListMonitors(): Promise { +export async function adminListMonitors(): Promise { const t = getAdminToken(); if (!t) throw new Error("no_token"); - const r = await fetch("/api/admin/monitors", { headers: { Authorization: `Bearer ${t}` } }); + const r = await fetch("/api/admin/monitors", { + cache: "no-store", + headers: { Authorization: `Bearer ${t}` }, + }); if (!r.ok) throw new Error("admin_list_failed"); - return r.json(); + return r.json() as Promise; } export async function adminGetProbeSettings(): Promise { @@ -70,6 +73,8 @@ type CreateBody = { protocol: MonitorProtocol; category?: string; enabled?: boolean; + probe_stream?: boolean; + show_on_dashboard?: boolean; }; export async function adminCreateMonitor(body: CreateBody): Promise { diff --git a/src/assets/hero.png b/frontend/src/assets/hero.png similarity index 100% rename from src/assets/hero.png rename to frontend/src/assets/hero.png diff --git a/src/assets/react.svg b/frontend/src/assets/react.svg similarity index 100% rename from src/assets/react.svg rename to frontend/src/assets/react.svg diff --git a/src/assets/vite.svg b/frontend/src/assets/vite.svg similarity index 100% rename from src/assets/vite.svg rename to frontend/src/assets/vite.svg diff --git a/frontend/src/components/AdminTokenDialog.tsx b/frontend/src/components/AdminTokenDialog.tsx new file mode 100644 index 0000000..75bab24 --- /dev/null +++ b/frontend/src/components/AdminTokenDialog.tsx @@ -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>, { 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(null); + const [busy, setBusy] = useState(false); + const inputRef = useRef(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 ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
void submit(e)} onClick={(e) => e.stopPropagation()}> +

管理 Token

+ setToken(e.target.value)} + placeholder="Token" + autoComplete="off" + disabled={busy} + /> + {err ?

{err}

: null} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/BrandLogo.tsx b/frontend/src/components/BrandLogo.tsx new file mode 100644 index 0000000..62e9519 --- /dev/null +++ b/frontend/src/components/BrandLogo.tsx @@ -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) => { + 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 ( + <> + + {dialogOpen ? ( + setDialogOpen(false)} + onSuccess={() => { + setDialogOpen(false); + window.dispatchEvent(new Event("modelping:admin-auth")); + void navigate("/admin", { replace: true }); + }} + /> + ) : null} + + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..0c02e57 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -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(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 ( +
+ {bgUrl ? ( + + ) : null} +
+ + + ModelPing + +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/components/MonitorCard.tsx b/frontend/src/components/MonitorCard.tsx new file mode 100644 index 0000000..3ab45ce --- /dev/null +++ b/frontend/src/components/MonitorCard.tsx @@ -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(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 ( +
+
+
+
+

{m.display_name}

+
+
+ {m.model} +
+
+ {protoLines.name} + {protoLines.path} + {m.category?.trim() ? ( + {m.category.trim()} + ) : null} + + {m.probe_stream !== 0 ? "流式" : "非流式"} + + {m.show_on_dashboard === 0 ? ( + 首页隐藏 + ) : null} + {m.enabled === 0 ? 已停用 : null} +
+
+
+
+
+ + {ok ? "运行正常" : m.lastProbe ? "异常" : "尚无数据"} + + +
+
+ +
+ {timelineDaily.length === 0 ? ( +
暂无历史条形数据
+ ) : ( + timelineDaily.map((d) => ( + + )) + )} +
+ +
+ 约 30 天前 + {formatPct(pct30)} 可用率 + {shanghaiAmPmNow()} +
+ +
+
+ 状态 + {st != null ? st : "—"} +
+
+ 首字延迟 + {ft != null ? `${ft} ms` : "—"} +
+
+ 24h 可用 + {formatPct(m.availability24h)} +
+
+ 探测时间 + + {probe ? formatLastProbeTime(probe.ts) : "—"} + +
+
+ 探测次数 + {m.probe_count} +
+
+ + {footerExtra ?
{footerExtra}
: null} + + +
+
+

最近探测详情

+ +
+
+
+
显示名称
+
{m.display_name}
+
协议
+
{protocolLabel(m.protocol)}
+
模型
+
{m.model}
+
API 根地址
+
+ {m.api_base_url} + +
+
探测时间
+
+ {probe + ? new Date(probe.ts * 1000).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "medium", + }) + : "—"} +
+
结果
+
{probe ? (probe.ok === 1 ? "成功" : "失败") : "尚无记录"}
+
HTTP 状态
+
{probe?.http_status != null ? probe.http_status : "—"}
+
首字延迟
+
{probe?.first_token_ms != null ? `${probe.first_token_ms} ms` : "—"}
+
+ {probe?.error_message ? ( +
+ 错误信息 +
{probe.error_message}
+
+ ) : null} + {probe ? ( +
+ 输入(本次请求发送的用户消息) +
{probe.probe_input ?? "—"}
+ 输出(模型流式正文摘要,最长约数千字) +
+                  {probe.probe_output ?? "—"}
+                
+
+ ) : null} +
+
+
+
+ ); +} diff --git a/frontend/src/components/PwaUpdatePrompt.tsx b/frontend/src/components/PwaUpdatePrompt.tsx new file mode 100644 index 0000000..8463a83 --- /dev/null +++ b/frontend/src/components/PwaUpdatePrompt.tsx @@ -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 ( +
+

发现新版本,更新后即可使用最新功能。

+
+ + +
+
+ ); +} diff --git a/frontend/src/components/SplashScreen.tsx b/frontend/src/components/SplashScreen.tsx new file mode 100644 index 0000000..b7b18c5 --- /dev/null +++ b/frontend/src/components/SplashScreen.tsx @@ -0,0 +1,42 @@ +import "../splash.css"; + +const APP_NAME = "ModelPing"; + +export function SplashScreen() { + return ( +
+
+
+ +
+
+
+ + + +
+ +
+ +

{APP_NAME}

+

加载中

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

{msg}

: null} + +
+

全局设置

+
+ +