diff --git a/.claude/commands/git-bump.md b/.claude/commands/git-bump.md new file mode 100644 index 0000000..5ed2ed0 --- /dev/null +++ b/.claude/commands/git-bump.md @@ -0,0 +1,24 @@ +--- +name: git-bump +description: 一键版本升级并 git 提交 +category: Release +tags: [version, git, release] +--- + +**一键版本升级** + +自动递增 patch 版本并提交。 + +**立即执行以下操作,无需确认**: + +1. 读取 `package.json` 中的 `version` 字段 +2. 递增 patch 版本 (例: 1.20.0 → 1.20.1) +3. 使用 Edit 工具更新 `package.json` +4. 执行 `git add -A` 暂存所有变更 +5. 执行 `git diff --cached --stat` 查看暂存区的变更文件 +6. 根据变更内容生成 commit 消息: + - 如果只有 `package.json` 变更: `chore: bump version to <新版本>` + - 如果有其他文件变更: 分析变更内容,生成符合 Conventional Commits 规范的消息,并在末尾附加 `(v<新版本>)` +7. 执行 `git commit -m "<生成的消息>"` +8. 创建 tag: `git tag v<新版本>` +9. 输出: `✓ 版本升级完成: <旧版本> → <新版本>,本地 tag v<新版本> 已创建` diff --git a/.claude/commands/git-push.md b/.claude/commands/git-push.md new file mode 100644 index 0000000..c94891c --- /dev/null +++ b/.claude/commands/git-push.md @@ -0,0 +1,16 @@ +--- +name: git-push +description: 推送 commit 和 tag 到远程仓库 +category: Release +tags: [git, push, release] +--- + +**推送到远程仓库** + +推送当前分支的 commit 和所有本地 tag 到远程。 + +**立即执行以下操作,无需确认**: + +1. 执行: `git push` +2. 执行: `git push --tags` +3. 输出: `✓ 已推送 commit 和 tags 到远程仓库` diff --git a/.claude/commands/openspec/apply.md b/.claude/commands/openspec/apply.md new file mode 100644 index 0000000..a36fd96 --- /dev/null +++ b/.claude/commands/openspec/apply.md @@ -0,0 +1,23 @@ +--- +name: OpenSpec: Apply +description: Implement an approved OpenSpec change and keep tasks in sync. +category: OpenSpec +tags: [openspec, apply] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +Track these steps as TODOs and complete them one by one. +1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. +2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. +3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished. +4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. +5. Reference `openspec list` or `openspec show ` when additional context is required. + +**Reference** +- Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. + diff --git a/.claude/commands/openspec/archive.md b/.claude/commands/openspec/archive.md new file mode 100644 index 0000000..dbc7695 --- /dev/null +++ b/.claude/commands/openspec/archive.md @@ -0,0 +1,27 @@ +--- +name: OpenSpec: Archive +description: Archive a deployed OpenSpec change and update specs. +category: OpenSpec +tags: [openspec, archive] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +1. Determine the change ID to archive: + - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. + - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. + - Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding. + - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. +2. Validate the change ID by running `openspec list` (or `openspec show `) and stop if the change is missing, already archived, or otherwise not ready to archive. +3. Run `openspec archive --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work). +4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. +5. Validate with `openspec validate --strict` and inspect with `openspec show ` if anything looks off. + +**Reference** +- Use `openspec list` to confirm change IDs before archiving. +- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. + diff --git a/.claude/commands/openspec/proposal.md b/.claude/commands/openspec/proposal.md new file mode 100644 index 0000000..cbb75ce --- /dev/null +++ b/.claude/commands/openspec/proposal.md @@ -0,0 +1,28 @@ +--- +name: OpenSpec: Proposal +description: Scaffold a new OpenSpec change and validate strictly. +category: OpenSpec +tags: [openspec, change] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. +- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. +- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval. + +**Steps** +1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. +3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. +4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. +5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. +6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. +7. Validate with `openspec validate --strict` and resolve every issue before sharing the proposal. + +**Reference** +- Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. +- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. +- Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. + diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..33f905f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# Dependencies +node_modules +.pnpm-store + +# Build outputs +.next +out +dist + +# Git +.git +.gitignore + +# IDE +.idea +.vscode +*.swp +*.swo + +# Environment (不应打包进镜像) +.env +.env.local +.env.*.local + +# Misc +*.log +npm-debug.log* +pnpm-debug.log* +.DS_Store +Thumbs.db + +# Test +coverage +.nyc_output + +# Cloudflare (与 Docker 无关) +.wrangler +.open-next diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e422553 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Supabase 数据库配置 +SUPABASE_URL=https://your-project.supabase.co +# Supabase 匿名访问Key +SUPABASE_PUBLISHABLE_OR_ANON_KEY=your-public-or-anon-key +# Service Role Key (服务端使用,绕过 RLS,切勿暴露到客户端) +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key + +# 数据库空间 +NODE_ENV=dev +# 轮询间隔(秒) +CHECK_POLL_INTERVAL_SECONDS=60 +# 节点身份(多节点部署时标识实例) +CHECK_NODE_ID=local +# 历史数据保留天数(范围 7-365) +HISTORY_RETENTION_DAYS=30 +# 官方状态检查间隔 +OFFICIAL_STATUS_CHECK_INTERVAL_SECONDS=60 +# 最大请求并发数 +CHECK_CONCURRENCY=8 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..175166c --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,49 @@ +name: Build and Push Docker Image + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +env: + REGISTRY: docker.io + IMAGE_NAME: bingzi233/check-cx + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64 diff --git a/.gitignore b/.gitignore index 5ef6a52..697842d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,41 +1,54 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# env files (can opt-in for committing if needed) -.env* - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# IDEA +.idea + +combined.log +error.log + +.playwright-mcp +.mcp.json + +# openspec +openspec/changes/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..31c4f6d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + + +# Repository Guidelines + +## Project Structure & Module Organization +- `app/` hosts the App Router surface; `page.tsx` hydrates dashboard data and `app/api/dashboard/route.ts` exposes the refresh endpoint. +- `components/` contains interactive widgets such as `dashboard-view.tsx`, `status-timeline.tsx`, and shared primitives inside `components/ui/`. +- `lib/` carries domain logic: `core/` for polling and state, `providers/` for OpenAI/Anthropic/Gemini adapters, `database/` plus `supabase/` for persistence helpers, `types/` for DTOs, and `utils/` for helpers like `cn`. +- Keep assets in `public/`, and store schema or seed changes exclusively in `supabase/migrations/` so Supabase stays reproducible. + +## Build, Test, and Development Commands +- `pnpm install` syncs dependencies; re-run whenever `pnpm-lock.yaml` changes. +- `pnpm dev` launches the Next.js dev server configured via `.env`. +- `pnpm build` compiles the production bundle, while `pnpm start` serves that output for local smoke tests. +- `pnpm lint` runs the Next.js Core Web Vitals rule set (`eslint.config.mjs`); fix findings before pushing. + +## Coding Style & Naming Conventions +Default to server components and add `"use client"` only when hooks or browser APIs are required. TypeScript files use two-space indentation, `const` bindings, and descriptive PascalCase component names (`DashboardView`). Sort imports Node → packages → `@/` aliases, avoiding long relative paths. Compose styling through Tailwind utility classes plus `clsx`/`tailwind-merge`, and move repeated variants into `components/ui/`. + +## Testing Guidelines +Automated tests are not wired up yet, but new logic should ship with either Vitest/Jest specs named `*.spec.ts` or clearly documented manual steps. Target ≥80 % coverage on `lib/core` and provider modules; explain any exception in the PR description. Until a runner is introduced, validate by running `pnpm dev`, exercising dashboard refreshes, and replaying Supabase migrations against a staging project before promoting to production. + +## Commit & Pull Request Guidelines +History follows Conventional Commits (`feat:`, `fix:`, `chore:`, `refactor:`). Keep each commit scoped to a single concern and include related SQL migrations or config edits together. Pull requests must describe the change, link issues, attach UI screenshots/GIFs when visuals move, list the commands executed (build/lint/test), and mention any Supabase migration IDs applied. Request review early for provider updates so credentials and rate limits get a second set of eyes. + +## Security & Configuration Tips +Copy `.env.example`, fill in `SUPABASE_*` plus `CHECK_POLL_INTERVAL_SECONDS`, and never commit real keys. Provider credentials belong in the `check_configs` table—seed them with the SQL snippets in `.env.example` instead of plaintext env vars. Use the mock endpoint in `lib/providers/stream-check.ts` for latency tests and scrub client logs before sharing. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..23ba6ef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,460 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + + +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +Check CX 是一个基于 Next.js 的 AI 模型健康监控面板,用于实时监控 OpenAI、Gemini、Anthropic 等 AI 模型的 API 可用性、延迟和错误信息。项目采用分层架构,通过后台轮询持续采集健康结果,并提供可视化 Dashboard 与只读状态 API,适合团队内部状态墙、供应商 SLA 监控与多模型对比。 + +## 核心特性 + +- **统一 Provider 支持**:OpenAI、Gemini、Anthropic,支持 Chat Completions 与 Responses 端点 +- **实时延迟监控**:首 token 延迟、Ping 延迟与历史时间线 +- **分组管理**:支持分组视图与分组详情页,包含分组标签与官网链接 +- **维护模式**:支持系统通知横幅(Markdown 格式,多条轮播) +- **官方状态集成**:自动轮询 OpenAI 与 Anthropic 官方状态 +- **多节点部署**:数据库租约保证单节点执行轮询,避免重复工作 +- **安全设计**:模型密钥仅保存在数据库,服务端使用 service role key 读取 + +## 常用命令 + +```bash +# 安装依赖 +pnpm install + +# 本地开发 +pnpm dev + +# 构建生产版本 +pnpm build + +# 运行生产服务器 +pnpm start + +# 代码检查 +pnpm lint + +# Docker 构建与运行 +./deploy.sh # 构建并运行 Docker 容器 +docker-compose up -d # 使用 docker-compose 启动 +``` + +## 环境配置 + +复制环境变量模板并配置: + +```bash +cp .env.example .env.local +``` + +必需的环境变量: +- `SUPABASE_URL` - Supabase 项目 URL +- `SUPABASE_PUBLISHABLE_OR_ANON_KEY` - Supabase 公共访问 Key +- `SUPABASE_SERVICE_ROLE_KEY` - Service Role Key(服务端使用,勿暴露) +- `CHECK_NODE_ID` - 节点身份,用于多节点选主(默认:`local`) +- `CHECK_POLL_INTERVAL_SECONDS` - 检测间隔(15–600 秒,默认:60) + +## 核心架构 + +### 代码结构 (重构后) + +项目采用分层架构,清晰的职责划分: + +``` +lib/ +├── types/ # 统一类型定义 +│ ├── index.ts # 类型导出入口 +│ ├── provider.ts # Provider 相关类型 +│ ├── check.ts # 检查结果类型 +│ ├── database.ts # 数据库表类型 +│ └── dashboard.ts # Dashboard 数据类型 +├── providers/ # Provider 检查逻辑 +│ ├── index.ts # 统一入口,批量执行检查 +│ ├── openai.ts # OpenAI 完整实现 +│ ├── gemini.ts # Gemini 完整实现 +│ ├── anthropic.ts # Anthropic 完整实现 +│ └── stream-check.ts # 流式检查通用逻辑 +├── database/ # 数据库操作 +│ ├── config-loader.ts # 配置加载 +│ └── history.ts # 历史记录管理 +├── utils/ # 工具函数 +│ ├── index.ts # 工具函数统一导出 +│ ├── cn.ts # Tailwind className 合并 +│ ├── url-helpers.ts # URL 处理工具 +│ └── error-handler.ts # 统一错误处理 +├── core/ # 核心模块 +│ ├── global-state.ts # 全局状态管理 +│ ├── poller.ts # 后台轮询器 +│ ├── dashboard-data.ts # Dashboard 数据聚合 +│ ├── status.ts # 状态元数据 +│ └── polling-config.ts # 轮询配置 +└── supabase/ # Supabase 客户端 + ├── client.ts # 浏览器端 + ├── server.ts # 服务器端 + └── middleware.ts # 会话中间件 +``` + +### 后台轮询系统 + +项目核心是一个服务器端轮询系统,在应用启动时自动初始化并持续运行: + +- **入口**: `lib/core/poller.ts` 在模块加载时立即启动轮询 +- **触发**: 使用 `setInterval` 按 `CHECK_POLL_INTERVAL_SECONDS` 间隔执行检测(默认 60 秒,支持 15-600 秒) +- **全局状态**: 通过 `lib/core/global-state.ts` 统一管理轮询定时器和运行状态,防止 Next.js 热重载时重复创建定时器 +- **并发控制**: 使用 `__checkCxPollerRunning` 标志位防止多个检测任务重叠执行 +- **选主机制**: `lib/core/poller-leadership.ts` 通过数据库租约选主,保证多节点部署时仅单节点执行轮询 +- **官方状态轮询**: `lib/core/official-status-poller.ts` 定时抓取 OpenAI 和 Anthropic 官方状态 + +### 配置管理 + +配置已从环境变量迁移到 Supabase 数据库的 `check_models` / `check_configs` / `check_request_templates` 三层结构: + +- **配置加载**: `lib/database/config-loader.ts:loadProviderConfigsFromDB()` 从数据库读取已启用的配置,并关联模型与模板 +- **动态启用/禁用**: 通过更新数据库 `enabled` 字段即可控制检测任务,无需重启应用 +- **维护模式**: 设置 `is_maintenance = true` 保留卡片但停止轮询,显示维护状态 +- **分组管理**: 通过 `group_name` 字段对配置进行分组,支持分组视图和详情页 +- **模型复用**: 通过 `check_models` 统一维护模型名与模板绑定,`check_configs` 使用 `model_id` 关联 +- **默认请求参数**: `request_header` 和 `metadata` 只保存在 `check_request_templates` +- **链路关系**: `check_configs` → `check_models` → `check_request_templates` +- **类型安全**: 使用 `lib/types/database.ts` 中定义的 `CheckConfigRow` 类型 + +### 健康检查流程 + +1. **配置加载**: `lib/database/config-loader.ts:loadProviderConfigsFromDB()` 读取所有启用的配置 +2. **数学挑战验证**: `lib/providers/challenge.ts` 生成数学题验证模型响应能力 +3. **Provider 检查**: `lib/providers/ai-sdk-check.ts` 使用 Vercel AI SDK 并发执行所有配置的检查 +4. **延迟测量**: 测量首 token 延迟和端点 Ping 延迟 +5. **状态判定**: + - `operational`: 请求成功且延迟 ≤ 6000ms + - `degraded`: 请求成功但延迟 > 6000ms + - `failed`: 请求失败或超时(默认超时 15 秒) + - `maintenance`: 配置标记为维护模式 +6. **三类 Provider 支持**: + - **OpenAI**: 支持 Chat Completions 和 Responses API + - **Gemini**: Google AI 模型支持 + - **Anthropic**: Claude 系列模型支持 + +### 数据存储与历史 + +- **历史写入**: `lib/database/history.ts:appendHistory()` 将检测结果写入 Supabase `check_history` 表 +- **数据清理**: 自动调用 `prune_check_history` RPC,每个配置最多保留 60 条历史记录 +- **可用性统计**: `availability_stats` 视图提供 7/15/30 天的可用性统计数据 +- **快照服务**: `lib/core/health-snapshot-service.ts` 统一读取历史与触发刷新 +- **数据结构**: 使用 `config_id` 外键关联 `check_configs` 表,存储 `status`、`latency_ms`、`checked_at`、`message` 字段 +- **类型安全**: 使用 `lib/types/database.ts` 中定义的 `CheckHistoryRow` 类型 + +### Dashboard 数据流 + +1. **页面渲染**: `app/page.tsx` 使用 `loadDashboardData({ refreshMode: "missing" })` 加载初始数据 +2. **API 路由**: + - `app/api/dashboard/route.ts` - Dashboard 数据 API(ETag + CDN 缓存) + - `app/api/group/[groupName]/route.ts` - 分组数据 API + - `app/api/v1/status/route.ts` - 对外只读状态 API +3. **刷新模式**: + - `missing`: 仅当数据库中无历史记录时触发一次实时检测 + - `always`: 强制触发实时检测(用于 `/api/dashboard` 路由) + - `never`: 仅从数据库读取历史记录 +4. **缓存机制**: + - 后端:`lib/core/health-snapshot-service.ts` 使用全局缓存,避免在轮询间隔内重复检测 + - 前端:`lib/utils/frontend-cache.ts` 实现 SWR 风格缓存,配合 ETag +5. **前端轮询**: `components/dashboard-view.tsx` 使用客户端定时器定期调用 `/api/dashboard` 获取最新数据 +6. **数据聚合**: `lib/core/dashboard-data.ts` 和 `lib/core/group-data.ts` 负责分组与统计数据 + +### Supabase 集成 + +- **服务端**: `lib/supabase/server.ts` 提供服务器端客户端(支持 SSR 和 cookies) +- **管理端**: `lib/supabase/admin.ts` 提供管理员客户端(绕过 RLS) +- **中间件**: `lib/supabase/middleware.ts` 处理会话刷新 +- **环境变量**: + - `SUPABASE_URL`: Supabase 项目 URL + - `SUPABASE_PUBLISHABLE_OR_ANON_KEY`: 公开/匿名 key + - `SUPABASE_SERVICE_ROLE_KEY`: Service Role key(管理员权限) + +### 数据库表结构 + +```sql +-- 模型表 +check_models ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type TEXT NOT NULL, -- 'openai' | 'gemini' | 'anthropic' + model TEXT NOT NULL, + template_id UUID REFERENCES check_request_templates(id), + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +) + +-- 配置表 +check_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + type TEXT NOT NULL, -- 'openai' | 'gemini' | 'anthropic' + model_id UUID NOT NULL REFERENCES check_models(id), + endpoint TEXT NOT NULL, + api_key TEXT NOT NULL, + enabled BOOLEAN DEFAULT true, + is_maintenance BOOLEAN DEFAULT false, -- 维护模式 + group_name TEXT, -- 分组名称 + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +) + +-- 历史记录表 +check_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + config_id UUID REFERENCES check_configs(id), + status TEXT NOT NULL, -- 'operational' | 'degraded' | 'failed' | 'maintenance' + latency_ms INTEGER, + ping_latency_ms INTEGER, -- Ping 延迟 + checked_at TIMESTAMPTZ DEFAULT now(), + message TEXT +) + +-- 分组信息表 +group_info ( + group_name TEXT PRIMARY KEY, + display_name TEXT, + description TEXT, + website_url TEXT, + icon_url TEXT +) + +-- 系统通知表 +system_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message TEXT NOT NULL, -- Markdown 格式 + level TEXT DEFAULT 'info', -- 'info' | 'warning' | 'error' + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() +) + +-- 轮询器租约表 +check_poller_leases ( + id INTEGER PRIMARY KEY DEFAULT 1, -- 单行表 + leader_node_id TEXT NOT NULL, + last_renewed_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL +) +``` + +### 数据库视图和函数 + +- **availability_stats**: 7/15/30 天可用性统计视图 +- **get_recent_check_history**: 获取最近的检查历史 RPC +- **prune_check_history**: 清理历史记录的 RPC + +## 关键约定 + +### 数据流向 + +- **后台 → 数据库**: `lib/core/poller.ts` → `lib/providers/` → `lib/database/history.ts` → Supabase +- **数据库 → 前端**: Supabase → `lib/core/dashboard-data.ts` → `app/page.tsx` → `components/dashboard-view.tsx` +- **实时刷新**: 前端定时器 → `/api/dashboard` → `lib/core/dashboard-data.ts` + +### 类型系统 + +- **统一导出**: 所有类型从 `lib/types/index.ts` 统一导出 +- **分类清晰**: 类型按职责分为 provider、check、database、dashboard 四类 +- **类型安全**: 数据库查询使用明确的类型定义,避免类型断言 + +### 模块职责 + +- **单一职责**: 每个模块专注单一功能,文件不超过 200 行 +- **清晰边界**: providers 负责检查,database 负责存储,core 负责协调 +- **易于扩展**: 新增 Provider 只需在 `lib/providers/` 添加一个文件 + +### 性能优化 + +1. **流式响应**: 使用 Vercel AI SDK 的流式 API,只需接收到首个 token 即可判定可用性 +2. **Token 限制**: 所有请求设置 `max_tokens: 1`,最小化响应数据量 +3. **数学挑战**: 使用简单的数学题验证模型响应,避免复杂 prompt 的开销 +4. **缓存策略**: + - 后端快照缓存:基于轮询间隔的全局缓存,避免重复检测 + - 前端 SWR 缓存:配合 ETag 实现高效的客户端缓存 + - 官方状态缓存:内存 Map 缓存官方状态结果 +5. **并发控制**: 使用 `p-limit` 控制最大并发数(默认 5,可配置) +6. **数据清理**: 自动清理历史记录,每个配置最多保留 60 条 +7. **数据库优化**: 使用物化视图和 RPC 函数提升查询性能 + +### 错误处理 + +- 所有网络请求都有 15 秒超时控制 +- 检测失败时返回 `status: "failed"`,不抛出异常 +- 数据库操作失败时记录日志并返回空数据/上次缓存 +- 轮询器使用 `try-catch` 包裹,单次失败不影响后续执行 +- **统一日志**: 使用 `lib/utils/error-handler.ts` 的 `logError()` 记录错误 + +## 添加新的 AI Provider + +1. 在 `lib/types/provider.ts` 中添加 `ProviderType` 类型 +2. 在 `lib/providers/` 创建新文件,实现 `checkXxx()` 函数 +3. 使用 `runStreamCheck()` 提供的通用流式检查逻辑 +4. 实现 Provider 特定的流解析器 (`parseXxxStream()`) +5. 在 `lib/providers/index.ts` 的 `checkProvider()` switch 中添加分支 +6. 在 `lib/core/status.ts` 的 `PROVIDER_LABEL` 中添加显示名称 +7. 在 `components/provider-icon.tsx` 中添加对应图标 + +**示例**: + +```typescript +// lib/providers/新provider.ts +import type { CheckResult, ProviderConfig } from "../types"; +import { ensurePath } from "../utils"; +import { runStreamCheck } from "./stream-check"; + +async function parse新ProviderStream( + reader: ReadableStreamDefaultReader +): Promise { + // 实现流解析逻辑 +} + +export async function check新Provider( + config: ProviderConfig +): Promise { + const url = ensurePath(config.endpoint, "/api/endpoint"); + const payload = { /* ... */ }; + + return runStreamCheck(config, { + url, + displayEndpoint: config.endpoint, + init: { + headers: { /* ... */ }, + body: JSON.stringify(payload), + }, + parseStream: parse新ProviderStream, + }); +} +``` + +## 修改配置 + +不要通过环境变量管理 CHECK 配置,请使用 SQL 命令在 Supabase 中操作: + +```sql +-- 先创建或复用模板 +INSERT INTO check_request_templates (name, type, request_header, metadata) +VALUES ( + 'openai-default', + 'openai', + '{"User-Agent": "check-cx"}', + '{"temperature": 0}' +) +ON CONFLICT (name) DO NOTHING; + +-- 再创建或复用模型,并把模板绑到模型上 +INSERT INTO check_models (type, model, template_id) +SELECT 'openai', 'gpt-4o-mini', id +FROM check_request_templates +WHERE name = 'openai-default' +ON CONFLICT (type, model) DO NOTHING; + +-- 添加配置 +INSERT INTO check_configs (name, type, model_id, endpoint, api_key, enabled) +SELECT '主力 OpenAI', 'openai', id, + 'https://api.openai.com/v1/chat/completions', + 'sk-xxx', true +FROM check_models +WHERE type = 'openai' AND model = 'gpt-4o-mini'; + +-- 更新模型绑定的模板 +UPDATE check_models +SET template_id = ( + SELECT id + FROM check_request_templates + WHERE name = 'openai-default' +) +WHERE type = 'openai' + AND model = 'gpt-4o-mini'; + +-- 禁用配置 +UPDATE check_configs SET enabled = false WHERE name = '主力 OpenAI'; + +-- 删除配置 +DELETE FROM check_configs WHERE name = '旧配置'; + +-- 设置维护模式 +UPDATE check_configs SET is_maintenance = true WHERE name = '维护中的服务'; + +-- 设置分组 +UPDATE check_configs SET group_name = '生产环境' WHERE name IN ('OpenAI GPT-4', 'Claude 3'); + +-- 添加分组信息 +INSERT INTO group_info (group_name, display_name, description, website_url) +VALUES ('生产环境', 'Production', '核心生产环境模型', 'https://status.openai.com'); + +-- 添加系统通知 +INSERT INTO system_notifications (message, level, start_time, end_time) +VALUES ('**系统维护通知**:今晚 22:00-24:00 进行系统维护,可能影响服务可用性。', 'warning', NOW(), NOW() + INTERVAL '2 days'); +``` + +## 调试轮询器 + +轮询器在每次执行时会输出详细日志: + +- 检测开始/结束时间 +- 每个配置的检测结果、延迟、状态 +- 历史记录写入结果 +- 下次预计执行时间 + +查看服务器日志: + +```bash +pnpm dev # 在开发模式下日志会输出到终端 +``` + +## 测试指南 + +目前项目尚未集成自动化测试框架,但建议: + +1. **手动测试**:运行 `pnpm dev`,验证 Dashboard 刷新和数据显示 +2. **数据库测试**:在测试环境执行 Supabase 迁移,验证数据完整性 +3. **Provider 测试**:使用 mock 端点测试不同 Provider 的适配性 +4. **性能测试**:验证多配置并发检查的性能表现 + +## 开发约定 + +### 代码风格 +- 默认使用 Server Components,仅在需要时添加 `"use client"` +- TypeScript 文件使用 2 空格缩进,优先使用 `const` +- 组件命名使用 PascalCase,如 `DashboardView` +- 导入排序:Node 内置模块 → 第三方包 → `@/` 别名路径 + +### 提交规范 +遵循 Conventional Commits: +- `feat:` - 新功能 +- `fix:` - Bug 修复 +- `chore:` - 构建或工具变更 +- `refactor:` - 代码重构 +- `docs:` - 文档更新 + +### 安全提醒 +- 不要提交真实的 API 密钥到版本控制 +- 使用环境变量或数据库存储敏感配置 +- 在分享日志前清理敏感信息 + +## 扩展文档 + +更多详细信息请参考项目文档: +- `docs/ARCHITECTURE.md` - 架构设计说明 +- `docs/OPERATIONS.md` - 运维手册 +- `docs/EXTENDING_PROVIDERS.md` - Provider 扩展指南 +- `AGENTS.md` - 项目规范和约定 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d85769 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# ============================================ +# Stage 1: 基础镜像 + pnpm +# ============================================ +FROM node:22-alpine AS base +RUN corepack enable && corepack prepare pnpm@10.10.0 --activate + +# ============================================ +# Stage 2: 安装依赖 +# ============================================ +FROM base AS deps +WORKDIR /app + +# 复制依赖清单 +COPY package.json pnpm-lock.yaml ./ + +# 安装生产依赖 +RUN pnpm install --frozen-lockfile + +# ============================================ +# Stage 3: 构建应用 +# ============================================ +FROM base AS builder +WORKDIR /app + +# 复制依赖 +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# 构建 Next.js (standalone 模式) +RUN pnpm build + +# ============================================ +# Stage 4: 生产运行时 +# ============================================ +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV HOSTNAME="0.0.0.0" +ENV PORT=3000 + +# 创建非 root 用户 +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# 复制 standalone 构建产物 +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +# 设置权限 +RUN chown -R nextjs:nodejs /app + +USER nextjs + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..84164ab --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 冰子 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e215bc4..23dd056 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,145 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Check CX -## Getting Started +Check CX 是一个用于监控 AI 模型 API 可用性与延迟的健康监测面板。项目基于 Next.js App Router 与 Supabase 构建,通过后台轮询持续采集健康检查结果,并提供可视化 +Dashboard 与只读状态 API,适用于团队内部状态展示、供应商 SLA 监控以及多模型能力对比等场景。 -First, run the development server: +## 相关项目 + +- 后台管理端:[`BingZi-233/check-cx-admin`](https://github.com/BingZi-233/check-cx-admin) +- 管理端用于维护 `check_models`、`check_configs`、分组信息、系统通知等后台数据;当前项目负责健康检查执行、状态展示与只读 API 输出。 + +![Check CX Dashboard](docs/images/index.png) + +## 功能概览 + +- 统一的 Provider 健康检查能力(OpenAI / Gemini / Anthropic),支持 Chat Completions 与 Responses 端点 +- 实时延迟、Ping 延迟与历史时间线,支持 7/15/30 天可用性统计 +- 分组视图与分组详情页(`group_name` + `group_info`),支持分组标签与官网链接 +- 维护模式与系统通知横幅(支持 Markdown,多条轮播) +- 官方状态轮询(当前支持 OpenAI 与 Anthropic) +- 多节点部署场景下的自动选主能力(通过数据库租约保证单节点执行轮询) +- 安全默认:模型密钥仅保存在数据库中,由服务端通过 service role key 读取 + +## 快速开始 + +### 1. 环境准备 + +- Node.js 18 及以上版本(建议使用 20 LTS) +- pnpm 10 +- Supabase 项目(PostgreSQL) + +### 2. 安装依赖 + +```bash +pnpm install +``` + +### 3. 配置环境变量 + +```bash +cp .env.example .env.local +``` + +在 `.env.local` 中填写以下内容: + +```env +SUPABASE_URL=... +SUPABASE_PUBLISHABLE_OR_ANON_KEY=... +SUPABASE_SERVICE_ROLE_KEY=... +CHECK_NODE_ID=local +CHECK_POLL_INTERVAL_SECONDS=60 +HISTORY_RETENTION_DAYS=30 +OFFICIAL_STATUS_CHECK_INTERVAL_SECONDS=300 +CHECK_CONCURRENCY=5 +``` + +### 4. 初始化数据库 + +- 全新项目:执行 `supabase/schema.sql`;如需使用开发 schema,请执行 `supabase/schema-dev.sql`。 +- 已存在数据库:按顺序执行 `supabase/migrations/` 目录中的迁移文件;如使用 dev schema,请同步执行 `*_dev.sql` 迁移。 + +### 5. 添加最小配置 + +```sql +-- 1) 先创建模型 +INSERT INTO check_models (type, model) +VALUES ('openai', 'gpt-4o-mini') +ON CONFLICT (type, model) DO NOTHING; + +-- 2) 再创建配置实例 +INSERT INTO check_configs (name, type, model_id, endpoint, api_key, enabled) +SELECT 'OpenAI GPT-4o', + 'openai', + id, + 'https://api.openai.com/v1/chat/completions', + 'sk-your-api-key', + true +FROM check_models +WHERE type = 'openai' + AND model = 'gpt-4o-mini'; +``` + +### 6. 启动开发服务器 ```bash -npm run dev -# or -yarn dev -# or pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +启动后访问 `http://localhost:3000` 查看 Dashboard。 + +## 运行与部署 + +```bash +pnpm dev # 本地开发 +pnpm build # 生产构建 +pnpm start # 生产运行 +pnpm lint # 代码检查 +``` + +部署时,请将 `.env.local` 中的变量注入到目标平台,例如 Vercel、容器环境或自建服务器。 + +## 配置说明 + +### 环境变量 + +| 变量 | 必需 | 默认值 | 说明 | +|------------------------------------------|----|---------|-----------------------------| +| `SUPABASE_URL` | 是 | - | Supabase 项目 URL | +| `SUPABASE_PUBLISHABLE_OR_ANON_KEY` | 是 | - | Supabase 公共访问 Key | +| `SUPABASE_SERVICE_ROLE_KEY` | 是 | - | Service Role Key(服务端使用,勿暴露) | +| `CHECK_NODE_ID` | 否 | `local` | 节点身份,用于多节点选主 | +| `CHECK_POLL_INTERVAL_SECONDS` | 否 | `60` | 检测间隔(15–600 秒) | +| `CHECK_CONCURRENCY` | 否 | `5` | 最大并发(1–20) | +| `OFFICIAL_STATUS_CHECK_INTERVAL_SECONDS` | 否 | `300` | 官方状态轮询间隔(60–3600 秒) | +| `HISTORY_RETENTION_DAYS` | 否 | `30` | 历史保留天数(7–365) | -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +### Provider 配置要点 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +- `check_models` 用于统一维护模型定义与模板绑定关系,`check_configs` 通过 `model_id` 关联模型。 +- `check_configs.type` 目前支持 `openai` / `gemini` / `anthropic`。 +- `endpoint` 必须填写完整端点: + - `/v1/chat/completions` 使用 Chat Completions + - `/v1/responses` 使用 Responses API +- `check_models.template_id` 可选关联 `check_request_templates`,用于复用默认请求头与 metadata。 +- `check_request_templates.type` 必须与 `check_models.type` 一致(如 `anthropic` 模型只能绑定 `anthropic` 模板)。 +- `check_configs.model_id` 关联的模型类型必须与 `check_configs.type` 一致。 +- 请求头与 metadata 统一从 `check_request_templates` 读取;配置实例不再提供覆盖字段。 +- `is_maintenance = true` 会保留卡片但停止轮询;`enabled = false` 则表示该配置完全不纳入检测。 -## Learn More +## API 概览 -To learn more about Next.js, take a look at the following resources: +- `GET /api/dashboard?trendPeriod=7d|15d|30d`:Dashboard 聚合数据(带 ETag)。返回完整时间线与可用性统计。 +- `GET /api/group/[groupName]?trendPeriod=7d|15d|30d`:分组详情数据。 +- `GET /api/v1/status?group=...&model=...`:对外只读状态 API。 -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +更详细的接口定义与数据结构说明请参见下列文档。 -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## 文档 -## Deploy on Vercel +- 架构说明:`docs/ARCHITECTURE.md` +- 运维手册:`docs/OPERATIONS.md` +- Provider 扩展:`docs/EXTENDING_PROVIDERS.md` -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## 许可证 -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +[MIT](LICENSE) diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts new file mode 100644 index 0000000..7264e93 --- /dev/null +++ b/app/api/dashboard/route.ts @@ -0,0 +1,68 @@ +import {NextResponse} from "next/server"; + +import {loadDashboardDataWithEtag} from "@/lib/core/dashboard-data"; +import {getPollingIntervalMs} from "@/lib/core/polling-config"; +import type {AvailabilityPeriod} from "@/lib/types"; + +export const revalidate = 0; +export const dynamic = "force-dynamic"; + +const VALID_PERIODS: AvailabilityPeriod[] = ["7d", "15d", "30d"]; + +/** 数据变化周期:5 分钟 */ +const DATA_CHANGE_CYCLE_SECONDS = 5 * 60; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const period = searchParams.get("trendPeriod"); + const forceRefreshParam = searchParams.get("forceRefresh"); + const shouldForceRefresh = + forceRefreshParam === "1" || forceRefreshParam === "true"; + const trendPeriod = VALID_PERIODS.includes(period as AvailabilityPeriod) + ? (period as AvailabilityPeriod) + : undefined; + + const { data, etag } = await loadDashboardDataWithEtag({ + refreshMode: shouldForceRefresh ? "always" : "never", + trendPeriod, + }); + + // 检查条件请求 + const ifNoneMatch = request.headers.get("If-None-Match"); + if (ifNoneMatch === etag) { + // 数据未变,返回 304 + return new Response(null, { + status: 304, + headers: { + ETag: etag, + }, + }); + } + + // 计算缓存时间 + const pollIntervalSeconds = Math.floor(getPollingIntervalMs() / 1000); + + // 构建响应 + const response = NextResponse.json(data); + + // 设置缓存头 + // Cache-Control: 浏览器每次都向 CDN 验证 + response.headers.set("Cache-Control", "public, no-cache"); + + // CDN-Cache-Control: Cloudflare 边缘节点缓存 + response.headers.set("CDN-Cache-Control", `max-age=${pollIntervalSeconds}`); + + // Cloudflare-CDN-Cache-Control: 支持 stale-while-revalidate + response.headers.set( + "Cloudflare-CDN-Cache-Control", + `max-age=${pollIntervalSeconds}, stale-while-revalidate=${DATA_CHANGE_CYCLE_SECONDS}` + ); + + // ETag + response.headers.set("ETag", etag); + + // Vary: 确保不同参数的请求分开缓存 + response.headers.set("Vary", "Accept-Encoding"); + + return response; +} diff --git a/app/api/group/[groupName]/route.ts b/app/api/group/[groupName]/route.ts new file mode 100644 index 0000000..ee51dbe --- /dev/null +++ b/app/api/group/[groupName]/route.ts @@ -0,0 +1,91 @@ +import {NextResponse} from "next/server"; +import {loadGroupDashboardData} from "@/lib/core/group-data"; +import {getPollingIntervalMs} from "@/lib/core/polling-config"; +import type {AvailabilityPeriod} from "@/lib/types"; + +interface RouteContext { + params: Promise<{ groupName: string }>; +} + +export const revalidate = 0; +export const dynamic = "force-dynamic"; + +/** 数据变化周期:5 分钟 */ +const DATA_CHANGE_CYCLE_SECONDS = 5 * 60; + +/** + * 生成简单的哈希作为 ETag + * 使用 djb2 算法,足够快且碰撞率低 + */ +function generateETag(data: string): string { + let hash = 5381; + for (let i = 0; i < data.length; i++) { + hash = ((hash << 5) + hash) ^ data.charCodeAt(i); + } + // 转为无符号 32 位整数的十六进制 + return `"${(hash >>> 0).toString(16)}"`; +} + +export async function GET(_request: Request, context: RouteContext) { + const { groupName } = await context.params; + const decodedGroupName = decodeURIComponent(groupName); + + const { searchParams } = new URL(_request.url); + const period = searchParams.get("trendPeriod"); + const forceRefreshParam = searchParams.get("forceRefresh"); + const shouldForceRefresh = + forceRefreshParam === "1" || forceRefreshParam === "true"; + const trendPeriod = (["7d", "15d", "30d"] as AvailabilityPeriod[]).includes( + period as AvailabilityPeriod + ) + ? (period as AvailabilityPeriod) + : undefined; + + const data = await loadGroupDashboardData(decodedGroupName, { + refreshMode: shouldForceRefresh ? "always" : "never", + trendPeriod, + }); + + if (!data) { + return NextResponse.json( + { error: "分组不存在或没有配置" }, + { status: 404 } + ); + } + + // 生成 ETag(基于数据内容) + const { generatedAt, ...etagPayload } = data; + void generatedAt; + const jsonBody = JSON.stringify(etagPayload); + const etag = generateETag(jsonBody); + + // 检查条件请求 + const ifNoneMatch = _request.headers.get("If-None-Match"); + if (ifNoneMatch === etag) { + return new Response(null, { + status: 304, + headers: { + ETag: etag, + }, + }); + } + + // 计算缓存时间 + const pollIntervalSeconds = Math.floor(getPollingIntervalMs() / 1000); + + const response = NextResponse.json(data); + + // Cache-Control: 浏览器每次都向 CDN 验证 + response.headers.set("Cache-Control", "public, no-cache"); + // CDN-Cache-Control: Cloudflare 边缘节点缓存 + response.headers.set("CDN-Cache-Control", `max-age=${pollIntervalSeconds}`); + // Cloudflare-CDN-Cache-Control: 支持 stale-while-revalidate + response.headers.set( + "Cloudflare-CDN-Cache-Control", + `max-age=${pollIntervalSeconds}, stale-while-revalidate=${DATA_CHANGE_CYCLE_SECONDS}` + ); + response.headers.set("ETag", etag); + response.headers.set("Vary", "Accept-Encoding"); + + return response; +} diff --git a/app/api/internal/cache-metrics/route.ts b/app/api/internal/cache-metrics/route.ts new file mode 100644 index 0000000..0cfd914 --- /dev/null +++ b/app/api/internal/cache-metrics/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { getAvailabilityCacheMetrics, resetAvailabilityCacheMetrics } from "@/lib/database/availability"; +import { getConfigCacheMetrics, resetConfigCacheMetrics } from "@/lib/database/config-loader"; +import { getGroupInfoCacheMetrics, resetGroupInfoCacheMetrics } from "@/lib/database/group-info"; +import { getDashboardCacheMetrics, resetDashboardCacheMetrics } from "@/lib/core/dashboard-data"; + +export const revalidate = 0; +export const dynamic = "force-dynamic"; + +function isAuthorized(request: Request): boolean { + const token = process.env.INTERNAL_METRICS_TOKEN; + if (!token) { + return false; + } + const headerToken = request.headers.get("x-internal-token"); + return headerToken === token; +} + +function buildMetricsResponse() { + const availability = getAvailabilityCacheMetrics(); + const config = getConfigCacheMetrics(); + const groupInfo = getGroupInfoCacheMetrics(); + const dashboard = getDashboardCacheMetrics(); + + return NextResponse.json({ + availabilityCache: availability, + configCache: config, + groupInfoCache: groupInfo, + dashboardCache: dashboard, + combinedDbCache: { + hits: availability.hits + config.hits + groupInfo.hits, + misses: availability.misses + config.misses + groupInfo.misses, + }, + generatedAt: new Date().toISOString(), + }); +} + +export async function GET(request: Request) { + if (!isAuthorized(request)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + return buildMetricsResponse(); +} + +export async function POST(request: Request) { + if (!isAuthorized(request)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + resetAvailabilityCacheMetrics(); + resetConfigCacheMetrics(); + resetGroupInfoCacheMetrics(); + resetDashboardCacheMetrics(); + + return buildMetricsResponse(); +} diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..512844b --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; + +import { getActiveSystemNotifications } from "@/lib/database/notifications"; + +export const revalidate = 0; +export const dynamic = "force-dynamic"; + +export async function GET() { + const notifications = await getActiveSystemNotifications(); + + return NextResponse.json(notifications, { + headers: { + "Cache-Control": "public, max-age=60, stale-while-revalidate=300", + }, + }); +} diff --git a/app/api/v1/status/route.ts b/app/api/v1/status/route.ts new file mode 100644 index 0000000..ead74c7 --- /dev/null +++ b/app/api/v1/status/route.ts @@ -0,0 +1,256 @@ +import { NextRequest, NextResponse } from "next/server"; +import { loadHistory } from "@/lib/database/history"; +import { loadProviderConfigsFromDB } from "@/lib/database/config-loader"; +import { getPollingIntervalMs, getPollingIntervalLabel } from "@/lib/core/polling-config"; +import type { CheckResult, HealthStatus } from "@/lib/types"; + +export const revalidate = 0; +export const dynamic = "force-dynamic"; + +interface ProviderStatistics { + totalChecks: number; + operationalCount: number; + degradedCount: number; + failedCount: number; + validationFailedCount: number; + successRate: number; + avgLatencyMs: number | null; + minLatencyMs: number | null; + maxLatencyMs: number | null; +} + +interface ProviderStatus { + id: string; + name: string; + type: string; + model: string; + group: string | null; + endpoint: string; + latest: { + status: HealthStatus; + latencyMs: number | null; + pingLatencyMs: number | null; + checkedAt: string; + message: string; + } | null; + statistics: ProviderStatistics; + timeline: Array<{ + status: HealthStatus; + latencyMs: number | null; + pingLatencyMs: number | null; + checkedAt: string; + message: string; + }>; +} + +interface StatusSummary { + total: number; + operational: number; + degraded: number; + failed: number; + validationFailed: number; + maintenance: number; + avgLatencyMs: number | null; +} + +interface ApiResponse { + providers: ProviderStatus[]; + summary: StatusSummary; + metadata: { + generatedAt: string; + pollIntervalMs: number; + pollIntervalLabel: string; + filters: { + group: string | null; + model: string | null; + }; + }; +} + +function computeStatistics(items: CheckResult[]): ProviderStatistics { + if (items.length === 0) { + return { + totalChecks: 0, + operationalCount: 0, + degradedCount: 0, + failedCount: 0, + validationFailedCount: 0, + successRate: 0, + avgLatencyMs: null, + minLatencyMs: null, + maxLatencyMs: null, + }; + } + + let operationalCount = 0; + let degradedCount = 0; + let failedCount = 0; + let validationFailedCount = 0; + const latencies: number[] = []; + + for (const item of items) { + switch (item.status) { + case "operational": + operationalCount++; + break; + case "degraded": + degradedCount++; + break; + case "failed": + failedCount++; + break; + case "validation_failed": + validationFailedCount++; + break; + } + if (item.latencyMs !== null) { + latencies.push(item.latencyMs); + } + } + + const successCount = operationalCount + degradedCount; + const successRate = items.length > 0 ? (successCount / items.length) * 100 : 0; + + let avgLatencyMs: number | null = null; + let minLatencyMs: number | null = null; + let maxLatencyMs: number | null = null; + + if (latencies.length > 0) { + avgLatencyMs = Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length); + minLatencyMs = Math.min(...latencies); + maxLatencyMs = Math.max(...latencies); + } + + return { + totalChecks: items.length, + operationalCount, + degradedCount, + failedCount, + validationFailedCount, + successRate: Math.round(successRate * 100) / 100, + avgLatencyMs, + minLatencyMs, + maxLatencyMs, + }; +} + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const groupFilter = searchParams.get("group"); + const modelFilter = searchParams.get("model"); + + const allConfigs = await loadProviderConfigsFromDB(); + const activeConfigs = allConfigs.filter((cfg) => !cfg.is_maintenance); + const maintenanceConfigIds = new Set( + allConfigs.filter((cfg) => cfg.is_maintenance).map((cfg) => cfg.id) + ); + + const allowedIds = new Set(activeConfigs.map((cfg) => cfg.id)); + const history = await loadHistory({ allowedIds }); + + const providers: ProviderStatus[] = []; + + for (const config of allConfigs) { + if (groupFilter && config.groupName !== groupFilter) { + continue; + } + if (modelFilter && config.model !== modelFilter) { + continue; + } + + const items = history[config.id] || []; + + const latest = items[0] || null; + const statistics = computeStatistics(items); + + const isMaintenance = maintenanceConfigIds.has(config.id); + + providers.push({ + id: config.id, + name: config.name, + type: config.type, + model: config.model, + group: config.groupName || null, + endpoint: config.endpoint, + latest: latest + ? { + status: isMaintenance ? "maintenance" : latest.status, + latencyMs: latest.latencyMs, + pingLatencyMs: latest.pingLatencyMs, + checkedAt: latest.checkedAt, + message: latest.message, + } + : null, + statistics, + timeline: items.map((item) => ({ + status: isMaintenance ? "maintenance" : item.status, + latencyMs: item.latencyMs, + pingLatencyMs: item.pingLatencyMs, + checkedAt: item.checkedAt, + message: item.message, + })), + }); + } + + let summaryOperational = 0; + let summaryDegraded = 0; + let summaryFailed = 0; + let summaryValidationFailed = 0; + let summaryMaintenance = 0; + const allLatencies: number[] = []; + + for (const provider of providers) { + if (!provider.latest) continue; + + switch (provider.latest.status) { + case "operational": + summaryOperational++; + break; + case "degraded": + summaryDegraded++; + break; + case "failed": + summaryFailed++; + break; + case "validation_failed": + summaryValidationFailed++; + break; + case "maintenance": + summaryMaintenance++; + break; + } + + if (provider.latest.latencyMs !== null) { + allLatencies.push(provider.latest.latencyMs); + } + } + + const summary: StatusSummary = { + total: providers.length, + operational: summaryOperational, + degraded: summaryDegraded, + failed: summaryFailed, + validationFailed: summaryValidationFailed, + maintenance: summaryMaintenance, + avgLatencyMs: + allLatencies.length > 0 + ? Math.round(allLatencies.reduce((a, b) => a + b, 0) / allLatencies.length) + : null, + }; + + const response: ApiResponse = { + providers, + summary, + metadata: { + generatedAt: new Date().toISOString(), + pollIntervalMs: getPollingIntervalMs(), + pollIntervalLabel: getPollingIntervalLabel(), + filters: { + group: groupFilter, + model: modelFilter, + }, + }, + }; + + return NextResponse.json(response); +} diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/favicon.png b/app/favicon.png new file mode 100644 index 0000000..63a021e Binary files /dev/null and b/app/favicon.png differ diff --git a/app/globals.css b/app/globals.css index a2dc41e..49e0c67 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,141 @@ @import "tailwindcss"; +@import "tw-animate-css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +@layer base { + * { + @apply border-border outline-ring/50; + } + html { + @apply bg-background text-foreground; + } + + body { + @apply relative min-h-screen; + } + + body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + background-image: + linear-gradient(to right, var(--border) 1px, transparent 1px), + linear-gradient(to bottom, var(--border) 1px, transparent 1px); + background-size: 40px 40px; + background-position: -1px -1px; + mask-image: linear-gradient(to bottom, black 80%, transparent 100%); + opacity: 0.6; + pointer-events: none; + } } diff --git a/app/group/[groupName]/page.tsx b/app/group/[groupName]/page.tsx new file mode 100644 index 0000000..e27277c --- /dev/null +++ b/app/group/[groupName]/page.tsx @@ -0,0 +1,51 @@ +import {notFound} from "next/navigation"; +import Link from "next/link"; +import {ChevronLeft} from "lucide-react"; + +import {GroupDashboardBootstrap} from "@/components/group-dashboard-bootstrap"; +import {getAvailableGroups} from "@/lib/core/group-data"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +interface GroupPageProps { + params: Promise<{ groupName: string }>; +} + +// 生成页面元数据 +export async function generateMetadata({ params }: GroupPageProps) { + const { groupName } = await params; + const decodedGroupName = decodeURIComponent(groupName); + + return { + title: `${decodedGroupName} - 模型健康面板`, + description: `查看 ${decodedGroupName} 分组下的模型健康状态`, + }; +} + +export default async function GroupPage({ params }: GroupPageProps) { + const { groupName } = await params; + const decodedGroupName = decodeURIComponent(groupName); + + const availableGroups = await getAvailableGroups(); + if (!availableGroups.includes(decodedGroupName)) { + notFound(); + } + + return ( +
+
+ {/* 返回首页链接 */} + + + 返回首页 + + + +
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..7830ef9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,10 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import type {Metadata} from "next"; +import {Geist, Geist_Mono} from "next/font/google"; import "./globals.css"; +import "@/lib/core/poller"; +import NextTopLoader from "nextjs-toploader"; +import {ThemeProvider} from "@/components/theme-provider"; +import {NotificationBanner} from "@/components/notification-banner"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,21 +17,47 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "LINUX DO - 模型中转状态检测", + description: "实时检测 OpenAI / Gemini / Anthropic 对话接口的可用性与延迟", + icons: { + icon: "/favicon.png", + }, }; +const themeBootScript = `(()=>{ + const hour = new Date().getHours(); + const isDark = hour >= 19 || hour < 7; + const root = document.documentElement; + root.classList.toggle('dark', isDark); + root.style.colorScheme = isDark ? 'dark' : 'light'; +})();`; + export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( - + + +