Initial commit

This commit is contained in:
shumengya
2026-05-17 20:28:47 +08:00
commit ea07531243
38 changed files with 7692 additions and 0 deletions

4
.dev.vars.example Normal file
View File

@@ -0,0 +1,4 @@
# 复制为 .dev.vars勿提交仓库
# 第一行必须是 ADMIN_TOKEN=,中间不要有空格
ADMIN_TOKEN=shumengya520
# ENCRYPTION_KEY= 可选

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
.wrangler/
*.local
.dev.vars
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

115
README.md Normal file
View File

@@ -0,0 +1,115 @@
# modelping · 萌芽Ping
在 Cloudflare Workers 上运行的大模型 API **流式首字延迟**与**可用性**监控面板React + Vite 前端Static Assets CDN+ D1 存储(默认保留 30 天探测记录)+ 每分钟 Cron 调度。
## 功能概要
- 管理端配置API 根地址、API Key、模型名、协议**OpenAI Chat Completions 经典** / **OpenAI Responses 新版** / Claude Anthropic
- 探测间隔1、5、10、30、60、360分钟
- 公开仪表盘24h / 30d 可用率、按日时间条、最近状态码与首字延迟。
- 管理员入口:访问 **`/admin?token=<ADMIN_TOKEN>`**(与 `.dev.vars` / Secrets 中 `ADMIN_TOKEN` 一致即可,示例 `shumengya520`)。校验通过后 token 写入当前浏览器 `sessionStorage`,并自动 **`replace``/admin`**(从地址栏去掉 token减少泄露
- 一键部署:`npm run build && wrangler deploy`Cloudflare Vite 插件会自动使用 `dist/modelping` 构建产物与资源目录)。
## 前置条件
- Node.js 20+
- Cloudflare 账号与 [Wrangler](https://developers.cloudflare.com/workers/wrangler/) 登录(`npx wrangler login`
## 初始化 D1
```bash
npx wrangler d1 create modelping
```
将输出中的 `database_id` 写入根目录 [`wrangler.json`](wrangler.json) 里 `d1_databases[0].database_id`(替换 `replace-with-your-d1-database-id`)。
应用迁移本地与远程各执行一次按你开发需要升级含「OpenAI 新版 Responses」协议时请确保已执行最新 SQL
```bash
npx wrangler d1 migrations apply modelping --local
npx wrangler d1 migrations apply modelping --remote
```
## 密钥与环境变量
| 变量 | 说明 |
|------|------|
| `ADMIN_TOKEN` | 管理 API Bearer Token本地可复制 [`.dev.vars.example`](.dev.vars.example) 为 `.dev.vars`。生产务必执行 `npx wrangler secret put ADMIN_TOKEN`。 |
| `ENCRYPTION_KEY` | 可选。32 字节 Base64或任意字符串将做哈希派生。不设时`ADMIN_TOKEN` 派生加密密钥(仅建议用于内网/演示)。 |
本地开发:
```bash
cp .dev.vars.example .dev.vars
# 编辑 .dev.vars
```
## 开发与部署
```bash
npm install
npm run dev
```
生产构建与部署:
```bash
npm run deploy
```
等价于 `tsc -b && vite build && wrangler deploy`。Wrangler 会使用重定向后的 `dist/modelping/wrangler.json` 上传 Worker 与 `dist/client` 静态资源。
## 出现「服务端未配置 ADMIN_TOKEN」503怎么修
含义:当前运行的 Worker **读不到** `ADMIN_TOKEN`(既不是 Secret本地也没从 `.dev.vars` 注入)。
**线上(你已 `wrangler deploy` 的网站)**
`.dev.vars` **只对本地 dev 有效**,不会跟着部署上去。在项目目录执行:
```bash
npx wrangler secret put ADMIN_TOKEN
```
按提示粘贴与 URL 里一致的口令(例如 `shumengya520`)。成功后无需改代码,再访问 `/admin?token=...` 即可。
也可使用快捷脚本:`npm run secret:admin`(同上,仍是交互式输入 Secret
**本地(`npm run dev`**
1. 项目根目录必须有 **`.dev.vars`**,且**第一行**建议直接写:`ADMIN_TOKEN=你的口令`(不要先写一大段注释,部分环境解析更稳)。
2. 保存后 **关掉 dev 再重新执行** `npm run dev`
3. 浏览器访问的必须是 dev 打印出的地址(如 `http://localhost:5173/admin?token=你的口令`),不要误用线上域名或 `vite preview` 的静态站(可能没有 Worker
## 管理员登录排障(`/admin?token=...` 进不去)
1. 打开浏览器 **开发者工具 → Network**,访问 `/admin?token=你的口令`,查看 **`/api/admin/ping`**
- **(失败 / 无记录)**:当前页面没有打到本项目的 Worker。本地请使用 **`npm run dev`**Cloudflare Vite 插件会同时起前端与 Worker不要只用纯静态预览访问否则没有 `/api/*`
- **503 + `admin_token_not_configured`**Worker 上 **没有可用的 `ADMIN_TOKEN`**。本地检查项目根目录 **`.dev.vars`** 并重启 dev已部署环境必须执行 **`npx wrangler secret put ADMIN_TOKEN`**`.dev.vars` **不会**随 `deploy` 传到线上)。
- **401**:请求里的 token 与 `ADMIN_TOKEN` **不一致**(含多余空格时,服务端与 URL 两侧已做 trim但仍需内容一致
- **200**:校验已通过;若仍停在说明页,请看 **Application → Session Storage** 是否写入 `modelping_admin_token`,并尝试硬性刷新或排除扩展拦截。
2. 未登录页面上方如显示 **红色提示条**,即为本次 URL 校验失败原因(此前固定文案下不会显示,容易造成「后台没做」的误解)。
## 默认内网口令(仅示例)
本地示例默认 Admin Token 为 `shumengya520`。**线上环境请在 Dashboard 或 `wrangler secret put` 修改为强随机字符串。**
## API 摘要
| 方法 | 路径 | 认证 | 说明 |
|------|-----|------|------|
| GET | `/api/monitors` | 无 | 公开列表与聚合统计 |
| GET | `/api/monitors/:id` | 无 | 单条详情 |
| GET | `/api/admin/ping` | `Authorization: Bearer <ADMIN_TOKEN>` | 校验 Token |
| GET/POST/PUT/DELETE | `/api/admin/monitors`… | Bearer | 增删改监控;`POST .../:id/run` 立即探测一次 |
## 备注
- Cron 表达式为每分钟 `* * * * *`,在 Worker 内根据 `next_run_at``interval_minutes` 决定是否真正发起外呼。
- 探测使用 `stream: true`,以**首个内容增量**到达时间作为首字延迟。
- Worker 脚本体积请保持精简;前端由 CDN 提供,整体符合常见 Workers 体积限制实践。
## 许可证
MIT若需其他许可证请自行替换。

22
eslint.config.js Normal file
View File

@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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>

31
migrations/0001_init.sql Normal file
View File

@@ -0,0 +1,31 @@
-- modelping: monitors + probe events (30d retention enforced in worker)
CREATE TABLE monitors (
id TEXT PRIMARY KEY NOT NULL,
display_name TEXT NOT NULL,
api_base_url TEXT NOT NULL,
model TEXT NOT NULL,
protocol TEXT NOT NULL CHECK (protocol IN ('openai', 'claude')),
interval_minutes INTEGER NOT NULL CHECK (interval_minutes IN (1, 5, 10, 30, 60, 360)),
enabled INTEGER NOT NULL DEFAULT 1,
category TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
api_key_ciphertext BLOB NOT NULL,
api_key_nonce BLOB NOT NULL,
last_run_at INTEGER,
next_run_at INTEGER NOT NULL
);
CREATE INDEX idx_monitors_next_run ON monitors (enabled, next_run_at);
CREATE TABLE probe_events (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
monitor_id TEXT NOT NULL,
ts INTEGER NOT NULL,
ok INTEGER NOT NULL,
first_token_ms INTEGER,
http_status INTEGER,
error_message TEXT
);
CREATE INDEX idx_probe_events_monitor_ts ON probe_events (monitor_id, ts);

View File

@@ -0,0 +1,24 @@
-- Allow OpenAI Responses API alongside Chat Completions (SQLite cannot alter CHECK in-place)
CREATE TABLE monitors_new (
id TEXT PRIMARY KEY NOT NULL,
display_name TEXT NOT NULL,
api_base_url TEXT NOT NULL,
model TEXT NOT NULL,
protocol TEXT NOT NULL CHECK (protocol IN ('openai', 'openai_responses', 'claude')),
interval_minutes INTEGER NOT NULL CHECK (interval_minutes IN (1, 5, 10, 30, 60, 360)),
enabled INTEGER NOT NULL DEFAULT 1,
category TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
api_key_ciphertext BLOB NOT NULL,
api_key_nonce BLOB NOT NULL,
last_run_at INTEGER,
next_run_at INTEGER NOT NULL
);
INSERT INTO monitors_new SELECT * FROM monitors;
DROP TABLE monitors;
ALTER TABLE monitors_new RENAME TO monitors;
CREATE INDEX idx_monitors_next_run ON monitors (enabled, next_run_at);

View File

@@ -0,0 +1,6 @@
-- 可配置探测用语(多行随机)+ 每次探测记录的输入/输出摘要
ALTER TABLE monitors ADD COLUMN probe_prompts TEXT NOT NULL DEFAULT '';
ALTER TABLE probe_events ADD COLUMN probe_input TEXT;
ALTER TABLE probe_events ADD COLUMN probe_output TEXT;

View File

@@ -0,0 +1,48 @@
-- 全局探测间隔 + 探测用语;监控表不再存储 interval_minutes / probe_prompts
CREATE TABLE app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
probe_prompts TEXT NOT NULL DEFAULT '',
probe_interval_minutes INTEGER NOT NULL DEFAULT 5 CHECK (probe_interval_minutes IN (1, 5, 10, 30, 60, 360))
);
INSERT INTO app_settings (id, probe_prompts, probe_interval_minutes) VALUES (1, '', 5);
UPDATE app_settings SET probe_interval_minutes = (
SELECT M.interval_minutes FROM monitors M LIMIT 1
) WHERE EXISTS (SELECT 1 FROM monitors LIMIT 1);
UPDATE app_settings SET probe_prompts = (
SELECT M.probe_prompts FROM monitors M WHERE TRIM(COALESCE(M.probe_prompts, '')) != '' LIMIT 1
) WHERE EXISTS (
SELECT 1 FROM monitors M WHERE TRIM(COALESCE(M.probe_prompts, '')) != ''
);
CREATE TABLE monitors_new (
id TEXT PRIMARY KEY NOT NULL,
display_name TEXT NOT NULL,
api_base_url TEXT NOT NULL,
model TEXT NOT NULL,
protocol TEXT NOT NULL CHECK (protocol IN ('openai', 'openai_responses', 'claude')),
enabled INTEGER NOT NULL DEFAULT 1,
category TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
api_key_ciphertext BLOB NOT NULL,
api_key_nonce BLOB NOT NULL,
last_run_at INTEGER,
next_run_at INTEGER NOT NULL
);
INSERT INTO monitors_new (
id, display_name, api_base_url, model, protocol, enabled, category, created_at,
api_key_ciphertext, api_key_nonce, last_run_at, next_run_at
)
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
FROM monitors;
DROP TABLE monitors;
ALTER TABLE monitors_new RENAME TO monitors;
CREATE INDEX idx_monitors_next_run ON monitors (enabled, next_run_at);

4323
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "modelping",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"deploy": "npm run build && wrangler deploy",
"cf-typegen": "wrangler types",
"secret:admin": "wrangler secret put ADMIN_TOKEN",
"lint": "eslint .",
"preview": "npm run build && vite preview"
},
"dependencies": {
"hono": "^4.12.19",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.1"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.37.1",
"@cloudflare/workers-types": "^4.20260517.1",
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"wrangler": "^4.92.0"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<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>

After

Width:  |  Height:  |  Size: 4.9 KiB

18
src/App.tsx Normal file
View File

@@ -0,0 +1,18 @@
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>
);
}

143
src/api.ts Normal file
View File

@@ -0,0 +1,143 @@
import type { AdminMonitorRow, GlobalProbeSettings, MonitorDto, MonitorProtocol } from "./types";
import { getAdminToken } from "./types";
export async function fetchMonitors(): Promise<MonitorDto[]> {
const r = await fetch("/api/monitors");
if (!r.ok) throw new Error("failed_to_load");
return r.json() as Promise<MonitorDto[]>;
}
export type AdminPingResult =
| { ok: true }
| { ok: false; reason: "not_configured" | "unauthorized" | "network" };
export async function adminPing(token: string): Promise<AdminPingResult> {
try {
const r = await fetch("/api/admin/ping", {
headers: { Authorization: `Bearer ${token.trim()}` },
});
if (r.ok) return { ok: true };
if (r.status === 503) return { ok: false, reason: "not_configured" };
return { ok: false, reason: "unauthorized" };
} catch {
return { ok: false, reason: "network" };
}
}
export async function adminListMonitors(): Promise<AdminMonitorRow[]> {
const t = getAdminToken();
if (!t) throw new Error("no_token");
const r = await fetch("/api/admin/monitors", { headers: { Authorization: `Bearer ${t}` } });
if (!r.ok) throw new Error("admin_list_failed");
return r.json();
}
export async function adminGetProbeSettings(): Promise<GlobalProbeSettings> {
const t = getAdminToken();
if (!t) throw new Error("no_token");
const r = await fetch("/api/admin/probe-settings", { headers: { Authorization: `Bearer ${t}` } });
if (!r.ok) throw new Error("settings_load_failed");
return r.json() as Promise<GlobalProbeSettings>;
}
export async function adminSaveProbeSettings(patch: Partial<GlobalProbeSettings>): Promise<void> {
const t = getAdminToken();
if (!t) throw new Error("no_token");
const r = await fetch("/api/admin/probe-settings", {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify(patch),
});
let body: { error?: string; message?: string } = {};
try {
body = (await r.json()) as { error?: string; message?: string };
} catch {
/* ignore */
}
if (!r.ok) {
const parts = [typeof body.error === "string" ? body.error : null, typeof body.message === "string" ? body.message : null]
.filter(Boolean)
.join(": ");
throw new Error(parts || `保存失败HTTP ${r.status}`);
}
}
type CreateBody = {
display_name: string;
api_base_url: string;
api_key: string;
model: string;
protocol: MonitorProtocol;
category?: string;
enabled?: boolean;
};
export async function adminCreateMonitor(body: CreateBody): Promise<string> {
const t = getAdminToken();
if (!t) throw new Error("no_token");
const r = await fetch("/api/admin/monitors", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error("create_failed");
const j = (await r.json()) as { id: string };
return j.id;
}
export async function adminUpdateMonitor(
id: string,
patch: Partial<CreateBody> & { api_key?: string }
): Promise<void> {
const t = getAdminToken();
if (!t) throw new Error("no_token");
const r = await fetch(`/api/admin/monitors/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify(patch),
});
let body: { error?: string; message?: string } = {};
try {
body = (await r.json()) as { error?: string; message?: string };
} catch {
/* ignore */
}
if (!r.ok) {
const parts = [typeof body.error === "string" ? body.error : null, typeof body.message === "string" ? body.message : null]
.filter(Boolean)
.join(": ");
throw new Error(parts || `保存失败HTTP ${r.status}`);
}
}
export async function adminDeleteMonitor(id: string): Promise<void> {
const t = getAdminToken();
if (!t) throw new Error("no_token");
const r = await fetch(`/api/admin/monitors/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${t}` },
});
if (!r.ok) throw new Error("delete_failed");
}
export async function adminRunMonitor(id: string): Promise<void> {
const t = getAdminToken();
if (!t) throw new Error("no_token");
const r = await fetch(`/api/admin/monitors/${id}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${t}` },
});
let body: { ok?: boolean; error?: string } = {};
try {
body = (await r.json()) as { ok?: boolean; error?: string };
} catch {
/* ignore */
}
if (!r.ok) {
const msg = typeof body.error === "string" && body.error ? body.error : "run_failed";
throw new Error(msg);
}
if (body.ok === false) {
throw new Error(typeof body.error === "string" && body.error ? body.error : "run_failed");
}
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

18
src/components/Layout.tsx Normal file
View File

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

View File

@@ -0,0 +1,158 @@
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>
);
}

726
src/index.css Normal file
View File

@@ -0,0 +1,726 @@
:root {
font-family: system-ui, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.5;
font-weight: 400;
color: #222;
background-color: #f4f6f8;
--green: #2ecc71;
--green-dark: #27ae60;
--red: #e74c3c;
--card: #fff;
--muted: #6b7280;
--shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
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);
}
#root {
min-height: 100vh;
}
a {
color: var(--green-dark);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
position: sticky;
top: 0;
z-index: 20;
}
a.brand {
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: inherit;
padding: 0.35rem 0.5rem;
margin: 0;
border-radius: 10px;
cursor: pointer;
font: inherit;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
a.brand:hover {
text-decoration: none;
}
a.brand:focus-visible {
outline: 2px solid var(--green);
outline-offset: 2px;
}
.admin-code-sample {
display: inline-block;
padding: 0.25rem 0.45rem;
background: #f1f5f9;
border-radius: 6px;
font-size: 0.9rem;
word-break: break-all;
}
.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);
}
.brand-text {
font-weight: 700;
font-size: 1.1rem;
}
.top-nav {
display: flex;
gap: 1rem;
}
.top-nav a {
font-weight: 500;
color: #374151;
}
.main-area {
flex: 1;
padding: 1.25rem;
max-width: 1280px;
margin: 0 auto;
width: 100%;
}
.page {
display: flex;
flex-direction: column;
gap: 1rem;
}
.panel-intro {
background: var(--card);
border-radius: 14px;
padding: 1rem 1.15rem;
box-shadow: var(--shadow);
}
.status-line {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
}
.dot.ok {
background: var(--green);
}
.muted {
color: var(--muted);
}
.small {
font-size: 0.85rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-top: 0.75rem;
}
.pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.pill {
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 999px;
padding: 0.35rem 0.85rem;
background: #fff;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.pill.active {
border-color: var(--green);
background: rgba(46, 204, 113, 0.12);
color: var(--green-dark);
}
.search input {
min-width: 220px;
width: min(420px, 100%);
padding: 0.45rem 0.65rem;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.14);
font: inherit;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.card {
background: var(--card);
border-radius: 16px;
padding: 1rem;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.6rem;
}
.card-title-row {
display: flex;
gap: 0.55rem;
align-items: flex-start;
min-width: 0;
}
.card-name {
margin: 0;
font-size: 1.05rem;
word-break: break-word;
}
.card-sub {
margin: 0.15rem 0 0;
font-size: 0.8rem;
color: var(--muted);
}
.card-head-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.35rem;
flex-shrink: 0;
}
.badge {
font-size: 0.78rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-weight: 600;
}
.badge-ok {
background: rgba(46, 204, 113, 0.16);
color: var(--green-dark);
}
.badge-bad {
background: rgba(231, 76, 60, 0.12);
color: #c0392b;
}
.btn-detail {
display: inline-block;
border: none;
cursor: pointer;
font: inherit;
font-size: 0.78rem;
padding: 0.3rem 0.65rem;
border-radius: 8px;
background: linear-gradient(145deg, var(--green), #58d68d);
color: #fff;
font-weight: 600;
-webkit-tap-highlight-color: transparent;
}
.btn-detail:hover {
filter: brightness(1.03);
}
.btn-detail:focus-visible {
outline: 2px solid var(--green-dark);
outline-offset: 2px;
}
.probe-detail-dialog {
max-width: min(520px, 94vw);
width: 100%;
margin: auto;
border: none;
border-radius: 16px;
padding: 0;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.22);
}
.probe-detail-dialog::backdrop {
background: rgba(15, 23, 42, 0.4);
}
.probe-detail-inner {
padding: 1rem 1.15rem 1.15rem;
}
.probe-detail-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.probe-detail-title {
margin: 0;
font-size: 1.05rem;
}
.probe-detail-close {
border: none;
background: #f1f5f9;
width: 2rem;
height: 2rem;
border-radius: 8px;
font-size: 1.35rem;
line-height: 1;
cursor: pointer;
color: #64748b;
display: grid;
place-items: center;
padding: 0;
}
.probe-detail-close:hover {
background: #e2e8f0;
}
.probe-detail-dl {
margin: 0;
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.35rem 0.75rem;
font-size: 0.88rem;
}
.probe-detail-dl dt {
margin: 0;
color: var(--muted);
font-weight: 500;
}
.probe-detail-dl dd {
margin: 0;
word-break: break-word;
}
.probe-detail-code {
display: block;
font-size: 0.78rem;
background: #f8fafc;
padding: 0.35rem 0.5rem;
border-radius: 8px;
word-break: break-all;
margin-bottom: 0.35rem;
}
.probe-detail-link-wrap {
font-size: 0.85rem;
}
.probe-detail-error {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.probe-detail-error strong {
display: block;
font-size: 0.85rem;
margin-bottom: 0.4rem;
color: #c0392b;
}
.probe-detail-pre {
margin: 0;
font-family: ui-monospace, "Cascadia Code", monospace;
font-size: 0.78rem;
white-space: pre-wrap;
word-break: break-word;
background: #fef2f2;
border: 1px solid rgba(231, 76, 60, 0.2);
border-radius: 8px;
padding: 0.55rem 0.65rem;
max-height: 220px;
overflow: auto;
}
.probe-detail-io {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.probe-detail-io strong {
display: block;
font-size: 0.85rem;
margin-bottom: 0.35rem;
margin-top: 0.65rem;
color: #374151;
}
.probe-detail-io strong:first-child {
margin-top: 0;
}
.probe-detail-io-pre {
margin: 0;
font-family: ui-monospace, "Cascadia Code", monospace;
font-size: 0.78rem;
white-space: pre-wrap;
word-break: break-word;
background: #f8fafc;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 8px;
padding: 0.55rem 0.65rem;
max-height: 200px;
overflow: auto;
}
.probe-detail-io-out {
background: #f0fdf4;
border-color: rgba(46, 204, 113, 0.25);
}
.timeline {
display: flex;
align-items: flex-end;
gap: 2px;
height: 42px;
padding: 4px 2px;
border-radius: 10px;
background: #f8fafc;
border: 1px solid rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.timeline-empty {
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;
align-self: stretch;
background: rgba(46, 204, 113, 0.35);
}
.tl-seg.tl-up {
background: var(--green);
}
.tl-seg.tl-down {
background: var(--red);
}
.card-mid {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: var(--muted);
}
.card-mid strong {
color: #111827;
font-size: 0.95rem;
}
.card-foot {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem 0.5rem;
font-size: 0.78rem;
color: var(--muted);
}
.card-foot-probe-time {
display: flex;
flex-direction: column;
gap: 0.12rem;
min-width: 0;
}
.card-foot-probe-label {
font-size: 0.72rem;
color: var(--muted);
}
.card-foot-probe-value {
font-size: 0.76rem;
color: #374151;
font-weight: 600;
line-height: 1.3;
word-break: break-word;
}
.card-foot-wide {
grid-column: 1 / -1;
font-size: 0.76rem;
}
.card-foot-wide strong.card-foot-count {
font-weight: 700;
color: #1f2937;
}
.banner {
background: rgba(46, 204, 113, 0.1);
border: 1px solid rgba(46, 204, 113, 0.35);
padding: 0.55rem 0.75rem;
border-radius: 10px;
margin: 0;
}
.banner.error {
background: rgba(231, 76, 60, 0.08);
border-color: rgba(231, 76, 60, 0.35);
color: #922b21;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: grid;
place-items: center;
z-index: 9999;
padding: 1rem;
}
.modal {
background: #fff;
border-radius: 12px;
padding: 1rem 1.1rem;
width: min(360px, 100%);
box-shadow: 0 22px 48px rgba(15, 23, 42, 0.28);
}
.modal-title {
margin: 0 0 0.65rem;
font-size: 1rem;
}
.modal-input {
width: 100%;
padding: 0.5rem 0.55rem;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.15);
font: inherit;
}
.modal-err {
color: #c0392b;
font-size: 0.85rem;
margin: 0.35rem 0 0;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.75rem;
}
.btn {
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.12);
padding: 0.4rem 0.75rem;
font: inherit;
cursor: pointer;
background: #fff;
}
.btn.primary {
background: linear-gradient(145deg, var(--green), #58d68d);
color: #fff;
border: none;
font-weight: 600;
}
.btn.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn.ghost {
background: transparent;
}
.btn.small {
font-size: 0.78rem;
padding: 0.3rem 0.55rem;
}
.btn.danger {
border-color: rgba(192, 57, 43, 0.45);
color: #c0392b;
}
.admin-form-section {
background: var(--card);
border-radius: 14px;
padding: 1rem;
box-shadow: var(--shadow);
}
.admin-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0.65rem 1rem;
}
.admin-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #374151;
}
.admin-form input,
.admin-form select,
.admin-form textarea {
padding: 0.45rem 0.5rem;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.14);
font: inherit;
}
.admin-form textarea {
grid-column: 1 / -1;
min-height: 5.5rem;
resize: vertical;
}
.checkbox-row {
flex-direction: row !important;
align-items: center;
gap: 0.4rem !important;
}
.form-actions {
grid-column: 1 / -1;
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.admin-list {
list-style: none;
padding: 0;
margin: 0.5rem 0 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.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-row-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.admin-actions-bar {
display: flex;
justify-content: flex-end;
}
@media (max-width: 640px) {
.topbar {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.card-foot {
grid-template-columns: 1fr;
}
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
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>,
)

415
src/pages/Admin.tsx Normal file
View File

@@ -0,0 +1,415 @@
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>
);
}

92
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { useEffect, useMemo, useState } from "react";
import { fetchMonitors } from "../api";
import { MonitorCard } from "../components/MonitorCard";
import type { MonitorDto } from "../types";
export function Dashboard() {
const [rows, setRows] = useState<MonitorDto[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const [filterCat, setFilterCat] = useState<string>("__all__");
const [q, setQ] = useState("");
const load = () => {
fetchMonitors()
.then(setRows)
.catch(() => setErr("加载失败"));
};
useEffect(() => {
load();
const t = setInterval(load, 60_000);
return () => clearInterval(t);
}, []);
const categories = useMemo(() => {
const s = new Set<string>();
for (const m of rows ?? []) {
if (m.category?.trim()) s.add(m.category.trim());
}
return ["__all__", ...[...s].sort()];
}, [rows]);
const filtered = useMemo(() => {
let list = rows ?? [];
if (filterCat !== "__all__") list = list.filter((m) => (m.category || "").trim() === filterCat);
const qq = q.trim().toLowerCase();
if (qq) {
list = list.filter(
(m) =>
m.display_name.toLowerCase().includes(qq) ||
m.model.toLowerCase().includes(qq) ||
m.api_base_url.toLowerCase().includes(qq)
);
}
return list;
}, [rows, filterCat, q]);
const online = (rows ?? []).filter((m) => m.lastProbe?.ok === 1).length;
const offline = (rows ?? []).filter((m) => m.lastProbe && m.lastProbe.ok !== 1).length;
return (
<div className="page dashboard">
<section className="panel-intro">
<div className="status-line">
<span className="dot ok" />
<span>
{online} 线 | <span className="muted">{offline} 线</span>
</span>
</div>
<div className="toolbar">
<div className="pills">
{categories.map((c) => (
<button
key={c}
type="button"
className={`pill ${filterCat === c ? "active" : ""}`}
onClick={() => setFilterCat(c)}
>
{c === "__all__" ? "全部" : c}
</button>
))}
</div>
<label className="search">
<span className="sr-only"></span>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="搜索名称、模型或地址…" />
</label>
</div>
</section>
{err ? <p className="banner error">{err}</p> : null}
{!rows ? <p className="muted"></p> : null}
{rows?.length === 0 ? <p className="muted"></p> : null}
<div className="card-grid">
{filtered.map((m) => (
<MonitorCard key={m.id} m={m} />
))}
</div>
</div>
);
}

64
src/types.ts Normal file
View File

@@ -0,0 +1,64 @@
export type MonitorProtocol = "openai" | "openai_responses" | "claude";
export type MonitorDto = {
id: string;
display_name: string;
api_base_url: string;
model: string;
protocol: MonitorProtocol;
interval_minutes: number;
enabled: number;
category: string;
created_at: number;
last_run_at: number | null;
next_run_at: number;
availability24h: number | null;
availability30d: number | null;
probe_count: number;
lastProbe: {
ts: number;
ok: number;
first_token_ms: number | null;
http_status: number | null;
error_message: string | null;
probe_input: string | null;
probe_output: string | null;
} | null;
timelineDaily: Array<{ t: number; up: boolean; ratio: number }>;
};
/** 管理后台列表无统计字段interval_minutes 为全站统一值) */
export type AdminMonitorRow = Pick<
MonitorDto,
| "id"
| "display_name"
| "api_base_url"
| "model"
| "protocol"
| "interval_minutes"
| "enabled"
| "category"
| "created_at"
| "last_run_at"
| "next_run_at"
>;
/** /api/admin/probe-settings 与后台表单 */
export type GlobalProbeSettings = {
probe_interval_minutes: number;
probe_prompts: string;
};
export const ADMIN_TOKEN_KEY = "modelping_admin_token";
export function getAdminToken(): string | null {
return sessionStorage.getItem(ADMIN_TOKEN_KEY);
}
export function setAdminToken(token: string): void {
sessionStorage.setItem(ADMIN_TOKEN_KEY, token);
}
export function clearAdminToken(): void {
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
}

68
src/worker/crypto.ts Normal file
View File

@@ -0,0 +1,68 @@
/** AES-GCM encrypt/decrypt for API keys at rest. */
function encEncoder(): TextEncoder {
return new TextEncoder();
}
async function getCryptoKey(env: Env): Promise<CryptoKey> {
const raw = await resolveRawKey(env);
return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
}
async function resolveRawKey(env: Env): Promise<ArrayBuffer> {
if (env.ENCRYPTION_KEY?.trim()) {
const s = env.ENCRYPTION_KEY.trim();
try {
const buf = Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
if (buf.byteLength === 32) return buf.buffer;
} catch {
/* fall through */
}
const digest = await crypto.subtle.digest("SHA-256", encEncoder().encode(s));
return digest;
}
const digest = await crypto.subtle.digest("SHA-256", encEncoder().encode(env.ADMIN_TOKEN));
return digest;
}
export async function encryptSecret(plain: string, env: Env): Promise<{ ciphertext: ArrayBuffer; nonce: Uint8Array }> {
const key = await getCryptoKey(env);
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, encEncoder().encode(plain));
return { ciphertext, nonce };
}
/** Normalize D1 BLOB columns: cloud/local may return ArrayBuffer, Uint8Array, or number[]. */
export function d1BlobToUint8Array(v: unknown): Uint8Array {
if (v == null) throw new TypeError("blob is null or undefined");
if (v instanceof Uint8Array) return v;
if (v instanceof ArrayBuffer) return new Uint8Array(v);
if (Array.isArray(v)) return new Uint8Array(v as number[]);
if (typeof v === "object" && "buffer" in v && (v as ArrayBufferView).buffer instanceof ArrayBuffer) {
const t = v as ArrayBufferView;
return new Uint8Array(t.buffer.slice(t.byteOffset, t.byteOffset + t.byteLength));
}
throw new TypeError("unsupported BLOB shape from D1");
}
export async function decryptSecret(ciphertext: unknown, nonce: unknown, env: Env): Promise<string> {
const ct = d1BlobToUint8Array(ciphertext);
const iv = d1BlobToUint8Array(nonce);
const key = await getCryptoKey(env);
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, key, ct);
return new TextDecoder().decode(plain);
}
export function toB64(buf: ArrayBuffer): string {
const u8 = new Uint8Array(buf);
let s = "";
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]!);
return btoa(s);
}
export function fromB64(s: string): ArrayBuffer {
const bin = atob(s);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return u8.buffer;
}

324
src/worker/db.ts Normal file
View File

@@ -0,0 +1,324 @@
import { d1BlobToUint8Array } from "./crypto";
import type { Protocol } from "./probe";
const THIRTY_DAYS_SEC = 30 * 24 * 60 * 60;
export async function pruneProbeEvents(db: D1Database): Promise<void> {
const cutoff = Math.floor(Date.now() / 1000) - THIRTY_DAYS_SEC;
await db.prepare("DELETE FROM probe_events WHERE ts < ?").bind(cutoff).run();
}
export type AppSettingsRow = {
id: number;
probe_prompts: string;
probe_interval_minutes: number;
};
export async function getAppSettings(db: D1Database): Promise<AppSettingsRow> {
const r = await db
.prepare("SELECT id, probe_prompts, probe_interval_minutes FROM app_settings WHERE id = 1")
.first<AppSettingsRow>();
if (r) return r;
await db
.prepare("INSERT INTO app_settings (id, probe_prompts, probe_interval_minutes) VALUES (1, '', 5)")
.run();
return { id: 1, probe_prompts: "", probe_interval_minutes: 5 };
}
export async function updateAppSettings(
db: D1Database,
patch: Partial<Pick<AppSettingsRow, "probe_prompts" | "probe_interval_minutes">>
): Promise<void> {
const cur = await getAppSettings(db);
const next = {
probe_prompts: patch.probe_prompts !== undefined ? patch.probe_prompts : cur.probe_prompts,
probe_interval_minutes:
patch.probe_interval_minutes !== undefined ? patch.probe_interval_minutes : cur.probe_interval_minutes,
};
await db
.prepare("UPDATE app_settings SET probe_prompts = ?, probe_interval_minutes = ? WHERE id = 1")
.bind(next.probe_prompts, next.probe_interval_minutes)
.run();
}
/** 库中存证的监控行(不含全站探测间隔,间隔在 app_settings */
export type MonitorRow = {
id: string;
display_name: string;
api_base_url: string;
model: string;
protocol: Protocol;
enabled: number;
category: string;
created_at: number;
api_key_ciphertext: ArrayBuffer;
api_key_nonce: ArrayBuffer;
last_run_at: number | null;
next_run_at: number;
};
/** 列表 API 在合并 interval_minutes 之前的行 */
export type MonitorPublic = {
id: string;
display_name: string;
api_base_url: string;
model: string;
protocol: Protocol;
enabled: number;
category: string;
created_at: number;
last_run_at: number | null;
next_run_at: number;
};
export async function listMonitorsPublic(db: D1Database): Promise<MonitorPublic[]> {
const r = await db
.prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
created_at, last_run_at, next_run_at
FROM monitors ORDER BY display_name ASC`
)
.all();
return (r.results ?? []) as unknown as MonitorPublic[];
}
export async function listAllMonitors(db: D1Database): Promise<MonitorRow[]> {
const r = await db
.prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at
FROM monitors ORDER BY display_name ASC`
)
.all();
return (r.results ?? []) as unknown as MonitorRow[];
}
export async function listDueMonitors(db: D1Database, nowSec: number): Promise<MonitorRow[]> {
const r = await db
.prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at
FROM monitors WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC`
)
.bind(nowSec)
.all();
return (r.results ?? []) as unknown as MonitorRow[];
}
export async function getMonitor(db: D1Database, id: string): Promise<MonitorRow | null> {
const r = await db
.prepare(
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at
FROM monitors WHERE id = ?`
)
.bind(id)
.first();
return (r as unknown as MonitorRow) ?? null;
}
export async function insertMonitor(
db: D1Database,
row: Omit<MonitorRow, "last_run_at"> & { last_run_at: number | null }
): Promise<void> {
const ct = d1BlobToUint8Array(row.api_key_ciphertext as unknown);
const nn = d1BlobToUint8Array(row.api_key_nonce as unknown);
await db
.prepare(
`INSERT INTO monitors (id, display_name, api_base_url, model, protocol, enabled, category,
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
row.id,
row.display_name,
row.api_base_url,
row.model,
row.protocol,
row.enabled,
row.category,
row.created_at,
ct,
nn,
row.last_run_at,
row.next_run_at
)
.run();
}
export async function updateMonitorMeta(
db: D1Database,
id: string,
patch: Partial<{
display_name: string;
api_base_url: string;
model: string;
protocol: Protocol;
category: string;
enabled: number;
api_key_ciphertext: ArrayBuffer;
api_key_nonce: ArrayBuffer;
next_run_at: number;
}>
): Promise<void> {
const cur = await getMonitor(db, id);
if (!cur) return;
const next = { ...cur, ...patch };
const ct = d1BlobToUint8Array(next.api_key_ciphertext as unknown);
const nn = d1BlobToUint8Array(next.api_key_nonce as unknown);
await db
.prepare(
`UPDATE monitors SET display_name=?, api_base_url=?, model=?, protocol=?,
enabled=?, category=?, api_key_ciphertext=?, api_key_nonce=?, next_run_at=?
WHERE id=?`
)
.bind(
next.display_name,
next.api_base_url,
next.model,
next.protocol,
next.enabled,
next.category,
ct,
nn,
next.next_run_at,
id
)
.run();
}
export async function deleteMonitor(db: D1Database, id: string): Promise<void> {
await db.prepare("DELETE FROM probe_events WHERE monitor_id = ?").bind(id).run();
await db.prepare("DELETE FROM monitors WHERE id = ?").bind(id).run();
}
export async function insertProbeEvent(
db: D1Database,
e: {
monitor_id: string;
ts: number;
ok: number;
first_token_ms: number | null;
http_status: number | null;
error_message: string | null;
probe_input: string | null;
probe_output: string | null;
}
): Promise<void> {
await db
.prepare(
`INSERT INTO probe_events (monitor_id, ts, ok, first_token_ms, http_status, error_message, probe_input, probe_output)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
e.monitor_id,
e.ts,
e.ok,
e.first_token_ms,
e.http_status,
e.error_message,
e.probe_input,
e.probe_output
)
.run();
}
export async function updateMonitorRunTimes(
db: D1Database,
id: string,
last_run_at: number,
next_run_at: number
): Promise<void> {
await db
.prepare("UPDATE monitors SET last_run_at = ?, next_run_at = ? WHERE id = ?")
.bind(last_run_at, next_run_at, id)
.run();
}
/** Per-day bucket: dayStart unix sec at UTC midnight approximation (floor to day in UTC) */
export async function availabilityAndTimeline(
db: D1Database,
monitorId: string
): Promise<{
availability24h: number | null;
availability30d: number | null;
probe_count: number;
daily: Array<{ dayStart: number; ok: number; total: number }>;
lastProbe: {
ts: number;
ok: number;
first_token_ms: number | null;
http_status: number | null;
error_message: string | null;
probe_input: string | null;
probe_output: string | null;
} | null;
}> {
const now = Math.floor(Date.now() / 1000);
const start24 = now - 86400;
const start30 = now - THIRTY_DAYS_SEC;
const lastProbe = await db
.prepare(
`SELECT ts, ok, first_token_ms, http_status, error_message, probe_input, probe_output FROM probe_events WHERE monitor_id = ? ORDER BY ts DESC LIMIT 1`
)
.bind(monitorId)
.first<{
ts: number;
ok: number;
first_token_ms: number | null;
http_status: number | null;
error_message: string | null;
probe_input: string | null;
probe_output: string | null;
}>();
const stats24 = await db
.prepare(
`SELECT SUM(ok) AS okc, COUNT(*) AS total FROM probe_events WHERE monitor_id = ? AND ts >= ?`
)
.bind(monitorId, start24)
.first<{ okc: number | null; total: number | null }>();
const stats30 = await db
.prepare(
`SELECT SUM(ok) AS okc, COUNT(*) AS total FROM probe_events WHERE monitor_id = ? AND ts >= ?`
)
.bind(monitorId, start30)
.first<{ okc: number | null; total: number | null }>();
const rows = await db
.prepare(
`SELECT (CAST(ts / 86400 AS INTEGER) * 86400) AS day_start,
SUM(ok) AS okc,
COUNT(*) AS cnt
FROM probe_events WHERE monitor_id = ? AND ts >= ?
GROUP BY day_start ORDER BY day_start ASC`
)
.bind(monitorId, start30)
.all();
const countRow = await db
.prepare(`SELECT COUNT(*) AS c FROM probe_events WHERE monitor_id = ?`)
.bind(monitorId)
.first<{ c: number | null }>();
const daily = (rows.results ?? []).map((r) => ({
dayStart: Number((r as { day_start: number }).day_start),
ok: Number((r as { okc: number | null }).okc ?? 0),
total: Number((r as { cnt: number | null }).cnt ?? 0),
}));
const availability24h =
stats24?.total && stats24.total > 0 ? (Number(stats24.okc ?? 0) / Number(stats24.total)) * 100 : null;
const availability30d =
stats30?.total && stats30.total > 0 ? (Number(stats30.okc ?? 0) / Number(stats30.total)) * 100 : null;
return {
availability24h,
availability30d,
probe_count: Number(countRow?.c ?? 0),
daily,
lastProbe: lastProbe ?? null,
};
}

262
src/worker/index.ts Normal file
View File

@@ -0,0 +1,262 @@
import { Hono } from "hono";
import { availabilityAndTimeline, getAppSettings, getMonitor, listMonitorsPublic, updateAppSettings } from "./db";
import {
createMonitorFromPayload,
deleteMonitor,
runScheduled,
runSingleProbe,
updateMonitorFromPayload,
} from "./scheduler";
type MonitorCreateBody = {
display_name: string;
api_base_url: string;
api_key: string;
model: string;
protocol: "openai" | "openai_responses" | "claude";
category?: string;
enabled?: boolean;
};
const app = new Hono<{ Bindings: Env }>();
const ALLOWED = new Set([1, 5, 10, 30, 60, 360]);
function checkAdmin(c: { req: { header: (k: string) => string | undefined }; env: Env }): Response | null {
const configured = (c.env.ADMIN_TOKEN ?? "").trim();
if (!configured) {
return new Response(
JSON.stringify({
error: "admin_token_not_configured",
message: "Configure ADMIN_TOKEN in .dev.vars (local) or wrangler secret put ADMIN_TOKEN (production).",
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
const h = c.req.header("Authorization") ?? "";
const m = /^Bearer\s+(.+)$/i.exec(h);
const bearer = (m?.[1] ?? "").trim();
if (bearer !== configured) {
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
return null;
}
async function parseMonitorCreate(c: { req: { json: () => Promise<unknown> } }): Promise<MonitorCreateBody | null> {
let body: unknown;
try {
body = await c.req.json();
} catch {
return null;
}
if (!body || typeof body !== "object") return null;
const o = body as Record<string, unknown>;
const display_name = typeof o.display_name === "string" ? o.display_name : "";
const api_base_url = typeof o.api_base_url === "string" ? o.api_base_url : "";
const api_key = typeof o.api_key === "string" ? o.api_key : "";
const model = typeof o.model === "string" ? o.model : "";
const protocol =
o.protocol === "openai" || o.protocol === "openai_responses" || o.protocol === "claude" ? o.protocol : null;
if (!display_name.trim() || !api_base_url.trim() || !api_key.trim() || !model.trim() || !protocol) {
return null;
}
const category = typeof o.category === "string" ? o.category : undefined;
const enabled = typeof o.enabled === "boolean" ? o.enabled : undefined;
return {
display_name,
api_base_url,
api_key,
model,
protocol,
category,
enabled,
};
}
async function withIntervalForApi(
db: D1Database,
rows: Awaited<ReturnType<typeof listMonitorsPublic>>
): Promise<Array<(typeof rows)[number] & { interval_minutes: number }>> {
const s = await getAppSettings(db);
const iv = s.probe_interval_minutes;
return rows.map((m) => ({ ...m, interval_minutes: iv }));
}
app.get("/api/monitors", async (c) => {
const db = c.env.DB;
const rows = await listMonitorsPublic(db);
const merged = await withIntervalForApi(db, rows);
const out = [];
for (const m of merged) {
const stats = await availabilityAndTimeline(db, m.id);
const timelineDaily = stats.daily.map((d) => ({
t: d.dayStart,
up: d.total > 0 && d.ok === d.total,
ratio: d.total > 0 ? d.ok / d.total : 0,
}));
out.push({
...m,
availability24h: stats.availability24h,
availability30d: stats.availability30d,
probe_count: stats.probe_count,
lastProbe: stats.lastProbe,
timelineDaily,
});
}
return c.json(out);
});
app.get("/api/monitors/:id", async (c) => {
const id = c.req.param("id");
const db = c.env.DB;
const m = await getMonitor(db, id);
if (!m) return c.json({ error: "not_found" }, 404);
const s = await getAppSettings(db);
const stats = await availabilityAndTimeline(db, id);
const timelineDaily = stats.daily.map((d) => ({
t: d.dayStart,
up: d.total > 0 && d.ok === d.total,
ratio: d.total > 0 ? d.ok / d.total : 0,
}));
return c.json({
id: m.id,
display_name: m.display_name,
api_base_url: m.api_base_url,
model: m.model,
protocol: m.protocol,
interval_minutes: s.probe_interval_minutes,
enabled: m.enabled,
category: m.category,
created_at: m.created_at,
last_run_at: m.last_run_at,
next_run_at: m.next_run_at,
availability24h: stats.availability24h,
availability30d: stats.availability30d,
probe_count: stats.probe_count,
lastProbe: stats.lastProbe,
timelineDaily,
});
});
app.get("/api/admin/ping", (c) => {
const err = checkAdmin(c);
if (err) return err;
return c.json({ ok: true });
});
app.get("/api/admin/probe-settings", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const s = await getAppSettings(c.env.DB);
return c.json({
probe_prompts: s.probe_prompts,
probe_interval_minutes: s.probe_interval_minutes,
});
});
app.put("/api/admin/probe-settings", async (c) => {
const err = checkAdmin(c);
if (err) return err;
let body: Record<string, unknown>;
try {
body = (await c.req.json()) as Record<string, unknown>;
} catch {
return c.json({ error: "invalid_json" }, 400);
}
const patch: Partial<{ probe_prompts: string; probe_interval_minutes: number }> = {};
if (typeof body.probe_prompts === "string") patch.probe_prompts = body.probe_prompts;
if (typeof body.probe_interval_minutes === "number") {
if (!ALLOWED.has(body.probe_interval_minutes)) {
return c.json({ error: "invalid_interval" }, 400);
}
patch.probe_interval_minutes = body.probe_interval_minutes as 1 | 5 | 10 | 30 | 60 | 360;
}
if (Object.keys(patch).length === 0) return c.json({ error: "invalid_body" }, 400);
try {
await updateAppSettings(c.env.DB, patch);
return c.json({ ok: true });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return c.json({ error: "update_failed", message }, 500);
}
});
app.get("/api/admin/monitors", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const db = c.env.DB;
const rows = await listMonitorsPublic(db);
return c.json(await withIntervalForApi(db, rows));
});
app.post("/api/admin/monitors", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const parsed = await parseMonitorCreate(c);
if (!parsed) return c.json({ error: "invalid_body" }, 400);
const { id } = await createMonitorFromPayload(c.env, c.env.DB, parsed);
return c.json({ id }, 201);
});
app.put("/api/admin/monitors/:id", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const id = c.req.param("id");
let body: Record<string, unknown>;
try {
body = (await c.req.json()) as Record<string, unknown>;
} catch {
return c.json({ error: "invalid_json" }, 400);
}
const patch: Parameters<typeof updateMonitorFromPayload>[3] = {};
if (typeof body.display_name === "string") patch.display_name = body.display_name;
if (typeof body.api_base_url === "string") patch.api_base_url = body.api_base_url;
if (typeof body.api_key === "string") patch.api_key = body.api_key;
if (typeof body.model === "string") patch.model = body.model;
if (body.protocol === "openai" || body.protocol === "openai_responses" || body.protocol === "claude")
patch.protocol = body.protocol;
if (typeof body.category === "string") patch.category = body.category;
if (typeof body.enabled === "boolean") patch.enabled = body.enabled;
try {
const ok = await updateMonitorFromPayload(c.env, c.env.DB, id, patch);
if (!ok) return c.json({ error: "not_found" }, 404);
return c.json({ ok: true });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return c.json({ error: "update_failed", message }, 500);
}
});
app.delete("/api/admin/monitors/:id", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const id = c.req.param("id");
const m = await getMonitor(c.env.DB, id);
if (!m) return c.json({ error: "not_found" }, 404);
await deleteMonitor(c.env.DB, id);
return c.json({ ok: true });
});
app.post("/api/admin/monitors/:id/run", async (c) => {
const err = checkAdmin(c);
if (err) return err;
const id = c.req.param("id");
const m = await getMonitor(c.env.DB, id);
if (!m) return c.json({ error: "not_found" }, 404);
const nowSec = Math.floor(Date.now() / 1000);
const outcome = await runSingleProbe(c.env, id, c.env.DB, nowSec);
if (!outcome.success) {
return c.json({ ok: false, error: outcome.error }, 200);
}
return c.json({ ok: true });
});
export default {
fetch: app.fetch,
scheduled: (_event: ScheduledEvent, env: Env, ctx: ExecutionContext) => {
ctx.waitUntil(runScheduled(env));
},
};

382
src/worker/probe.ts Normal file
View File

@@ -0,0 +1,382 @@
export type Protocol = "openai" | "openai_responses" | "claude";
const PROBE_TIMEOUT_MS = 45_000;
const PROBE_MAX_TOKENS = 256;
/** 存入 D1 前在 Worker 侧截断的流式输出上限 */
const STREAM_OUTPUT_CAP = 4096;
/** 非 2xx 时读取响应体截断长度scheduler 还会再截断入库) */
const HTTP_ERROR_BODY_MAX = 6000;
function tryFormatJsonBody(raw: string): string {
const t = raw.trim();
if (!t.startsWith("{") && !t.startsWith("[")) return raw;
try {
return JSON.stringify(JSON.parse(t), null, 2);
} catch {
return raw;
}
}
async function readHttpErrorBody(res: Response): Promise<string> {
try {
const raw = (await res.text()).trim();
if (!raw) return "";
const formatted = tryFormatJsonBody(raw);
if (formatted.length <= HTTP_ERROR_BODY_MAX) return formatted;
return formatted.slice(0, HTTP_ERROR_BODY_MAX) + "…";
} catch {
return "";
}
}
function buildHttpErrorMessage(res: Response, body: string): string {
const line = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
if (!body) return `${line}\n响应体为空常见原因网关未返回 JSON 错误详情)`;
return `${line}\n\n${body}`;
}
function normalizeBase(url: string): string {
return url.replace(/\/+$/, "");
}
function appendCap(base: string, add: string, max: number): string {
if (base.length >= max) return base;
const room = max - base.length;
return base + (add.length <= room ? add : add.slice(0, room));
}
/** 经典 Chat Completions/v1/chat/completions兼容绝大多数 OpenAI 兼容网关 */
function openAiChatUrl(base: string): string {
const b = normalizeBase(base);
if (b.endsWith("/chat/completions")) return b;
if (b.endsWith("/v1")) return `${b}/chat/completions`;
return `${b}/chat/completions`;
}
/** OpenAI Responses API/v1/responses */
function openAiResponsesUrl(base: string): string {
const b = normalizeBase(base);
if (b.endsWith("/responses")) return b;
if (b.endsWith("/v1")) return `${b}/responses`;
return `${b}/v1/responses`;
}
function claudeUrl(base: string): string {
const b = normalizeBase(base);
if (b.endsWith("/messages")) return b;
if (b.endsWith("/v1")) return `${b}/messages`;
return `${b}/v1/messages`;
}
async function readStreamOpenAIChat(
res: Response,
started: number
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
if (!res.ok || !res.body) {
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let firstTokenMs: number | null = null;
let outputText = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const t = line.trim();
if (!t.startsWith("data:")) continue;
const payload = t.slice(5).trim();
if (payload === "[DONE]") {
return { firstTokenMs, httpStatus: res.status, outputText };
}
try {
const obj = JSON.parse(payload) as {
choices?: Array<{ delta?: { content?: string } }>;
};
const c = obj.choices?.[0]?.delta?.content;
if (c != null && c !== "") {
if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started);
outputText = appendCap(outputText, c, STREAM_OUTPUT_CAP);
}
} catch {
/* ignore bad json line */
}
}
}
} finally {
reader.releaseLock();
}
return { firstTokenMs, httpStatus: res.status, outputText };
}
async function readStreamOpenAIResponses(
res: Response,
started: number
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
if (!res.ok || !res.body) {
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let firstTokenMs: number | null = null;
let outputText = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const t = line.trim();
if (!t.startsWith("data:")) continue;
const payload = t.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const obj = JSON.parse(payload) as { type?: string; delta?: string };
if (obj.type === "response.output_text.delta" && obj.delta != null && obj.delta !== "") {
if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started);
outputText = appendCap(outputText, obj.delta, STREAM_OUTPUT_CAP);
}
} catch {
/* ignore */
}
}
}
} finally {
reader.releaseLock();
}
return { firstTokenMs, httpStatus: res.status, outputText };
}
/** 首字可来自正文或 thinking输出只累计 assistant 可见正文 delta.text */
function claudeStreamDeltaParts(
data: string,
currentEvent: string
): { outputChunk: string | null; marksFirstToken: boolean } {
try {
const obj = JSON.parse(data) as {
type?: string;
delta?: { type?: string; text?: string; thinking?: string };
};
const match = currentEvent === "content_block_delta" || obj.type === "content_block_delta";
if (!match) return { outputChunk: null, marksFirstToken: false };
const d = obj.delta;
if (!d) return { outputChunk: null, marksFirstToken: false };
const hasThinking = typeof d.thinking === "string" && d.thinking !== "";
const hasText = typeof d.text === "string" && d.text !== "";
return {
outputChunk: hasText ? (d.text as string) : null,
marksFirstToken: hasThinking || hasText,
};
} catch {
return { outputChunk: null, marksFirstToken: false };
}
}
async function readStreamClaude(
res: Response,
started: number
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
if (!res.ok || !res.body) {
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
let currentEvent = "";
let firstTokenMs: number | null = null;
let outputText = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf("\n")) >= 0) {
let line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (line.endsWith("\r")) line = line.slice(0, -1);
if (line === "") {
currentEvent = "";
continue;
}
if (line.startsWith("event:")) {
currentEvent = line.slice(6).trim();
continue;
}
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
const parts = claudeStreamDeltaParts(data, currentEvent);
if (parts.marksFirstToken && firstTokenMs == null) {
firstTokenMs = Math.max(0, Date.now() - started);
}
if (parts.outputChunk) {
outputText = appendCap(outputText, parts.outputChunk, STREAM_OUTPUT_CAP);
}
}
}
} finally {
reader.releaseLock();
}
return { firstTokenMs, httpStatus: res.status, outputText };
}
export type ProbeResult = {
ok: boolean;
firstTokenMs: number | null;
httpStatus: number | null;
errorMessage: string | null;
requestMessage: string;
responseText: string | null;
};
export async function runProbe(params: {
apiBaseUrl: string;
apiKey: string;
model: string;
protocol: Protocol;
userMessage: string;
}): Promise<ProbeResult> {
const userMessage = params.userMessage.trim() || "ping";
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
const started = Date.now();
try {
if (params.protocol === "openai") {
const url = openAiChatUrl(params.apiBaseUrl);
const res = await fetch(url, {
method: "POST",
signal: ac.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: params.model,
messages: [{ role: "user", content: userMessage }],
max_tokens: PROBE_MAX_TOKENS,
stream: true,
}),
});
if (!res.ok) {
const errBody = await readHttpErrorBody(res);
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: buildHttpErrorMessage(res, errBody),
requestMessage: userMessage,
responseText: null,
};
}
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,
};
}
if (params.protocol === "openai_responses") {
const url = openAiResponsesUrl(params.apiBaseUrl);
const res = await fetch(url, {
method: "POST",
signal: ac.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: params.model,
input: userMessage,
stream: true,
}),
});
if (!res.ok) {
const errBody = await readHttpErrorBody(res);
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: buildHttpErrorMessage(res, errBody),
requestMessage: userMessage,
responseText: null,
};
}
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,
};
}
const url = claudeUrl(params.apiBaseUrl);
const res = await fetch(url, {
method: "POST",
signal: ac.signal,
headers: {
"Content-Type": "application/json",
"x-api-key": params.apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: params.model,
max_tokens: PROBE_MAX_TOKENS,
messages: [{ role: "user", content: userMessage }],
stream: true,
}),
});
if (!res.ok) {
const errBody = await readHttpErrorBody(res);
return {
ok: false,
firstTokenMs: null,
httpStatus: res.status,
errorMessage: buildHttpErrorMessage(res, errBody),
requestMessage: userMessage,
responseText: null,
};
}
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,
};
} catch (e) {
const msg = e instanceof Error ? (e.name === "AbortError" ? "timeout" : e.message) : "unknown_error";
return {
ok: false,
firstTokenMs: null,
httpStatus: null,
errorMessage: truncateErr(msg),
requestMessage: userMessage,
responseText: null,
};
} finally {
clearTimeout(t);
}
}
function truncateErr(s: string, max = 120): string {
if (s.length <= max) return s;
return s.slice(0, max);
}

221
src/worker/scheduler.ts Normal file
View File

@@ -0,0 +1,221 @@
import { decryptSecret, encryptSecret } from "./crypto";
import {
deleteMonitor,
getAppSettings,
getMonitor,
insertMonitor,
insertProbeEvent,
listDueMonitors,
pruneProbeEvents,
updateMonitorMeta,
updateMonitorRunTimes,
} from "./db";
import { runProbe } from "./probe";
const PROBE_ERROR_MAX = 8000;
const PROBE_IO_FIELD_MAX = 6000;
const DEFAULT_PROBE_PROMPTS = ["你好", "hello", "ping", "Hi", "测试一下"];
function parseProbePromptsConfig(raw: string): string[] {
const lines = raw.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0);
return lines.length > 0 ? lines : DEFAULT_PROBE_PROMPTS;
}
function pickProbeUserMessage(raw: string): string {
const list = parseProbePromptsConfig(raw);
const u = new Uint32Array(1);
crypto.getRandomValues(u);
return list[u[0]! % list.length]!;
}
function truncateProbeError(s: string): string {
if (s.length <= PROBE_ERROR_MAX) return s;
return s.slice(0, PROBE_ERROR_MAX);
}
function truncateIo(s: string | null): string | null {
if (s == null) return null;
if (s.length <= PROBE_IO_FIELD_MAX) return s;
return s.slice(0, PROBE_IO_FIELD_MAX) + "…";
}
export type RunSingleProbeOutcome = { success: true } | { success: false; error: string };
export async function runScheduled(env: Env): Promise<void> {
const db = env.DB;
try {
await pruneProbeEvents(db);
} catch {
/* ignore */
}
const nowSec = Math.floor(Date.now() / 1000);
let due: Awaited<ReturnType<typeof listDueMonitors>>;
try {
due = await listDueMonitors(db, nowSec);
} catch {
return;
}
for (const m of due) {
try {
await runSingleProbe(env, m.id, db, nowSec);
} catch {
/* runSingleProbe should not throw; defensive for unexpected runtime errors */
}
}
}
export async function runSingleProbe(
env: Env,
monitorId: string,
db: D1Database,
nowSec: number
): Promise<RunSingleProbeOutcome> {
const m = await getMonitor(db, monitorId);
if (!m || !m.enabled) return { success: true };
let settings: Awaited<ReturnType<typeof getAppSettings>>;
try {
settings = await getAppSettings(db);
} catch {
return { success: false, error: "app_settings_unavailable" };
}
const userMsg = pickProbeUserMessage(settings.probe_prompts ?? "");
const intervalMin = settings.probe_interval_minutes;
const advanceSchedule = async () => {
try {
const nextRun = nowSec + intervalMin * 60;
await updateMonitorRunTimes(db, m.id, nowSec, nextRun);
} catch {
/* ignore */
}
};
const recordInfrastructureFailure = async (errorMessage: string) => {
try {
await insertProbeEvent(db, {
monitor_id: m.id,
ts: nowSec,
ok: 0,
first_token_ms: null,
http_status: null,
error_message: truncateProbeError(errorMessage),
probe_input: truncateIo(userMsg),
probe_output: null,
});
} catch {
/* ignore */
}
await advanceSchedule();
};
try {
const apiKey = await decryptSecret(m.api_key_ciphertext, m.api_key_nonce, env);
const result = await runProbe({
apiBaseUrl: m.api_base_url,
apiKey,
model: m.model,
protocol: m.protocol,
userMessage: userMsg,
});
try {
await insertProbeEvent(db, {
monitor_id: m.id,
ts: nowSec,
ok: result.ok ? 1 : 0,
first_token_ms: result.firstTokenMs,
http_status: result.httpStatus,
error_message: result.errorMessage ? truncateProbeError(result.errorMessage) : null,
probe_input: truncateIo(result.requestMessage),
probe_output: truncateIo(result.responseText),
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
await advanceSchedule();
return { success: false, error: msg };
}
await advanceSchedule();
return { success: true };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
await recordInfrastructureFailure(msg);
return { success: false, error: msg };
}
}
export async function createMonitorFromPayload(
env: Env,
db: D1Database,
body: {
display_name: string;
api_base_url: string;
api_key: string;
model: string;
protocol: "openai" | "openai_responses" | "claude";
category?: string;
enabled?: boolean;
}
): Promise<{ id: string }> {
const id = crypto.randomUUID();
const nowSec = Math.floor(Date.now() / 1000);
const { ciphertext, nonce } = await encryptSecret(body.api_key, env);
const nonceCopy = new Uint8Array(nonce);
await insertMonitor(db, {
id,
display_name: body.display_name.trim(),
api_base_url: body.api_base_url.trim(),
model: body.model.trim(),
protocol: body.protocol,
enabled: body.enabled === false ? 0 : 1,
category: (body.category ?? "").trim(),
created_at: nowSec,
api_key_ciphertext: ciphertext,
api_key_nonce: nonceCopy.buffer.slice(nonceCopy.byteOffset, nonceCopy.byteOffset + nonceCopy.byteLength),
last_run_at: null,
next_run_at: nowSec,
});
return { id };
}
export async function updateMonitorFromPayload(
env: Env,
db: D1Database,
id: string,
body: Partial<{
display_name: string;
api_base_url: string;
api_key: string;
model: string;
protocol: "openai" | "openai_responses" | "claude";
category: string;
enabled: boolean;
}>
): Promise<boolean> {
const cur = await getMonitor(db, id);
if (!cur) return false;
let ciphertext = cur.api_key_ciphertext as ArrayBuffer;
let nonceBuf = cur.api_key_nonce as ArrayBuffer;
if (body.api_key != null && body.api_key !== "") {
const enc = await encryptSecret(body.api_key, env);
const nc = new Uint8Array(enc.nonce);
ciphertext = enc.ciphertext;
nonceBuf = nc.buffer.slice(nc.byteOffset, nc.byteOffset + nc.byteLength);
}
const nowSec = Math.floor(Date.now() / 1000);
await updateMonitorMeta(db, id, {
display_name: body.display_name?.trim() ?? cur.display_name,
api_base_url: body.api_base_url?.trim() ?? cur.api_base_url,
model: body.model?.trim() ?? cur.model,
protocol: body.protocol ?? cur.protocol,
category: body.category !== undefined ? body.category.trim() : cur.category,
enabled: body.enabled != null ? (body.enabled ? 1 : 0) : cur.enabled,
api_key_ciphertext: ciphertext,
api_key_nonce: nonceBuf,
next_run_at: nowSec,
});
return true;
}
export { deleteMonitor };

26
tsconfig.app.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"exclude": ["src/worker"]
}

8
tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.worker.json" }
]
}

24
tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

10
tsconfig.worker.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.node.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo",
"lib": ["ES2023"],
"types": ["@cloudflare/workers-types"],
"moduleResolution": "bundler"
},
"include": ["src/worker/**/*.ts", "worker-configuration.d.ts"]
}

8
vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { cloudflare } from '@cloudflare/vite-plugin'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), cloudflare()],
})

8
worker-configuration.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare global {
interface Env {
DB: D1Database;
ADMIN_TOKEN: string;
ENCRYPTION_KEY?: string;
}
}
export {};

30
wrangler.json Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "modelping",
"main": "./src/worker/index.ts",
"compatibility_date": "2025-10-08",
"compatibility_flags": [
"nodejs_compat"
],
"observability": {
"enabled": true
},
"upload_source_maps": true,
"assets": {
"directory": "./dist/client",
"not_found_handling": "single-page-application"
},
"triggers": {
"crons": [
"* * * * *"
]
},
"d1_databases": [
{
"binding": "DB",
"database_name": "modelping",
"database_id": "66b6a2c4-fc47-459b-852a-645277c5d6cd",
"migrations_dir": "migrations"
}
]
}

0
测试短语.txt Normal file
View File