cacodex commited on
Commit
3857d4d
·
verified ·
1 Parent(s): b0f7d3f

Upload 12 files

Browse files
Files changed (7) hide show
  1. README.md +20 -13
  2. app/main.py +35 -3
  3. static/index.html +31 -79
  4. static/models.html +58 -0
  5. static/models.js +120 -0
  6. static/public.js +68 -162
  7. static/style.css +349 -394
README.md CHANGED
@@ -19,9 +19,9 @@ pinned: false
19
  - 支持 `previous_response_id` 对话续写
20
  - 对 `/v1/responses` 和 `/v1/responses/{response_id}` 使用用户自带的 NIM Key 做鉴权与上游转发
21
  - `/v1/models` 直接返回来自 NVIDIA 官方 `/v1/models` 的同步结果,保持 OpenAI 风格结构
22
- - 前端第一页展示总调用次数、平均健康度、每个模型 10 分钟成功率
23
- - 前端第二页展示官方模型目录,并按提供商分类展示
24
- - 页面采用横向双页切换,带平滑动画与现代卡片式设计
25
  - 使用共享 HTTP 连接池、SQLite WAL 和异步线程化落库来增强高并发场景下的转发性能
26
 
27
  ## 用户如何调用
@@ -42,15 +42,22 @@ pinned: false
42
  同步后的模型目录同时用于:
43
 
44
  - `GET /v1/models`
45
- - 前端第二页“官方模型库”展示
 
46
 
47
- ## 公开页面
48
 
49
- - `GET /`:首页,双页展示
50
- - `GET /api/dashboard`:健康度与统计数据
51
- - `GET /api/catalog`:官方模型目录与提供商分类
52
 
53
- ## 兼容接口
 
 
 
 
 
 
 
 
54
 
55
  - `POST /v1/responses`
56
  - `GET /v1/responses/{response_id}`
@@ -59,9 +66,9 @@ pinned: false
59
  ## 环境变量
60
 
61
  - `NVIDIA_API_BASE`:默认 `https://integrate.api.nvidia.com/v1`
62
- - `MODEL_LIST`:首页监控模型列表,逗号分隔
63
  - `MODEL_SYNC_INTERVAL_MINUTES`:官方模型目录同步周期,默认 `30`
64
- - `PUBLIC_HISTORY_BUCKETS`:首页展示最近多少个 10 分钟时间片,默认 `6`
65
  - `REQUEST_TIMEOUT_SECONDS`:上游请求超时,默认 `90`
66
  - `MAX_UPSTREAM_CONNECTIONS`:共享连接池最大连接数,默认 `512`
67
  - `MAX_KEEPALIVE_CONNECTIONS`:共享连接池最大 keep-alive 连接数,默认 `128`
@@ -72,10 +79,10 @@ pinned: false
72
  我已经完成两层本地联调:
73
 
74
  1. Mock 联调:
75
- - 通过 [scripts/local_smoke_test.py](D:\Code\NIM2response\scripts\local_smoke_test.py) 验证了协议转换、官方模型同步、用户 Key 鉴权、`previous_response_id`、tool call 与前端数据接口。
76
 
77
  2. 真实上游联调:
78
- - 通过 [scripts/live_e2e_validation.py](D:\Code\NIM2response\scripts\live_e2e_validation.py) 使用你提供的测试 NIM Key,真实调用了 NVIDIA 官方模型目录和实际模型响应。
79
  - 实测结果:`live_gateway_ok`,并成功通过 `z-ai/glm5` 得到 `OK`。
80
 
81
  ## 部署到 Hugging Face Space
 
19
  - 支持 `previous_response_id` 对话续写
20
  - 对 `/v1/responses` 和 `/v1/responses/{response_id}` 使用用户自带的 NIM Key 做鉴权与上游转发
21
  - `/v1/models` 直接返回来自 NVIDIA 官方 `/v1/models` 的同步结果,保持 OpenAI 风格结构
22
+ - `/` 为白色主题的模型健康度页面,按 10 分钟成功率矩阵展示 MODEL_LIST 中的模型
23
+ - `/models` 为独立的白色主题官方模型列表页面,支持按提供商筛选模型
24
+ - 模型提供商卡片为固定高度,避免模型较多时卡片过长
25
  - 使用共享 HTTP 连接池、SQLite WAL 和异步线程化落库来增强高并发场景下的转发性能
26
 
27
  ## 用户如何调用
 
42
  同步后的模型目录同时用于:
43
 
44
  - `GET /v1/models`
45
+ - `GET /models`
46
+ - `GET /api/catalog`
47
 
48
+ ## 页面与接口
49
 
50
+ 页面:
 
 
51
 
52
+ - `GET /`:模型健康度页面
53
+ - `GET /models`:官方模型列表页面
54
+
55
+ 前端数据接口:
56
+
57
+ - `GET /api/dashboard`
58
+ - `GET /api/catalog`
59
+
60
+ 兼容接口:
61
 
62
  - `POST /v1/responses`
63
  - `GET /v1/responses/{response_id}`
 
66
  ## 环境变量
67
 
68
  - `NVIDIA_API_BASE`:默认 `https://integrate.api.nvidia.com/v1`
69
+ - `MODEL_LIST`:健康度页面监控模型列表,逗号分隔
70
  - `MODEL_SYNC_INTERVAL_MINUTES`:官方模型目录同步周期,默认 `30`
71
+ - `PUBLIC_HISTORY_BUCKETS`:健康页展示最近多少个 10 分钟时间片,默认 `6`
72
  - `REQUEST_TIMEOUT_SECONDS`:上游请求超时,默认 `90`
73
  - `MAX_UPSTREAM_CONNECTIONS`:共享连接池最大连接数,默认 `512`
74
  - `MAX_KEEPALIVE_CONNECTIONS`:共享连接池最大 keep-alive 连接数,默认 `128`
 
79
  我已经完成两层本地联调:
80
 
81
  1. Mock 联调:
82
+ - 通过 `scripts/local_smoke_test.py` 验证了协议转换、官方模型同步、用户 Key 鉴权、`previous_response_id`、tool call、健康页数据接口、模型页数据接口和两个独立页面路由。
83
 
84
  2. 真实上游联调:
85
+ - 通过 `scripts/live_e2e_validation.py` 使用提供的测试 NIM Key,真实调用了 NVIDIA 官方模型目录和实际模型响应。
86
  - 实测结果:`live_gateway_ok`,并成功通过 `z-ai/glm5` 得到 `OK`。
87
 
88
  ## 部署到 Hugging Face Space
app/main.py CHANGED
@@ -895,6 +895,11 @@ async def homepage() -> HTMLResponse:
895
  return render_html("index.html")
896
 
897
 
 
 
 
 
 
898
  @app.get("/api/dashboard")
899
  async def dashboard_api() -> dict[str, Any]:
900
  return await run_db(load_dashboard_data)
@@ -910,20 +915,47 @@ async def catalog_api() -> dict[str, Any]:
910
  return build_catalog_payload()
911
 
912
 
913
- @app.get("/v1/models")
914
- async def list_models() -> dict[str, Any]:
915
  if not model_cache:
916
  await refresh_official_models(force=True)
917
  return {"object": "list", "data": model_cache}
918
 
919
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
920
  @app.get("/v1/responses/{response_id}")
 
 
 
 
 
921
  async def get_response(response_id: str, api_key: str = Depends(extract_user_api_key)) -> dict[str, Any]:
922
- return await run_db(load_response_record, hash_api_key(api_key), response_id)
923
 
924
 
925
  @app.post("/v1/responses")
 
 
 
 
 
926
  async def create_response(request: Request, api_key: str = Depends(extract_user_api_key)):
 
 
 
 
927
  body = await request.json()
928
  if not isinstance(body, dict):
929
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请求体必须是 JSON 对象。")
 
895
  return render_html("index.html")
896
 
897
 
898
+ @app.get("/model_list", response_class=HTMLResponse)
899
+ async def models_page() -> HTMLResponse:
900
+ return render_html("models.html")
901
+
902
+
903
  @app.get("/api/dashboard")
904
  async def dashboard_api() -> dict[str, Any]:
905
  return await run_db(load_dashboard_data)
 
915
  return build_catalog_payload()
916
 
917
 
918
+ async def build_models_response() -> dict[str, Any]:
 
919
  if not model_cache:
920
  await refresh_official_models(force=True)
921
  return {"object": "list", "data": model_cache}
922
 
923
 
924
+ @app.get("/v1/models")
925
+ async def list_models_v1() -> dict[str, Any]:
926
+ return await build_models_response()
927
+
928
+
929
+ @app.get("/models")
930
+ async def list_models() -> dict[str, Any]:
931
+ return await build_models_response()
932
+
933
+
934
+ async def fetch_response_record(response_id: str, api_key: str) -> dict[str, Any]:
935
+ return await run_db(load_response_record, hash_api_key(api_key), response_id)
936
+
937
+
938
  @app.get("/v1/responses/{response_id}")
939
+ async def get_response_v1(response_id: str, api_key: str = Depends(extract_user_api_key)) -> dict[str, Any]:
940
+ return await fetch_response_record(response_id, api_key)
941
+
942
+
943
+ @app.get("/responses/{response_id}")
944
  async def get_response(response_id: str, api_key: str = Depends(extract_user_api_key)) -> dict[str, Any]:
945
+ return await fetch_response_record(response_id, api_key)
946
 
947
 
948
  @app.post("/v1/responses")
949
+ async def create_response_v1(request: Request, api_key: str = Depends(extract_user_api_key)):
950
+ return await create_response_impl(request, api_key)
951
+
952
+
953
+ @app.post("/responses")
954
  async def create_response(request: Request, api_key: str = Depends(extract_user_api_key)):
955
+ return await create_response_impl(request, api_key)
956
+
957
+
958
+ async def create_response_impl(request: Request, api_key: str):
959
  body = await request.json()
960
  if not isinstance(body, dict):
961
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请求体必须是 JSON 对象。")
static/index.html CHANGED
@@ -4,7 +4,7 @@
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>NIM 模型健康与模型库</title>
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
  <link
@@ -13,88 +13,40 @@
13
  />
14
  <link rel="stylesheet" href="/static/style.css" />
15
  </head>
16
- <body class="showcase-body">
17
- <div class="ambient ambient-left"></div>
18
- <div class="ambient ambient-right"></div>
19
- <div class="ambient ambient-bottom"></div>
 
 
 
 
 
 
 
 
 
20
 
21
- <header class="topbar">
22
- <div class="brand-mark">
23
- <span class="brand-badge">NIM Responses Gateway</span>
24
- <strong>模型健康与官方模型库</strong>
25
- </div>
26
- <nav class="page-nav">
27
- <button class="page-nav-btn active" data-page="0">健康看板</button>
28
- <button class="page-nav-btn" data-page="1">官方模型库</button>
29
- </nav>
30
- </header>
31
 
32
- <main class="showcase-shell">
33
- <div class="page-track" id="page-track">
34
- <section class="page-section active" data-page="0">
35
- <section class="hero-stage">
36
- <div class="hero-copy">
37
- <span class="hero-badge">每 10 分钟统计</span>
38
- <h1>实时观察模型调用质量</h1>
39
- <p>
40
- 本网关不保存任何用户 NIM Key。每次调用都由用户自带 NIM 密钥直连上游,
41
- 页面仅展示聚合后的调用次数、成功率和官方模型目录。
42
- </p>
43
- <div class="hero-actions">
44
- <button class="primary-btn" data-jump="1">查看官方模型库</button>
45
- <button class="ghost-btn" id="refresh-dashboard" type="button">刷新数据</button>
46
- </div>
47
- </div>
48
- <div class="hero-metrics" id="overview-cards"></div>
49
- </section>
50
 
51
- <section class="panel-shell">
52
- <div class="section-head">
53
- <div>
54
- <span class="section-tag">健康看板</span>
55
- <h2>监控模型 10 分钟成功率</h2>
56
- </div>
57
- <div class="section-meta">
58
- <span>最近更新</span>
59
- <strong id="dashboard-updated">--</strong>
60
- </div>
61
- </div>
62
- <div class="health-grid" id="health-grid"></div>
63
- <p class="panel-hint" id="dashboard-empty"></p>
64
- </section>
65
- </section>
66
-
67
- <section class="page-section" data-page="1">
68
- <section class="hero-stage catalog-stage">
69
- <div class="hero-copy narrow">
70
- <span class="hero-badge">官方目录同步</span>
71
- <h1>来自 NVIDIA 官方的模型列表</h1>
72
- <p>
73
- 网关会定时从官方 `https://integrate.api.nvidia.com/v1/models` 拉取模型目录,
74
- 并按照模型 ID 对应的提供商进行分类展示。
75
- </p>
76
- </div>
77
- <div class="catalog-summary" id="catalog-summary"></div>
78
- </section>
79
-
80
- <section class="panel-shell">
81
- <div class="section-head">
82
- <div>
83
- <span class="section-tag">模型目录</span>
84
- <h2>按提供商分类</h2>
85
- </div>
86
- <div class="section-meta">
87
- <span>同步时间</span>
88
- <strong id="catalog-updated">--</strong>
89
- </div>
90
- </div>
91
- <div class="provider-grid" id="provider-grid"></div>
92
- <p class="panel-hint" id="catalog-empty"></p>
93
- </section>
94
- </section>
95
- </div>
96
  </main>
97
-
98
  <script src="/static/public.js" charset="utf-8" defer></script>
99
  </body>
100
  </html>
 
 
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>NIM 模型健康度</title>
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
  <link
 
13
  />
14
  <link rel="stylesheet" href="/static/style.css" />
15
  </head>
16
+ <body class="health-page-body">
17
+ <main class="health-page-shell">
18
+ <header class="health-topbar">
19
+ <div>
20
+ <span class="mini-badge">NIM Responses Gateway</span>
21
+ <h1>模型健康度</h1>
22
+ <p>按 10 分钟为粒度展示 MODEL_LIST 中各模型的成功率趋势。</p>
23
+ </div>
24
+ <div class="topbar-actions">
25
+ <a class="text-nav-link" href="/model_list">查看官方模型列表</a>
26
+ <button class="refresh-link" id="refresh-dashboard" type="button">刷新数据</button>
27
+ </div>
28
+ </header>
29
 
30
+ <section class="health-summary-grid" id="overview-cards"></section>
 
 
 
 
 
 
 
 
 
31
 
32
+ <section class="health-board-panel">
33
+ <div class="board-header-row">
34
+ <div>
35
+ <span class="mini-badge subtle">健康趋势</span>
36
+ <h2>最近 10 分钟成功率矩阵</h2>
37
+ </div>
38
+ <div class="board-meta-box">
39
+ <span>最近更新</span>
40
+ <strong id="dashboard-updated">--</strong>
41
+ </div>
42
+ </div>
 
 
 
 
 
 
 
43
 
44
+ <div class="timeline-header" id="timeline-header"></div>
45
+ <div class="health-list" id="health-grid"></div>
46
+ <p class="panel-hint" id="dashboard-empty"></p>
47
+ </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  </main>
 
49
  <script src="/static/public.js" charset="utf-8" defer></script>
50
  </body>
51
  </html>
52
+
static/models.html ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>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="catalog-page-body">
17
+ <main class="catalog-page-shell">
18
+ <header class="catalog-topbar">
19
+ <div>
20
+ <span class="mini-badge">官方模型目录</span>
21
+ <h1>NVIDIA NIM 模型列表</h1>
22
+ <p>按模型提供商分类展示,支持快速筛选查看所需模型 ID。</p>
23
+ </div>
24
+ <div class="topbar-actions">
25
+ <a class="text-nav-link" href="/">返回健康度首页</a>
26
+ </div>
27
+ </header>
28
+
29
+ <section class="catalog-summary-grid" id="catalog-summary"></section>
30
+
31
+ <section class="catalog-filter-panel">
32
+ <div>
33
+ <span class="mini-badge subtle">筛选</span>
34
+ <h2>按模型提供商筛选</h2>
35
+ </div>
36
+ <div class="provider-filter-bar" id="provider-filter-bar"></div>
37
+ </section>
38
+
39
+ <section class="catalog-board-panel">
40
+ <div class="board-header-row">
41
+ <div>
42
+ <span class="mini-badge subtle">模型目录</span>
43
+ <h2>提供商卡片列表</h2>
44
+ </div>
45
+ <div class="board-meta-box">
46
+ <span>同步时间</span>
47
+ <strong id="catalog-updated">--</strong>
48
+ </div>
49
+ </div>
50
+
51
+ <div class="provider-grid" id="provider-grid"></div>
52
+ <p class="panel-hint" id="catalog-empty"></p>
53
+ </section>
54
+ </main>
55
+ <script src="/static/models.js" charset="utf-8" defer></script>
56
+ </body>
57
+ </html>
58
+
static/models.js ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const catalogSummary = document.getElementById("catalog-summary");
2
+ const providerFilterBar = document.getElementById("provider-filter-bar");
3
+ const providerGrid = document.getElementById("provider-grid");
4
+ const catalogUpdated = document.getElementById("catalog-updated");
5
+ const catalogEmpty = document.getElementById("catalog-empty");
6
+
7
+ let catalogState = {
8
+ providers: [],
9
+ activeProvider: "all",
10
+ };
11
+
12
+ const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
13
+ month: "2-digit",
14
+ day: "2-digit",
15
+ hour: "2-digit",
16
+ minute: "2-digit",
17
+ hour12: false,
18
+ });
19
+
20
+ function formatDateTime(value) {
21
+ if (!value) return "--";
22
+ const date = new Date(value);
23
+ if (Number.isNaN(date.getTime())) return "--";
24
+ return dateTimeFormatter.format(date);
25
+ }
26
+
27
+ function createSummaryCard(label, value, detail = "") {
28
+ const card = document.createElement("article");
29
+ card.className = "summary-card";
30
+ card.innerHTML = `<span>${label}</span><strong>${value}</strong><p>${detail}</p>`;
31
+ return card;
32
+ }
33
+
34
+ function renderSummary(data) {
35
+ catalogSummary.innerHTML = "";
36
+ catalogSummary.appendChild(createSummaryCard("官方模型总数", data.total_models ?? 0, "来自 NVIDIA 官方 /v1/models"));
37
+ catalogSummary.appendChild(createSummaryCard("提供商数量", data.providers?.length ?? 0, "按模型 ID 中的提供商前缀自动归类"));
38
+ }
39
+
40
+ function renderFilterBar() {
41
+ providerFilterBar.innerHTML = "";
42
+ const options = [{ provider: "all", count: catalogState.providers.reduce((sum, item) => sum + (item.count || 0), 0), label: "全部" }].concat(
43
+ catalogState.providers.map((group) => ({ provider: group.provider, count: group.count, label: group.provider }))
44
+ );
45
+
46
+ options.forEach((option) => {
47
+ const button = document.createElement("button");
48
+ button.className = `provider-filter-btn ${catalogState.activeProvider === option.provider ? "active" : ""}`;
49
+ button.type = "button";
50
+ button.textContent = `${option.label} (${option.count})`;
51
+ button.addEventListener("click", () => {
52
+ catalogState.activeProvider = option.provider;
53
+ renderFilterBar();
54
+ renderProviders();
55
+ });
56
+ providerFilterBar.appendChild(button);
57
+ });
58
+ }
59
+
60
+ function renderProviders() {
61
+ providerGrid.innerHTML = "";
62
+ const filtered = catalogState.activeProvider === "all"
63
+ ? catalogState.providers
64
+ : catalogState.providers.filter((group) => group.provider === catalogState.activeProvider);
65
+
66
+ if (filtered.length === 0) {
67
+ catalogEmpty.textContent = "当前筛选条件下没有可展示的模型。";
68
+ return;
69
+ }
70
+ catalogEmpty.textContent = "";
71
+
72
+ filtered.forEach((group) => {
73
+ const card = document.createElement("article");
74
+ card.className = "provider-card provider-card-fixed";
75
+ card.innerHTML = `
76
+ <div class="provider-card-head">
77
+ <div>
78
+ <h3>${group.provider}</h3>
79
+ <p>${group.count} 个模型</p>
80
+ </div>
81
+ </div>
82
+ `;
83
+
84
+ const list = document.createElement("div");
85
+ list.className = "provider-model-list";
86
+ (group.models || []).forEach((model) => {
87
+ const chip = document.createElement("div");
88
+ chip.className = "provider-model-chip";
89
+ chip.title = model.id;
90
+ chip.textContent = model.id;
91
+ list.appendChild(chip);
92
+ });
93
+
94
+ card.appendChild(list);
95
+ providerGrid.appendChild(card);
96
+ });
97
+ }
98
+
99
+ async function loadCatalog() {
100
+ const response = await fetch("/api/catalog", { headers: { Accept: "application/json" } });
101
+ if (!response.ok) {
102
+ throw new Error("模型列表加载失败");
103
+ }
104
+ const payload = await response.json();
105
+ catalogUpdated.textContent = formatDateTime(payload.synced_at || payload.generated_at);
106
+ catalogState.providers = payload.providers || [];
107
+ renderSummary(payload);
108
+ renderFilterBar();
109
+ renderProviders();
110
+ }
111
+
112
+ window.addEventListener("DOMContentLoaded", async () => {
113
+ try {
114
+ await loadCatalog();
115
+ } catch (error) {
116
+ catalogEmpty.textContent = error.message;
117
+ providerGrid.innerHTML = "";
118
+ providerFilterBar.innerHTML = "";
119
+ }
120
+ });
static/public.js CHANGED
@@ -1,34 +1,16 @@
1
- const pageTrack = document.getElementById("page-track");
2
- const navButtons = document.querySelectorAll(".page-nav-btn");
3
- const jumpButtons = document.querySelectorAll("[data-jump]");
4
- const overviewCards = document.getElementById("overview-cards");
5
  const dashboardUpdated = document.getElementById("dashboard-updated");
 
6
  const healthGrid = document.getElementById("health-grid");
7
  const dashboardEmpty = document.getElementById("dashboard-empty");
8
- const catalogSummary = document.getElementById("catalog-summary");
9
- const catalogUpdated = document.getElementById("catalog-updated");
10
- const providerGrid = document.getElementById("provider-grid");
11
- const catalogEmpty = document.getElementById("catalog-empty");
12
  const refreshDashboardBtn = document.getElementById("refresh-dashboard");
13
 
14
- const STATUS_CLASSES = {
15
- green: "is-green",
16
- yellow: "is-yellow",
17
- orange: "is-orange",
18
- red: "is-red",
19
- idle: "is-idle",
20
- };
21
-
22
  const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
23
- month: "2-digit",
24
- day: "2-digit",
25
  hour: "2-digit",
26
  minute: "2-digit",
 
27
  });
28
 
29
- let currentPage = 0;
30
- let wheelLocked = false;
31
-
32
  function formatDateTime(value) {
33
  if (!value) return "--";
34
  const date = new Date(value);
@@ -37,189 +19,113 @@ function formatDateTime(value) {
37
  }
38
 
39
  function rateMeta(rate) {
40
- if (rate === null || rate === undefined) return { label: "暂无数据", tone: "idle" };
41
- if (rate >= 95) return { label: "优秀", tone: "green" };
42
- if (rate >= 80) return { label: "良好", tone: "yellow" };
43
- if (rate >= 50) return { label: "告警", tone: "orange" };
44
- return { label: "异常", tone: "red" };
45
  }
46
 
47
- function switchPage(nextPage) {
48
- currentPage = Math.max(0, Math.min(1, nextPage));
49
- pageTrack.style.transform = `translate3d(${-50 * currentPage}%, 0, 0)`;
50
- navButtons.forEach((button) => button.classList.toggle("active", Number(button.dataset.page) === currentPage));
51
- document.querySelectorAll(".page-section").forEach((section) => {
52
- section.classList.toggle("active", Number(section.dataset.page) === currentPage);
53
- });
54
- }
55
-
56
- navButtons.forEach((button) => button.addEventListener("click", () => switchPage(Number(button.dataset.page))));
57
- jumpButtons.forEach((button) => button.addEventListener("click", () => switchPage(Number(button.dataset.jump))));
58
-
59
- window.addEventListener(
60
- "wheel",
61
- (event) => {
62
- if (wheelLocked) return;
63
- if (Math.abs(event.deltaX) > Math.abs(event.deltaY) || Math.abs(event.deltaY) < 20) return;
64
- wheelLocked = true;
65
- switchPage(currentPage + (event.deltaY > 0 ? 1 : -1));
66
- window.setTimeout(() => {
67
- wheelLocked = false;
68
- }, 650);
69
- },
70
- { passive: true }
71
- );
72
-
73
- window.addEventListener("keydown", (event) => {
74
- if (event.key === "ArrowRight") switchPage(1);
75
- if (event.key === "ArrowLeft") switchPage(0);
76
- });
77
-
78
- function createOverviewCard(label, value, detail = "") {
79
  const card = document.createElement("article");
80
- card.className = "overview-card";
81
  card.innerHTML = `<span>${label}</span><strong>${value}</strong><p>${detail}</p>`;
82
  return card;
83
  }
84
 
85
  function renderOverview(data) {
86
  overviewCards.innerHTML = "";
87
- const averageHealth = data.average_health === null || data.average_health === undefined ? "--" : `${data.average_health.toFixed(1)}%`;
88
- const activeModels = (data.models || []).filter((model) => model.latest_success_rate !== null && model.latest_success_rate !== undefined).length;
89
- const averageLatencyModels = (data.models || [])
90
- .flatMap((model) => model.points || [])
91
- .filter((point) => point.total_count > 0 && point.success_rate !== null && point.success_rate !== undefined);
92
-
93
- overviewCards.appendChild(createOverviewCard("总调用次数", data.total_requests ?? 0, "来自网关历史累计转发记录"));
94
- overviewCards.appendChild(createOverviewCard("平均健康度", averageHealth, "按监控模型最近 10 分钟成功率平均值计算"));
95
- overviewCards.appendChild(createOverviewCard("活跃模型数", activeModels, "最近统计窗口内有调用记录的模型数量"));
96
- overviewCards.appendChild(createOverviewCard("统计粒度", `${data.bucket_minutes} 分钟`, `已展示最近 ${data.models?.[0]?.points?.length || 0} 个时间片`));
97
  dashboardUpdated.textContent = formatDateTime(data.generated_at);
98
  }
99
 
100
- function renderHealthCards(models) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  healthGrid.innerHTML = "";
102
  if (!models || models.length === 0) {
103
- dashboardEmpty.textContent = "当前未配置 MODEL_LIST,或尚无可展示的统计结果。";
104
  return;
105
  }
106
  dashboardEmpty.textContent = "";
 
107
 
108
  models.forEach((model) => {
 
109
  const latestMeta = rateMeta(model.latest_success_rate);
110
- const latestRate = model.latest_success_rate === null || model.latest_success_rate === undefined ? "--" : `${model.latest_success_rate.toFixed(1)}%`;
111
- const averageRate = model.average_success_rate === null || model.average_success_rate === undefined ? "--" : `${model.average_success_rate.toFixed(1)}%`;
112
-
113
- const card = document.createElement("article");
114
- card.className = `health-card ${STATUS_CLASSES[latestMeta.tone]}`;
115
- card.innerHTML = `
116
- <div class="card-head">
117
- <div>
118
- <h3>${model.model_id}</h3>
119
- <p>${model.provider}</p>
120
- </div>
121
- <span class="status-pill ${STATUS_CLASSES[latestMeta.tone]}">${latestMeta.label}</span>
122
- </div>
123
- <div class="scoreboard">
124
- <div class="score-item">
125
- <span>最近 10 分钟</span>
126
- <strong>${latestRate}</strong>
127
- </div>
128
- <div class="score-item">
129
- <span>近 1 小时均值</span>
130
- <strong>${averageRate}</strong>
131
- </div>
132
- <div class="score-item">
133
- <span>累计调用次数</span>
134
- <strong>${model.total_calls ?? 0}</strong>
135
- </div>
136
  </div>
137
  `;
138
 
139
- const timeline = document.createElement("div");
140
- timeline.className = "timeline-strip";
141
  (model.points || []).forEach((point) => {
142
  const meta = rateMeta(point.success_rate);
143
- const item = document.createElement("div");
144
- item.className = `timeline-box ${STATUS_CLASSES[meta.tone]}`;
145
- item.innerHTML = `<span>${point.label}</span><strong>${point.success_rate === null || point.success_rate === undefined ? "--" : `${point.success_rate.toFixed(0)}%`}</strong>`;
146
- item.title = `${point.label} 成功 ${point.success_count}/${point.total_count}`;
147
- timeline.appendChild(item);
148
  });
149
- card.appendChild(timeline);
150
- healthGrid.appendChild(card);
151
- });
152
- }
153
-
154
- function renderCatalogSummary(data) {
155
- catalogSummary.innerHTML = "";
156
- catalogSummary.appendChild(createOverviewCard("官方模型总数", data.total_models ?? 0, "来自 NVIDIA 官方模型目录"));
157
- catalogSummary.appendChild(createOverviewCard("提供商数量", data.providers?.length ?? 0, "按模型 ID 前缀自动归类"));
158
- }
159
 
160
- function renderCatalogProviders(providers) {
161
- providerGrid.innerHTML = "";
162
- if (!providers || providers.length === 0) {
163
- catalogEmpty.textContent = "暂时还没有拉取到官方模型目录,请稍后刷新。";
164
- return;
165
- }
166
- catalogEmpty.textContent = "";
167
-
168
- providers.forEach((group) => {
169
- const card = document.createElement("article");
170
- card.className = "provider-card";
171
- card.innerHTML = `
172
- <div class="provider-head">
173
- <div>
174
- <h3>${group.provider}</h3>
175
- <p>${group.count} 个模型</p>
176
- </div>
177
- </div>
178
- `;
179
- const chipWrap = document.createElement("div");
180
- chipWrap.className = "model-chip-wrap";
181
- (group.models || []).forEach((model) => {
182
- const chip = document.createElement("span");
183
- chip.className = "model-chip";
184
- chip.textContent = model.id;
185
- chipWrap.appendChild(chip);
186
- });
187
- card.appendChild(chipWrap);
188
- providerGrid.appendChild(card);
189
  });
190
  }
191
 
192
  async function loadDashboard() {
193
  const response = await fetch("/api/dashboard", { headers: { Accept: "application/json" } });
194
- if (!response.ok) throw new Error("仪表盘数据加载失败");
 
 
195
  const payload = await response.json();
196
  renderOverview(payload);
197
- renderHealthCards(payload.models || []);
198
- }
199
-
200
- async function loadCatalog() {
201
- const response = await fetch("/api/catalog", { headers: { Accept: "application/json" } });
202
- if (!response.ok) throw new Error("模型目录加载失败");
203
- const payload = await response.json();
204
- catalogUpdated.textContent = formatDateTime(payload.synced_at || payload.generated_at);
205
- renderCatalogSummary(payload);
206
- renderCatalogProviders(payload.providers || []);
207
  }
208
 
209
- async function refreshAll() {
210
  try {
211
- await Promise.all([loadDashboard(), loadCatalog()]);
212
  } catch (error) {
213
  dashboardEmpty.textContent = error.message;
214
- catalogEmpty.textContent = error.message;
 
215
  }
216
  }
217
 
218
- refreshDashboardBtn?.addEventListener("click", refreshAll);
219
 
220
  window.addEventListener("DOMContentLoaded", () => {
221
- switchPage(0);
222
- refreshAll();
223
- window.setInterval(loadDashboard, 60 * 1000);
224
- window.setInterval(loadCatalog, 5 * 60 * 1000);
225
  });
 
1
+ const overviewCards = document.getElementById("overview-cards");
 
 
 
2
  const dashboardUpdated = document.getElementById("dashboard-updated");
3
+ const timelineHeader = document.getElementById("timeline-header");
4
  const healthGrid = document.getElementById("health-grid");
5
  const dashboardEmpty = document.getElementById("dashboard-empty");
 
 
 
 
6
  const refreshDashboardBtn = document.getElementById("refresh-dashboard");
7
 
 
 
 
 
 
 
 
 
8
  const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
 
 
9
  hour: "2-digit",
10
  minute: "2-digit",
11
+ hour12: false,
12
  });
13
 
 
 
 
14
  function formatDateTime(value) {
15
  if (!value) return "--";
16
  const date = new Date(value);
 
19
  }
20
 
21
  function rateMeta(rate) {
22
+ if (rate === null || rate === undefined) return { label: "暂无数据", className: "status-cyan", colorClass: "cell-empty" };
23
+ if (rate >= 95) return { label: "优秀", className: "status-cyan", colorClass: "cell-cyan" };
24
+ if (rate >= 80) return { label: "良好", className: "status-green", colorClass: "cell-green" };
25
+ if (rate >= 50) return { label: "告警", className: "status-orange", colorClass: "cell-orange" };
26
+ return { label: "异常", className: "status-red", colorClass: "cell-red" };
27
  }
28
 
29
+ function createSummaryCard(label, value, detail = "") {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  const card = document.createElement("article");
31
+ card.className = "summary-card";
32
  card.innerHTML = `<span>${label}</span><strong>${value}</strong><p>${detail}</p>`;
33
  return card;
34
  }
35
 
36
  function renderOverview(data) {
37
  overviewCards.innerHTML = "";
38
+ const averageHealth = data.average_health === null || data.average_health === undefined ? "--" : `${data.average_health.toFixed(2)}%`;
39
+ const totalCalls = data.total_requests ?? 0;
40
+ const healthyModels = (data.models || []).filter((model) => (model.latest_success_rate ?? 0) >= 95).length;
41
+ const displayedBuckets = data.models?.[0]?.points?.length || 0;
42
+
43
+ overviewCards.appendChild(createSummaryCard("总调用次数", totalCalls, "统计来自网关累计成功与失败请求"));
44
+ overviewCards.appendChild(createSummaryCard("平均健康度", averageHealth, "按监控模型最近 10 分钟成功率平均值计算"));
45
+ overviewCards.appendChild(createSummaryCard("高健康模型数", healthyModels, "最近一档成功率达到 95% 以上的模型数量"));
46
+ overviewCards.appendChild(createSummaryCard("统计窗口", `${displayedBuckets * (data.bucket_minutes || 10)} 分钟`, `当前按 ${data.bucket_minutes || 10} 分钟粒度滚动统计`));
 
47
  dashboardUpdated.textContent = formatDateTime(data.generated_at);
48
  }
49
 
50
+ function renderTimelineHeader(models) {
51
+ timelineHeader.innerHTML = "";
52
+ const points = models?.[0]?.points || [];
53
+ const emptyCell = document.createElement("div");
54
+ emptyCell.className = "timeline-label timeline-left-placeholder";
55
+ timelineHeader.appendChild(emptyCell);
56
+ points.forEach((point) => {
57
+ const cell = document.createElement("div");
58
+ cell.className = "timeline-label";
59
+ cell.textContent = point.label || formatDateTime(point.bucket_start);
60
+ timelineHeader.appendChild(cell);
61
+ });
62
+ }
63
+
64
+ function renderHealthRows(models) {
65
  healthGrid.innerHTML = "";
66
  if (!models || models.length === 0) {
67
+ dashboardEmpty.textContent = "当前没有可展示的模型健康数据,请确认 MODEL_LIST 配置并等待请求进入统计。";
68
  return;
69
  }
70
  dashboardEmpty.textContent = "";
71
+ renderTimelineHeader(models);
72
 
73
  models.forEach((model) => {
74
+ const latestRate = model.latest_success_rate === null || model.latest_success_rate === undefined ? "--" : `${model.latest_success_rate.toFixed(2)}%`;
75
  const latestMeta = rateMeta(model.latest_success_rate);
76
+
77
+ const row = document.createElement("article");
78
+ row.className = "health-row-card";
79
+
80
+ const summary = document.createElement("div");
81
+ summary.className = "health-row-summary";
82
+ summary.innerHTML = `
83
+ <div class="health-row-accent ${latestMeta.className}"></div>
84
+ <div class="health-row-copy">
85
+ <h3 title="${model.model_id}">${model.model_id}</h3>
86
+ <span class="health-rate-pill ${latestMeta.className}">${latestRate}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  </div>
88
  `;
89
 
90
+ const cells = document.createElement("div");
91
+ cells.className = "health-cells";
92
  (model.points || []).forEach((point) => {
93
  const meta = rateMeta(point.success_rate);
94
+ const cell = document.createElement("div");
95
+ cell.className = `health-cell ${meta.colorClass}`;
96
+ cell.title = `${point.label} 成功 ${point.success_count}/${point.total_count}`;
97
+ cells.appendChild(cell);
 
98
  });
 
 
 
 
 
 
 
 
 
 
99
 
100
+ row.appendChild(summary);
101
+ row.appendChild(cells);
102
+ healthGrid.appendChild(row);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  });
104
  }
105
 
106
  async function loadDashboard() {
107
  const response = await fetch("/api/dashboard", { headers: { Accept: "application/json" } });
108
+ if (!response.ok) {
109
+ throw new Error("健康看板数据加载失败");
110
+ }
111
  const payload = await response.json();
112
  renderOverview(payload);
113
+ renderHealthRows(payload.models || []);
 
 
 
 
 
 
 
 
 
114
  }
115
 
116
+ async function refreshDashboard() {
117
  try {
118
+ await loadDashboard();
119
  } catch (error) {
120
  dashboardEmpty.textContent = error.message;
121
+ timelineHeader.innerHTML = "";
122
+ healthGrid.innerHTML = "";
123
  }
124
  }
125
 
126
+ refreshDashboardBtn?.addEventListener("click", refreshDashboard);
127
 
128
  window.addEventListener("DOMContentLoaded", () => {
129
+ refreshDashboard();
130
+ window.setInterval(refreshDashboard, 60 * 1000);
 
 
131
  });
static/style.css CHANGED
@@ -1,24 +1,21 @@
1
  :root {
2
- --bg: #050816;
3
- --bg-deep: #02040b;
4
- --panel: rgba(10, 15, 28, 0.72);
5
- --panel-strong: rgba(14, 20, 36, 0.92);
6
- --panel-soft: rgba(255, 255, 255, 0.05);
7
- --line: rgba(255, 255, 255, 0.09);
8
- --line-strong: rgba(255, 255, 255, 0.16);
9
- --text: #f4f7ff;
10
- --muted: #91a3c4;
11
- --muted-strong: #c7d4ea;
12
- --green: #38f3a5;
13
- --yellow: #ffd24d;
14
- --orange: #ff9b43;
15
- --red: #ff637f;
16
- --idle: #5e6d88;
17
- --shadow: 0 24px 60px rgba(0, 0, 0, 0.28);
18
- --glow: 0 0 0 1px rgba(56, 243, 165, 0.12), 0 24px 70px rgba(56, 243, 165, 0.12);
19
  --font-sans: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
20
  --font-display: "Space Grotesk", "Noto Sans SC", sans-serif;
21
- color-scheme: dark;
22
  }
23
 
24
  * {
@@ -35,535 +32,493 @@ body {
35
  font-family: var(--font-sans);
36
  color: var(--text);
37
  background:
38
- radial-gradient(circle at 12% 16%, rgba(56, 243, 165, 0.18), transparent 24%),
39
- radial-gradient(circle at 84% 18%, rgba(79, 125, 255, 0.18), transparent 26%),
40
- linear-gradient(180deg, #08111f 0%, var(--bg) 42%, var(--bg-deep) 100%);
41
- overflow-x: hidden;
42
- }
43
-
44
- body::before {
45
- content: "";
46
- position: fixed;
47
- inset: 0;
48
- background-image:
49
- linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
50
- linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
51
- background-size: 36px 36px;
52
- opacity: 0.08;
53
- pointer-events: none;
54
- }
55
-
56
- .ambient {
57
- position: fixed;
58
- border-radius: 999px;
59
- filter: blur(100px);
60
- pointer-events: none;
61
- opacity: 0.36;
62
- }
63
-
64
- .ambient-left {
65
- width: 26rem;
66
- height: 26rem;
67
- top: -8rem;
68
- left: -8rem;
69
- background: rgba(56, 243, 165, 0.36);
70
- }
71
-
72
- .ambient-right {
73
- width: 32rem;
74
- height: 32rem;
75
- top: 8rem;
76
- right: -10rem;
77
- background: rgba(110, 125, 255, 0.28);
78
- }
79
-
80
- .ambient-bottom {
81
- width: 28rem;
82
- height: 28rem;
83
- bottom: -10rem;
84
- right: 18%;
85
- background: rgba(255, 171, 72, 0.18);
86
  }
87
 
88
- .topbar {
89
- position: sticky;
90
- top: 0;
91
- z-index: 30;
92
- width: min(1360px, calc(100vw - 32px));
93
  margin: 0 auto;
94
- padding: 22px 0 12px;
95
- display: flex;
96
- justify-content: space-between;
97
- align-items: center;
98
- gap: 18px;
99
- backdrop-filter: blur(10px);
100
- }
101
-
102
- .brand-mark {
103
- display: flex;
104
- flex-direction: column;
105
- gap: 6px;
106
- }
107
-
108
- .brand-mark strong,
109
- .hero-copy h1,
110
- .section-head h2,
111
- .provider-head h3,
112
- .health-card h3 {
113
- font-family: var(--font-display);
114
- }
115
-
116
- .brand-badge,
117
- .hero-badge,
118
- .section-tag {
119
- display: inline-flex;
120
- align-items: center;
121
- width: fit-content;
122
- padding: 8px 14px;
123
- border-radius: 999px;
124
- border: 1px solid rgba(56, 243, 165, 0.26);
125
- background: rgba(56, 243, 165, 0.1);
126
- color: #8affd0;
127
- font-size: 13px;
128
- font-weight: 700;
129
- letter-spacing: 0.08em;
130
- }
131
-
132
- .page-nav {
133
- display: inline-flex;
134
- gap: 10px;
135
- padding: 8px;
136
- border-radius: 999px;
137
- background: rgba(255, 255, 255, 0.05);
138
- border: 1px solid var(--line);
139
- box-shadow: var(--shadow);
140
- }
141
-
142
- .page-nav-btn,
143
- .primary-btn,
144
- .ghost-btn {
145
- border: none;
146
- cursor: pointer;
147
- font: inherit;
148
- transition: transform 0.22s ease, opacity 0.22s ease, background 0.22s ease, border-color 0.22s ease;
149
- }
150
-
151
- .page-nav-btn {
152
- padding: 10px 16px;
153
- border-radius: 999px;
154
- background: transparent;
155
- color: var(--muted-strong);
156
- }
157
-
158
- .page-nav-btn.active {
159
- color: #04110d;
160
- background: linear-gradient(135deg, var(--green), #7affd8);
161
- font-weight: 700;
162
- }
163
-
164
- .showcase-shell {
165
- position: relative;
166
- width: min(1360px, calc(100vw - 32px));
167
- margin: 0 auto 42px;
168
- overflow: hidden;
169
- }
170
-
171
- .page-track {
172
- display: flex;
173
- width: 200%;
174
- transform: translate3d(0, 0, 0);
175
- transition: transform 0.82s cubic-bezier(0.22, 1, 0.36, 1);
176
  }
177
 
178
- .page-section {
179
- width: 50%;
180
- padding-right: 18px;
181
- opacity: 0.74;
182
- transform: scale(0.985);
183
- transition: opacity 0.55s ease, transform 0.55s ease;
184
- }
185
-
186
- .page-section.active {
187
- opacity: 1;
188
- transform: scale(1);
189
- }
190
-
191
- .hero-stage,
192
- .panel-shell,
193
- .overview-card,
194
- .health-card,
195
  .provider-card {
196
- position: relative;
197
- border-radius: 28px;
198
  border: 1px solid var(--line);
199
- background: linear-gradient(180deg, rgba(17, 25, 44, 0.84), rgba(8, 12, 22, 0.95));
200
  box-shadow: var(--shadow);
201
- backdrop-filter: blur(18px);
202
- overflow: hidden;
203
  }
204
 
205
- .hero-stage::after,
206
- .panel-shell::after,
207
- .health-card::after,
208
- .provider-card::after,
209
- .overview-card::after {
210
- content: "";
211
- position: absolute;
212
- inset: 0;
213
- background: linear-gradient(140deg, rgba(138, 255, 208, 0.08), transparent 32%, transparent 68%, rgba(116, 126, 255, 0.08));
214
- pointer-events: none;
215
  }
216
 
217
- .hero-stage {
218
- display: grid;
219
- grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.9fr);
220
- gap: 24px;
221
- padding: 34px;
 
 
222
  }
223
 
224
- .catalog-stage {
225
- grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
 
 
 
 
226
  }
227
 
228
- .hero-copy h1 {
229
- margin: 16px 0 12px;
230
- font-size: clamp(34px, 3vw, 58px);
231
- line-height: 1.06;
 
232
  }
233
 
234
- .hero-copy p,
235
- .section-head p,
236
- .brand-mark strong,
237
- .provider-head p,
238
  .panel-hint,
239
- .health-card p,
240
- .overview-card p {
 
 
241
  color: var(--muted);
242
- line-height: 1.75;
243
  }
244
 
245
- .hero-actions {
246
- margin-top: 28px;
247
  display: flex;
248
  gap: 14px;
 
249
  flex-wrap: wrap;
250
  }
251
 
252
- .primary-btn,
253
- .ghost-btn {
254
- padding: 14px 20px;
255
- border-radius: 18px;
 
 
 
 
 
256
  font-weight: 700;
 
257
  }
258
 
259
- .primary-btn {
260
- background: linear-gradient(135deg, var(--green), #7affd8);
261
- color: #04110d;
262
- box-shadow: var(--glow);
263
  }
264
 
265
- .ghost-btn {
266
- background: rgba(255, 255, 255, 0.05);
 
 
 
 
267
  color: var(--text);
268
- border: 1px solid rgba(255, 255, 255, 0.12);
 
 
 
 
 
 
 
 
 
 
 
269
  }
270
 
271
- .hero-metrics,
272
- .catalog-summary {
 
273
  display: grid;
274
- grid-template-columns: repeat(2, minmax(0, 1fr));
275
- gap: 14px;
276
- align-content: start;
 
 
277
  }
278
 
279
- .overview-card {
 
280
  padding: 18px 20px;
281
  }
282
 
283
- .overview-card span {
 
284
  color: var(--muted);
285
  font-size: 13px;
286
  }
287
 
288
- .overview-card strong {
289
  display: block;
290
  margin: 10px 0 8px;
291
  font-family: var(--font-display);
292
- font-size: 30px;
293
  }
294
 
295
- .panel-shell {
296
- margin-top: 22px;
297
- padding: 28px;
 
 
298
  }
299
 
300
- .section-head {
301
  display: flex;
302
  justify-content: space-between;
303
  align-items: flex-start;
304
  gap: 18px;
305
  flex-wrap: wrap;
306
- margin-bottom: 20px;
307
  }
308
 
309
- .section-head h2 {
310
  margin: 12px 0 0;
311
- font-size: clamp(26px, 2vw, 38px);
312
  }
313
 
314
- .section-meta {
315
- min-width: 220px;
316
- padding: 16px 18px;
317
- border-radius: 22px;
318
- background: rgba(255, 255, 255, 0.05);
319
  border: 1px solid var(--line);
320
  }
321
 
322
- .section-meta span {
323
  display: block;
324
- color: var(--muted);
325
- font-size: 13px;
326
- margin-bottom: 8px;
327
- }
328
-
329
- .section-meta strong {
330
  font-family: var(--font-display);
331
  font-size: 20px;
332
  }
333
 
334
- .health-grid,
335
- .provider-grid {
336
  display: grid;
337
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
338
- gap: 18px;
 
 
339
  }
340
 
341
- .health-card,
342
- .provider-card {
343
- padding: 22px;
344
- transition: transform 0.26s ease, border-color 0.26s ease, box-shadow 0.26s ease;
345
  }
346
 
347
- .health-card:hover,
348
- .provider-card:hover,
349
- .overview-card:hover {
350
- transform: translateY(-4px);
351
- border-color: rgba(255, 255, 255, 0.16);
352
- box-shadow: var(--glow);
353
  }
354
 
355
- .card-head,
356
- .provider-head {
357
  display: flex;
358
- justify-content: space-between;
359
- gap: 12px;
360
- align-items: flex-start;
361
  }
362
 
363
- .health-card h3,
364
- .provider-head h3 {
365
- margin: 0;
366
- font-size: 22px;
 
 
 
367
  }
368
 
369
- .health-card p,
370
- .provider-head p {
371
- margin: 8px 0 0;
 
 
 
 
 
 
372
  }
373
 
374
- .status-pill {
375
- padding: 8px 12px;
 
 
376
  border-radius: 999px;
377
- font-size: 12px;
378
- font-weight: 700;
379
- border: 1px solid transparent;
 
 
 
 
 
 
 
 
 
 
380
  }
381
 
382
- .scoreboard {
 
 
 
 
 
 
 
 
 
 
 
383
  display: grid;
384
- grid-template-columns: repeat(3, minmax(0, 1fr));
385
  gap: 12px;
386
- margin-top: 18px;
387
  }
388
 
389
- .score-item {
390
- padding: 14px 16px;
391
- border-radius: 18px;
392
- background: rgba(255, 255, 255, 0.045);
393
- border: 1px solid rgba(255, 255, 255, 0.08);
 
394
  }
395
 
396
- .score-item span {
397
- display: block;
398
- color: var(--muted);
399
- font-size: 12px;
400
  }
401
 
402
- .score-item strong {
403
- display: block;
404
- margin-top: 10px;
405
- font-family: var(--font-display);
406
- font-size: 18px;
407
  }
408
 
409
- .timeline-strip {
410
- display: grid;
411
- grid-template-columns: repeat(auto-fit, minmax(70px, 1fr));
412
- gap: 10px;
413
- margin-top: 18px;
414
  }
415
 
416
- .timeline-box {
417
- padding: 12px 10px;
418
- border-radius: 16px;
419
- border: 1px solid rgba(255, 255, 255, 0.08);
420
- text-align: center;
421
- background: rgba(255, 255, 255, 0.04);
422
  }
423
 
424
- .timeline-box span {
425
- display: block;
426
- color: var(--muted);
427
- font-size: 12px;
428
  }
429
 
430
- .timeline-box strong {
431
- display: block;
432
- margin-top: 8px;
433
- font-family: var(--font-display);
434
- font-size: 18px;
 
 
 
 
 
 
 
 
 
 
 
435
  }
436
 
437
- .model-chip-wrap {
 
 
 
 
 
 
 
 
 
 
 
438
  display: flex;
 
 
 
439
  flex-wrap: wrap;
440
- gap: 10px;
441
- margin-top: 18px;
442
  }
443
 
444
- .model-chip {
445
- display: inline-flex;
446
- align-items: center;
447
- padding: 10px 14px;
448
- border-radius: 999px;
449
- background: rgba(255, 255, 255, 0.05);
450
- border: 1px solid rgba(255, 255, 255, 0.08);
451
- color: var(--muted-strong);
452
- font-size: 13px;
453
  }
454
 
455
- .panel-hint {
456
- margin-top: 16px;
 
 
457
  }
458
 
459
- .is-green {
460
- border-color: rgba(56, 243, 165, 0.26);
 
 
 
461
  }
462
 
463
- .is-green .status-pill,
464
- .is-green.timeline-box,
465
- .timeline-box.is-green {
466
- background: rgba(56, 243, 165, 0.16);
467
- color: #8affd0;
468
- border-color: rgba(56, 243, 165, 0.3);
469
  }
470
 
471
- .is-yellow {
472
- border-color: rgba(255, 210, 77, 0.22);
 
 
 
473
  }
474
 
475
- .is-yellow .status-pill,
476
- .is-yellow.timeline-box,
477
- .timeline-box.is-yellow {
478
- background: rgba(255, 210, 77, 0.14);
479
- color: #ffe59b;
480
- border-color: rgba(255, 210, 77, 0.28);
481
  }
482
 
483
- .is-orange {
484
- border-color: rgba(255, 155, 67, 0.26);
 
 
 
485
  }
486
 
487
- .is-orange .status-pill,
488
- .is-orange.timeline-box,
489
- .timeline-box.is-orange {
490
- background: rgba(255, 155, 67, 0.15);
491
- color: #ffc48a;
492
- border-color: rgba(255, 155, 67, 0.3);
493
  }
494
 
495
- .is-red {
496
- border-color: rgba(255, 99, 127, 0.28);
497
  }
498
 
499
- .is-red .status-pill,
500
- .is-red.timeline-box,
501
- .timeline-box.is-red {
502
- background: rgba(255, 99, 127, 0.16);
503
- color: #ffbbca;
504
- border-color: rgba(255, 99, 127, 0.32);
 
505
  }
506
 
507
- .is-idle .status-pill,
508
- .is-idle.timeline-box,
509
- .timeline-box.is-idle {
510
- background: rgba(94, 109, 136, 0.15);
511
- color: #c5d0df;
512
- border-color: rgba(94, 109, 136, 0.26);
 
 
 
 
 
 
513
  }
514
 
515
- .hidden {
516
- display: none !important;
 
 
 
 
 
 
 
 
517
  }
518
 
519
  @media (max-width: 980px) {
520
- .topbar,
521
- .showcase-shell {
522
- width: min(100vw - 24px, 1360px);
 
 
 
 
 
523
  }
524
 
525
- .hero-stage,
526
- .catalog-stage {
 
 
 
527
  grid-template-columns: 1fr;
 
 
 
 
 
528
  }
529
 
530
- .hero-metrics,
531
- .catalog-summary,
532
- .scoreboard {
533
- grid-template-columns: repeat(2, minmax(0, 1fr));
534
  }
535
  }
536
 
537
  @media (max-width: 720px) {
538
- .topbar {
539
- flex-direction: column;
540
- align-items: stretch;
 
 
 
 
 
 
 
 
 
541
  }
542
 
543
- .page-nav {
 
544
  width: 100%;
545
- justify-content: space-between;
546
  }
547
 
548
- .page-nav-btn {
549
- flex: 1;
 
 
 
550
  }
551
 
552
- .page-section {
553
- padding-right: 10px;
554
  }
555
 
556
- .hero-stage,
557
- .panel-shell {
558
- padding: 20px;
559
  }
560
 
561
- .hero-metrics,
562
- .catalog-summary,
563
- .health-grid,
564
- .provider-grid,
565
- .scoreboard,
566
- .timeline-strip {
567
- grid-template-columns: 1fr;
568
  }
569
  }
 
1
  :root {
2
+ --bg: #f8fbfd;
3
+ --surface: #ffffff;
4
+ --surface-soft: #f1f8fb;
5
+ --surface-muted: #eef6f9;
6
+ --line: #deedf3;
7
+ --line-strong: #c8e5ef;
8
+ --text: #3d4652;
9
+ --muted: #7b8a97;
10
+ --cyan: #57c8df;
11
+ --cyan-deep: #46bfd8;
12
+ --green: #69c36c;
13
+ --yellow: #a8cf6d;
14
+ --orange: #f0ae46;
15
+ --red: #f06d6f;
16
+ --shadow: 0 20px 42px rgba(109, 153, 177, 0.12);
 
 
17
  --font-sans: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
18
  --font-display: "Space Grotesk", "Noto Sans SC", sans-serif;
 
19
  }
20
 
21
  * {
 
32
  font-family: var(--font-sans);
33
  color: var(--text);
34
  background:
35
+ radial-gradient(circle at top left, rgba(105, 195, 108, 0.08), transparent 22%),
36
+ radial-gradient(circle at top right, rgba(87, 200, 223, 0.12), transparent 20%),
37
+ linear-gradient(180deg, #fbfdfe 0%, #f6fbfd 100%);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  }
39
 
40
+ .health-page-shell,
41
+ .catalog-page-shell {
42
+ width: min(1560px, calc(100vw - 56px));
 
 
43
  margin: 0 auto;
44
+ padding: 26px 0 44px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  }
46
 
47
+ .health-topbar,
48
+ .catalog-topbar,
49
+ .health-summary-grid,
50
+ .health-board-panel,
51
+ .catalog-summary-grid,
52
+ .catalog-filter-panel,
53
+ .catalog-board-panel,
54
+ .summary-card,
 
 
 
 
 
 
 
 
 
55
  .provider-card {
56
+ background: var(--surface);
 
57
  border: 1px solid var(--line);
 
58
  box-shadow: var(--shadow);
 
 
59
  }
60
 
61
+ .health-topbar,
62
+ .catalog-topbar,
63
+ .health-board-panel,
64
+ .catalog-filter-panel,
65
+ .catalog-board-panel {
66
+ border-radius: 28px;
 
 
 
 
67
  }
68
 
69
+ .health-topbar,
70
+ .catalog-topbar {
71
+ display: flex;
72
+ justify-content: space-between;
73
+ align-items: flex-start;
74
+ gap: 20px;
75
+ padding: 26px 30px;
76
  }
77
 
78
+ .health-topbar h1,
79
+ .catalog-topbar h1,
80
+ .board-header-row h2,
81
+ .health-row-copy h3,
82
+ .provider-card h3 {
83
+ font-family: var(--font-display);
84
  }
85
 
86
+ .health-topbar h1,
87
+ .catalog-topbar h1 {
88
+ margin: 12px 0 10px;
89
+ font-size: clamp(30px, 2.5vw, 44px);
90
+ line-height: 1.1;
91
  }
92
 
93
+ .health-topbar p,
94
+ .catalog-topbar p,
 
 
95
  .panel-hint,
96
+ .provider-card p,
97
+ .summary-card p,
98
+ .timeline-label,
99
+ .board-meta-box span {
100
  color: var(--muted);
 
101
  }
102
 
103
+ .topbar-actions {
 
104
  display: flex;
105
  gap: 14px;
106
+ align-items: center;
107
  flex-wrap: wrap;
108
  }
109
 
110
+ .mini-badge {
111
+ display: inline-flex;
112
+ align-items: center;
113
+ padding: 8px 14px;
114
+ border-radius: 999px;
115
+ background: rgba(87, 200, 223, 0.12);
116
+ color: var(--cyan-deep);
117
+ border: 1px solid rgba(87, 200, 223, 0.26);
118
+ font-size: 13px;
119
  font-weight: 700;
120
+ letter-spacing: 0.05em;
121
  }
122
 
123
+ .mini-badge.subtle {
124
+ background: var(--surface-soft);
125
+ color: var(--muted);
126
+ border-color: var(--line);
127
  }
128
 
129
+ .text-nav-link,
130
+ .refresh-link,
131
+ .provider-filter-btn {
132
+ appearance: none;
133
+ border: 1px solid var(--line-strong);
134
+ background: var(--surface);
135
  color: var(--text);
136
+ text-decoration: none;
137
+ border-radius: 999px;
138
+ padding: 11px 16px;
139
+ font: inherit;
140
+ cursor: pointer;
141
+ }
142
+
143
+ .text-nav-link:hover,
144
+ .refresh-link:hover,
145
+ .provider-filter-btn:hover {
146
+ border-color: var(--cyan);
147
+ color: var(--cyan-deep);
148
  }
149
 
150
+ .health-summary-grid,
151
+ .catalog-summary-grid {
152
+ margin-top: 18px;
153
  display: grid;
154
+ grid-template-columns: repeat(4, minmax(0, 1fr));
155
+ gap: 16px;
156
+ background: transparent;
157
+ border: none;
158
+ box-shadow: none;
159
  }
160
 
161
+ .summary-card {
162
+ border-radius: 22px;
163
  padding: 18px 20px;
164
  }
165
 
166
+ .summary-card span {
167
+ display: block;
168
  color: var(--muted);
169
  font-size: 13px;
170
  }
171
 
172
+ .summary-card strong {
173
  display: block;
174
  margin: 10px 0 8px;
175
  font-family: var(--font-display);
176
+ font-size: 28px;
177
  }
178
 
179
+ .health-board-panel,
180
+ .catalog-filter-panel,
181
+ .catalog-board-panel {
182
+ margin-top: 18px;
183
+ padding: 26px 28px;
184
  }
185
 
186
+ .board-header-row {
187
  display: flex;
188
  justify-content: space-between;
189
  align-items: flex-start;
190
  gap: 18px;
191
  flex-wrap: wrap;
192
+ margin-bottom: 18px;
193
  }
194
 
195
+ .board-header-row h2 {
196
  margin: 12px 0 0;
197
+ font-size: clamp(24px, 2vw, 34px);
198
  }
199
 
200
+ .board-meta-box {
201
+ min-width: 200px;
202
+ padding: 14px 16px;
203
+ border-radius: 20px;
204
+ background: var(--surface-soft);
205
  border: 1px solid var(--line);
206
  }
207
 
208
+ .board-meta-box strong {
209
  display: block;
210
+ margin-top: 8px;
 
 
 
 
 
211
  font-family: var(--font-display);
212
  font-size: 20px;
213
  }
214
 
215
+ .timeline-header {
 
216
  display: grid;
217
+ grid-template-columns: 360px repeat(6, minmax(74px, 1fr));
218
+ gap: 12px;
219
+ align-items: center;
220
+ margin: 10px 0 12px;
221
  }
222
 
223
+ .timeline-label {
224
+ text-align: center;
225
+ font-size: 13px;
 
226
  }
227
 
228
+ .timeline-left-placeholder {
229
+ text-align: left;
 
 
 
 
230
  }
231
 
232
+ .health-list {
 
233
  display: flex;
234
+ flex-direction: column;
235
+ gap: 16px;
 
236
  }
237
 
238
+ .health-row-card {
239
+ display: grid;
240
+ grid-template-columns: 360px minmax(0, 1fr);
241
+ gap: 18px;
242
+ align-items: center;
243
+ padding: 18px 0;
244
+ border-bottom: 1px solid var(--line);
245
  }
246
 
247
+ .health-row-card:last-child {
248
+ border-bottom: none;
249
+ }
250
+
251
+ .health-row-summary {
252
+ display: flex;
253
+ align-items: center;
254
+ gap: 16px;
255
+ min-width: 0;
256
  }
257
 
258
+ .health-row-accent {
259
+ width: 22px;
260
+ min-width: 22px;
261
+ height: 92px;
262
  border-radius: 999px;
263
+ background: var(--cyan);
264
+ }
265
+
266
+ .health-row-copy {
267
+ min-width: 0;
268
+ }
269
+
270
+ .health-row-copy h3 {
271
+ margin: 0;
272
+ font-size: 26px;
273
+ white-space: nowrap;
274
+ overflow: hidden;
275
+ text-overflow: ellipsis;
276
  }
277
 
278
+ .health-rate-pill {
279
+ display: inline-flex;
280
+ align-items: center;
281
+ margin-top: 12px;
282
+ padding: 9px 14px;
283
+ border-radius: 14px;
284
+ font-weight: 800;
285
+ font-size: 15px;
286
+ background: var(--surface-soft);
287
+ }
288
+
289
+ .health-cells {
290
  display: grid;
291
+ grid-template-columns: repeat(6, minmax(74px, 1fr));
292
  gap: 12px;
293
+ align-items: center;
294
  }
295
 
296
+ .health-cell {
297
+ width: 100%;
298
+ aspect-ratio: 1 / 1;
299
+ border-radius: 14px;
300
+ border: 4px solid transparent;
301
+ background: var(--surface-muted);
302
  }
303
 
304
+ .cell-cyan {
305
+ border-color: var(--cyan);
306
+ background: rgba(87, 200, 223, 0.12);
 
307
  }
308
 
309
+ .cell-green {
310
+ border-color: var(--green);
311
+ background: rgba(105, 195, 108, 0.1);
 
 
312
  }
313
 
314
+ .cell-orange {
315
+ border-color: var(--orange);
316
+ background: rgba(240, 174, 70, 0.1);
 
 
317
  }
318
 
319
+ .cell-red {
320
+ border-color: var(--red);
321
+ background: rgba(240, 109, 111, 0.1);
 
 
 
322
  }
323
 
324
+ .cell-empty {
325
+ border-color: transparent;
326
+ background: var(--surface-muted);
 
327
  }
328
 
329
+ .status-cyan,
330
+ .status-cyan.health-row-accent {
331
+ color: var(--cyan-deep);
332
+ background-color: var(--cyan);
333
+ }
334
+
335
+ .status-green,
336
+ .status-green.health-row-accent {
337
+ color: var(--green);
338
+ background-color: var(--green);
339
+ }
340
+
341
+ .status-orange,
342
+ .status-orange.health-row-accent {
343
+ color: var(--orange);
344
+ background-color: var(--orange);
345
  }
346
 
347
+ .status-red,
348
+ .status-red.health-row-accent {
349
+ color: var(--red);
350
+ background-color: var(--red);
351
+ }
352
+
353
+ .health-rate-pill.status-cyan { color: var(--cyan-deep); background: rgba(87, 200, 223, 0.14); }
354
+ .health-rate-pill.status-green { color: var(--green); background: rgba(105, 195, 108, 0.14); }
355
+ .health-rate-pill.status-orange { color: var(--orange); background: rgba(240, 174, 70, 0.14); }
356
+ .health-rate-pill.status-red { color: var(--red); background: rgba(240, 109, 111, 0.14); }
357
+
358
+ .provider-filter-panel {
359
  display: flex;
360
+ justify-content: space-between;
361
+ align-items: center;
362
+ gap: 18px;
363
  flex-wrap: wrap;
 
 
364
  }
365
 
366
+ .provider-filter-panel h2 {
367
+ margin: 12px 0 0;
368
+ font-family: var(--font-display);
369
+ font-size: clamp(22px, 2vw, 30px);
 
 
 
 
 
370
  }
371
 
372
+ .provider-filter-bar {
373
+ display: flex;
374
+ gap: 10px;
375
+ flex-wrap: wrap;
376
  }
377
 
378
+ .provider-filter-btn.active {
379
+ background: rgba(87, 200, 223, 0.12);
380
+ border-color: rgba(87, 200, 223, 0.3);
381
+ color: var(--cyan-deep);
382
+ font-weight: 700;
383
  }
384
 
385
+ .provider-grid {
386
+ display: grid;
387
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
388
+ gap: 18px;
 
 
389
  }
390
 
391
+ .provider-card {
392
+ border-radius: 24px;
393
+ padding: 20px;
394
+ display: flex;
395
+ flex-direction: column;
396
  }
397
 
398
+ .provider-card-fixed {
399
+ height: 320px;
 
 
 
 
400
  }
401
 
402
+ .provider-card-head {
403
+ display: flex;
404
+ justify-content: space-between;
405
+ align-items: flex-start;
406
+ gap: 12px;
407
  }
408
 
409
+ .provider-card h3 {
410
+ margin: 0;
411
+ font-size: 24px;
 
 
 
412
  }
413
 
414
+ .provider-card p {
415
+ margin: 10px 0 0;
416
  }
417
 
418
+ .provider-model-list {
419
+ margin-top: 18px;
420
+ display: flex;
421
+ flex-direction: column;
422
+ gap: 10px;
423
+ overflow-y: auto;
424
+ padding-right: 4px;
425
  }
426
 
427
+ .provider-model-chip {
428
+ display: block;
429
+ padding: 11px 14px;
430
+ border-radius: 16px;
431
+ background: var(--surface-soft);
432
+ border: 1px solid var(--line);
433
+ font-family: var(--font-display);
434
+ font-size: 13px;
435
+ color: var(--text);
436
+ white-space: nowrap;
437
+ overflow: hidden;
438
+ text-overflow: ellipsis;
439
  }
440
 
441
+ .panel-hint {
442
+ margin-top: 16px;
443
+ }
444
+
445
+ @media (max-width: 1200px) {
446
+ .timeline-header,
447
+ .health-row-card,
448
+ .health-cells {
449
+ grid-template-columns: 300px repeat(6, minmax(60px, 1fr));
450
+ }
451
  }
452
 
453
  @media (max-width: 980px) {
454
+ .health-page-shell,
455
+ .catalog-page-shell {
456
+ width: min(100vw - 24px, 1560px);
457
+ }
458
+
459
+ .health-summary-grid,
460
+ .catalog-summary-grid {
461
+ grid-template-columns: repeat(2, minmax(0, 1fr));
462
  }
463
 
464
+ .timeline-header {
465
+ display: none;
466
+ }
467
+
468
+ .health-row-card {
469
  grid-template-columns: 1fr;
470
+ gap: 14px;
471
+ padding: 18px;
472
+ border: 1px solid var(--line);
473
+ border-radius: 24px;
474
+ background: var(--surface);
475
  }
476
 
477
+ .health-cells {
478
+ grid-template-columns: repeat(6, minmax(40px, 1fr));
 
 
479
  }
480
  }
481
 
482
  @media (max-width: 720px) {
483
+ .health-topbar,
484
+ .catalog-topbar,
485
+ .health-board-panel,
486
+ .catalog-filter-panel,
487
+ .catalog-board-panel {
488
+ padding: 20px;
489
+ }
490
+
491
+ .health-summary-grid,
492
+ .catalog-summary-grid,
493
+ .provider-grid {
494
+ grid-template-columns: 1fr;
495
  }
496
 
497
+ .topbar-actions,
498
+ .provider-filter-bar {
499
  width: 100%;
 
500
  }
501
 
502
+ .text-nav-link,
503
+ .refresh-link,
504
+ .provider-filter-btn {
505
+ width: 100%;
506
+ text-align: center;
507
  }
508
 
509
+ .health-row-summary {
510
+ align-items: flex-start;
511
  }
512
 
513
+ .health-row-accent {
514
+ height: 72px;
 
515
  }
516
 
517
+ .health-row-copy h3 {
518
+ font-size: 22px;
519
+ }
520
+
521
+ .health-cells {
522
+ grid-template-columns: repeat(3, minmax(54px, 1fr));
 
523
  }
524
  }