2 Commits
Author SHA1 Message Date
m1saka c8938cb514 Add available-models admin tab with live model list and latency probing
build / build (push) Successful in 2m29s
2026-08-19 20:23:21 +08:00
root 8a1cf4d61f Redesign admin UI: unified tabbed console, humanized and paginated stats
build / build (push) Successful in 2m32s
- Unify /admin/login and /admin/stats into a single /admin page with tabs
  (Token stats default, credential login); old paths redirect to /admin.
- Restyle from dark glass-morphism to a clean light theme (single accent,
  system font stack, 1px-bordered cards, generous whitespace).
- Humanize token counts with K/M/B suffixes via a humanNum template func.
- Paginate recent requests client-side (10 per page) with a compact pager.
- Drop the database path from the UI.
- Fix mobile horizontal overflow caused by grid min-width:auto.
2026-08-19 17:12:30 +08:00
4 changed files with 819 additions and 451 deletions
+1
View File
@@ -13,3 +13,4 @@ extension/
tmp/ tmp/
temp/ temp/
source/ source/
output/
+17
View File
@@ -11,6 +11,7 @@
- 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。 - 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
- OpenAI 函数/工具调用:支持 `tools``tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。 - OpenAI 函数/工具调用:支持 `tools``tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。
- Token 消耗统计:流式与非流式请求均解析上游 `usage`,按模型/按日/最近明细写入本地 SQLite(`zhanlu.db`),凭据也一并持久化在同一个库中。管理页提供 `GET /admin/stats` 可视化与 `GET /api/stats` JSON 接口。 - Token 消耗统计:流式与非流式请求均解析上游 `usage`,按模型/按日/最近明细写入本地 SQLite(`zhanlu.db`),凭据也一并持久化在同一个库中。管理页提供 `GET /admin/stats` 可视化与 `GET /api/stats` JSON 接口。
- 可用模型:管理后台新增「可用模型」tab,实时拉取上游 `/gateway/v1/model/info` 返回的模型列表,并提供单模型可用性及首字延时(TTFT)探测(`GET /api/models``POST /api/models/test`)。
## 运行 ## 运行
@@ -234,6 +235,22 @@ curl http://127.0.0.1:8080/v1/models `
设置 `ZHANLU_STATS_DISABLED=true` 可停止写入统计。 设置 `ZHANLU_STATS_DISABLED=true` 可停止写入统计。
## 可用模型
管理后台「可用模型」tab(位于「Token 统计」之后)实时展示当前上游接口返回的模型列表,并提供单模型可用性与延时探测:
- 进入 tab 时自动拉取一次,也可随时点击「刷新」重新获取。
- 每个模型行可单独「测试」,或点击「全部测试」依次探测所有模型。
- 探测向上游 `/chat/completions` 发送一条极简流式请求(`hi`),测量首字延时(TTFT,首个 SSE 数据块到达时间)与总耗时,并在收到首个内容块后立即关闭连接,避免消耗额外 token。
- 探测请求不计入 Token 统计。
对应 JSON 接口(需先登录管理页面):
- `GET /api/models`:实时返回上游模型列表 `{"ok":true,"models":["GLM-4.7",...]}`
- `POST /api/models/test`:请求体 `{"model":"<model_id>"}`,返回 `{"ok":true,"model":"...","available":true,"ttft_ms":123,"total_ms":456}``{"ok":true,"model":"...","available":false,"error":"...","total_ms":789}`
探测超时上限为 30 秒(`modelTestTimeout`);凭据未配置或 API Key 缺失时 `GET /api/models``POST /api/models/test` 会按需自动换取 API Key,与聊天接口一致。
## 安全说明 ## 安全说明
- `zhanlu.db` 数据库包含明文 `AccessKey``SecretKey``Token``apiKey`,请不要提交到仓库。 - `zhanlu.db` 数据库包含明文 `AccessKey``SecretKey``Token``apiKey`,请不要提交到仓库。
+707 -451
View File
File diff suppressed because it is too large Load Diff
+94
View File
@@ -271,6 +271,100 @@ func TestStats(t *testing.T) {
} }
} }
// TestAdminModels verifies the admin /api/models endpoint returns the live
// model list advertised by the upstream gateway model-info endpoint.
func TestAdminModels(t *testing.T) {
upstream, proxy, st := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
creds := auth.Credentials{
AccessKey: "AK", SecretKey: "SK", Token: "TOKEN",
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
}
if err := st.SaveCredentials(creds); err != nil {
t.Fatal(err)
}
resp, err := http.Get(proxy.URL + "/api/models")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var data map[string]any
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
t.Fatal(err)
}
if ok, _ := data["ok"].(bool); !ok {
t.Fatalf("models endpoint not ok: %v", data)
}
models, _ := data["models"].([]any)
if len(models) != 2 {
t.Fatalf("models = %v, want 2", models)
}
}
// TestModelTest verifies the admin /api/models/test endpoint probes a model and
// reports availability plus latency against the mock streaming upstream.
func TestModelTest(t *testing.T) {
upstream, proxy, st := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
creds := auth.Credentials{
AccessKey: "AK", SecretKey: "SK", Token: "TOKEN",
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
}
if err := st.SaveCredentials(creds); err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{"model": "GLM-4.7"})
resp, err := http.Post(proxy.URL+"/api/models/test", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var data map[string]any
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
t.Fatal(err)
}
if ok, _ := data["ok"].(bool); !ok {
t.Fatalf("test endpoint not ok: %v", data)
}
if available, _ := data["available"].(bool); !available {
t.Fatalf("model should be available: %v", data)
}
if _, ok := data["ttft_ms"]; !ok {
t.Fatalf("ttft_ms should be present: %v", data)
}
}
// TestModelTestNoCredentials verifies the test endpoint reports unavailable
// gracefully when no credentials are configured, instead of erroring.
func TestModelTestNoCredentials(t *testing.T) {
upstream, proxy, _ := setupTestServer(t)
defer upstream.Close()
defer proxy.Close()
body, _ := json.Marshal(map[string]string{"model": "GLM-4.7"})
resp, err := http.Post(proxy.URL+"/api/models/test", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var data map[string]any
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
t.Fatal(err)
}
if ok, _ := data["ok"].(bool); !ok {
t.Fatalf("test endpoint should stay ok: %v", data)
}
if available, _ := data["available"].(bool); available {
t.Fatalf("model should not be available without creds: %v", data)
}
}
// totNum extracts an int from a JSON-decoded numeric value (float64). // totNum extracts an int from a JSON-decoded numeric value (float64).
func totNum(v any) int { func totNum(v any) int {
f, _ := v.(float64) f, _ := v.(float64)