cacodex commited on
Commit
2f1c18e
·
verified ·
1 Parent(s): c74679b

Upload 12 files

Browse files
Files changed (11) hide show
  1. .dockerignore +1 -1
  2. .env.example +2 -1
  3. Dockerfile +1 -1
  4. README.md +68 -53
  5. app/main.py +26 -21
  6. requirements.txt +1 -1
  7. static/admin.html +99 -58
  8. static/admin.js +89 -41
  9. static/index.html +45 -23
  10. static/public.js +85 -45
  11. static/style.css +559 -292
.dockerignore CHANGED
@@ -1,4 +1,4 @@
1
- __pycache__/
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=Reply with the single word OK.
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
- FROM python:3.13-slim
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 Responses Gateway
3
  sdk: docker
4
  app_port: 7860
5
  pinned: false
6
  ---
7
 
8
- # NVIDIA NIM Responses Gateway
9
 
10
- A FastAPI gateway that converts NVIDIA NIM's official chat endpoint:
11
 
12
  `https://integrate.api.nvidia.com/v1/chat/completions`
13
 
14
- into an OpenAI-style `/v1/responses` interface, with:
15
 
16
- - tool calling / function calling passthrough
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
- ## Included NVIDIA models
 
 
 
 
 
 
 
 
 
24
 
25
- The app seeds these models on first startup:
 
 
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
- You can add or remove more models from the admin page.
 
 
 
 
35
 
36
- ## Routes
 
37
 
38
- - `GET /` public health dashboard
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
- Admin API:
 
 
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
- ## Environment variables
56
 
57
- - `PASSWORD` required for admin login
58
- - `SESSION_SECRET` optional cookie signing secret; falls back to `PASSWORD`
59
- - `GATEWAY_API_KEY` optional bearer token to protect `/v1/models` and `/v1/responses`
60
- - `NVIDIA_API_BASE` defaults to `https://integrate.api.nvidia.com/v1`
61
- - `NVIDIA_NIM_API_KEY` optional bootstrap key inserted on first startup
62
- - `HEALTHCHECK_INTERVAL_MINUTES` default `60`
63
- - `HEALTHCHECK_PROMPT` default `Reply with the single word OK.`
64
- - `PUBLIC_HISTORY_HOURS` default `48`
65
- - `DATABASE_PATH` default `./data.sqlite3`
66
 
67
- A starter file is available at `.env.example`.
68
 
69
- ## Local run
70
 
71
- Install runtime dependencies:
72
 
73
  ```bash
74
  pip install -r requirements.txt
75
  ```
76
 
77
- For local verification with the smoke script:
78
 
79
  ```bash
80
  pip install -r requirements-dev.txt
81
  python scripts/local_smoke_test.py
82
  ```
83
 
84
- Run the app:
85
 
86
  ```bash
87
  uvicorn app.main:app --host 0.0.0.0 --port 7860
88
  ```
89
 
90
- ## Hugging Face Space deployment
 
 
91
 
92
- This repository is prepared as a Docker Space.
 
 
 
93
 
94
- 1. Create a new Hugging Face Space with `SDK: Docker`.
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
- ## Notes on API compatibility
100
 
101
- - The gateway accepts OpenAI-style `input` payloads and converts them to chat-completions `messages`.
102
- - Function tools are mapped to NVIDIA NIM's OpenAI-compatible `tools` format.
103
- - Returned tool calls are exposed as `function_call` items inside the `output` array.
104
- - `stream: true` is supported as SSE, but the current implementation emits buffered response events after the upstream completion finishes.
 
 
 
105
 
106
- ## References
107
 
108
- - OpenAI Responses API guide: https://platform.openai.com/docs/guides/responses-vs-chat-completions
109
- - OpenAI function calling guide: https://platform.openai.com/docs/guides/function-calling
110
- - NVIDIA Build portal: https://build.nvidia.com/
111
- - NVIDIA NIM API reference: https://docs.api.nvidia.com/
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
- from __future__ import annotations
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 FileResponse, JSONResponse, StreamingResponse
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", "Reply with the single word OK.")
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="Admin authentication required.")
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="No enabled NVIDIA NIM API key is configured.")
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 key not found.")
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="Model not found.")
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 Responses Gateway", lifespan=lifespan)
904
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
905
 
906
 
 
 
 
 
 
907
  @app.get("/")
908
- async def public_dashboard() -> FileResponse:
909
- return FileResponse(STATIC_DIR / "index.html")
910
 
911
 
912
  @app.get("/admin")
913
- async def admin_dashboard() -> FileResponse:
914
- return FileResponse(STATIC_DIR / "admin.html")
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 is not configured.")
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="Invalid password.")
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": "Logged out."}
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="Model not found.")
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="Model not found.")
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 key not found.")
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 key not found.")
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="Model not found.")
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 "No details available.", "latency": row["latency_ms"], "status_code": row["status_code"], "checked_at": row["checked_at"]} for row in rows]
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
- 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
 
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="en">
3
  <head>
4
  <meta charset="UTF-8" />
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Admin - NVIDIA NIM Operations</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;600;700&display=swap"
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
- <h3>Sections</h3>
19
- <button class="sidebar-btn active" data-panel="overview">Overview</button>
20
- <button class="sidebar-btn" data-panel="models">Models</button>
21
- <button class="sidebar-btn" data-panel="keys">API Keys</button>
22
- <button class="sidebar-btn" data-panel="health">Health Checks</button>
23
- <button class="sidebar-btn" data-panel="settings">Settings</button>
 
 
 
 
 
24
  </aside>
 
25
  <section class="admin-content">
26
  <div class="glass-panel" data-panel="overview">
27
- <h2>Command center</h2>
28
- <div class="section-grid" id="overview-metrics"></div>
29
- <div class="glass-panel" style="margin-top: 1rem;">
30
- <h3>Recent checks</h3>
 
 
 
 
 
 
 
 
 
 
 
31
  <table class="table">
32
  <thead>
33
  <tr>
34
- <th>Time</th>
35
- <th>Model</th>
36
- <th>Status</th>
37
- <th>Latency</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>Total models</h3>
49
  <strong id="model-count">-</strong>
50
  </div>
51
  <div class="metric-card">
52
- <h3>Healthy</h3>
53
  <strong id="model-healthy">-</strong>
54
  </div>
55
  </div>
56
- <div class="form-grid" style="margin-top: 1rem;">
57
- <input id="model-id" placeholder="Model ID (e.g. z-ai/glm5)" />
58
- <input id="model-display-name" placeholder="Display name" />
59
- <textarea id="model-description" placeholder="Description for the admin catalog"></textarea>
60
- <button id="model-add" type="button">Add or update model</button>
61
  </div>
62
- <table class="table" style="margin-top: 1rem;">
63
  <thead>
64
  <tr>
65
- <th>Model</th>
66
- <th>Status</th>
67
- <th>Requests</th>
68
- <th>Health</th>
69
- <th>Actions</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
- <h3>API Keys</h3>
 
 
 
 
 
 
78
  <div class="form-grid compact-grid">
79
- <input id="key-label" placeholder="Key label" />
80
- <input id="key-value" placeholder="NVIDIA NIM key" />
81
- <button id="key-add" type="button">Store key</button>
82
  </div>
83
- <table class="table" style="margin-top: 1rem;">
84
  <thead>
85
  <tr>
86
- <th>Label</th>
87
- <th>Masked</th>
88
- <th>Requests</th>
89
- <th>Last tested</th>
90
- <th>Status</th>
91
- <th>Actions</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="toolbar-row">
100
  <div>
101
- <h3>Health checks</h3>
102
- <p class="status-text">Manual runs are stored and surfaced on the public board hour by hour.</p>
103
  </div>
104
- <button id="run-healthcheck" type="button">Run checks now</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
- <h3>Scheduler settings</h3>
 
 
 
 
 
 
111
  <div class="form-grid">
112
  <label class="checkbox-row">
113
  <input id="healthcheck-enabled" type="checkbox" />
114
- <span>Enable scheduled health checks</span>
115
  </label>
116
- <input id="healthcheck-interval" type="number" min="5" step="5" placeholder="Interval in minutes" />
117
- <input id="public-history-hours" type="number" min="1" step="1" placeholder="Public history hours" />
118
- <textarea id="healthcheck-prompt" placeholder="Prompt used for hourly health checks"></textarea>
119
  <div class="inline-actions">
120
- <button id="settings-save" type="button">Save settings</button>
121
- <button class="secondary-btn" id="refresh-now" type="button">Reload dashboard</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
- <h2>Admin login</h2>
132
- <p class="status-text">Enter the PASSWORD environment variable to continue.</p>
133
- <label for="admin-password">Password</label>
 
134
  <input type="password" id="admin-password" autocomplete="current-password" />
135
- <button id="login-btn">Unlock dashboard</button>
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 showPanel = (name) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const apiRequest = async (endpoint, opts = {}) => {
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 || "Request failed");
36
  }
37
  return response.json();
38
- };
39
 
40
- const metricCard = ({ label, value }) => {
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
- const pill = (status) => `<span class="pill">${status || "unknown"}</span>`;
 
 
 
48
 
49
  async function renderOverview() {
50
  const payload = await apiRequest("overview");
 
51
  overviewMetrics.innerHTML = "";
52
- (payload.metrics || []).forEach((metric) => overviewMetrics.appendChild(metricCard(metric)));
 
 
 
53
 
54
  recentChecks.innerHTML = "";
55
  (payload.recent_checks || []).forEach((check) => {
56
  const row = document.createElement("tr");
57
  row.innerHTML = `
58
- <td>${new Date(check.time).toLocaleString()}</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}">Test</button>
86
- <button class="secondary-btn" data-action="remove-model" data-id="${item.model_id}">Remove</button>
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>${item.last_tested ? new Date(item.last_tested).toLocaleString() : "-"}</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}">Test</button>
109
- <button class="secondary-btn" data-action="remove-key" data-id="${item.name}">Delete</button>
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
- (payload.items || []).slice(0, 12).forEach((item) => {
121
- const card = document.createElement("div");
122
- card.className = "glass-panel";
 
 
 
 
 
 
 
 
 
123
  card.innerHTML = `
124
  <div class="toolbar-row">
125
- <h4>${item.model}</h4>
 
 
 
126
  ${pill(item.status)}
127
  </div>
128
- <p class="status-text">${item.detail || "No detail"}</p>
129
- <div class="health-meta">
130
- <span>${item.api_key || "No key recorded"}</span>
131
- <span>${item.latency ? `${item.latency} ms` : "-"}</span>
132
- <span>${item.checked_at ? new Date(item.checked_at).toLocaleString() : "-"}</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 || "Reply with the single word OK.";
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} -> ${payload.status}`);
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} -> ${payload.status}`);
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("Model ID is required.");
193
  return;
194
  }
195
- await apiRequest("models", { method: "POST", body: JSON.stringify({ model_id: modelId, display_name: displayName || modelId, description }) });
 
 
 
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("Label and key are required.");
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 = "Settings saved.";
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 = "Enter a password to continue.";
242
  return;
243
  }
244
  try {
245
- loginStatus.textContent = "Authenticating...";
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 || "Invalid password");
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 (error) {
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="en">
3
  <head>
4
  <meta charset="UTF-8" />
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Model Health �� NVIDIA NIM</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;600;700&display=swap"
11
  rel="stylesheet"
12
  />
13
  <link rel="stylesheet" href="/static/style.css" />
14
  </head>
15
- <body>
16
- <main class="app-shell">
17
- <section class="glass-panel">
18
- <div class="hero">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  <div>
20
- <p class="chip chip--healthy">Live metrics</p>
21
- <h1>Model health, hour by hour</h1>
22
- <p>
23
- Each hour block shows whether the model responded with a healthy,
24
- intermittent, or degraded signal. We poll NVIDIA NIM to keep the
25
- grid in sync.
26
- </p>
27
  </div>
28
- <div class="chip-list" id="summary-chips"></div>
29
  </div>
 
30
  </section>
31
- <section class="glass-panel">
32
- <div class="status-line">
33
- <strong>Heat map</strong>
34
- <span id="last-updated">��</span>
 
 
 
 
35
  </div>
36
- <div class="hour-grid" id="model-grid"></div>
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 statusStyles = {
 
 
 
 
 
 
 
7
  healthy: "ok",
8
  degraded: "warn",
9
  down: "down",
10
- unknown: "warn",
11
  };
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  function formatHourSegment(segment) {
14
  const span = document.createElement("span");
15
- span.textContent = new Date(segment.time).getHours();
16
- span.classList.add(statusStyles[segment.status] || "warn");
17
- span.title = `${segment.status} �� ${new Date(segment.time).toLocaleTimeString()} `;
 
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="health-meta">
27
- <span class="pill">${model.status || "unknown"}</span>
28
- <span>Beat: ${model.beat || "��"}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  </div>
30
- <h2>${model.name}</h2>
31
- <small>${model.endpoint || "NIM chat"}</small>
32
- `.trim();
33
 
34
  const timeline = document.createElement("div");
35
  timeline.className = "timeline";
36
 
37
- (model.hourly || [])
38
- .slice(-12)
39
- .forEach((segment) => timeline.appendChild(formatHourSegment(segment)));
 
 
 
 
 
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("Health endpoint unavailable");
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
- ? new Date(payload.last_updated).toLocaleString()
83
- : new Date().toLocaleString();
84
- } catch (err) {
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
- --base-bg: #030711;
3
- --panel-bg: rgba(7, 18, 34, 0.87);
4
- --accent: #00f18d;
5
- --accent-strong: #32ffd3;
6
- --muted: #8ca3c5;
7
- --border: rgba(255, 255, 255, 0.12);
8
- --glow: 0 10px 40px rgba(0, 241, 141, 0.25);
9
- --font-sans: "Space Grotesk", "Titillium Web", "Segoe UI", sans-serif;
 
 
 
 
 
 
 
 
 
 
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
- background: radial-gradient(circle at top right, rgba(0, 241, 141, 0.18), transparent 40%),
21
- linear-gradient(180deg, #050a15 0%, #020408 50%, #030711 100%);
22
- color: #f1f6ff;
23
- min-height: 100vh;
24
  }
25
 
26
- .app-shell {
27
- padding: 2rem;
28
- max-width: 1200px;
29
- margin: 0 auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  .glass-panel {
33
- background: var(--panel-bg);
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
- display: flex;
44
- flex-wrap: wrap;
45
- gap: 1rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  align-items: center;
47
- justify-content: space-between;
 
 
 
 
 
 
 
 
48
  }
49
 
50
- .hero h1 {
51
- font-size: clamp(2rem, 1.8vw + 2rem, 3rem);
52
- margin: 0;
53
- line-height: 1.2;
 
 
 
 
 
54
  }
55
 
56
- .hero p {
 
 
 
 
 
57
  color: var(--muted);
58
- max-width: 540px;
59
- margin: 0.5rem 0 0;
60
- font-size: 1rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  }
62
 
63
- .chip-list {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  display: flex;
 
 
 
65
  flex-wrap: wrap;
66
- gap: 0.5rem;
67
- margin-top: 1rem;
68
  }
69
 
70
- .chip {
71
- padding: 0.3rem 0.9rem;
72
- border-radius: 999px;
73
- border: 1px solid rgba(255, 255, 255, 0.15);
74
- font-size: 0.9rem;
75
- color: #b1c2dd;
76
  }
77
 
78
- .chip--healthy {
79
- border-color: rgba(0, 241, 141, 0.5);
80
- color: var(--accent-strong);
 
 
 
81
  }
82
 
83
- .hour-grid {
84
- display: grid;
85
- grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
86
- gap: 1rem;
 
87
  }
88
 
89
- .model-card {
90
- padding: 1.25rem;
91
- border-radius: 16px;
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
- .model-card:hover {
98
- transform: translateY(-6px);
99
- border-color: rgba(0, 241, 141, 0.6);
 
 
100
  }
101
 
102
- .model-card h2 {
103
- margin: 0;
104
- font-size: 1.25rem;
 
 
 
105
  }
106
 
107
- .model-card small {
 
 
108
  color: var(--muted);
 
 
109
  }
110
 
111
- .timeline {
112
- display: flex;
113
- align-items: center;
114
- gap: 0.3rem;
115
- margin-top: 0.9rem;
116
- flex-wrap: wrap;
117
  }
118
 
119
- .timeline span {
120
- width: 28px;
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
- .timeline span.ok {
132
- background: linear-gradient(120deg, #00d97a, #00f18d);
133
- box-shadow: 0 6px 12px rgba(0, 241, 141, 0.4);
134
  }
135
 
136
- .timeline span.warn {
137
- background: linear-gradient(120deg, #ff9a56, #ffa63a);
138
  }
139
 
140
- .timeline span.down {
141
- background: linear-gradient(120deg, #ff5f6d, #ffc371);
 
 
 
142
  }
143
 
144
- .status-line {
145
- margin-top: 1rem;
146
- display: flex;
147
- justify-content: space-between;
148
- font-size: 0.9rem;
149
- color: var(--muted);
150
- align-items: center;
151
  }
152
 
153
- .status-line strong {
154
- color: #fff;
 
 
 
155
  }
156
 
157
- .health-meta {
 
 
 
 
158
  display: flex;
159
- gap: 0.75rem;
160
  flex-wrap: wrap;
161
- align-items: center;
162
- margin-top: 0.5rem;
163
- color: var(--muted);
164
  }
165
 
166
- .pulse {
167
- width: 8px;
168
- height: 8px;
169
- border-radius: 50%;
170
- background: var(--accent);
171
- animation: pulse 1.6s infinite;
172
  }
173
 
174
- @keyframes pulse {
175
- 0% {
176
- box-shadow: 0 0 0 0 rgba(0, 241, 141, 0.6);
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
- button {
187
- font-family: var(--font-sans);
188
- border: none;
189
- cursor: pointer;
 
 
 
 
 
 
 
 
 
 
190
  border-radius: 999px;
191
- padding: 0.65rem 1.2rem;
192
- background: linear-gradient(120deg, #16a085, #00f18d);
193
- color: #020408;
194
- font-weight: 600;
195
- transition: transform 0.2s ease;
196
  }
197
 
198
- button:hover {
199
- transform: translateY(-2px);
 
 
 
 
200
  }
201
 
202
- .admin-shell {
203
- display: grid;
204
- grid-template-columns: 260px 1fr;
205
- min-height: 100vh;
 
 
206
  }
207
 
208
- .admin-sidebar {
209
- background: rgba(3, 7, 17, 0.9);
210
- border-right: 1px solid rgba(255, 255, 255, 0.06);
211
- padding: 2rem 1.5rem;
212
- display: flex;
213
- flex-direction: column;
214
- gap: 0.75rem;
215
  }
216
 
217
- .admin-sidebar h3 {
218
- margin: 0 0 1rem;
219
- font-size: 1rem;
220
- letter-spacing: 0.2em;
221
- text-transform: uppercase;
222
- color: var(--muted);
223
  }
224
 
225
- .admin-sidebar button {
226
- width: 100%;
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
- .admin-sidebar button.active {
238
- border-color: var(--accent);
239
- color: var(--accent);
240
- box-shadow: var(--glow);
 
 
241
  }
242
 
243
- .admin-content {
244
- padding: 2rem;
245
- background: linear-gradient(180deg, rgba(4, 6, 15, 0.9), rgba(2, 3, 6, 0.95));
 
 
246
  }
247
 
248
- .login-overlay {
249
- position: fixed;
250
- inset: 0;
251
- background: rgba(2, 3, 6, 0.8);
 
 
252
  display: flex;
 
 
 
 
 
 
 
 
 
 
253
  align-items: center;
254
  justify-content: center;
255
- z-index: 10;
 
 
 
 
256
  }
257
 
258
- .login-card {
259
- width: min(400px, 90vw);
260
- padding: 2rem;
261
- background: var(--panel-bg);
262
- border-radius: 22px;
263
- border: 1px solid var(--border);
264
- box-shadow: var(--glow);
265
  }
266
 
267
- .login-card h2 {
268
- margin-top: 0;
269
- letter-spacing: 0.08em;
 
270
  }
271
 
272
- .login-card label {
273
- display: block;
274
- font-size: 0.85rem;
275
- text-transform: uppercase;
276
- margin-bottom: 0.25rem;
 
 
277
  color: var(--muted);
278
- letter-spacing: 0.2em;
279
  }
280
 
281
- .login-card input {
 
282
  width: 100%;
283
- padding: 0.9rem;
284
- border-radius: 12px;
285
- border: 1px solid rgba(255, 255, 255, 0.15);
286
- background: rgba(255, 255, 255, 0.03);
287
- color: #fff;
288
- margin-bottom: 1rem;
289
- font-size: 1rem;
290
  }
291
 
292
- .section-grid {
293
- display: grid;
294
- grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
295
- gap: 1.25rem;
296
  }
297
 
298
- .metric-card {
299
- background: rgba(255, 255, 255, 0.03);
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  border-radius: 16px;
301
- padding: 1rem;
302
- border: 1px solid rgba(255, 255, 255, 0.06);
 
 
303
  }
304
 
305
- .metric-card h3 {
306
- margin: 0;
307
- font-size: 1.1rem;
 
 
 
308
  }
309
 
310
- .metric-card strong {
311
- font-size: 2rem;
312
- display: block;
313
- margin-top: 0.5rem;
314
  }
315
 
316
- .table {
317
- width: 100%;
318
- border-collapse: separate;
319
- border-spacing: 0;
 
320
  }
321
 
322
- .table thead th {
323
- text-align: left;
324
- font-size: 0.85rem;
325
- text-transform: uppercase;
326
- color: var(--muted);
327
- padding-bottom: 0.5rem;
328
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
329
  }
330
 
331
- .table tbody tr {
332
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
333
  }
334
 
335
- .table td {
336
- padding: 0.75rem 0;
 
 
 
337
  }
338
 
339
- .inline-actions {
340
- display: flex;
341
- gap: 0.5rem;
 
 
 
 
 
 
342
  }
343
 
344
- .pill {
345
- padding: 0.25rem 0.8rem;
346
- border-radius: 999px;
347
- border: 1px solid transparent;
348
- font-size: 0.75rem;
349
- letter-spacing: 0.1em;
350
- text-transform: uppercase;
351
- background: rgba(0, 241, 141, 0.1);
352
- color: var(--accent);
353
  }
354
 
355
- .form-inline {
356
- display: flex;
357
- gap: 0.6rem;
358
- flex-wrap: wrap;
359
- margin-top: 0.5rem;
360
  }
361
 
362
- .form-inline input {
363
- flex: 1;
364
- min-width: 120px;
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
- .status-text {
373
- font-size: 0.85rem;
374
- color: var(--muted);
375
  }
376
 
377
- .secondary-btn {
378
- border-radius: 12px;
379
- padding: 0.55rem 1rem;
380
- background: transparent;
381
- border: 1px solid rgba(255, 255, 255, 0.25);
382
- color: #fff;
383
  }
384
 
385
- .secondary-btn:hover {
386
- border-color: var(--accent);
387
- color: var(--accent);
388
  }
389
 
390
- @media (max-width: 768px) {
391
- .admin-shell {
392
- grid-template-columns: 1fr;
393
- }
 
 
 
394
 
395
- .admin-sidebar {
396
- flex-direction: row;
397
- overflow-x: auto;
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(220px, 1fr));
411
  }
412
 
 
413
  .form-grid textarea,
414
- .form-grid input {
415
  width: 100%;
416
- min-width: 0;
417
- background: rgba(255, 255, 255, 0.03);
418
  border: 1px solid rgba(255, 255, 255, 0.1);
419
- border-radius: 12px;
420
- padding: 0.85rem;
421
- color: #fff;
422
  font: inherit;
 
423
  }
424
 
425
- .form-grid textarea {
426
- min-height: 110px;
427
- grid-column: 1 / -1;
428
- resize: vertical;
 
429
  }
430
 
431
- .toolbar-row {
432
- display: flex;
433
- justify-content: space-between;
434
- gap: 1rem;
435
- align-items: center;
436
- flex-wrap: wrap;
437
  }
438
 
439
  .checkbox-row {
440
  display: flex;
441
  align-items: center;
442
- gap: 0.75rem;
443
- color: #fff;
444
  }
445
 
446
  .checkbox-row input {
@@ -448,8 +624,99 @@ button:hover {
448
  height: 18px;
449
  }
450
 
451
- @media (max-width: 768px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  }