diff --git a/.gitignore b/.gitignore index e9b4404..1ce7795 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ extension/ tmp/ temp/ source/ +output/ diff --git a/internal/server/server.go b/internal/server/server.go index 721d21c..3653c78 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -47,7 +47,9 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /", s.index) s.mux.HandleFunc("GET /healthz", s.healthz) s.mux.HandleFunc("GET /login", s.loginPage) - s.mux.HandleFunc("GET /admin/login", s.adminLoginPage) + s.mux.HandleFunc("GET /admin", s.adminPage) + s.mux.HandleFunc("GET /admin/login", s.redirectAdmin) + s.mux.HandleFunc("GET /admin/stats", s.redirectAdmin) s.mux.HandleFunc("POST /api/login", s.passwordLogin) s.mux.HandleFunc("POST /api/logout", s.passwordLogout) s.mux.HandleFunc("GET /auth/start", s.withLoginSession(s.startSSO)) @@ -59,7 +61,6 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode)) s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats)) s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats)) - s.mux.HandleFunc("GET /admin/stats", s.withLoginSession(s.statsPage)) s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models)) s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions)) } @@ -91,24 +92,46 @@ func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc { func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { if s.hasLoginSession(r) { - http.Redirect(w, r, "/admin/login", http.StatusFound) + http.Redirect(w, r, "/admin", http.StatusFound) return } if s.cfg.LoginPassword == "" { - http.Redirect(w, r, "/admin/login", http.StatusFound) + http.Redirect(w, r, "/admin", http.StatusFound) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = loginTemplate.Execute(w, map[string]any{"DBPath": s.cfg.DBPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": true, "AdminMode": false}) + _ = loginTemplate.Execute(w, map[string]any{"DBPath": s.cfg.DBPath, "PasswordEnabled": true}) } -func (s *Server) adminLoginPage(w http.ResponseWriter, r *http.Request) { +func (s *Server) redirectAdmin(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin", http.StatusFound) +} + +// adminPage renders the unified management console: a single page with tabs for +// token statistics (default) and credential (phone code) login. It requires an +// authenticated management session; without one it bounces to /login. +func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) { if !s.hasLoginSession(r) { http.Redirect(w, r, "/login", http.StatusFound) return } + var summary *stats.Summary + enabled := s.statsEnabled + if s.st != nil { + if sm, err := s.st.Stats(stats.Query{}); err == nil { + summary = sm + } + } + if summary == nil { + summary = &stats.Summary{} + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = loginTemplate.Execute(w, map[string]any{"DBPath": s.cfg.DBPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": s.cfg.LoginPassword != "", "AdminMode": true}) + _ = adminTemplate.Execute(w, map[string]any{ + "Enabled": enabled, + "Stats": summary, + "DBPath": s.cfg.DBPath, + "PasswordEnabled": s.cfg.LoginPassword != "", + }) } func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) { @@ -570,24 +593,6 @@ func (s *Server) resetStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } -func (s *Server) statsPage(w http.ResponseWriter, r *http.Request) { - var summary *stats.Summary - enabled := s.statsEnabled - if s.st != nil { - if sm, err := s.st.Stats(stats.Query{}); err == nil { - summary = sm - } - } - if summary == nil { - summary = &stats.Summary{} - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = statsTemplate.Execute(w, map[string]any{ - "Enabled": enabled, - "Stats": summary, - }) -} - func parseLimit(s string) int { n := 0 for _, c := range s { @@ -962,6 +967,35 @@ func firstNonEmpty(a, b string) string { return b } +// humanNum renders an integer-like value with K/M/B suffixes for compact, +// scannable token counts (e.g. 31384 -> "31.4K", 1000 -> "1K", 1500000 -> +// "1.5M"). Values below 1000 are shown as plain integers. It accepts int, +// int64 and float64 so the same template func works for both Record (int) +// and Totals/ModelStat/DayStat (int64) fields. +func humanNum(v any) string { + var f float64 + switch n := v.(type) { + case int: + f = float64(n) + case int64: + f = float64(n) + case float64: + f = n + default: + return fmt.Sprintf("%v", v) + } + switch { + case f < 1000: + return fmt.Sprintf("%d", int64(f)) + case f < 1e6: + return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e3), ".0") + "K" + case f < 1e9: + return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e6), ".0") + "M" + default: + return strings.TrimSuffix(fmt.Sprintf("%.1f", f/1e9), ".0") + "B" + } +} + func redactSensitive(s string) string { for _, key := range []string{"AccessKey", "authorization", "Signature"} { s = redactQueryValue(s, key) @@ -990,377 +1024,126 @@ var loginTemplate = template.Must(template.New("login").Parse(` - 湛卢代理登录 -
-
-
-
Zhanlu Proxy
-

湛卢代理登录

-

输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。服务会保存凭据,后续 OpenAI 兼容接口自动使用。

-
-
- 数据库位置 - {{.DBPath}} -
-
-
- {{if not .AdminMode}} -

管理登录

-

请输入服务环境变量 ZHANLU_LOGIN_PASSWORD 配置的管理密码。

-
- - -
-
需要登录后才能管理湛卢凭据。
- {{else}} -

手机号验证码登录

-

手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。

-
- - - -
-
正在检查登录状态...
-
凭据保存到 JSON;验证码本身不会保存。{{if .PasswordEnabled}} {{end}}
- {{end}} -
+
+
Zhanlu Proxy
+

湛卢代理登录

+

请输入服务环境变量 ZHANLU_LOGIN_PASSWORD 配置的管理密码,验证后进入管理后台。

+
+ + +
+
需要登录后才能管理湛卢凭据。
`)) var loginResultTemplate = template.Must(template.New("login-result").Parse(` - - -湛卢登录结果 -
-
-{{if .Success}}{{else}}{{end}} -
-{{if .Success}}

登录成功

{{else}}

登录失败

{{end}} -

{{.Message}}

-返回登录页 -
`)) + + + + + 湛卢登录结果 + + + +
+
+ {{if .Success}}{{else}}{{end}} +
+ {{if .Success}}

登录成功

{{else}}

登录失败

{{end}} +

{{.Message}}

+ 返回管理后台 +
+ +`)) -var statsTemplate = template.Must(template.New("stats").Funcs(template.FuncMap{ +var adminTemplate = template.Must(template.New("admin").Funcs(template.FuncMap{ "pct": func(f float64) string { return fmt.Sprintf("%.1f%%", f*100) }, "rate": func(cached, prompt int64) string { if prompt <= 0 { @@ -1368,109 +1151,204 @@ var statsTemplate = template.Must(template.New("stats").Funcs(template.FuncMap{ } return fmt.Sprintf("%.1f%%", float64(cached)/float64(prompt)*100) }, + "human": humanNum, }).Parse(` - - 湛卢 Token 统计 + 湛卢代理管理后台
-
+
-
Zhanlu Proxy · Token 统计
-

Token 消耗统计

-
-
- 返回登录管理 - - +
Zhanlu Proxy · 管理后台
+

湛卢代理管理

+
{{if .PasswordEnabled}}{{end}}
- {{if not .Enabled}}

统计已关闭(ZHANLU_STATS_DISABLED=true)。

{{end}} -
-

总览

-
-
请求总数
{{.Stats.Totals.Requests}}
成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}
-
Prompt Tokens
{{.Stats.Totals.PromptTokens}}
缓存 {{.Stats.Totals.CachedTokens}}
-
Completion Tokens
{{.Stats.Totals.CompletionTokens}}
含思考 {{.Stats.Totals.ReasoningTokens}}
-
Total Tokens
{{.Stats.Totals.TotalTokens}}
-
缓存命中率
{{pct .Stats.Totals.CacheRate}}
缓存 {{.Stats.Totals.CachedTokens}} / Prompt {{.Stats.Totals.PromptTokens}}
+ +
+ + +
+ +
+
+ +
-
-
-

按模型

- {{if .Stats.PerModel}} -
- - - {{range .Stats.PerModel}}{{end}} - -
模型请求数PromptCompTotal缓存命中率
{{.Model}}{{.Requests}}{{.PromptTokens}}{{.CompletionTokens}}{{.TotalTokens}}{{.CachedTokens}}{{rate .CachedTokens .PromptTokens}}
- {{else}}

暂无数据

{{end}} -
-
-

按日

- {{if .Stats.Daily}} -
- {{range .Stats.Daily}}
{{.Day}}
{{.TotalTokens}}
{{end}} + {{if not .Enabled}}

统计已关闭(ZHANLU_STATS_DISABLED=true)。

{{end}} +
+

总览

+
+
请求总数
{{.Stats.Totals.Requests}}
成功 {{.Stats.Totals.SuccessRequests}} · 失败 {{.Stats.Totals.ErrorRequests}}
+
Prompt Tokens
{{human .Stats.Totals.PromptTokens}}
缓存 {{human .Stats.Totals.CachedTokens}}
+
Completion Tokens
{{human .Stats.Totals.CompletionTokens}}
含思考 {{human .Stats.Totals.ReasoningTokens}}
+
Total Tokens
{{human .Stats.Totals.TotalTokens}}
+
缓存命中率
{{pct .Stats.Totals.CacheRate}}
缓存 {{human .Stats.Totals.CachedTokens}} / Prompt {{human .Stats.Totals.PromptTokens}}
+
- {{else}}

暂无数据

{{end}} -
-
-

最近请求

- {{if .Stats.Recent}} -
- - - {{range .Stats.Recent}}{{end}} - -
时间模型模式PromptCompTotal缓存状态耗时
{{.Ts.Format "01-02 15:04:05"}}{{.Model}}{{if .Stream}}流式{{else}}非流式{{end}}{{.PromptTokens}}{{.CompletionTokens}}{{.TotalTokens}}{{.CachedTokens}}{{if eq .Status "success"}}成功{{else}}失败{{end}}{{.LatencyMs}}ms
- {{else}}

暂无数据

{{end}} -
+
+

按模型

+ {{if .Stats.PerModel}} +
+ + + {{range .Stats.PerModel}}{{end}} + +
模型请求数PromptCompTotal缓存命中率
{{.Model}}{{.Requests}}{{human .PromptTokens}}{{human .CompletionTokens}}{{human .TotalTokens}}{{human .CachedTokens}}{{rate .CachedTokens .PromptTokens}}
+ {{else}}

暂无数据

{{end}} +
+
+

按日

+ {{if .Stats.Daily}} +
+ {{range .Stats.Daily}}
{{.Day}}
{{human .TotalTokens}}
{{end}} +
+ {{else}}

暂无数据

{{end}} +
+
+

最近请求

+ {{if .Stats.Recent}} +
+ + + {{range .Stats.Recent}}{{end}} + +
时间模型模式PromptCompTotal缓存状态耗时
{{.Ts.Format "01-02 15:04:05"}}{{.Model}}{{if .Stream}}流式{{else}}非流式{{end}}{{human .PromptTokens}}{{human .CompletionTokens}}{{human .TotalTokens}}{{human .CachedTokens}}{{if eq .Status "success"}}成功{{else}}失败{{end}}{{.LatencyMs}}ms
+
+ {{else}}

暂无数据

{{end}} +
+ + +
+ +