Initial commit
This commit is contained in:
4
.dev.vars.example
Normal file
4
.dev.vars.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# 复制为 .dev.vars(勿提交仓库)
|
||||
# 第一行必须是 ADMIN_TOKEN=,中间不要有空格
|
||||
ADMIN_TOKEN=shumengya520
|
||||
# ENCRYPTION_KEY= 可选
|
||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
115
README.md
Normal 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
22
eslint.config.js
Normal 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
13
index.html
Normal 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
31
migrations/0001_init.sql
Normal 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);
|
||||
24
migrations/0002_protocol_openai_responses.sql
Normal file
24
migrations/0002_protocol_openai_responses.sql
Normal 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);
|
||||
6
migrations/0003_probe_prompts_and_io.sql
Normal file
6
migrations/0003_probe_prompts_and_io.sql
Normal 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;
|
||||
48
migrations/0004_global_probe_settings.sql
Normal file
48
migrations/0004_global_probe_settings.sql
Normal 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
4323
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal 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
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
24
public/icons.svg
Normal 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
18
src/App.tsx
Normal 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
143
src/api.ts
Normal 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
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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
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
18
src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/components/MonitorCard.tsx
Normal file
158
src/components/MonitorCard.tsx
Normal 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
726
src/index.css
Normal 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
10
src/main.tsx
Normal 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
415
src/pages/Admin.tsx
Normal 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
92
src/pages/Dashboard.tsx
Normal 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
64
src/types.ts
Normal 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
68
src/worker/crypto.ts
Normal 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
324
src/worker/db.ts
Normal 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
262
src/worker/index.ts
Normal 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
382
src/worker/probe.ts
Normal 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
221
src/worker/scheduler.ts
Normal 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
26
tsconfig.app.json
Normal 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
8
tsconfig.json
Normal 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
24
tsconfig.node.json
Normal 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
10
tsconfig.worker.json
Normal 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
8
vite.config.ts
Normal 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
8
worker-configuration.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
declare global {
|
||||
interface Env {
|
||||
DB: D1Database;
|
||||
ADMIN_TOKEN: string;
|
||||
ENCRYPTION_KEY?: string;
|
||||
}
|
||||
}
|
||||
export {};
|
||||
30
wrangler.json
Normal file
30
wrangler.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user