Upload 12 files
Browse files- .dockerignore +1 -1
- .env.example +2 -1
- Dockerfile +1 -1
- README.md +68 -53
- app/main.py +26 -21
- requirements.txt +1 -1
- static/admin.html +99 -58
- static/admin.js +89 -41
- static/index.html +45 -23
- static/public.js +85 -45
- static/style.css +559 -292
.dockerignore
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
|
| 2 |
.pytest_cache/
|
| 3 |
.pytest_tmp/
|
| 4 |
*.pyc
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
.pytest_cache/
|
| 3 |
.pytest_tmp/
|
| 4 |
*.pyc
|
.env.example
CHANGED
|
@@ -4,6 +4,7 @@ GATEWAY_API_KEY=
|
|
| 4 |
NVIDIA_API_BASE=https://integrate.api.nvidia.com/v1
|
| 5 |
NVIDIA_NIM_API_KEY=
|
| 6 |
HEALTHCHECK_INTERVAL_MINUTES=60
|
| 7 |
-
HEALTHCHECK_PROMPT=
|
| 8 |
PUBLIC_HISTORY_HOURS=48
|
| 9 |
DATABASE_PATH=./data.sqlite3
|
|
|
|
|
|
| 4 |
NVIDIA_API_BASE=https://integrate.api.nvidia.com/v1
|
| 5 |
NVIDIA_NIM_API_KEY=
|
| 6 |
HEALTHCHECK_INTERVAL_MINUTES=60
|
| 7 |
+
HEALTHCHECK_PROMPT=请只回复 OK。
|
| 8 |
PUBLIC_HISTORY_HOURS=48
|
| 9 |
DATABASE_PATH=./data.sqlite3
|
| 10 |
+
|
Dockerfile
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
PYTHONUNBUFFERED=1 \
|
|
|
|
| 1 |
+
FROM python:3.13-slim
|
| 2 |
|
| 3 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
PYTHONUNBUFFERED=1 \
|
README.md
CHANGED
|
@@ -1,28 +1,34 @@
|
|
| 1 |
---
|
| 2 |
-
title: NVIDIA NIM
|
| 3 |
sdk: docker
|
| 4 |
app_port: 7860
|
| 5 |
pinned: false
|
| 6 |
---
|
| 7 |
|
| 8 |
-
# NVIDIA NIM
|
| 9 |
|
| 10 |
-
|
| 11 |
|
| 12 |
`https://integrate.api.nvidia.com/v1/chat/completions`
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
| 17 |
-
- `previous_response_id` conversation chaining
|
| 18 |
-
- `/v1/models` model listing
|
| 19 |
-
- a public model health dashboard
|
| 20 |
-
- an admin SPA for model management, NVIDIA NIM key management, health checks, and scheduler settings
|
| 21 |
-
- Docker packaging for Hugging Face Spaces
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
| 26 |
|
| 27 |
- `z-ai/glm5`
|
| 28 |
- `minimaxai/minimax-m2.5`
|
|
@@ -31,19 +37,24 @@ The app seeds these models on first startup:
|
|
| 31 |
- `google/gemma-4-31b-it`
|
| 32 |
- `qwen/qwen3.5-397b-a17b`
|
| 33 |
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
- `GET /admin` admin SPA
|
| 40 |
-
- `GET /api/health/public` public hourly health data
|
| 41 |
-
- `GET /v1/models` OpenAI-style model list
|
| 42 |
-
- `POST /v1/responses` OpenAI-style responses endpoint
|
| 43 |
-
- `GET /v1/responses/{response_id}` retrieve a stored response
|
| 44 |
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
|
|
|
|
|
|
|
|
|
|
| 47 |
- `POST /admin/api/login`
|
| 48 |
- `GET /admin/api/overview`
|
| 49 |
- `GET/POST/DELETE /admin/api/models...`
|
|
@@ -52,61 +63,65 @@ Admin API:
|
|
| 52 |
- `POST /admin/api/healthchecks/run`
|
| 53 |
- `GET/PUT /admin/api/settings`
|
| 54 |
|
| 55 |
-
##
|
| 56 |
|
| 57 |
-
- `PASSWORD`
|
| 58 |
-
- `SESSION_SECRET`
|
| 59 |
-
- `GATEWAY_API_KEY`
|
| 60 |
-
- `NVIDIA_API_BASE`
|
| 61 |
-
- `NVIDIA_NIM_API_KEY`
|
| 62 |
-
- `HEALTHCHECK_INTERVAL_MINUTES`
|
| 63 |
-
- `HEALTHCHECK_PROMPT`
|
| 64 |
-
- `PUBLIC_HISTORY_HOURS`
|
| 65 |
-
- `DATABASE_PATH`
|
| 66 |
|
| 67 |
-
|
| 68 |
|
| 69 |
-
##
|
| 70 |
|
| 71 |
-
|
| 72 |
|
| 73 |
```bash
|
| 74 |
pip install -r requirements.txt
|
| 75 |
```
|
| 76 |
|
| 77 |
-
|
| 78 |
|
| 79 |
```bash
|
| 80 |
pip install -r requirements-dev.txt
|
| 81 |
python scripts/local_smoke_test.py
|
| 82 |
```
|
| 83 |
|
| 84 |
-
|
| 85 |
|
| 86 |
```bash
|
| 87 |
uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 88 |
```
|
| 89 |
|
| 90 |
-
## Hugging Face Space
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
-
|
| 95 |
-
2. Push this repository to the Space.
|
| 96 |
-
3. Add Space secrets for at least `PASSWORD` and one NVIDIA NIM key.
|
| 97 |
-
4. Open `/admin`, add or verify the stored keys, then run health checks.
|
| 98 |
|
| 99 |
-
|
| 100 |
|
| 101 |
-
-
|
| 102 |
-
-
|
| 103 |
-
-
|
| 104 |
-
-
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
##
|
| 107 |
|
| 108 |
-
- OpenAI Responses API
|
| 109 |
-
- OpenAI
|
| 110 |
-
- NVIDIA Build
|
| 111 |
-
- NVIDIA NIM API
|
| 112 |
-
|
|
|
|
| 1 |
---
|
| 2 |
+
title: NVIDIA NIM 响应网关
|
| 3 |
sdk: docker
|
| 4 |
app_port: 7860
|
| 5 |
pinned: false
|
| 6 |
---
|
| 7 |
|
| 8 |
+
# NVIDIA NIM 响应网关
|
| 9 |
|
| 10 |
+
这是一个基于 FastAPI 的兼容层项目,用来把 NVIDIA 官方接口:
|
| 11 |
|
| 12 |
`https://integrate.api.nvidia.com/v1/chat/completions`
|
| 13 |
|
| 14 |
+
转换为 OpenAI 风格的 `/v1/responses` 接口,并附带一个公开健康看板和一个中文后台管理系统。
|
| 15 |
|
| 16 |
+
## 已支持能力
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
- `POST /v1/responses`
|
| 19 |
+
- `GET /v1/models`
|
| 20 |
+
- `GET /v1/responses/{response_id}`
|
| 21 |
+
- tool calling / function calling 转换
|
| 22 |
+
- `function_call_output` 回灌转换
|
| 23 |
+
- `previous_response_id` 对话续写
|
| 24 |
+
- 模型管理
|
| 25 |
+
- NVIDIA NIM Key 管理
|
| 26 |
+
- 按小时健康巡检与公开状态页展示
|
| 27 |
+
- Docker 方式部署到 Hugging Face Space
|
| 28 |
|
| 29 |
+
## 预置模型
|
| 30 |
+
|
| 31 |
+
首次启动会自动写入以下模型:
|
| 32 |
|
| 33 |
- `z-ai/glm5`
|
| 34 |
- `minimaxai/minimax-m2.5`
|
|
|
|
| 37 |
- `google/gemma-4-31b-it`
|
| 38 |
- `qwen/qwen3.5-397b-a17b`
|
| 39 |
|
| 40 |
+
你也可以在后台继续添加、删除和测试模型。
|
| 41 |
+
|
| 42 |
+
## 页面与接口
|
| 43 |
+
|
| 44 |
+
公开页面:
|
| 45 |
|
| 46 |
+
- `GET /` 模型健康度看板
|
| 47 |
+
- `GET /api/health/public` 公开健康数据
|
| 48 |
|
| 49 |
+
兼容接口:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
+
- `POST /v1/responses`
|
| 52 |
+
- `GET /v1/models`
|
| 53 |
+
- `GET /v1/responses/{response_id}`
|
| 54 |
|
| 55 |
+
后台页面:
|
| 56 |
+
|
| 57 |
+
- `GET /admin`
|
| 58 |
- `POST /admin/api/login`
|
| 59 |
- `GET /admin/api/overview`
|
| 60 |
- `GET/POST/DELETE /admin/api/models...`
|
|
|
|
| 63 |
- `POST /admin/api/healthchecks/run`
|
| 64 |
- `GET/PUT /admin/api/settings`
|
| 65 |
|
| 66 |
+
## 环境变量
|
| 67 |
|
| 68 |
+
- `PASSWORD`:后台登录密码,必填
|
| 69 |
+
- `SESSION_SECRET`:后台会话签名密钥,可选;默认回退到 `PASSWORD`
|
| 70 |
+
- `GATEWAY_API_KEY`:如果需要给 `/v1/models` 和 `/v1/responses` 再加一层 Bearer 保护,可以设置它
|
| 71 |
+
- `NVIDIA_API_BASE`:默认 `https://integrate.api.nvidia.com/v1`
|
| 72 |
+
- `NVIDIA_NIM_API_KEY`:可选,首次启动时自动导入为默认 Key
|
| 73 |
+
- `HEALTHCHECK_INTERVAL_MINUTES`:默认 `60`
|
| 74 |
+
- `HEALTHCHECK_PROMPT`:默认 `请只回复 OK。`
|
| 75 |
+
- `PUBLIC_HISTORY_HOURS`:默认 `48`
|
| 76 |
+
- `DATABASE_PATH`:默认 `./data.sqlite3`
|
| 77 |
|
| 78 |
+
示例配置见 `.env.example`。
|
| 79 |
|
| 80 |
+
## 本地运行
|
| 81 |
|
| 82 |
+
安装运行依赖:
|
| 83 |
|
| 84 |
```bash
|
| 85 |
pip install -r requirements.txt
|
| 86 |
```
|
| 87 |
|
| 88 |
+
如需本地联调与 smoke test:
|
| 89 |
|
| 90 |
```bash
|
| 91 |
pip install -r requirements-dev.txt
|
| 92 |
python scripts/local_smoke_test.py
|
| 93 |
```
|
| 94 |
|
| 95 |
+
启动服务:
|
| 96 |
|
| 97 |
```bash
|
| 98 |
uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 99 |
```
|
| 100 |
|
| 101 |
+
## 部署到 Hugging Face Space
|
| 102 |
+
|
| 103 |
+
这个仓库已经按 Docker Space 准备好了部署文件。
|
| 104 |
|
| 105 |
+
1. 新建一个 Hugging Face Space,SDK 选择 `Docker`
|
| 106 |
+
2. 将 `hf_space` 目录内的内容作为 Space 根目录上传
|
| 107 |
+
3. 在 Space Secrets 中至少配置 `PASSWORD` 和一个 NVIDIA NIM Key
|
| 108 |
+
4. 打开 `/admin`,确认 Key 可用,并执行一次巡检
|
| 109 |
|
| 110 |
+
## 本地验证情况
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
+
我已经通过本地 smoke test 验证了以下链路:
|
| 113 |
|
| 114 |
+
- 中文首页与中文后台页面可正常返回
|
| 115 |
+
- HTML 响应头包含 `charset=utf-8`
|
| 116 |
+
- `/v1/responses` 文本回复转换正常
|
| 117 |
+
- tool call / function call 转换正常
|
| 118 |
+
- `function_call_output` 回灌到上游消息格式正常
|
| 119 |
+
- `previous_response_id` 上下文拼接正常
|
| 120 |
+
- 后台登录、手动巡检、公开健康页同步正常
|
| 121 |
|
| 122 |
+
## 参考资料
|
| 123 |
|
| 124 |
+
- OpenAI Responses API: https://platform.openai.com/docs/guides/responses-vs-chat-completions
|
| 125 |
+
- OpenAI Function Calling: https://platform.openai.com/docs/guides/function-calling
|
| 126 |
+
- NVIDIA Build: https://build.nvidia.com/
|
| 127 |
+
- NVIDIA NIM API 文档: https://docs.api.nvidia.com/
|
|
|
app/main.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
import json
|
| 4 |
import os
|
|
@@ -13,7 +13,7 @@ from typing import Any
|
|
| 13 |
import httpx
|
| 14 |
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
| 15 |
from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response, status
|
| 16 |
-
from fastapi.responses import
|
| 17 |
from fastapi.staticfiles import StaticFiles
|
| 18 |
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
| 19 |
|
|
@@ -32,7 +32,7 @@ GATEWAY_API_KEY = os.getenv("GATEWAY_API_KEY")
|
|
| 32 |
DEFAULT_ENV_KEY = os.getenv("NVIDIA_NIM_API_KEY") or os.getenv("NVIDIA_API_KEY")
|
| 33 |
REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "90"))
|
| 34 |
DEFAULT_HEALTH_INTERVAL_MINUTES = int(os.getenv("HEALTHCHECK_INTERVAL_MINUTES", "60"))
|
| 35 |
-
DEFAULT_HEALTH_PROMPT = os.getenv("HEALTHCHECK_PROMPT", "
|
| 36 |
PUBLIC_HISTORY_HOURS = int(os.getenv("PUBLIC_HISTORY_HOURS", "48"))
|
| 37 |
|
| 38 |
DEFAULT_MODELS = [
|
|
@@ -247,7 +247,7 @@ def require_admin(request: Request, authorization: str | None = Header(default=N
|
|
| 247 |
if not token:
|
| 248 |
token = request.cookies.get(COOKIE_NAME)
|
| 249 |
if not token or not verify_admin_token(token):
|
| 250 |
-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="
|
| 251 |
return True
|
| 252 |
|
| 253 |
|
|
@@ -294,7 +294,7 @@ def select_api_key(conn: sqlite3.Connection, explicit_id: int | None = None) ->
|
|
| 294 |
"""
|
| 295 |
).fetchone()
|
| 296 |
if not row:
|
| 297 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="
|
| 298 |
return row
|
| 299 |
|
| 300 |
|
|
@@ -821,7 +821,7 @@ async def run_healthchecks(model_identifier: str | int | None = None, api_key_id
|
|
| 821 |
if api_key_identifier is not None:
|
| 822 |
api_key_row = fetch_key_by_identifier(conn, api_key_identifier, enabled_only=True)
|
| 823 |
if not api_key_row:
|
| 824 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API
|
| 825 |
key_rows = [api_key_row]
|
| 826 |
else:
|
| 827 |
key_rows = conn.execute("SELECT * FROM api_keys WHERE enabled = 1 ORDER BY id ASC").fetchall()
|
|
@@ -830,7 +830,7 @@ async def run_healthchecks(model_identifier: str | int | None = None, api_key_id
|
|
| 830 |
if model_identifier is not None:
|
| 831 |
model_row = fetch_model_by_identifier(conn, model_identifier, enabled_only=True)
|
| 832 |
if not model_row:
|
| 833 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="
|
| 834 |
model_rows = [model_row]
|
| 835 |
else:
|
| 836 |
model_rows = conn.execute("SELECT * FROM proxy_models WHERE enabled = 1 ORDER BY sort_order ASC, model_id ASC").fetchall()
|
|
@@ -900,18 +900,23 @@ async def lifespan(_app: FastAPI):
|
|
| 900 |
scheduler.shutdown(wait=False)
|
| 901 |
|
| 902 |
|
| 903 |
-
app = FastAPI(title="NIM
|
| 904 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 905 |
|
| 906 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 907 |
@app.get("/")
|
| 908 |
-
async def public_dashboard() ->
|
| 909 |
-
return
|
| 910 |
|
| 911 |
|
| 912 |
@app.get("/admin")
|
| 913 |
-
async def admin_dashboard() ->
|
| 914 |
-
return
|
| 915 |
|
| 916 |
|
| 917 |
@app.get("/api/health/public")
|
|
@@ -994,11 +999,11 @@ async def create_response(request: Request, _: bool = Depends(require_proxy_toke
|
|
| 994 |
@app.post("/admin/api/login")
|
| 995 |
async def admin_login(request: Request, response: Response):
|
| 996 |
if not ADMIN_PASSWORD:
|
| 997 |
-
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="PASSWORD
|
| 998 |
body = await request.json()
|
| 999 |
password = body.get("password") if isinstance(body, dict) else None
|
| 1000 |
if password != ADMIN_PASSWORD:
|
| 1001 |
-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="
|
| 1002 |
token = create_admin_token()
|
| 1003 |
response.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", secure=False, max_age=60 * 60 * 24 * 7)
|
| 1004 |
return {"token": token, "access_token": token, "token_type": "bearer"}
|
|
@@ -1007,7 +1012,7 @@ async def admin_login(request: Request, response: Response):
|
|
| 1007 |
@app.post("/admin/api/logout")
|
| 1008 |
async def admin_logout(response: Response, _: bool = Depends(require_admin)):
|
| 1009 |
response.delete_cookie(COOKIE_NAME)
|
| 1010 |
-
return {"message": "
|
| 1011 |
|
| 1012 |
|
| 1013 |
@app.get("/admin/api/session")
|
|
@@ -1103,7 +1108,7 @@ def delete_model_internal(model_identifier: str) -> dict[str, Any]:
|
|
| 1103 |
try:
|
| 1104 |
row = fetch_model_by_identifier(conn, model_identifier)
|
| 1105 |
if not row:
|
| 1106 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="
|
| 1107 |
conn.execute("DELETE FROM proxy_models WHERE id = ?", (row["id"],))
|
| 1108 |
conn.commit()
|
| 1109 |
return {"message": "Model deleted."}
|
|
@@ -1130,7 +1135,7 @@ async def test_model_internal(model_identifier: str, payload: dict[str, Any] | N
|
|
| 1130 |
try:
|
| 1131 |
row = fetch_model_by_identifier(conn, model_identifier, enabled_only=True)
|
| 1132 |
if not row:
|
| 1133 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="
|
| 1134 |
api_key_row = select_api_key(conn, payload.get("api_key_id") if payload else None)
|
| 1135 |
return await perform_healthcheck(conn, row, api_key_row, (payload or {}).get("prompt") or DEFAULT_HEALTH_PROMPT)
|
| 1136 |
finally:
|
|
@@ -1201,7 +1206,7 @@ def delete_key_internal(key_identifier: str) -> dict[str, Any]:
|
|
| 1201 |
try:
|
| 1202 |
row = fetch_key_by_identifier(conn, key_identifier)
|
| 1203 |
if not row:
|
| 1204 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API
|
| 1205 |
conn.execute("DELETE FROM api_keys WHERE id = ?", (row["id"],))
|
| 1206 |
conn.commit()
|
| 1207 |
return {"message": "API key deleted."}
|
|
@@ -1228,11 +1233,11 @@ async def test_key_internal(key_identifier: str, payload: dict[str, Any] | None
|
|
| 1228 |
try:
|
| 1229 |
key_row = fetch_key_by_identifier(conn, key_identifier, enabled_only=True)
|
| 1230 |
if not key_row:
|
| 1231 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API
|
| 1232 |
model_identifier = (payload or {}).get("model_id") or DEFAULT_MODELS[0][0]
|
| 1233 |
model_row = fetch_model_by_identifier(conn, model_identifier, enabled_only=True)
|
| 1234 |
if not model_row:
|
| 1235 |
-
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="
|
| 1236 |
return await perform_healthcheck(conn, model_row, key_row, (payload or {}).get("prompt") or DEFAULT_HEALTH_PROMPT)
|
| 1237 |
finally:
|
| 1238 |
conn.close()
|
|
@@ -1270,7 +1275,7 @@ async def admin_healthchecks(hours: int = 48, _: bool = Depends(require_admin)):
|
|
| 1270 |
""",
|
| 1271 |
(since.isoformat(),),
|
| 1272 |
).fetchall()
|
| 1273 |
-
items = [{"id": row["id"], "model": row["display_name"], "model_id": row["model_id"], "api_key": row["key_name"], "status": "healthy" if row["ok"] else "down", "detail": row["response_excerpt"] or row["error_message"] or "
|
| 1274 |
return {"items": items}
|
| 1275 |
finally:
|
| 1276 |
conn.close()
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
import os
|
|
|
|
| 13 |
import httpx
|
| 14 |
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
| 15 |
from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response, status
|
| 16 |
+
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
| 17 |
from fastapi.staticfiles import StaticFiles
|
| 18 |
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
| 19 |
|
|
|
|
| 32 |
DEFAULT_ENV_KEY = os.getenv("NVIDIA_NIM_API_KEY") or os.getenv("NVIDIA_API_KEY")
|
| 33 |
REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "90"))
|
| 34 |
DEFAULT_HEALTH_INTERVAL_MINUTES = int(os.getenv("HEALTHCHECK_INTERVAL_MINUTES", "60"))
|
| 35 |
+
DEFAULT_HEALTH_PROMPT = os.getenv("HEALTHCHECK_PROMPT", "请只回复 OK。")
|
| 36 |
PUBLIC_HISTORY_HOURS = int(os.getenv("PUBLIC_HISTORY_HOURS", "48"))
|
| 37 |
|
| 38 |
DEFAULT_MODELS = [
|
|
|
|
| 247 |
if not token:
|
| 248 |
token = request.cookies.get(COOKIE_NAME)
|
| 249 |
if not token or not verify_admin_token(token):
|
| 250 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="需要管理员登录。")
|
| 251 |
return True
|
| 252 |
|
| 253 |
|
|
|
|
| 294 |
"""
|
| 295 |
).fetchone()
|
| 296 |
if not row:
|
| 297 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前没有可用的 NVIDIA NIM Key。")
|
| 298 |
return row
|
| 299 |
|
| 300 |
|
|
|
|
| 821 |
if api_key_identifier is not None:
|
| 822 |
api_key_row = fetch_key_by_identifier(conn, api_key_identifier, enabled_only=True)
|
| 823 |
if not api_key_row:
|
| 824 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到 API Key。")
|
| 825 |
key_rows = [api_key_row]
|
| 826 |
else:
|
| 827 |
key_rows = conn.execute("SELECT * FROM api_keys WHERE enabled = 1 ORDER BY id ASC").fetchall()
|
|
|
|
| 830 |
if model_identifier is not None:
|
| 831 |
model_row = fetch_model_by_identifier(conn, model_identifier, enabled_only=True)
|
| 832 |
if not model_row:
|
| 833 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到模型。")
|
| 834 |
model_rows = [model_row]
|
| 835 |
else:
|
| 836 |
model_rows = conn.execute("SELECT * FROM proxy_models WHERE enabled = 1 ORDER BY sort_order ASC, model_id ASC").fetchall()
|
|
|
|
| 900 |
scheduler.shutdown(wait=False)
|
| 901 |
|
| 902 |
|
| 903 |
+
app = FastAPI(title="NIM 响应网关", lifespan=lifespan)
|
| 904 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 905 |
|
| 906 |
|
| 907 |
+
def render_html(filename: str) -> HTMLResponse:
|
| 908 |
+
content = (STATIC_DIR / filename).read_text(encoding="utf-8")
|
| 909 |
+
return HTMLResponse(content=content, media_type="text/html; charset=utf-8")
|
| 910 |
+
|
| 911 |
+
|
| 912 |
@app.get("/")
|
| 913 |
+
async def public_dashboard() -> HTMLResponse:
|
| 914 |
+
return render_html("index.html")
|
| 915 |
|
| 916 |
|
| 917 |
@app.get("/admin")
|
| 918 |
+
async def admin_dashboard() -> HTMLResponse:
|
| 919 |
+
return render_html("admin.html")
|
| 920 |
|
| 921 |
|
| 922 |
@app.get("/api/health/public")
|
|
|
|
| 999 |
@app.post("/admin/api/login")
|
| 1000 |
async def admin_login(request: Request, response: Response):
|
| 1001 |
if not ADMIN_PASSWORD:
|
| 1002 |
+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置 PASSWORD 环境变量。")
|
| 1003 |
body = await request.json()
|
| 1004 |
password = body.get("password") if isinstance(body, dict) else None
|
| 1005 |
if password != ADMIN_PASSWORD:
|
| 1006 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误。")
|
| 1007 |
token = create_admin_token()
|
| 1008 |
response.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", secure=False, max_age=60 * 60 * 24 * 7)
|
| 1009 |
return {"token": token, "access_token": token, "token_type": "bearer"}
|
|
|
|
| 1012 |
@app.post("/admin/api/logout")
|
| 1013 |
async def admin_logout(response: Response, _: bool = Depends(require_admin)):
|
| 1014 |
response.delete_cookie(COOKIE_NAME)
|
| 1015 |
+
return {"message": "已退出登录。"}
|
| 1016 |
|
| 1017 |
|
| 1018 |
@app.get("/admin/api/session")
|
|
|
|
| 1108 |
try:
|
| 1109 |
row = fetch_model_by_identifier(conn, model_identifier)
|
| 1110 |
if not row:
|
| 1111 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到模型。")
|
| 1112 |
conn.execute("DELETE FROM proxy_models WHERE id = ?", (row["id"],))
|
| 1113 |
conn.commit()
|
| 1114 |
return {"message": "Model deleted."}
|
|
|
|
| 1135 |
try:
|
| 1136 |
row = fetch_model_by_identifier(conn, model_identifier, enabled_only=True)
|
| 1137 |
if not row:
|
| 1138 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到模型。")
|
| 1139 |
api_key_row = select_api_key(conn, payload.get("api_key_id") if payload else None)
|
| 1140 |
return await perform_healthcheck(conn, row, api_key_row, (payload or {}).get("prompt") or DEFAULT_HEALTH_PROMPT)
|
| 1141 |
finally:
|
|
|
|
| 1206 |
try:
|
| 1207 |
row = fetch_key_by_identifier(conn, key_identifier)
|
| 1208 |
if not row:
|
| 1209 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到 API Key。")
|
| 1210 |
conn.execute("DELETE FROM api_keys WHERE id = ?", (row["id"],))
|
| 1211 |
conn.commit()
|
| 1212 |
return {"message": "API key deleted."}
|
|
|
|
| 1233 |
try:
|
| 1234 |
key_row = fetch_key_by_identifier(conn, key_identifier, enabled_only=True)
|
| 1235 |
if not key_row:
|
| 1236 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到 API Key。")
|
| 1237 |
model_identifier = (payload or {}).get("model_id") or DEFAULT_MODELS[0][0]
|
| 1238 |
model_row = fetch_model_by_identifier(conn, model_identifier, enabled_only=True)
|
| 1239 |
if not model_row:
|
| 1240 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到模型。")
|
| 1241 |
return await perform_healthcheck(conn, model_row, key_row, (payload or {}).get("prompt") or DEFAULT_HEALTH_PROMPT)
|
| 1242 |
finally:
|
| 1243 |
conn.close()
|
|
|
|
| 1275 |
""",
|
| 1276 |
(since.isoformat(),),
|
| 1277 |
).fetchall()
|
| 1278 |
+
items = [{"id": row["id"], "model": row["display_name"], "model_id": row["model_id"], "api_key": row["key_name"], "status": "healthy" if row["ok"] else "down", "detail": row["response_excerpt"] or row["error_message"] or "暂无详情。", "latency": row["latency_ms"], "status_code": row["status_code"], "checked_at": row["checked_at"]} for row in rows]
|
| 1279 |
return {"items": items}
|
| 1280 |
finally:
|
| 1281 |
conn.close()
|
requirements.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
|
| 2 |
uvicorn[standard]>=0.35.0,<1.0.0
|
| 3 |
httpx>=0.28.1,<1.0.0
|
| 4 |
apscheduler>=3.10.4,<4.0.0
|
|
|
|
| 1 |
+
fastapi>=0.116.0,<1.0.0
|
| 2 |
uvicorn[standard]>=0.35.0,<1.0.0
|
| 3 |
httpx>=0.28.1,<1.0.0
|
| 4 |
apscheduler>=3.10.4,<4.0.0
|
static/admin.html
CHANGED
|
@@ -1,40 +1,60 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
-
<html lang="
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
-
<title>
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
<link
|
| 10 |
-
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;
|
| 11 |
rel="stylesheet"
|
| 12 |
/>
|
| 13 |
<link rel="stylesheet" href="/static/style.css" />
|
| 14 |
</head>
|
| 15 |
-
<body>
|
|
|
|
|
|
|
| 16 |
<div class="admin-shell">
|
| 17 |
<aside class="admin-sidebar">
|
| 18 |
-
<
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
<
|
| 23 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
</aside>
|
|
|
|
| 25 |
<section class="admin-content">
|
| 26 |
<div class="glass-panel" data-panel="overview">
|
| 27 |
-
<
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
<table class="table">
|
| 32 |
<thead>
|
| 33 |
<tr>
|
| 34 |
-
<th>
|
| 35 |
-
<th>
|
| 36 |
-
<th>
|
| 37 |
-
<th>
|
| 38 |
</tr>
|
| 39 |
</thead>
|
| 40 |
<tbody id="recent-checks"></tbody>
|
|
@@ -43,30 +63,37 @@
|
|
| 43 |
</div>
|
| 44 |
|
| 45 |
<div class="glass-panel hidden" data-panel="models">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
<div class="section-grid compact-grid">
|
| 47 |
<div class="metric-card">
|
| 48 |
-
<h3>
|
| 49 |
<strong id="model-count">-</strong>
|
| 50 |
</div>
|
| 51 |
<div class="metric-card">
|
| 52 |
-
<h3>
|
| 53 |
<strong id="model-healthy">-</strong>
|
| 54 |
</div>
|
| 55 |
</div>
|
| 56 |
-
<div class="form-grid
|
| 57 |
-
<input id="model-id" placeholder="
|
| 58 |
-
<input id="model-display-name" placeholder="
|
| 59 |
-
<textarea id="model-description" placeholder="
|
| 60 |
-
<button id="model-add" type="button">
|
| 61 |
</div>
|
| 62 |
-
<table class="table
|
| 63 |
<thead>
|
| 64 |
<tr>
|
| 65 |
-
<th>
|
| 66 |
-
<th>
|
| 67 |
-
<th>
|
| 68 |
-
<th>
|
| 69 |
-
<th>
|
| 70 |
</tr>
|
| 71 |
</thead>
|
| 72 |
<tbody id="model-table"></tbody>
|
|
@@ -74,21 +101,27 @@
|
|
| 74 |
</div>
|
| 75 |
|
| 76 |
<div class="glass-panel hidden" data-panel="keys">
|
| 77 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
<div class="form-grid compact-grid">
|
| 79 |
-
<input id="key-label" placeholder="Key
|
| 80 |
-
<input id="key-value" placeholder="NVIDIA NIM
|
| 81 |
-
<button id="key-add" type="button">
|
| 82 |
</div>
|
| 83 |
-
<table class="table
|
| 84 |
<thead>
|
| 85 |
<tr>
|
| 86 |
-
<th>
|
| 87 |
-
<th>
|
| 88 |
-
<th>
|
| 89 |
-
<th>
|
| 90 |
-
<th>
|
| 91 |
-
<th>
|
| 92 |
</tr>
|
| 93 |
</thead>
|
| 94 |
<tbody id="key-table"></tbody>
|
|
@@ -96,29 +129,36 @@
|
|
| 96 |
</div>
|
| 97 |
|
| 98 |
<div class="glass-panel hidden" data-panel="health">
|
| 99 |
-
<div class="
|
| 100 |
<div>
|
| 101 |
-
<
|
| 102 |
-
<
|
| 103 |
</div>
|
| 104 |
-
<button id="run-healthcheck" type="button">
|
| 105 |
</div>
|
|
|
|
| 106 |
<div class="section-grid" id="health-grid"></div>
|
| 107 |
</div>
|
| 108 |
|
| 109 |
<div class="glass-panel hidden" data-panel="settings">
|
| 110 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
<div class="form-grid">
|
| 112 |
<label class="checkbox-row">
|
| 113 |
<input id="healthcheck-enabled" type="checkbox" />
|
| 114 |
-
<span>
|
| 115 |
</label>
|
| 116 |
-
<input id="healthcheck-interval" type="number" min="5" step="5" placeholder="
|
| 117 |
-
<input id="public-history-hours" type="number" min="1" step="1" placeholder="
|
| 118 |
-
<textarea id="healthcheck-prompt" placeholder="
|
| 119 |
<div class="inline-actions">
|
| 120 |
-
<button id="settings-save" type="button">
|
| 121 |
-
<button class="secondary-btn" id="refresh-now" type="button">
|
| 122 |
</div>
|
| 123 |
</div>
|
| 124 |
<p class="status-text" id="settings-status"></p>
|
|
@@ -128,14 +168,15 @@
|
|
| 128 |
|
| 129 |
<div class="login-overlay" id="login-overlay">
|
| 130 |
<div class="login-card">
|
| 131 |
-
<
|
| 132 |
-
<
|
| 133 |
-
<
|
|
|
|
| 134 |
<input type="password" id="admin-password" autocomplete="current-password" />
|
| 135 |
-
<button id="login-btn">
|
| 136 |
<p class="status-text" id="login-status"></p>
|
| 137 |
</div>
|
| 138 |
</div>
|
| 139 |
-
<script src="/static/admin.js" defer></script>
|
| 140 |
</body>
|
| 141 |
</html>
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<title>NVIDIA NIM 管理后台</title>
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
<link
|
| 11 |
+
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Noto+Sans+SC:wght@400;500;700;800&display=swap"
|
| 12 |
rel="stylesheet"
|
| 13 |
/>
|
| 14 |
<link rel="stylesheet" href="/static/style.css" />
|
| 15 |
</head>
|
| 16 |
+
<body class="admin-body">
|
| 17 |
+
<div class="ambient ambient-left"></div>
|
| 18 |
+
<div class="ambient ambient-right"></div>
|
| 19 |
<div class="admin-shell">
|
| 20 |
<aside class="admin-sidebar">
|
| 21 |
+
<div class="brand-block">
|
| 22 |
+
<span class="hero-badge">NVIDIA NIM</span>
|
| 23 |
+
<h1>运营控制台</h1>
|
| 24 |
+
<p>管理模型目录、API Key、健康巡检和公开看板的数据来源。</p>
|
| 25 |
+
</div>
|
| 26 |
+
<h3>功能导航</h3>
|
| 27 |
+
<button class="sidebar-btn active" data-panel="overview">总览</button>
|
| 28 |
+
<button class="sidebar-btn" data-panel="models">模型管理</button>
|
| 29 |
+
<button class="sidebar-btn" data-panel="keys">Key 管理</button>
|
| 30 |
+
<button class="sidebar-btn" data-panel="health">巡检记录</button>
|
| 31 |
+
<button class="sidebar-btn" data-panel="settings">调度设置</button>
|
| 32 |
</aside>
|
| 33 |
+
|
| 34 |
<section class="admin-content">
|
| 35 |
<div class="glass-panel" data-panel="overview">
|
| 36 |
+
<div class="panel-headline">
|
| 37 |
+
<div>
|
| 38 |
+
<span class="section-tag">控制中心</span>
|
| 39 |
+
<h2>网关运行总览</h2>
|
| 40 |
+
</div>
|
| 41 |
+
<p class="status-text">统一查看模型、Key 和近几次健康探测结果。</p>
|
| 42 |
+
</div>
|
| 43 |
+
<div class="section-grid compact-grid" id="overview-metrics"></div>
|
| 44 |
+
<div class="glass-panel sub-panel">
|
| 45 |
+
<div class="panel-headline compact">
|
| 46 |
+
<div>
|
| 47 |
+
<span class="section-tag">最近活动</span>
|
| 48 |
+
<h3>最近巡检</h3>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
<table class="table">
|
| 52 |
<thead>
|
| 53 |
<tr>
|
| 54 |
+
<th>时间</th>
|
| 55 |
+
<th>模型</th>
|
| 56 |
+
<th>状态</th>
|
| 57 |
+
<th>时延</th>
|
| 58 |
</tr>
|
| 59 |
</thead>
|
| 60 |
<tbody id="recent-checks"></tbody>
|
|
|
|
| 63 |
</div>
|
| 64 |
|
| 65 |
<div class="glass-panel hidden" data-panel="models">
|
| 66 |
+
<div class="panel-headline">
|
| 67 |
+
<div>
|
| 68 |
+
<span class="section-tag">目录配置</span>
|
| 69 |
+
<h2>模型管理</h2>
|
| 70 |
+
</div>
|
| 71 |
+
<p class="status-text">添加、删除、连通性测试,以及使用与巡检统计。</p>
|
| 72 |
+
</div>
|
| 73 |
<div class="section-grid compact-grid">
|
| 74 |
<div class="metric-card">
|
| 75 |
+
<h3>模型总数</h3>
|
| 76 |
<strong id="model-count">-</strong>
|
| 77 |
</div>
|
| 78 |
<div class="metric-card">
|
| 79 |
+
<h3>当前健康</h3>
|
| 80 |
<strong id="model-healthy">-</strong>
|
| 81 |
</div>
|
| 82 |
</div>
|
| 83 |
+
<div class="form-grid spaced-top">
|
| 84 |
+
<input id="model-id" placeholder="模型 ID,例如 z-ai/glm5" />
|
| 85 |
+
<input id="model-display-name" placeholder="展示名称,例如 GLM-5" />
|
| 86 |
+
<textarea id="model-description" placeholder="模型说明,将出现在后台管理视图中"></textarea>
|
| 87 |
+
<button id="model-add" type="button">新增或更新模型</button>
|
| 88 |
</div>
|
| 89 |
+
<table class="table spaced-top">
|
| 90 |
<thead>
|
| 91 |
<tr>
|
| 92 |
+
<th>模型</th>
|
| 93 |
+
<th>状态</th>
|
| 94 |
+
<th>调用次数</th>
|
| 95 |
+
<th>巡检统计</th>
|
| 96 |
+
<th>操作</th>
|
| 97 |
</tr>
|
| 98 |
</thead>
|
| 99 |
<tbody id="model-table"></tbody>
|
|
|
|
| 101 |
</div>
|
| 102 |
|
| 103 |
<div class="glass-panel hidden" data-panel="keys">
|
| 104 |
+
<div class="panel-headline">
|
| 105 |
+
<div>
|
| 106 |
+
<span class="section-tag">凭据配置</span>
|
| 107 |
+
<h2>NVIDIA NIM Key 管理</h2>
|
| 108 |
+
</div>
|
| 109 |
+
<p class="status-text">统一维护可用 Key,并统计请求和巡检使用情况。</p>
|
| 110 |
+
</div>
|
| 111 |
<div class="form-grid compact-grid">
|
| 112 |
+
<input id="key-label" placeholder="Key 名称,例如 主生产 Key" />
|
| 113 |
+
<input id="key-value" placeholder="输入 NVIDIA NIM Key" />
|
| 114 |
+
<button id="key-add" type="button">保存 Key</button>
|
| 115 |
</div>
|
| 116 |
+
<table class="table spaced-top">
|
| 117 |
<thead>
|
| 118 |
<tr>
|
| 119 |
+
<th>名称</th>
|
| 120 |
+
<th>脱敏值</th>
|
| 121 |
+
<th>请求次数</th>
|
| 122 |
+
<th>最近测试</th>
|
| 123 |
+
<th>状态</th>
|
| 124 |
+
<th>操作</th>
|
| 125 |
</tr>
|
| 126 |
</thead>
|
| 127 |
<tbody id="key-table"></tbody>
|
|
|
|
| 129 |
</div>
|
| 130 |
|
| 131 |
<div class="glass-panel hidden" data-panel="health">
|
| 132 |
+
<div class="panel-headline">
|
| 133 |
<div>
|
| 134 |
+
<span class="section-tag">健康巡检</span>
|
| 135 |
+
<h2>巡检记录</h2>
|
| 136 |
</div>
|
| 137 |
+
<button id="run-healthcheck" type="button">立即执行巡检</button>
|
| 138 |
</div>
|
| 139 |
+
<p class="status-text">手动触发的巡检结果会立刻写入数据库,并同步更新到公开健康页。</p>
|
| 140 |
<div class="section-grid" id="health-grid"></div>
|
| 141 |
</div>
|
| 142 |
|
| 143 |
<div class="glass-panel hidden" data-panel="settings">
|
| 144 |
+
<div class="panel-headline">
|
| 145 |
+
<div>
|
| 146 |
+
<span class="section-tag">计划任务</span>
|
| 147 |
+
<h2>调度设置</h2>
|
| 148 |
+
</div>
|
| 149 |
+
<p class="status-text">设置巡检开关、时间间隔、公开页保留时长和巡检提示词。</p>
|
| 150 |
+
</div>
|
| 151 |
<div class="form-grid">
|
| 152 |
<label class="checkbox-row">
|
| 153 |
<input id="healthcheck-enabled" type="checkbox" />
|
| 154 |
+
<span>启用定时健康巡检</span>
|
| 155 |
</label>
|
| 156 |
+
<input id="healthcheck-interval" type="number" min="5" step="5" placeholder="巡检间隔,单位分钟" />
|
| 157 |
+
<input id="public-history-hours" type="number" min="1" step="1" placeholder="公开页保留时长,单位小时" />
|
| 158 |
+
<textarea id="healthcheck-prompt" placeholder="用于健康巡检的提示词"></textarea>
|
| 159 |
<div class="inline-actions">
|
| 160 |
+
<button id="settings-save" type="button">保存设置</button>
|
| 161 |
+
<button class="secondary-btn" id="refresh-now" type="button">重新加载面板</button>
|
| 162 |
</div>
|
| 163 |
</div>
|
| 164 |
<p class="status-text" id="settings-status"></p>
|
|
|
|
| 168 |
|
| 169 |
<div class="login-overlay" id="login-overlay">
|
| 170 |
<div class="login-card">
|
| 171 |
+
<span class="section-tag">管理员登录</span>
|
| 172 |
+
<h2>进入后台</h2>
|
| 173 |
+
<p class="status-text">请输入环境变量 PASSWORD 的值。</p>
|
| 174 |
+
<label for="admin-password">后台密码</label>
|
| 175 |
<input type="password" id="admin-password" autocomplete="current-password" />
|
| 176 |
+
<button id="login-btn">解锁控制台</button>
|
| 177 |
<p class="status-text" id="login-status"></p>
|
| 178 |
</div>
|
| 179 |
</div>
|
| 180 |
+
<script src="/static/admin.js" charset="utf-8" defer></script>
|
| 181 |
</body>
|
| 182 |
</html>
|
static/admin.js
CHANGED
|
@@ -18,47 +18,76 @@ const state = {
|
|
| 18 |
panel: "overview",
|
| 19 |
};
|
| 20 |
|
| 21 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
panels.forEach((panel) => panel.classList.toggle("hidden", panel.getAttribute(PANEL_ATTR) !== name));
|
| 23 |
sidebarButtons.forEach((button) => button.classList.toggle("active", button.dataset.panel === name));
|
| 24 |
state.panel = name;
|
| 25 |
-
}
|
| 26 |
|
| 27 |
sidebarButtons.forEach((button) => button.addEventListener("click", () => showPanel(button.dataset.panel)));
|
| 28 |
|
| 29 |
-
|
| 30 |
-
const headers = { "Content-Type": "application/json" };
|
| 31 |
if (state.token) headers.Authorization = `Bearer ${state.token}`;
|
| 32 |
const response = await fetch(`/admin/api/${endpoint}`, { ...opts, headers: { ...headers, ...(opts.headers || {}) } });
|
| 33 |
if (!response.ok) {
|
| 34 |
const payload = await response.json().catch(() => ({}));
|
| 35 |
-
throw new Error(payload.message || payload.detail || payload.error?.message || "
|
| 36 |
}
|
| 37 |
return response.json();
|
| 38 |
-
}
|
| 39 |
|
| 40 |
-
|
| 41 |
const div = document.createElement("div");
|
| 42 |
div.className = "metric-card";
|
| 43 |
-
div.innerHTML = `<h3>${label}</h3><strong>${value}</strong>`;
|
| 44 |
return div;
|
| 45 |
-
}
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
async function renderOverview() {
|
| 50 |
const payload = await apiRequest("overview");
|
|
|
|
| 51 |
overviewMetrics.innerHTML = "";
|
| 52 |
-
(
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
recentChecks.innerHTML = "";
|
| 55 |
(payload.recent_checks || []).forEach((check) => {
|
| 56 |
const row = document.createElement("tr");
|
| 57 |
row.innerHTML = `
|
| 58 |
-
<td>${
|
| 59 |
<td>${check.model}</td>
|
| 60 |
<td>${pill(check.status)}</td>
|
| 61 |
-
<td>${check.latency ? `${check.latency} ms` : "-"}</td>
|
| 62 |
`;
|
| 63 |
recentChecks.appendChild(row);
|
| 64 |
});
|
|
@@ -70,20 +99,21 @@ async function renderModels() {
|
|
| 70 |
modelCount.textContent = items.length;
|
| 71 |
modelHealthy.textContent = items.filter((item) => item.status === "healthy").length;
|
| 72 |
modelTable.innerHTML = "";
|
|
|
|
| 73 |
items.forEach((item) => {
|
| 74 |
const row = document.createElement("tr");
|
| 75 |
row.innerHTML = `
|
| 76 |
<td>
|
| 77 |
<strong>${item.display_name || item.model_id}</strong><br />
|
| 78 |
-
<span class="status-text">${item.model_id}</span>
|
| 79 |
</td>
|
| 80 |
<td>${pill(item.status)}</td>
|
| 81 |
<td>${item.request_count}</td>
|
| 82 |
<td>${item.healthcheck_success_count}/${item.healthcheck_count}</td>
|
| 83 |
<td>
|
| 84 |
<div class="inline-actions">
|
| 85 |
-
<button class="secondary-btn" data-action="test-model" data-id="${item.model_id}">
|
| 86 |
-
<button class="secondary-btn" data-action="remove-model" data-id="${item.model_id}">
|
| 87 |
</div>
|
| 88 |
</td>
|
| 89 |
`;
|
|
@@ -95,18 +125,19 @@ async function renderKeys() {
|
|
| 95 |
const payload = await apiRequest("keys");
|
| 96 |
const items = payload.items || [];
|
| 97 |
keyTable.innerHTML = "";
|
|
|
|
| 98 |
items.forEach((item) => {
|
| 99 |
const row = document.createElement("tr");
|
| 100 |
row.innerHTML = `
|
| 101 |
<td>${item.label}</td>
|
| 102 |
-
<td>${item.masked_key}</td>
|
| 103 |
<td>${item.request_count}</td>
|
| 104 |
-
<td>${
|
| 105 |
<td>${pill(item.status)}</td>
|
| 106 |
<td>
|
| 107 |
<div class="inline-actions">
|
| 108 |
-
<button class="secondary-btn" data-action="test-key" data-id="${item.name}">
|
| 109 |
-
<button class="secondary-btn" data-action="remove-key" data-id="${item.name}">
|
| 110 |
</div>
|
| 111 |
</td>
|
| 112 |
`;
|
|
@@ -116,20 +147,33 @@ async function renderKeys() {
|
|
| 116 |
|
| 117 |
async function renderHealth() {
|
| 118 |
const payload = await apiRequest("healthchecks");
|
|
|
|
| 119 |
healthGrid.innerHTML = "";
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
card.innerHTML = `
|
| 124 |
<div class="toolbar-row">
|
| 125 |
-
<
|
|
|
|
|
|
|
|
|
|
| 126 |
${pill(item.status)}
|
| 127 |
</div>
|
| 128 |
-
<p class="status-text">${item.detail || "
|
| 129 |
-
<div class="
|
| 130 |
-
<span>${item.api_key || "
|
| 131 |
-
<span>${item.latency ? `${item.latency} ms` : "-"}</span>
|
| 132 |
-
<span>${
|
| 133 |
</div>
|
| 134 |
`;
|
| 135 |
healthGrid.appendChild(card);
|
|
@@ -141,7 +185,7 @@ async function renderSettings() {
|
|
| 141 |
document.getElementById("healthcheck-enabled").checked = Boolean(payload.healthcheck_enabled);
|
| 142 |
document.getElementById("healthcheck-interval").value = payload.healthcheck_interval_minutes || 60;
|
| 143 |
document.getElementById("public-history-hours").value = payload.public_history_hours || 48;
|
| 144 |
-
document.getElementById("healthcheck-prompt").value = payload.healthcheck_prompt || "
|
| 145 |
}
|
| 146 |
|
| 147 |
async function loadAll() {
|
|
@@ -150,7 +194,7 @@ async function loadAll() {
|
|
| 150 |
|
| 151 |
async function testModel(modelId) {
|
| 152 |
const payload = await apiRequest(`models/${encodeURIComponent(modelId)}/test`, { method: "POST", body: JSON.stringify({}) });
|
| 153 |
-
alert(`${payload.display_name || payload.model}
|
| 154 |
await loadAll();
|
| 155 |
}
|
| 156 |
|
|
@@ -161,7 +205,7 @@ async function removeModel(modelId) {
|
|
| 161 |
|
| 162 |
async function testKey(keyName) {
|
| 163 |
const payload = await apiRequest("keys/test", { method: "POST", body: JSON.stringify({ value: keyName }) });
|
| 164 |
-
alert(`${payload.api_key}
|
| 165 |
await loadAll();
|
| 166 |
}
|
| 167 |
|
|
@@ -189,10 +233,13 @@ document.getElementById("model-add")?.addEventListener("click", async () => {
|
|
| 189 |
const displayName = document.getElementById("model-display-name").value.trim();
|
| 190 |
const description = document.getElementById("model-description").value.trim();
|
| 191 |
if (!modelId) {
|
| 192 |
-
alert("
|
| 193 |
return;
|
| 194 |
}
|
| 195 |
-
await apiRequest("models", {
|
|
|
|
|
|
|
|
|
|
| 196 |
document.getElementById("model-id").value = "";
|
| 197 |
document.getElementById("model-display-name").value = "";
|
| 198 |
document.getElementById("model-description").value = "";
|
|
@@ -203,7 +250,7 @@ document.getElementById("key-add")?.addEventListener("click", async () => {
|
|
| 203 |
const name = document.getElementById("key-label").value.trim();
|
| 204 |
const apiKey = document.getElementById("key-value").value.trim();
|
| 205 |
if (!name || !apiKey) {
|
| 206 |
-
alert("
|
| 207 |
return;
|
| 208 |
}
|
| 209 |
await apiRequest("keys", { method: "POST", body: JSON.stringify({ name, api_key: apiKey }) });
|
|
@@ -226,7 +273,7 @@ document.getElementById("settings-save")?.addEventListener("click", async () =>
|
|
| 226 |
healthcheck_prompt: document.getElementById("healthcheck-prompt").value.trim(),
|
| 227 |
};
|
| 228 |
await apiRequest("settings", { method: "PUT", body: JSON.stringify(payload) });
|
| 229 |
-
settingsStatus.textContent = "
|
| 230 |
await loadAll();
|
| 231 |
} catch (error) {
|
| 232 |
settingsStatus.textContent = error.message;
|
|
@@ -238,18 +285,18 @@ document.getElementById("refresh-now")?.addEventListener("click", loadAll);
|
|
| 238 |
loginBtn.addEventListener("click", async () => {
|
| 239 |
const password = document.getElementById("admin-password").value.trim();
|
| 240 |
if (!password) {
|
| 241 |
-
loginStatus.textContent = "
|
| 242 |
return;
|
| 243 |
}
|
| 244 |
try {
|
| 245 |
-
loginStatus.textContent = "
|
| 246 |
const response = await fetch("/admin/api/login", {
|
| 247 |
method: "POST",
|
| 248 |
-
headers: { "Content-Type": "application/json" },
|
| 249 |
body: JSON.stringify({ password }),
|
| 250 |
});
|
| 251 |
const payload = await response.json().catch(() => ({}));
|
| 252 |
-
if (!response.ok) throw new Error(payload.detail || payload.message || "
|
| 253 |
state.token = payload.access_token || payload.token;
|
| 254 |
sessionStorage.setItem("nim_token", state.token);
|
| 255 |
loginOverlay.classList.add("hidden");
|
|
@@ -266,8 +313,9 @@ window.addEventListener("DOMContentLoaded", async () => {
|
|
| 266 |
try {
|
| 267 |
await loadAll();
|
| 268 |
setInterval(loadAll, 90 * 1000);
|
| 269 |
-
} catch (
|
| 270 |
sessionStorage.removeItem("nim_token");
|
| 271 |
loginOverlay.classList.remove("hidden");
|
| 272 |
}
|
| 273 |
});
|
|
|
|
|
|
| 18 |
panel: "overview",
|
| 19 |
};
|
| 20 |
|
| 21 |
+
const STATUS_LABELS = {
|
| 22 |
+
healthy: "正常",
|
| 23 |
+
degraded: "波动",
|
| 24 |
+
down: "异常",
|
| 25 |
+
unknown: "未巡检",
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
|
| 29 |
+
year: "numeric",
|
| 30 |
+
month: "2-digit",
|
| 31 |
+
day: "2-digit",
|
| 32 |
+
hour: "2-digit",
|
| 33 |
+
minute: "2-digit",
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
function formatDateTime(value) {
|
| 37 |
+
if (!value) return "--";
|
| 38 |
+
const date = new Date(value);
|
| 39 |
+
if (Number.isNaN(date.getTime())) return "--";
|
| 40 |
+
return dateTimeFormatter.format(date);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function showPanel(name) {
|
| 44 |
panels.forEach((panel) => panel.classList.toggle("hidden", panel.getAttribute(PANEL_ATTR) !== name));
|
| 45 |
sidebarButtons.forEach((button) => button.classList.toggle("active", button.dataset.panel === name));
|
| 46 |
state.panel = name;
|
| 47 |
+
}
|
| 48 |
|
| 49 |
sidebarButtons.forEach((button) => button.addEventListener("click", () => showPanel(button.dataset.panel)));
|
| 50 |
|
| 51 |
+
async function apiRequest(endpoint, opts = {}) {
|
| 52 |
+
const headers = { "Content-Type": "application/json", Accept: "application/json" };
|
| 53 |
if (state.token) headers.Authorization = `Bearer ${state.token}`;
|
| 54 |
const response = await fetch(`/admin/api/${endpoint}`, { ...opts, headers: { ...headers, ...(opts.headers || {}) } });
|
| 55 |
if (!response.ok) {
|
| 56 |
const payload = await response.json().catch(() => ({}));
|
| 57 |
+
throw new Error(payload.message || payload.detail || payload.error?.message || "请求失败");
|
| 58 |
}
|
| 59 |
return response.json();
|
| 60 |
+
}
|
| 61 |
|
| 62 |
+
function metricCard(label, value, detail = "") {
|
| 63 |
const div = document.createElement("div");
|
| 64 |
div.className = "metric-card";
|
| 65 |
+
div.innerHTML = `<h3>${label}</h3><strong>${value}</strong><p>${detail}</p>`;
|
| 66 |
return div;
|
| 67 |
+
}
|
| 68 |
|
| 69 |
+
function pill(status) {
|
| 70 |
+
const normalized = status || "unknown";
|
| 71 |
+
return `<span class="pill ${normalized}">${STATUS_LABELS[normalized] || normalized}</span>`;
|
| 72 |
+
}
|
| 73 |
|
| 74 |
async function renderOverview() {
|
| 75 |
const payload = await apiRequest("overview");
|
| 76 |
+
const totals = payload.totals || {};
|
| 77 |
overviewMetrics.innerHTML = "";
|
| 78 |
+
overviewMetrics.appendChild(metricCard("启用模型", totals.enabled_models ?? "--", `总数 ${totals.total_models ?? "--"}`));
|
| 79 |
+
overviewMetrics.appendChild(metricCard("启用 Key", totals.enabled_keys ?? "--", `总数 ${totals.total_keys ?? "--"}`));
|
| 80 |
+
overviewMetrics.appendChild(metricCard("代理请求", totals.total_requests ?? "--", `成功 ${totals.total_success ?? 0}`));
|
| 81 |
+
overviewMetrics.appendChild(metricCard("失败次数", totals.total_failures ?? "--", "累计转发失败或上游返回错误"));
|
| 82 |
|
| 83 |
recentChecks.innerHTML = "";
|
| 84 |
(payload.recent_checks || []).forEach((check) => {
|
| 85 |
const row = document.createElement("tr");
|
| 86 |
row.innerHTML = `
|
| 87 |
+
<td>${formatDateTime(check.time)}</td>
|
| 88 |
<td>${check.model}</td>
|
| 89 |
<td>${pill(check.status)}</td>
|
| 90 |
+
<td>${check.latency ? `${check.latency} ms` : "--"}</td>
|
| 91 |
`;
|
| 92 |
recentChecks.appendChild(row);
|
| 93 |
});
|
|
|
|
| 99 |
modelCount.textContent = items.length;
|
| 100 |
modelHealthy.textContent = items.filter((item) => item.status === "healthy").length;
|
| 101 |
modelTable.innerHTML = "";
|
| 102 |
+
|
| 103 |
items.forEach((item) => {
|
| 104 |
const row = document.createElement("tr");
|
| 105 |
row.innerHTML = `
|
| 106 |
<td>
|
| 107 |
<strong>${item.display_name || item.model_id}</strong><br />
|
| 108 |
+
<span class="status-text mono">${item.model_id}</span>
|
| 109 |
</td>
|
| 110 |
<td>${pill(item.status)}</td>
|
| 111 |
<td>${item.request_count}</td>
|
| 112 |
<td>${item.healthcheck_success_count}/${item.healthcheck_count}</td>
|
| 113 |
<td>
|
| 114 |
<div class="inline-actions">
|
| 115 |
+
<button class="secondary-btn" data-action="test-model" data-id="${item.model_id}">测试</button>
|
| 116 |
+
<button class="secondary-btn danger-btn" data-action="remove-model" data-id="${item.model_id}">删除</button>
|
| 117 |
</div>
|
| 118 |
</td>
|
| 119 |
`;
|
|
|
|
| 125 |
const payload = await apiRequest("keys");
|
| 126 |
const items = payload.items || [];
|
| 127 |
keyTable.innerHTML = "";
|
| 128 |
+
|
| 129 |
items.forEach((item) => {
|
| 130 |
const row = document.createElement("tr");
|
| 131 |
row.innerHTML = `
|
| 132 |
<td>${item.label}</td>
|
| 133 |
+
<td class="mono">${item.masked_key}</td>
|
| 134 |
<td>${item.request_count}</td>
|
| 135 |
+
<td>${formatDateTime(item.last_tested)}</td>
|
| 136 |
<td>${pill(item.status)}</td>
|
| 137 |
<td>
|
| 138 |
<div class="inline-actions">
|
| 139 |
+
<button class="secondary-btn" data-action="test-key" data-id="${item.name}">测试</button>
|
| 140 |
+
<button class="secondary-btn danger-btn" data-action="remove-key" data-id="${item.name}">删除</button>
|
| 141 |
</div>
|
| 142 |
</td>
|
| 143 |
`;
|
|
|
|
| 147 |
|
| 148 |
async function renderHealth() {
|
| 149 |
const payload = await apiRequest("healthchecks");
|
| 150 |
+
const items = payload.items || [];
|
| 151 |
healthGrid.innerHTML = "";
|
| 152 |
+
|
| 153 |
+
if (items.length === 0) {
|
| 154 |
+
const empty = document.createElement("div");
|
| 155 |
+
empty.className = "empty-card";
|
| 156 |
+
empty.textContent = "暂无巡检记录,执行一次巡检后这里会显示最新结果。";
|
| 157 |
+
healthGrid.appendChild(empty);
|
| 158 |
+
return;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
items.slice(0, 12).forEach((item) => {
|
| 162 |
+
const card = document.createElement("article");
|
| 163 |
+
card.className = "health-record";
|
| 164 |
card.innerHTML = `
|
| 165 |
<div class="toolbar-row">
|
| 166 |
+
<div>
|
| 167 |
+
<h4>${item.model}</h4>
|
| 168 |
+
<span class="status-text mono">${item.model_id}</span>
|
| 169 |
+
</div>
|
| 170 |
${pill(item.status)}
|
| 171 |
</div>
|
| 172 |
+
<p class="status-text">${item.detail || "暂无详情"}</p>
|
| 173 |
+
<div class="record-meta">
|
| 174 |
+
<span>Key: ${item.api_key || "未记录"}</span>
|
| 175 |
+
<span>时延: ${item.latency ? `${item.latency} ms` : "--"}</span>
|
| 176 |
+
<span>时间: ${formatDateTime(item.checked_at)}</span>
|
| 177 |
</div>
|
| 178 |
`;
|
| 179 |
healthGrid.appendChild(card);
|
|
|
|
| 185 |
document.getElementById("healthcheck-enabled").checked = Boolean(payload.healthcheck_enabled);
|
| 186 |
document.getElementById("healthcheck-interval").value = payload.healthcheck_interval_minutes || 60;
|
| 187 |
document.getElementById("public-history-hours").value = payload.public_history_hours || 48;
|
| 188 |
+
document.getElementById("healthcheck-prompt").value = payload.healthcheck_prompt || "请只回复 OK。";
|
| 189 |
}
|
| 190 |
|
| 191 |
async function loadAll() {
|
|
|
|
| 194 |
|
| 195 |
async function testModel(modelId) {
|
| 196 |
const payload = await apiRequest(`models/${encodeURIComponent(modelId)}/test`, { method: "POST", body: JSON.stringify({}) });
|
| 197 |
+
alert(`${payload.display_name || payload.model} 当前状态:${STATUS_LABELS[payload.status] || payload.status}`);
|
| 198 |
await loadAll();
|
| 199 |
}
|
| 200 |
|
|
|
|
| 205 |
|
| 206 |
async function testKey(keyName) {
|
| 207 |
const payload = await apiRequest("keys/test", { method: "POST", body: JSON.stringify({ value: keyName }) });
|
| 208 |
+
alert(`Key ${payload.api_key} 当前状态:${STATUS_LABELS[payload.status] || payload.status}`);
|
| 209 |
await loadAll();
|
| 210 |
}
|
| 211 |
|
|
|
|
| 233 |
const displayName = document.getElementById("model-display-name").value.trim();
|
| 234 |
const description = document.getElementById("model-description").value.trim();
|
| 235 |
if (!modelId) {
|
| 236 |
+
alert("请先填写模型 ID。");
|
| 237 |
return;
|
| 238 |
}
|
| 239 |
+
await apiRequest("models", {
|
| 240 |
+
method: "POST",
|
| 241 |
+
body: JSON.stringify({ model_id: modelId, display_name: displayName || modelId, description }),
|
| 242 |
+
});
|
| 243 |
document.getElementById("model-id").value = "";
|
| 244 |
document.getElementById("model-display-name").value = "";
|
| 245 |
document.getElementById("model-description").value = "";
|
|
|
|
| 250 |
const name = document.getElementById("key-label").value.trim();
|
| 251 |
const apiKey = document.getElementById("key-value").value.trim();
|
| 252 |
if (!name || !apiKey) {
|
| 253 |
+
alert("请填�� Key 名称和内容。");
|
| 254 |
return;
|
| 255 |
}
|
| 256 |
await apiRequest("keys", { method: "POST", body: JSON.stringify({ name, api_key: apiKey }) });
|
|
|
|
| 273 |
healthcheck_prompt: document.getElementById("healthcheck-prompt").value.trim(),
|
| 274 |
};
|
| 275 |
await apiRequest("settings", { method: "PUT", body: JSON.stringify(payload) });
|
| 276 |
+
settingsStatus.textContent = "设置已保存。";
|
| 277 |
await loadAll();
|
| 278 |
} catch (error) {
|
| 279 |
settingsStatus.textContent = error.message;
|
|
|
|
| 285 |
loginBtn.addEventListener("click", async () => {
|
| 286 |
const password = document.getElementById("admin-password").value.trim();
|
| 287 |
if (!password) {
|
| 288 |
+
loginStatus.textContent = "请输入后台密码。";
|
| 289 |
return;
|
| 290 |
}
|
| 291 |
try {
|
| 292 |
+
loginStatus.textContent = "正在验证身份...";
|
| 293 |
const response = await fetch("/admin/api/login", {
|
| 294 |
method: "POST",
|
| 295 |
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
| 296 |
body: JSON.stringify({ password }),
|
| 297 |
});
|
| 298 |
const payload = await response.json().catch(() => ({}));
|
| 299 |
+
if (!response.ok) throw new Error(payload.detail || payload.message || "登录失败");
|
| 300 |
state.token = payload.access_token || payload.token;
|
| 301 |
sessionStorage.setItem("nim_token", state.token);
|
| 302 |
loginOverlay.classList.add("hidden");
|
|
|
|
| 313 |
try {
|
| 314 |
await loadAll();
|
| 315 |
setInterval(loadAll, 90 * 1000);
|
| 316 |
+
} catch (_error) {
|
| 317 |
sessionStorage.removeItem("nim_token");
|
| 318 |
loginOverlay.classList.remove("hidden");
|
| 319 |
}
|
| 320 |
});
|
| 321 |
+
|
static/index.html
CHANGED
|
@@ -1,42 +1,64 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
-
<title>
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
<link
|
| 10 |
-
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;
|
| 11 |
rel="stylesheet"
|
| 12 |
/>
|
| 13 |
<link rel="stylesheet" href="/static/style.css" />
|
| 14 |
</head>
|
| 15 |
-
<body>
|
| 16 |
-
<
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
<div>
|
| 20 |
-
<
|
| 21 |
-
<
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
</p>
|
| 27 |
</div>
|
| 28 |
-
<div class="chip-list" id="summary-chips"></div>
|
| 29 |
</div>
|
|
|
|
| 30 |
</section>
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
</div>
|
| 36 |
-
<div class="
|
| 37 |
-
<p class="status-text" id="error-text"></p>
|
| 38 |
</section>
|
| 39 |
</main>
|
| 40 |
-
<script src="/static/public.js" defer></script>
|
| 41 |
</body>
|
| 42 |
</html>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<title>NVIDIA NIM 模型健康看板</title>
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
<link
|
| 11 |
+
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Noto+Sans+SC:wght@400;500;700;800&display=swap"
|
| 12 |
rel="stylesheet"
|
| 13 |
/>
|
| 14 |
<link rel="stylesheet" href="/static/style.css" />
|
| 15 |
</head>
|
| 16 |
+
<body class="public-body">
|
| 17 |
+
<div class="ambient ambient-left"></div>
|
| 18 |
+
<div class="ambient ambient-right"></div>
|
| 19 |
+
<main class="public-shell">
|
| 20 |
+
<section class="hero-panel">
|
| 21 |
+
<div class="hero-copy">
|
| 22 |
+
<span class="hero-badge">NVIDIA NIM 网关</span>
|
| 23 |
+
<h1>模型健康度看板</h1>
|
| 24 |
+
<p>
|
| 25 |
+
公开页面只展示健康状态。系统按小时定时调用 NVIDIA NIM,
|
| 26 |
+
记录模型能否正常响应、最近一次时延,以及过去几个小时的稳定性走势。
|
| 27 |
+
</p>
|
| 28 |
+
</div>
|
| 29 |
+
<div class="hero-side">
|
| 30 |
+
<div class="hero-kicker">监控视图</div>
|
| 31 |
+
<div class="hero-value">小时级可用性</div>
|
| 32 |
+
<p>由后台巡检任务驱动,支持管理员扩展模型列表和更换巡检 Key。</p>
|
| 33 |
+
</div>
|
| 34 |
+
</section>
|
| 35 |
+
|
| 36 |
+
<section class="summary-panel">
|
| 37 |
+
<div class="section-heading">
|
| 38 |
<div>
|
| 39 |
+
<span class="section-tag">公开状态页</span>
|
| 40 |
+
<h2>最近 12 次巡检趋势</h2>
|
| 41 |
+
</div>
|
| 42 |
+
<div class="refresh-meta">
|
| 43 |
+
<span>最近刷新</span>
|
| 44 |
+
<strong id="last-updated">--</strong>
|
|
|
|
| 45 |
</div>
|
|
|
|
| 46 |
</div>
|
| 47 |
+
<div class="summary-strip" id="summary-chips"></div>
|
| 48 |
</section>
|
| 49 |
+
|
| 50 |
+
<section class="board-panel">
|
| 51 |
+
<div class="board-head">
|
| 52 |
+
<div>
|
| 53 |
+
<span class="section-tag">模型矩阵</span>
|
| 54 |
+
<h2>健康状态总览</h2>
|
| 55 |
+
</div>
|
| 56 |
+
<p class="board-note">绿色表示正常,橙色表示波动,红色表示异常,灰色表示尚未巡检。</p>
|
| 57 |
</div>
|
| 58 |
+
<div class="model-grid" id="model-grid"></div>
|
| 59 |
+
<p class="status-text error-text" id="error-text"></p>
|
| 60 |
</section>
|
| 61 |
</main>
|
| 62 |
+
<script src="/static/public.js" charset="utf-8" defer></script>
|
| 63 |
</body>
|
| 64 |
</html>
|
static/public.js
CHANGED
|
@@ -1,75 +1,117 @@
|
|
| 1 |
-
const summaryChips = document.getElementById("summary-chips");
|
| 2 |
const modelGrid = document.getElementById("model-grid");
|
| 3 |
const lastUpdated = document.getElementById("last-updated");
|
| 4 |
const errorText = document.getElementById("error-text");
|
| 5 |
|
| 6 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
healthy: "ok",
|
| 8 |
degraded: "warn",
|
| 9 |
down: "down",
|
| 10 |
-
unknown: "
|
| 11 |
};
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
function formatHourSegment(segment) {
|
| 14 |
const span = document.createElement("span");
|
| 15 |
-
|
| 16 |
-
span.
|
| 17 |
-
span.
|
|
|
|
| 18 |
return span;
|
| 19 |
}
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
function renderModel(model) {
|
| 22 |
const card = document.createElement("article");
|
| 23 |
card.className = "model-card";
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
card.innerHTML = `
|
| 26 |
-
<div class="
|
| 27 |
-
<
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
</div>
|
| 30 |
-
|
| 31 |
-
<small>${model.endpoint || "NIM chat"}</small>
|
| 32 |
-
`.trim();
|
| 33 |
|
| 34 |
const timeline = document.createElement("div");
|
| 35 |
timeline.className = "timeline";
|
| 36 |
|
| 37 |
-
(
|
| 38 |
-
.
|
| 39 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
card.appendChild(timeline);
|
| 42 |
return card;
|
| 43 |
}
|
| 44 |
|
| 45 |
-
function renderSummary(models) {
|
| 46 |
-
summaryChips.innerHTML = "";
|
| 47 |
-
const total = models.length;
|
| 48 |
-
const healthy = models.filter((m) => m.status === "healthy").length;
|
| 49 |
-
const open = models.filter((m) => m.status === "down").length;
|
| 50 |
-
|
| 51 |
-
[
|
| 52 |
-
{ label: `Monitored models`, value: total },
|
| 53 |
-
{ label: `Healthy`, value: healthy },
|
| 54 |
-
{ label: `Issues`, value: open },
|
| 55 |
-
].forEach((metric) => {
|
| 56 |
-
const chip = document.createElement("span");
|
| 57 |
-
chip.className = "chip";
|
| 58 |
-
chip.textContent = `${metric.label}: ${metric.value}`;
|
| 59 |
-
if (metric.label === "Issues" && metric.value > 0) {
|
| 60 |
-
chip.style.borderColor = "#ff5f6d";
|
| 61 |
-
chip.style.color = "#ffb3a6";
|
| 62 |
-
}
|
| 63 |
-
summaryChips.appendChild(chip);
|
| 64 |
-
});
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
async function loadHealth() {
|
| 68 |
try {
|
| 69 |
errorText.textContent = "";
|
| 70 |
-
const response = await fetch("/api/health/public");
|
| 71 |
if (!response.ok) {
|
| 72 |
-
throw new Error("
|
| 73 |
}
|
| 74 |
const payload = await response.json();
|
| 75 |
const models = payload.models || [];
|
|
@@ -78,12 +120,10 @@ async function loadHealth() {
|
|
| 78 |
modelGrid.innerHTML = "";
|
| 79 |
models.forEach((model) => modelGrid.appendChild(renderModel(model)));
|
| 80 |
|
| 81 |
-
lastUpdated.textContent = payload.last_updated
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
errorText.textContent = "Unable to reach NVIDIA NIM. Please check your keys.";
|
| 86 |
-
lastUpdated.textContent = "��";
|
| 87 |
}
|
| 88 |
}
|
| 89 |
|
|
|
|
| 1 |
+
const summaryChips = document.getElementById("summary-chips");
|
| 2 |
const modelGrid = document.getElementById("model-grid");
|
| 3 |
const lastUpdated = document.getElementById("last-updated");
|
| 4 |
const errorText = document.getElementById("error-text");
|
| 5 |
|
| 6 |
+
const STATUS_LABELS = {
|
| 7 |
+
healthy: "正常",
|
| 8 |
+
degraded: "波动",
|
| 9 |
+
down: "异常",
|
| 10 |
+
unknown: "未巡检",
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
const STATUS_CLASS = {
|
| 14 |
healthy: "ok",
|
| 15 |
degraded: "warn",
|
| 16 |
down: "down",
|
| 17 |
+
unknown: "idle",
|
| 18 |
};
|
| 19 |
|
| 20 |
+
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
|
| 21 |
+
month: "2-digit",
|
| 22 |
+
day: "2-digit",
|
| 23 |
+
hour: "2-digit",
|
| 24 |
+
minute: "2-digit",
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
function formatDateTime(value) {
|
| 28 |
+
if (!value) return "--";
|
| 29 |
+
const date = new Date(value);
|
| 30 |
+
if (Number.isNaN(date.getTime())) return "--";
|
| 31 |
+
return dateTimeFormatter.format(date);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
function formatHourSegment(segment) {
|
| 35 |
const span = document.createElement("span");
|
| 36 |
+
const date = new Date(segment.time || segment.hour);
|
| 37 |
+
span.textContent = Number.isNaN(date.getTime()) ? "--" : String(date.getHours()).padStart(2, "0");
|
| 38 |
+
span.className = `timeline-item ${STATUS_CLASS[segment.status] || "idle"}`;
|
| 39 |
+
span.title = `${STATUS_LABELS[segment.status] || "未巡检"} ${formatDateTime(segment.time || segment.hour)}`;
|
| 40 |
return span;
|
| 41 |
}
|
| 42 |
|
| 43 |
+
function createSummaryChip(label, value, tone = "default") {
|
| 44 |
+
const chip = document.createElement("div");
|
| 45 |
+
chip.className = `summary-chip ${tone}`;
|
| 46 |
+
chip.innerHTML = `<span>${label}</span><strong>${value}</strong>`;
|
| 47 |
+
return chip;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function renderSummary(models) {
|
| 51 |
+
summaryChips.innerHTML = "";
|
| 52 |
+
const total = models.length;
|
| 53 |
+
const healthy = models.filter((item) => item.status === "healthy").length;
|
| 54 |
+
const issues = models.filter((item) => item.status === "down").length;
|
| 55 |
+
const latest = models.reduce((max, item) => {
|
| 56 |
+
if (!item.last_healthcheck_at) return max;
|
| 57 |
+
return !max || new Date(item.last_healthcheck_at) > new Date(max) ? item.last_healthcheck_at : max;
|
| 58 |
+
}, null);
|
| 59 |
+
|
| 60 |
+
summaryChips.appendChild(createSummaryChip("监控模型", total));
|
| 61 |
+
summaryChips.appendChild(createSummaryChip("健康模型", healthy, "good"));
|
| 62 |
+
summaryChips.appendChild(createSummaryChip("异常模型", issues, issues > 0 ? "danger" : "default"));
|
| 63 |
+
summaryChips.appendChild(createSummaryChip("最近探测", latest ? formatDateTime(latest) : "暂无数据"));
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
function renderModel(model) {
|
| 67 |
const card = document.createElement("article");
|
| 68 |
card.className = "model-card";
|
| 69 |
+
const status = model.status || "unknown";
|
| 70 |
+
const points = (model.hourly || []).slice(-12);
|
| 71 |
+
const successRate = typeof model.success_rate === "number" ? `${model.success_rate.toFixed(1)}%` : model.beat || "--";
|
| 72 |
|
| 73 |
card.innerHTML = `
|
| 74 |
+
<div class="card-top">
|
| 75 |
+
<div>
|
| 76 |
+
<div class="card-title">${model.display_name || model.name || model.model_id}</div>
|
| 77 |
+
<div class="model-subtitle">${model.model_id || "--"}</div>
|
| 78 |
+
</div>
|
| 79 |
+
<span class="status-chip ${status}">${STATUS_LABELS[status] || "未巡检"}</span>
|
| 80 |
+
</div>
|
| 81 |
+
<div class="metric-row">
|
| 82 |
+
<div class="metric-pill">
|
| 83 |
+
<span>成功率</span>
|
| 84 |
+
<strong>${successRate}</strong>
|
| 85 |
+
</div>
|
| 86 |
+
<div class="metric-pill">
|
| 87 |
+
<span>最近探测</span>
|
| 88 |
+
<strong>${formatDateTime(model.last_healthcheck_at)}</strong>
|
| 89 |
+
</div>
|
| 90 |
</div>
|
| 91 |
+
`;
|
|
|
|
|
|
|
| 92 |
|
| 93 |
const timeline = document.createElement("div");
|
| 94 |
timeline.className = "timeline";
|
| 95 |
|
| 96 |
+
if (points.length === 0) {
|
| 97 |
+
const empty = document.createElement("div");
|
| 98 |
+
empty.className = "empty-state";
|
| 99 |
+
empty.textContent = "暂无巡检记录";
|
| 100 |
+
timeline.appendChild(empty);
|
| 101 |
+
} else {
|
| 102 |
+
points.forEach((segment) => timeline.appendChild(formatHourSegment(segment)));
|
| 103 |
+
}
|
| 104 |
|
| 105 |
card.appendChild(timeline);
|
| 106 |
return card;
|
| 107 |
}
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
async function loadHealth() {
|
| 110 |
try {
|
| 111 |
errorText.textContent = "";
|
| 112 |
+
const response = await fetch("/api/health/public", { headers: { Accept: "application/json" } });
|
| 113 |
if (!response.ok) {
|
| 114 |
+
throw new Error("健康接口暂时不可用");
|
| 115 |
}
|
| 116 |
const payload = await response.json();
|
| 117 |
const models = payload.models || [];
|
|
|
|
| 120 |
modelGrid.innerHTML = "";
|
| 121 |
models.forEach((model) => modelGrid.appendChild(renderModel(model)));
|
| 122 |
|
| 123 |
+
lastUpdated.textContent = payload.last_updated ? formatDateTime(payload.last_updated) : formatDateTime(new Date().toISOString());
|
| 124 |
+
} catch (_error) {
|
| 125 |
+
errorText.textContent = "当前无法获取 NVIDIA NIM 的巡检结果,请检查后台配置或稍后再试。";
|
| 126 |
+
lastUpdated.textContent = "--";
|
|
|
|
|
|
|
| 127 |
}
|
| 128 |
}
|
| 129 |
|
static/style.css
CHANGED
|
@@ -1,12 +1,22 @@
|
|
| 1 |
-
:root {
|
| 2 |
-
--
|
| 3 |
-
--
|
| 4 |
-
--
|
| 5 |
-
--
|
| 6 |
-
--
|
| 7 |
-
--
|
| 8 |
-
--
|
| 9 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
color-scheme: dark;
|
| 11 |
}
|
| 12 |
|
|
@@ -14,433 +24,599 @@
|
|
| 14 |
box-sizing: border-box;
|
| 15 |
}
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
body {
|
| 18 |
margin: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
font-family: var(--font-sans);
|
| 20 |
-
|
| 21 |
-
linear-gradient(180deg, #050a15 0%, #020408 50%, #030711 100%);
|
| 22 |
-
color: #f1f6ff;
|
| 23 |
-
min-height: 100vh;
|
| 24 |
}
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
.glass-panel {
|
| 33 |
-
|
| 34 |
-
border: 1px solid var(--border);
|
| 35 |
-
padding: 1.5rem;
|
| 36 |
-
border-radius: 18px;
|
| 37 |
-
box-shadow: var(--glow);
|
| 38 |
-
backdrop-filter: blur(16px);
|
| 39 |
-
margin-bottom: 1.75rem;
|
| 40 |
}
|
| 41 |
|
| 42 |
-
.hero
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
align-items: center;
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
-
.hero h1
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
-
.hero p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
color: var(--muted);
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
display: flex;
|
|
|
|
|
|
|
|
|
|
| 65 |
flex-wrap: wrap;
|
| 66 |
-
gap: 0.5rem;
|
| 67 |
-
margin-top: 1rem;
|
| 68 |
}
|
| 69 |
|
| 70 |
-
.
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
}
|
| 77 |
|
| 78 |
-
.
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
-
.
|
| 84 |
-
display:
|
| 85 |
-
|
| 86 |
-
|
|
|
|
| 87 |
}
|
| 88 |
|
| 89 |
-
.
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
background: linear-gradient(135deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.04));
|
| 93 |
-
border: 1px solid transparent;
|
| 94 |
-
transition: border 0.3s ease, transform 0.3s ease;
|
| 95 |
}
|
| 96 |
|
| 97 |
-
.
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
| 100 |
}
|
| 101 |
|
| 102 |
-
.
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
| 105 |
}
|
| 106 |
|
| 107 |
-
.
|
|
|
|
|
|
|
| 108 |
color: var(--muted);
|
|
|
|
|
|
|
| 109 |
}
|
| 110 |
|
| 111 |
-
.
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
flex-wrap: wrap;
|
| 117 |
}
|
| 118 |
|
| 119 |
-
.
|
| 120 |
-
|
| 121 |
-
height: 28px;
|
| 122 |
-
border-radius: 8px;
|
| 123 |
-
background: rgba(255, 255, 255, 0.05);
|
| 124 |
-
display: inline-flex;
|
| 125 |
-
align-items: center;
|
| 126 |
-
justify-content: center;
|
| 127 |
-
font-size: 0.7rem;
|
| 128 |
-
font-weight: 600;
|
| 129 |
}
|
| 130 |
|
| 131 |
-
.
|
| 132 |
-
|
| 133 |
-
|
| 134 |
}
|
| 135 |
|
| 136 |
-
.
|
| 137 |
-
|
| 138 |
}
|
| 139 |
|
| 140 |
-
.
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
| 142 |
}
|
| 143 |
|
| 144 |
-
.
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
align-items: center;
|
| 151 |
}
|
| 152 |
|
| 153 |
-
.
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
| 155 |
}
|
| 156 |
|
| 157 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
display: flex;
|
| 159 |
-
gap:
|
| 160 |
flex-wrap: wrap;
|
| 161 |
-
align-items: center;
|
| 162 |
-
margin-top: 0.5rem;
|
| 163 |
-
color: var(--muted);
|
| 164 |
}
|
| 165 |
|
| 166 |
-
.
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
border-radius: 50%;
|
| 170 |
-
background: var(--accent);
|
| 171 |
-
animation: pulse 1.6s infinite;
|
| 172 |
}
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
70% {
|
| 179 |
-
box-shadow: 0 0 0 12px rgba(0, 241, 141, 0);
|
| 180 |
-
}
|
| 181 |
-
100% {
|
| 182 |
-
box-shadow: 0 0 0 0 rgba(0, 241, 141, 0);
|
| 183 |
-
}
|
| 184 |
}
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
border-radius: 999px;
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
transition: transform 0.2s ease;
|
| 196 |
}
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
}
|
| 201 |
|
| 202 |
-
.
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
| 206 |
}
|
| 207 |
|
| 208 |
-
.
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
}
|
| 216 |
|
| 217 |
-
.
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
color:
|
| 223 |
}
|
| 224 |
|
| 225 |
-
.
|
| 226 |
-
|
| 227 |
-
justify-content: flex-start;
|
| 228 |
-
background: transparent;
|
| 229 |
-
border-radius: 12px;
|
| 230 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 231 |
-
color: #fff;
|
| 232 |
-
padding-left: 0.9rem;
|
| 233 |
-
text-align: left;
|
| 234 |
-
letter-spacing: 0.05em;
|
| 235 |
}
|
| 236 |
|
| 237 |
-
.
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
| 241 |
}
|
| 242 |
|
| 243 |
-
.
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
| 246 |
}
|
| 247 |
|
| 248 |
-
.
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
|
|
|
|
|
|
| 252 |
display: flex;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
align-items: center;
|
| 254 |
justify-content: center;
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
}
|
| 257 |
|
| 258 |
-
.
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
border-radius: 22px;
|
| 263 |
-
border: 1px solid var(--border);
|
| 264 |
-
box-shadow: var(--glow);
|
| 265 |
}
|
| 266 |
|
| 267 |
-
.
|
| 268 |
-
|
| 269 |
-
|
|
|
|
| 270 |
}
|
| 271 |
|
| 272 |
-
.
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
| 277 |
color: var(--muted);
|
| 278 |
-
letter-spacing: 0.2em;
|
| 279 |
}
|
| 280 |
|
| 281 |
-
.
|
|
|
|
| 282 |
width: 100%;
|
| 283 |
-
padding:
|
| 284 |
-
border-radius:
|
| 285 |
-
|
| 286 |
-
background: rgba(255, 255, 255, 0.
|
| 287 |
-
|
| 288 |
-
margin-bottom: 1rem;
|
| 289 |
-
font-size: 1rem;
|
| 290 |
}
|
| 291 |
|
| 292 |
-
.
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
gap: 1.25rem;
|
| 296 |
}
|
| 297 |
|
| 298 |
-
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
border-radius: 16px;
|
| 301 |
-
|
| 302 |
-
|
|
|
|
|
|
|
| 303 |
}
|
| 304 |
|
| 305 |
-
.
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
| 308 |
}
|
| 309 |
|
| 310 |
-
.
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
}
|
| 315 |
|
| 316 |
-
.
|
| 317 |
-
|
| 318 |
-
border-
|
| 319 |
-
|
|
|
|
| 320 |
}
|
| 321 |
|
| 322 |
-
.
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
| 329 |
}
|
| 330 |
|
| 331 |
-
.
|
| 332 |
-
|
| 333 |
}
|
| 334 |
|
| 335 |
-
.
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
| 337 |
}
|
| 338 |
|
| 339 |
-
.
|
| 340 |
-
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
}
|
| 343 |
|
| 344 |
-
.
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
border:
|
| 348 |
-
|
| 349 |
-
letter-spacing: 0.1em;
|
| 350 |
-
text-transform: uppercase;
|
| 351 |
-
background: rgba(0, 241, 141, 0.1);
|
| 352 |
-
color: var(--accent);
|
| 353 |
}
|
| 354 |
|
| 355 |
-
.
|
| 356 |
-
|
| 357 |
-
gap: 0.6rem;
|
| 358 |
-
flex-wrap: wrap;
|
| 359 |
-
margin-top: 0.5rem;
|
| 360 |
}
|
| 361 |
|
| 362 |
-
.
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
background: rgba(255, 255, 255, 0.03);
|
| 366 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 367 |
-
border-radius: 12px;
|
| 368 |
-
padding: 0.75rem;
|
| 369 |
-
color: #fff;
|
| 370 |
}
|
| 371 |
|
| 372 |
-
.
|
| 373 |
-
|
| 374 |
-
color: var(--muted);
|
| 375 |
}
|
| 376 |
|
| 377 |
-
.
|
| 378 |
-
|
| 379 |
-
padding: 0.55rem 1rem;
|
| 380 |
-
background: transparent;
|
| 381 |
-
border: 1px solid rgba(255, 255, 255, 0.25);
|
| 382 |
-
color: #fff;
|
| 383 |
}
|
| 384 |
|
| 385 |
-
.
|
| 386 |
-
|
| 387 |
-
|
| 388 |
}
|
| 389 |
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
| 394 |
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
}
|
| 400 |
|
| 401 |
-
.hidden { display: none !important; }
|
| 402 |
-
|
| 403 |
.form-grid {
|
| 404 |
display: grid;
|
| 405 |
-
gap: 0.75rem;
|
| 406 |
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
| 407 |
}
|
| 408 |
|
| 409 |
.compact-grid {
|
| 410 |
-
grid-template-columns: repeat(auto-fit, minmax(
|
| 411 |
}
|
| 412 |
|
|
|
|
| 413 |
.form-grid textarea,
|
| 414 |
-
.
|
| 415 |
width: 100%;
|
| 416 |
-
|
| 417 |
-
|
| 418 |
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
color: #fff;
|
| 422 |
font: inherit;
|
|
|
|
| 423 |
}
|
| 424 |
|
| 425 |
-
.form-grid
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
|
|
|
| 429 |
}
|
| 430 |
|
| 431 |
-
.
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
align-items: center;
|
| 436 |
-
flex-wrap: wrap;
|
| 437 |
}
|
| 438 |
|
| 439 |
.checkbox-row {
|
| 440 |
display: flex;
|
| 441 |
align-items: center;
|
| 442 |
-
gap:
|
| 443 |
-
color:
|
| 444 |
}
|
| 445 |
|
| 446 |
.checkbox-row input {
|
|
@@ -448,8 +624,99 @@ button:hover {
|
|
| 448 |
height: 18px;
|
| 449 |
}
|
| 450 |
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
.form-grid {
|
| 453 |
grid-template-columns: 1fr;
|
| 454 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
}
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #050816;
|
| 3 |
+
--bg-deep: #02040b;
|
| 4 |
+
--panel: rgba(9, 15, 29, 0.82);
|
| 5 |
+
--panel-strong: rgba(12, 20, 38, 0.94);
|
| 6 |
+
--panel-soft: rgba(255, 255, 255, 0.04);
|
| 7 |
+
--text: #f4f7ff;
|
| 8 |
+
--muted: #94a7c7;
|
| 9 |
+
--muted-strong: #b4c6e4;
|
| 10 |
+
--line: rgba(255, 255, 255, 0.1);
|
| 11 |
+
--line-strong: rgba(255, 255, 255, 0.16);
|
| 12 |
+
--green: #35f0a1;
|
| 13 |
+
--green-strong: #6bffd0;
|
| 14 |
+
--orange: #ffb44f;
|
| 15 |
+
--red: #ff6e83;
|
| 16 |
+
--shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
|
| 17 |
+
--glow: 0 0 0 1px rgba(107, 255, 208, 0.16), 0 22px 54px rgba(30, 255, 179, 0.12);
|
| 18 |
+
--font-sans: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
| 19 |
+
--font-display: "Space Grotesk", "Noto Sans SC", sans-serif;
|
| 20 |
color-scheme: dark;
|
| 21 |
}
|
| 22 |
|
|
|
|
| 24 |
box-sizing: border-box;
|
| 25 |
}
|
| 26 |
|
| 27 |
+
html,
|
| 28 |
+
body {
|
| 29 |
+
min-height: 100%;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
body {
|
| 33 |
margin: 0;
|
| 34 |
+
background:
|
| 35 |
+
radial-gradient(circle at 8% 12%, rgba(53, 240, 161, 0.16), transparent 28%),
|
| 36 |
+
radial-gradient(circle at 88% 18%, rgba(67, 138, 255, 0.18), transparent 26%),
|
| 37 |
+
linear-gradient(180deg, #08101d 0%, var(--bg) 38%, var(--bg-deep) 100%);
|
| 38 |
+
color: var(--text);
|
| 39 |
font-family: var(--font-sans);
|
| 40 |
+
overflow-x: hidden;
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
+
body::before {
|
| 44 |
+
content: "";
|
| 45 |
+
position: fixed;
|
| 46 |
+
inset: 0;
|
| 47 |
+
background-image:
|
| 48 |
+
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
| 49 |
+
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
| 50 |
+
background-size: 34px 34px;
|
| 51 |
+
opacity: 0.12;
|
| 52 |
+
pointer-events: none;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.ambient {
|
| 56 |
+
position: fixed;
|
| 57 |
+
width: 32rem;
|
| 58 |
+
height: 32rem;
|
| 59 |
+
border-radius: 999px;
|
| 60 |
+
filter: blur(90px);
|
| 61 |
+
opacity: 0.38;
|
| 62 |
+
pointer-events: none;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.ambient-left {
|
| 66 |
+
top: -10rem;
|
| 67 |
+
left: -8rem;
|
| 68 |
+
background: rgba(53, 240, 161, 0.28);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.ambient-right {
|
| 72 |
+
top: 8rem;
|
| 73 |
+
right: -10rem;
|
| 74 |
+
background: rgba(91, 113, 255, 0.22);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.public-shell,
|
| 78 |
+
.admin-shell {
|
| 79 |
+
position: relative;
|
| 80 |
+
z-index: 1;
|
| 81 |
}
|
| 82 |
|
| 83 |
+
.public-shell {
|
| 84 |
+
width: min(1280px, calc(100vw - 32px));
|
| 85 |
+
margin: 0 auto;
|
| 86 |
+
padding: 32px 0 54px;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.hero-panel,
|
| 90 |
+
.summary-panel,
|
| 91 |
+
.board-panel,
|
| 92 |
+
.glass-panel,
|
| 93 |
+
.metric-card,
|
| 94 |
+
.health-record,
|
| 95 |
+
.empty-card {
|
| 96 |
+
position: relative;
|
| 97 |
+
border: 1px solid var(--line);
|
| 98 |
+
background: linear-gradient(180deg, rgba(15, 25, 48, 0.82), rgba(7, 12, 24, 0.9));
|
| 99 |
+
border-radius: 28px;
|
| 100 |
+
box-shadow: var(--shadow);
|
| 101 |
+
backdrop-filter: blur(18px);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.hero-panel,
|
| 105 |
+
.summary-panel,
|
| 106 |
+
.board-panel,
|
| 107 |
.glass-panel {
|
| 108 |
+
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
}
|
| 110 |
|
| 111 |
+
.hero-panel::after,
|
| 112 |
+
.summary-panel::after,
|
| 113 |
+
.board-panel::after,
|
| 114 |
+
.glass-panel::after {
|
| 115 |
+
content: "";
|
| 116 |
+
position: absolute;
|
| 117 |
+
inset: 0;
|
| 118 |
+
background: linear-gradient(135deg, rgba(107, 255, 208, 0.08), transparent 34%, transparent 66%, rgba(123, 157, 255, 0.08));
|
| 119 |
+
pointer-events: none;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.hero-panel {
|
| 123 |
+
display: grid;
|
| 124 |
+
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr);
|
| 125 |
+
gap: 24px;
|
| 126 |
+
padding: 34px;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.hero-copy h1,
|
| 130 |
+
.hero-side .hero-value,
|
| 131 |
+
.section-heading h2,
|
| 132 |
+
.board-head h2,
|
| 133 |
+
.panel-headline h2,
|
| 134 |
+
.login-card h2,
|
| 135 |
+
.brand-block h1 {
|
| 136 |
+
font-family: var(--font-display);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.hero-badge,
|
| 140 |
+
.section-tag {
|
| 141 |
+
display: inline-flex;
|
| 142 |
align-items: center;
|
| 143 |
+
gap: 8px;
|
| 144 |
+
padding: 8px 14px;
|
| 145 |
+
border-radius: 999px;
|
| 146 |
+
border: 1px solid rgba(107, 255, 208, 0.28);
|
| 147 |
+
background: rgba(53, 240, 161, 0.1);
|
| 148 |
+
color: var(--green-strong);
|
| 149 |
+
font-size: 13px;
|
| 150 |
+
font-weight: 700;
|
| 151 |
+
letter-spacing: 0.08em;
|
| 152 |
}
|
| 153 |
|
| 154 |
+
.hero-copy h1,
|
| 155 |
+
.board-head h2,
|
| 156 |
+
.panel-headline h2,
|
| 157 |
+
.section-heading h2,
|
| 158 |
+
.login-card h2,
|
| 159 |
+
.brand-block h1 {
|
| 160 |
+
margin: 16px 0 12px;
|
| 161 |
+
font-size: clamp(34px, 3vw, 52px);
|
| 162 |
+
line-height: 1.08;
|
| 163 |
}
|
| 164 |
|
| 165 |
+
.hero-copy p,
|
| 166 |
+
.hero-side p,
|
| 167 |
+
.board-note,
|
| 168 |
+
.status-text,
|
| 169 |
+
.metric-card p,
|
| 170 |
+
.brand-block p {
|
| 171 |
color: var(--muted);
|
| 172 |
+
line-height: 1.7;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.hero-side {
|
| 176 |
+
padding: 24px;
|
| 177 |
+
border-radius: 24px;
|
| 178 |
+
background: rgba(255, 255, 255, 0.04);
|
| 179 |
+
border: 1px solid var(--line-strong);
|
| 180 |
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.hero-kicker {
|
| 184 |
+
color: var(--muted-strong);
|
| 185 |
+
letter-spacing: 0.16em;
|
| 186 |
+
text-transform: uppercase;
|
| 187 |
+
font-size: 12px;
|
| 188 |
}
|
| 189 |
|
| 190 |
+
.hero-value {
|
| 191 |
+
margin-top: 14px;
|
| 192 |
+
font-size: 32px;
|
| 193 |
+
font-weight: 700;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.summary-panel,
|
| 197 |
+
.board-panel {
|
| 198 |
+
padding: 28px 30px;
|
| 199 |
+
margin-top: 22px;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.section-heading,
|
| 203 |
+
.board-head,
|
| 204 |
+
.panel-headline,
|
| 205 |
+
.toolbar-row {
|
| 206 |
display: flex;
|
| 207 |
+
justify-content: space-between;
|
| 208 |
+
align-items: flex-start;
|
| 209 |
+
gap: 18px;
|
| 210 |
flex-wrap: wrap;
|
|
|
|
|
|
|
| 211 |
}
|
| 212 |
|
| 213 |
+
.section-heading h2,
|
| 214 |
+
.board-head h2,
|
| 215 |
+
.panel-headline h2,
|
| 216 |
+
.panel-headline h3 {
|
| 217 |
+
margin-bottom: 0;
|
| 218 |
+
font-size: clamp(24px, 2vw, 34px);
|
| 219 |
}
|
| 220 |
|
| 221 |
+
.refresh-meta {
|
| 222 |
+
min-width: 220px;
|
| 223 |
+
padding: 16px 18px;
|
| 224 |
+
border-radius: 20px;
|
| 225 |
+
background: rgba(255, 255, 255, 0.04);
|
| 226 |
+
border: 1px solid var(--line);
|
| 227 |
}
|
| 228 |
|
| 229 |
+
.refresh-meta span {
|
| 230 |
+
display: block;
|
| 231 |
+
color: var(--muted);
|
| 232 |
+
font-size: 13px;
|
| 233 |
+
margin-bottom: 8px;
|
| 234 |
}
|
| 235 |
|
| 236 |
+
.refresh-meta strong {
|
| 237 |
+
font-family: var(--font-display);
|
| 238 |
+
font-size: 20px;
|
|
|
|
|
|
|
|
|
|
| 239 |
}
|
| 240 |
|
| 241 |
+
.summary-strip {
|
| 242 |
+
margin-top: 22px;
|
| 243 |
+
display: grid;
|
| 244 |
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 245 |
+
gap: 14px;
|
| 246 |
}
|
| 247 |
|
| 248 |
+
.summary-chip,
|
| 249 |
+
.metric-card {
|
| 250 |
+
padding: 18px 20px;
|
| 251 |
+
border-radius: 22px;
|
| 252 |
+
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
|
| 253 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 254 |
}
|
| 255 |
|
| 256 |
+
.summary-chip span,
|
| 257 |
+
.metric-card h3 {
|
| 258 |
+
display: block;
|
| 259 |
color: var(--muted);
|
| 260 |
+
font-size: 13px;
|
| 261 |
+
margin-bottom: 10px;
|
| 262 |
}
|
| 263 |
|
| 264 |
+
.summary-chip strong,
|
| 265 |
+
.metric-card strong {
|
| 266 |
+
font-family: var(--font-display);
|
| 267 |
+
font-size: 28px;
|
| 268 |
+
line-height: 1.2;
|
|
|
|
| 269 |
}
|
| 270 |
|
| 271 |
+
.summary-chip.good {
|
| 272 |
+
box-shadow: var(--glow);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
}
|
| 274 |
|
| 275 |
+
.summary-chip.danger {
|
| 276 |
+
border-color: rgba(255, 110, 131, 0.32);
|
| 277 |
+
background: linear-gradient(180deg, rgba(255, 110, 131, 0.16), rgba(255, 255, 255, 0.03));
|
| 278 |
}
|
| 279 |
|
| 280 |
+
.board-head {
|
| 281 |
+
margin-bottom: 18px;
|
| 282 |
}
|
| 283 |
|
| 284 |
+
.model-grid,
|
| 285 |
+
.section-grid {
|
| 286 |
+
display: grid;
|
| 287 |
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 288 |
+
gap: 18px;
|
| 289 |
}
|
| 290 |
|
| 291 |
+
.model-card {
|
| 292 |
+
padding: 22px;
|
| 293 |
+
border-radius: 24px;
|
| 294 |
+
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.025));
|
| 295 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 296 |
+
transition: transform 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease;
|
|
|
|
| 297 |
}
|
| 298 |
|
| 299 |
+
.model-card:hover,
|
| 300 |
+
.health-record:hover {
|
| 301 |
+
transform: translateY(-3px);
|
| 302 |
+
border-color: rgba(107, 255, 208, 0.28);
|
| 303 |
+
box-shadow: var(--glow);
|
| 304 |
}
|
| 305 |
|
| 306 |
+
.card-top,
|
| 307 |
+
.metric-row,
|
| 308 |
+
.record-meta,
|
| 309 |
+
.health-meta,
|
| 310 |
+
.inline-actions {
|
| 311 |
display: flex;
|
| 312 |
+
gap: 10px;
|
| 313 |
flex-wrap: wrap;
|
|
|
|
|
|
|
|
|
|
| 314 |
}
|
| 315 |
|
| 316 |
+
.card-top {
|
| 317 |
+
justify-content: space-between;
|
| 318 |
+
align-items: flex-start;
|
|
|
|
|
|
|
|
|
|
| 319 |
}
|
| 320 |
|
| 321 |
+
.card-title {
|
| 322 |
+
font-size: 22px;
|
| 323 |
+
font-weight: 700;
|
| 324 |
+
line-height: 1.3;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
}
|
| 326 |
|
| 327 |
+
.model-subtitle,
|
| 328 |
+
.mono {
|
| 329 |
+
font-family: var(--font-display);
|
| 330 |
+
color: var(--muted);
|
| 331 |
+
font-size: 13px;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.status-chip,
|
| 335 |
+
.pill {
|
| 336 |
+
display: inline-flex;
|
| 337 |
+
align-items: center;
|
| 338 |
+
justify-content: center;
|
| 339 |
+
padding: 8px 12px;
|
| 340 |
+
min-height: 34px;
|
| 341 |
border-radius: 999px;
|
| 342 |
+
font-size: 12px;
|
| 343 |
+
font-weight: 700;
|
| 344 |
+
letter-spacing: 0.05em;
|
| 345 |
+
border: 1px solid transparent;
|
|
|
|
| 346 |
}
|
| 347 |
|
| 348 |
+
.status-chip.healthy,
|
| 349 |
+
.pill.healthy,
|
| 350 |
+
.pill.good {
|
| 351 |
+
color: var(--green-strong);
|
| 352 |
+
background: rgba(53, 240, 161, 0.12);
|
| 353 |
+
border-color: rgba(107, 255, 208, 0.28);
|
| 354 |
}
|
| 355 |
|
| 356 |
+
.status-chip.down,
|
| 357 |
+
.pill.down,
|
| 358 |
+
.danger-btn {
|
| 359 |
+
color: #ffb5c0;
|
| 360 |
+
background: rgba(255, 110, 131, 0.12);
|
| 361 |
+
border-color: rgba(255, 110, 131, 0.28);
|
| 362 |
}
|
| 363 |
|
| 364 |
+
.status-chip.degraded,
|
| 365 |
+
.status-chip.warn,
|
| 366 |
+
.pill.degraded,
|
| 367 |
+
.pill.warn {
|
| 368 |
+
color: #ffd18d;
|
| 369 |
+
background: rgba(255, 180, 79, 0.12);
|
| 370 |
+
border-color: rgba(255, 180, 79, 0.28);
|
| 371 |
}
|
| 372 |
|
| 373 |
+
.status-chip.unknown,
|
| 374 |
+
.pill.unknown,
|
| 375 |
+
.pill.idle {
|
| 376 |
+
color: #c7d4ea;
|
| 377 |
+
background: rgba(255, 255, 255, 0.06);
|
| 378 |
+
border-color: rgba(255, 255, 255, 0.1);
|
| 379 |
}
|
| 380 |
|
| 381 |
+
.metric-row {
|
| 382 |
+
margin-top: 18px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
}
|
| 384 |
|
| 385 |
+
.metric-pill {
|
| 386 |
+
flex: 1 1 140px;
|
| 387 |
+
padding: 14px 16px;
|
| 388 |
+
border-radius: 18px;
|
| 389 |
+
background: rgba(255, 255, 255, 0.04);
|
| 390 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 391 |
}
|
| 392 |
|
| 393 |
+
.metric-pill span {
|
| 394 |
+
display: block;
|
| 395 |
+
color: var(--muted);
|
| 396 |
+
font-size: 12px;
|
| 397 |
+
margin-bottom: 8px;
|
| 398 |
}
|
| 399 |
|
| 400 |
+
.metric-pill strong {
|
| 401 |
+
font-family: var(--font-display);
|
| 402 |
+
font-size: 17px;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
.timeline {
|
| 406 |
display: flex;
|
| 407 |
+
gap: 10px;
|
| 408 |
+
flex-wrap: wrap;
|
| 409 |
+
margin-top: 18px;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.timeline-item {
|
| 413 |
+
width: 40px;
|
| 414 |
+
height: 40px;
|
| 415 |
+
border-radius: 14px;
|
| 416 |
+
display: inline-flex;
|
| 417 |
align-items: center;
|
| 418 |
justify-content: center;
|
| 419 |
+
font-family: var(--font-display);
|
| 420 |
+
font-size: 13px;
|
| 421 |
+
font-weight: 700;
|
| 422 |
+
background: rgba(255, 255, 255, 0.04);
|
| 423 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 424 |
}
|
| 425 |
|
| 426 |
+
.timeline-item.ok {
|
| 427 |
+
background: linear-gradient(135deg, rgba(53, 240, 161, 0.92), rgba(107, 255, 208, 0.92));
|
| 428 |
+
color: #04110d;
|
| 429 |
+
border-color: transparent;
|
|
|
|
|
|
|
|
|
|
| 430 |
}
|
| 431 |
|
| 432 |
+
.timeline-item.warn {
|
| 433 |
+
background: linear-gradient(135deg, rgba(255, 180, 79, 0.9), rgba(255, 218, 131, 0.85));
|
| 434 |
+
color: #1b1203;
|
| 435 |
+
border-color: transparent;
|
| 436 |
}
|
| 437 |
|
| 438 |
+
.timeline-item.down {
|
| 439 |
+
background: linear-gradient(135deg, rgba(255, 110, 131, 0.95), rgba(255, 171, 128, 0.84));
|
| 440 |
+
color: #19080d;
|
| 441 |
+
border-color: transparent;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.timeline-item.idle {
|
| 445 |
color: var(--muted);
|
|
|
|
| 446 |
}
|
| 447 |
|
| 448 |
+
.empty-state,
|
| 449 |
+
.empty-card {
|
| 450 |
width: 100%;
|
| 451 |
+
padding: 18px;
|
| 452 |
+
border-radius: 18px;
|
| 453 |
+
color: var(--muted);
|
| 454 |
+
background: rgba(255, 255, 255, 0.04);
|
| 455 |
+
border: 1px dashed rgba(255, 255, 255, 0.12);
|
|
|
|
|
|
|
| 456 |
}
|
| 457 |
|
| 458 |
+
.error-text {
|
| 459 |
+
margin-top: 18px;
|
| 460 |
+
color: #ffb2bf;
|
|
|
|
| 461 |
}
|
| 462 |
|
| 463 |
+
button,
|
| 464 |
+
.secondary-btn {
|
| 465 |
+
border: none;
|
| 466 |
+
cursor: pointer;
|
| 467 |
+
font: inherit;
|
| 468 |
+
transition: transform 0.2s ease, opacity 0.2s ease, border-color 0.2s ease;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
button:hover,
|
| 472 |
+
.secondary-btn:hover {
|
| 473 |
+
transform: translateY(-1px);
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
button {
|
| 477 |
+
padding: 12px 18px;
|
| 478 |
border-radius: 16px;
|
| 479 |
+
background: linear-gradient(135deg, #2be89a, #64ffd6);
|
| 480 |
+
color: #03110d;
|
| 481 |
+
font-weight: 800;
|
| 482 |
+
box-shadow: 0 14px 30px rgba(53, 240, 161, 0.18);
|
| 483 |
}
|
| 484 |
|
| 485 |
+
.secondary-btn {
|
| 486 |
+
padding: 10px 14px;
|
| 487 |
+
border-radius: 14px;
|
| 488 |
+
background: rgba(255, 255, 255, 0.05);
|
| 489 |
+
border: 1px solid rgba(255, 255, 255, 0.14);
|
| 490 |
+
color: var(--text);
|
| 491 |
}
|
| 492 |
|
| 493 |
+
.admin-shell {
|
| 494 |
+
display: grid;
|
| 495 |
+
grid-template-columns: 320px minmax(0, 1fr);
|
| 496 |
+
min-height: 100vh;
|
| 497 |
}
|
| 498 |
|
| 499 |
+
.admin-sidebar {
|
| 500 |
+
padding: 28px 22px;
|
| 501 |
+
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
| 502 |
+
background: rgba(4, 8, 17, 0.88);
|
| 503 |
+
backdrop-filter: blur(16px);
|
| 504 |
}
|
| 505 |
|
| 506 |
+
.brand-block {
|
| 507 |
+
padding: 22px;
|
| 508 |
+
border-radius: 24px;
|
| 509 |
+
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03));
|
| 510 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 511 |
+
margin-bottom: 24px;
|
|
|
|
| 512 |
}
|
| 513 |
|
| 514 |
+
.brand-block h1 {
|
| 515 |
+
font-size: 32px;
|
| 516 |
}
|
| 517 |
|
| 518 |
+
.admin-sidebar h3 {
|
| 519 |
+
margin: 0 0 12px;
|
| 520 |
+
color: var(--muted);
|
| 521 |
+
font-size: 13px;
|
| 522 |
+
letter-spacing: 0.18em;
|
| 523 |
}
|
| 524 |
|
| 525 |
+
.admin-sidebar .sidebar-btn {
|
| 526 |
+
width: 100%;
|
| 527 |
+
margin-bottom: 10px;
|
| 528 |
+
padding: 14px 16px;
|
| 529 |
+
text-align: left;
|
| 530 |
+
color: var(--text);
|
| 531 |
+
background: rgba(255, 255, 255, 0.04);
|
| 532 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 533 |
+
box-shadow: none;
|
| 534 |
}
|
| 535 |
|
| 536 |
+
.admin-sidebar .sidebar-btn.active {
|
| 537 |
+
color: var(--green-strong);
|
| 538 |
+
background: rgba(53, 240, 161, 0.1);
|
| 539 |
+
border-color: rgba(107, 255, 208, 0.22);
|
| 540 |
+
box-shadow: var(--glow);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
}
|
| 542 |
|
| 543 |
+
.admin-content {
|
| 544 |
+
padding: 30px;
|
|
|
|
|
|
|
|
|
|
| 545 |
}
|
| 546 |
|
| 547 |
+
.glass-panel {
|
| 548 |
+
padding: 28px;
|
| 549 |
+
margin-bottom: 22px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
}
|
| 551 |
|
| 552 |
+
.sub-panel {
|
| 553 |
+
margin-top: 20px;
|
|
|
|
| 554 |
}
|
| 555 |
|
| 556 |
+
.panel-headline.compact h3 {
|
| 557 |
+
margin-top: 8px;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
}
|
| 559 |
|
| 560 |
+
.table {
|
| 561 |
+
width: 100%;
|
| 562 |
+
border-collapse: collapse;
|
| 563 |
}
|
| 564 |
|
| 565 |
+
.table thead th {
|
| 566 |
+
padding: 14px 12px;
|
| 567 |
+
text-align: left;
|
| 568 |
+
color: var(--muted);
|
| 569 |
+
font-size: 13px;
|
| 570 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
| 571 |
+
}
|
| 572 |
|
| 573 |
+
.table tbody td {
|
| 574 |
+
padding: 16px 12px;
|
| 575 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
| 576 |
+
vertical-align: top;
|
| 577 |
+
}
|
| 578 |
|
|
|
|
|
|
|
| 579 |
.form-grid {
|
| 580 |
display: grid;
|
|
|
|
| 581 |
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 582 |
+
gap: 14px;
|
| 583 |
}
|
| 584 |
|
| 585 |
.compact-grid {
|
| 586 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 587 |
}
|
| 588 |
|
| 589 |
+
.form-grid input,
|
| 590 |
.form-grid textarea,
|
| 591 |
+
.login-card input {
|
| 592 |
width: 100%;
|
| 593 |
+
padding: 14px 16px;
|
| 594 |
+
border-radius: 16px;
|
| 595 |
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 596 |
+
background: rgba(255, 255, 255, 0.045);
|
| 597 |
+
color: var(--text);
|
|
|
|
| 598 |
font: inherit;
|
| 599 |
+
outline: none;
|
| 600 |
}
|
| 601 |
|
| 602 |
+
.form-grid input:focus,
|
| 603 |
+
.form-grid textarea:focus,
|
| 604 |
+
.login-card input:focus {
|
| 605 |
+
border-color: rgba(107, 255, 208, 0.32);
|
| 606 |
+
box-shadow: 0 0 0 4px rgba(53, 240, 161, 0.08);
|
| 607 |
}
|
| 608 |
|
| 609 |
+
.form-grid textarea {
|
| 610 |
+
min-height: 120px;
|
| 611 |
+
resize: vertical;
|
| 612 |
+
grid-column: 1 / -1;
|
|
|
|
|
|
|
| 613 |
}
|
| 614 |
|
| 615 |
.checkbox-row {
|
| 616 |
display: flex;
|
| 617 |
align-items: center;
|
| 618 |
+
gap: 12px;
|
| 619 |
+
color: var(--text);
|
| 620 |
}
|
| 621 |
|
| 622 |
.checkbox-row input {
|
|
|
|
| 624 |
height: 18px;
|
| 625 |
}
|
| 626 |
|
| 627 |
+
.spaced-top {
|
| 628 |
+
margin-top: 18px;
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
.health-record,
|
| 632 |
+
.empty-card {
|
| 633 |
+
padding: 20px;
|
| 634 |
+
border-radius: 22px;
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
.health-record h4,
|
| 638 |
+
.login-card h2 {
|
| 639 |
+
margin: 0;
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
.record-meta {
|
| 643 |
+
margin-top: 16px;
|
| 644 |
+
color: var(--muted);
|
| 645 |
+
font-size: 13px;
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
.login-overlay {
|
| 649 |
+
position: fixed;
|
| 650 |
+
inset: 0;
|
| 651 |
+
display: flex;
|
| 652 |
+
align-items: center;
|
| 653 |
+
justify-content: center;
|
| 654 |
+
background: rgba(3, 6, 14, 0.76);
|
| 655 |
+
backdrop-filter: blur(12px);
|
| 656 |
+
z-index: 20;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.login-card {
|
| 660 |
+
width: min(460px, calc(100vw - 32px));
|
| 661 |
+
padding: 30px;
|
| 662 |
+
border-radius: 28px;
|
| 663 |
+
background: linear-gradient(180deg, rgba(13, 22, 41, 0.96), rgba(8, 12, 24, 0.96));
|
| 664 |
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
| 665 |
+
box-shadow: var(--shadow);
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
.login-card label {
|
| 669 |
+
display: block;
|
| 670 |
+
margin: 16px 0 10px;
|
| 671 |
+
color: var(--muted-strong);
|
| 672 |
+
font-size: 13px;
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
.hidden {
|
| 676 |
+
display: none !important;
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
@media (max-width: 980px) {
|
| 680 |
+
.hero-panel {
|
| 681 |
+
grid-template-columns: 1fr;
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
.admin-shell {
|
| 685 |
+
grid-template-columns: 1fr;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
.admin-sidebar {
|
| 689 |
+
position: sticky;
|
| 690 |
+
top: 0;
|
| 691 |
+
z-index: 5;
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
@media (max-width: 720px) {
|
| 696 |
+
.public-shell {
|
| 697 |
+
width: min(100vw - 20px, 1280px);
|
| 698 |
+
padding-top: 20px;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
.hero-panel,
|
| 702 |
+
.summary-panel,
|
| 703 |
+
.board-panel,
|
| 704 |
+
.glass-panel,
|
| 705 |
+
.admin-content {
|
| 706 |
+
padding: 20px;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
.form-grid {
|
| 710 |
grid-template-columns: 1fr;
|
| 711 |
}
|
| 712 |
+
|
| 713 |
+
.admin-sidebar {
|
| 714 |
+
padding: 18px;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.summary-strip,
|
| 718 |
+
.model-grid,
|
| 719 |
+
.section-grid {
|
| 720 |
+
grid-template-columns: 1fr;
|
| 721 |
+
}
|
| 722 |
}
|