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 配置的管理密码,验证后进入管理后台。
+
+ 需要登录后才能管理湛卢凭据。