Upload 12 files
Browse files- README.md +20 -13
- app/main.py +35 -3
- static/index.html +31 -79
- static/models.html +58 -0
- static/models.js +120 -0
- static/public.js +68 -162
- 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 |
-
-
|
| 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 |
-
|
| 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`:
|
| 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 |
-
- 通过
|
| 76 |
|
| 77 |
2. 真实上游联调:
|
| 78 |
-
- 通过
|
| 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 |
-
|
| 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
|
| 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 模型健康
|
| 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="
|
| 17 |
-
<
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
| 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 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
<
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 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
|
| 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: "暂无数据",
|
| 41 |
-
if (rate >= 95) return { label: "优秀",
|
| 42 |
-
if (rate >= 80) return { label: "良好",
|
| 43 |
-
if (rate >= 50) return { label: "告警",
|
| 44 |
-
return { label: "异常",
|
| 45 |
}
|
| 46 |
|
| 47 |
-
function
|
| 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 = "
|
| 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(
|
| 88 |
-
const
|
| 89 |
-
const
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
overviewCards.appendChild(
|
| 94 |
-
overviewCards.appendChild(
|
| 95 |
-
overviewCards.appendChild(
|
| 96 |
-
overviewCards.appendChild(createOverviewCard("统计粒度", `${data.bucket_minutes} 分钟`, `已展示最近 ${data.models?.[0]?.points?.length || 0} 个时间片`));
|
| 97 |
dashboardUpdated.textContent = formatDateTime(data.generated_at);
|
| 98 |
}
|
| 99 |
|
| 100 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
healthGrid.innerHTML = "";
|
| 102 |
if (!models || models.length === 0) {
|
| 103 |
-
dashboardEmpty.textContent = "当前
|
| 104 |
return;
|
| 105 |
}
|
| 106 |
dashboardEmpty.textContent = "";
|
|
|
|
| 107 |
|
| 108 |
models.forEach((model) => {
|
|
|
|
| 109 |
const latestMeta = rateMeta(model.latest_success_rate);
|
| 110 |
-
|
| 111 |
-
const
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
</
|
| 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
|
| 140 |
-
|
| 141 |
(model.points || []).forEach((point) => {
|
| 142 |
const meta = rateMeta(point.success_rate);
|
| 143 |
-
const
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 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 |
-
|
| 161 |
-
|
| 162 |
-
|
| 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)
|
|
|
|
|
|
|
| 195 |
const payload = await response.json();
|
| 196 |
renderOverview(payload);
|
| 197 |
-
|
| 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
|
| 210 |
try {
|
| 211 |
-
await
|
| 212 |
} catch (error) {
|
| 213 |
dashboardEmpty.textContent = error.message;
|
| 214 |
-
|
|
|
|
| 215 |
}
|
| 216 |
}
|
| 217 |
|
| 218 |
-
refreshDashboardBtn?.addEventListener("click",
|
| 219 |
|
| 220 |
window.addEventListener("DOMContentLoaded", () => {
|
| 221 |
-
|
| 222 |
-
|
| 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: #
|
| 3 |
-
--
|
| 4 |
-
--
|
| 5 |
-
--
|
| 6 |
-
--
|
| 7 |
-
--line:
|
| 8 |
-
--
|
| 9 |
-
--
|
| 10 |
-
--
|
| 11 |
-
--
|
| 12 |
-
--green: #
|
| 13 |
-
--yellow: #
|
| 14 |
-
--orange: #
|
| 15 |
-
--red: #
|
| 16 |
-
--
|
| 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
|
| 39 |
-
radial-gradient(circle at
|
| 40 |
-
linear-gradient(180deg, #
|
| 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 |
-
.
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
z-index: 30;
|
| 92 |
-
width: min(1360px, calc(100vw - 32px));
|
| 93 |
margin: 0 auto;
|
| 94 |
-
padding:
|
| 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 |
-
.
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 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 |
-
|
| 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 |
-
.
|
| 206 |
-
.
|
| 207 |
-
.health-
|
| 208 |
-
.
|
| 209 |
-
.
|
| 210 |
-
|
| 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 |
-
.
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
| 222 |
}
|
| 223 |
|
| 224 |
-
.
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
}
|
| 227 |
|
| 228 |
-
.
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
| 232 |
}
|
| 233 |
|
| 234 |
-
.
|
| 235 |
-
.
|
| 236 |
-
.brand-mark strong,
|
| 237 |
-
.provider-head p,
|
| 238 |
.panel-hint,
|
| 239 |
-
.
|
| 240 |
-
.
|
|
|
|
|
|
|
| 241 |
color: var(--muted);
|
| 242 |
-
line-height: 1.75;
|
| 243 |
}
|
| 244 |
|
| 245 |
-
.
|
| 246 |
-
margin-top: 28px;
|
| 247 |
display: flex;
|
| 248 |
gap: 14px;
|
|
|
|
| 249 |
flex-wrap: wrap;
|
| 250 |
}
|
| 251 |
|
| 252 |
-
.
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
font-weight: 700;
|
|
|
|
| 257 |
}
|
| 258 |
|
| 259 |
-
.
|
| 260 |
-
background:
|
| 261 |
-
color:
|
| 262 |
-
|
| 263 |
}
|
| 264 |
|
| 265 |
-
.
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
color: var(--text);
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
}
|
| 270 |
|
| 271 |
-
.
|
| 272 |
-
.catalog-summary {
|
|
|
|
| 273 |
display: grid;
|
| 274 |
-
grid-template-columns: repeat(
|
| 275 |
-
gap:
|
| 276 |
-
|
|
|
|
|
|
|
| 277 |
}
|
| 278 |
|
| 279 |
-
.
|
|
|
|
| 280 |
padding: 18px 20px;
|
| 281 |
}
|
| 282 |
|
| 283 |
-
.
|
|
|
|
| 284 |
color: var(--muted);
|
| 285 |
font-size: 13px;
|
| 286 |
}
|
| 287 |
|
| 288 |
-
.
|
| 289 |
display: block;
|
| 290 |
margin: 10px 0 8px;
|
| 291 |
font-family: var(--font-display);
|
| 292 |
-
font-size:
|
| 293 |
}
|
| 294 |
|
| 295 |
-
.
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
| 298 |
}
|
| 299 |
|
| 300 |
-
.
|
| 301 |
display: flex;
|
| 302 |
justify-content: space-between;
|
| 303 |
align-items: flex-start;
|
| 304 |
gap: 18px;
|
| 305 |
flex-wrap: wrap;
|
| 306 |
-
margin-bottom:
|
| 307 |
}
|
| 308 |
|
| 309 |
-
.
|
| 310 |
margin: 12px 0 0;
|
| 311 |
-
font-size: clamp(
|
| 312 |
}
|
| 313 |
|
| 314 |
-
.
|
| 315 |
-
min-width:
|
| 316 |
-
padding:
|
| 317 |
-
border-radius:
|
| 318 |
-
background:
|
| 319 |
border: 1px solid var(--line);
|
| 320 |
}
|
| 321 |
|
| 322 |
-
.
|
| 323 |
display: block;
|
| 324 |
-
|
| 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 |
-
.
|
| 335 |
-
.provider-grid {
|
| 336 |
display: grid;
|
| 337 |
-
grid-template-columns: repeat(
|
| 338 |
-
gap:
|
|
|
|
|
|
|
| 339 |
}
|
| 340 |
|
| 341 |
-
.
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
transition: transform 0.26s ease, border-color 0.26s ease, box-shadow 0.26s ease;
|
| 345 |
}
|
| 346 |
|
| 347 |
-
.
|
| 348 |
-
|
| 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 |
-
.
|
| 356 |
-
.provider-head {
|
| 357 |
display: flex;
|
| 358 |
-
|
| 359 |
-
gap:
|
| 360 |
-
align-items: flex-start;
|
| 361 |
}
|
| 362 |
|
| 363 |
-
.health-card
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
| 367 |
}
|
| 368 |
|
| 369 |
-
.health-card
|
| 370 |
-
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
}
|
| 373 |
|
| 374 |
-
.
|
| 375 |
-
|
|
|
|
|
|
|
| 376 |
border-radius: 999px;
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
}
|
| 381 |
|
| 382 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
display: grid;
|
| 384 |
-
grid-template-columns: repeat(
|
| 385 |
gap: 12px;
|
| 386 |
-
|
| 387 |
}
|
| 388 |
|
| 389 |
-
.
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
border:
|
|
|
|
| 394 |
}
|
| 395 |
|
| 396 |
-
.
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
font-size: 12px;
|
| 400 |
}
|
| 401 |
|
| 402 |
-
.
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
font-family: var(--font-display);
|
| 406 |
-
font-size: 18px;
|
| 407 |
}
|
| 408 |
|
| 409 |
-
.
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
gap: 10px;
|
| 413 |
-
margin-top: 18px;
|
| 414 |
}
|
| 415 |
|
| 416 |
-
.
|
| 417 |
-
|
| 418 |
-
|
| 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 |
-
.
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
font-size: 12px;
|
| 428 |
}
|
| 429 |
|
| 430 |
-
.
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
}
|
| 436 |
|
| 437 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
display: flex;
|
|
|
|
|
|
|
|
|
|
| 439 |
flex-wrap: wrap;
|
| 440 |
-
gap: 10px;
|
| 441 |
-
margin-top: 18px;
|
| 442 |
}
|
| 443 |
|
| 444 |
-
.
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 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 |
-
.
|
| 456 |
-
|
|
|
|
|
|
|
| 457 |
}
|
| 458 |
|
| 459 |
-
.
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
| 461 |
}
|
| 462 |
|
| 463 |
-
.
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
color: #8affd0;
|
| 468 |
-
border-color: rgba(56, 243, 165, 0.3);
|
| 469 |
}
|
| 470 |
|
| 471 |
-
.
|
| 472 |
-
border-
|
|
|
|
|
|
|
|
|
|
| 473 |
}
|
| 474 |
|
| 475 |
-
.
|
| 476 |
-
|
| 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 |
-
.
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
| 485 |
}
|
| 486 |
|
| 487 |
-
.
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
background: rgba(255, 155, 67, 0.15);
|
| 491 |
-
color: #ffc48a;
|
| 492 |
-
border-color: rgba(255, 155, 67, 0.3);
|
| 493 |
}
|
| 494 |
|
| 495 |
-
.
|
| 496 |
-
|
| 497 |
}
|
| 498 |
|
| 499 |
-
.
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
|
|
|
| 505 |
}
|
| 506 |
|
| 507 |
-
.
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
border
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
}
|
| 514 |
|
| 515 |
-
.
|
| 516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
}
|
| 518 |
|
| 519 |
@media (max-width: 980px) {
|
| 520 |
-
.
|
| 521 |
-
.
|
| 522 |
-
width: min(100vw - 24px,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
}
|
| 524 |
|
| 525 |
-
.
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
| 527 |
grid-template-columns: 1fr;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
}
|
| 529 |
|
| 530 |
-
.
|
| 531 |
-
|
| 532 |
-
.scoreboard {
|
| 533 |
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 534 |
}
|
| 535 |
}
|
| 536 |
|
| 537 |
@media (max-width: 720px) {
|
| 538 |
-
.topbar
|
| 539 |
-
|
| 540 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
}
|
| 542 |
|
| 543 |
-
.
|
|
|
|
| 544 |
width: 100%;
|
| 545 |
-
justify-content: space-between;
|
| 546 |
}
|
| 547 |
|
| 548 |
-
.
|
| 549 |
-
|
|
|
|
|
|
|
|
|
|
| 550 |
}
|
| 551 |
|
| 552 |
-
.
|
| 553 |
-
|
| 554 |
}
|
| 555 |
|
| 556 |
-
.
|
| 557 |
-
|
| 558 |
-
padding: 20px;
|
| 559 |
}
|
| 560 |
|
| 561 |
-
.
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
.
|
| 566 |
-
|
| 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 |
}
|