diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f353876..c9344d2 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -15,6 +15,7 @@ import ( "git.misaka.ren/M1saka/zhanlu_proxy/internal/auth" "git.misaka.ren/M1saka/zhanlu_proxy/internal/config" + "git.misaka.ren/M1saka/zhanlu_proxy/internal/stats" "git.misaka.ren/M1saka/zhanlu_proxy/internal/store" ) @@ -365,6 +366,47 @@ func TestModelTestNoCredentials(t *testing.T) { } } +// TestAdminRendersModelRangeFilter verifies the admin page renders without +// panic for both empty and populated summaries, and that the by-model panel +// carries the 1d/7d/all range filter controls and a client-renderable tbody. +func TestAdminRendersModelRangeFilter(t *testing.T) { + populated := &stats.Summary{ + PerModel: []stats.ModelStat{ + {Model: "GLM-4.7", Requests: 3, PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, CachedTokens: 4}, + }, + } + cases := []struct { + name string + summary *stats.Summary + }{ + {"empty", &stats.Summary{}}, + {"populated", populated}, + } + for _, tc := range cases { + var buf bytes.Buffer + if err := adminTemplate.Execute(&buf, map[string]any{ + "Enabled": true, + "Stats": tc.summary, + "DBPath": "zhanlu.db", + "PasswordEnabled": false, + }); err != nil { + t.Fatalf("%s: render admin: %v", tc.name, err) + } + body := buf.String() + for _, want := range []string{ + `id="model-range"`, + `data-range="1d"`, + `data-range="7d"`, + `data-range="all"`, + `id="model-tbody"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("%s: admin output missing %q", tc.name, want) + } + } + } +} + // totNum extracts an int from a JSON-decoded numeric value (float64). func totNum(v any) int { f, _ := v.(float64) diff --git a/internal/server/templates/admin.html b/internal/server/templates/admin.html index 2f25421..551688f 100644 --- a/internal/server/templates/admin.html +++ b/internal/server/templates/admin.html @@ -41,6 +41,13 @@ .tabpanel{display:none;min-width:0} .tabpanel.active{display:block;min-width:0} .sub-actions{display:flex;justify-content:flex-end;gap:10px;margin-bottom:16px} + .panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px;flex-wrap:wrap} + .panel-head h2{margin:0} + .seg{display:inline-flex;border:1px solid var(--border-strong);border-radius:999px;padding:2px;background:var(--bg)} + .seg-btn{border:0;background:none;font:inherit;font-weight:600;font-size:12.5px;color:var(--body);padding:6px 14px;border-radius:999px;cursor:pointer;transition:background .15s ease,color .15s ease} + .seg-btn:hover{color:var(--ink)} + .seg-btn[aria-current="true"]{background:var(--accent);color:#fff} + .seg-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px} .panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 1px 2px rgba(16,24,40,.04);padding:24px;min-width:0} .panel.disabled{background:#eef0f3} h2{margin:0 0 16px;font-size:16px;font-weight:600;letter-spacing:-.01em} @@ -129,15 +136,21 @@
-

按模型

- {{if .Stats.PerModel}} -
+
+

按模型

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

暂无数据

{{end}} +

按日

@@ -232,6 +245,80 @@ fetch('/api/stats/reset', { method: 'POST' }).then(function () { location.reload(); }); }); + // by-model time-range filter (1d / 7d / all) — fetches /api/stats with a + // since cutoff and re-renders only the per-model table client-side. + (function () { + var seg = document.getElementById('model-range'); + var tbody = document.getElementById('model-tbody'); + var scroll = document.getElementById('model-scroll'); + var empty = document.getElementById('model-empty'); + if (!seg || !tbody) return; + function trimZero(s) { return s.indexOf('.') >= 0 ? s.replace(/\.0$/, '') : s; } + function human(v) { + var f = Number(v) || 0; + if (f < 1000) return String(Math.round(f)); + if (f < 1e6) return trimZero((f / 1e3).toFixed(1)) + 'K'; + if (f < 1e9) return trimZero((f / 1e6).toFixed(1)) + 'M'; + return trimZero((f / 1e9).toFixed(1)) + 'B'; + } + function rate(cached, prompt) { + if (prompt <= 0) return '0%'; + return (cached / prompt * 100).toFixed(1) + '%'; + } + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + function showEmpty(yes) { + scroll.style.display = yes ? 'none' : ''; + empty.style.display = yes ? '' : 'none'; + } + function render(rows) { + if (!rows || rows.length === 0) { tbody.innerHTML = ''; showEmpty(true); return; } + showEmpty(false); + var html = ''; + for (var i = 0; i < rows.length; i++) { + var r = rows[i]; + html += '' + escapeHtml(r.model) + '' + + '' + (r.requests || 0) + '' + + '' + human(r.prompt_tokens) + '' + + '' + human(r.completion_tokens) + '' + + '' + human(r.total_tokens) + '' + + '' + human(r.cached_tokens) + '' + + '' + rate(r.cached_tokens, r.prompt_tokens) + ''; + } + tbody.innerHTML = html; + } + // initial empty check (server-rendered "all" may have no rows) + if (!tbody.querySelector('tr')) showEmpty(true); + function sinceParam(range) { + if (range === 'all') return ''; + var days = range === '1d' ? 1 : 7; + return 'since=' + encodeURIComponent(new Date(Date.now() - days * 86400000).toISOString()); + } + seg.addEventListener('click', async function (ev) { + var btn = ev.target.closest('.seg-btn'); + if (!btn) return; + seg.querySelectorAll('.seg-btn').forEach(function (b) { + b.setAttribute('aria-current', b === btn ? 'true' : 'false'); + }); + var range = btn.dataset.range; + var param = sinceParam(range); + var url = '/api/stats' + (param ? '?' + param : ''); + showEmpty(false); + tbody.innerHTML = '加载中…'; + try { + var res = await fetch(url); + var data = await res.json(); + if (!data.stats) { render([]); return; } + render(data.stats.per_model || []); + } catch (e) { + tbody.innerHTML = '加载失败'; + } + }); + })(); + // paginate the recent-requests table (data is already rendered server-side) (function () { var tbody = document.querySelector('#recent tbody'); diff --git a/scripts/test-instance.sh b/scripts/test-instance.sh new file mode 100755 index 0000000..38267c3 --- /dev/null +++ b/scripts/test-instance.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# 实例测试流程固化脚本 +# +# ./scripts/test-instance.sh start # 编译新二进制 + 以当前目录 zhanlu.db 启动 +# ./scripts/test-instance.sh stop # 停止进程 + 清理 *-shm/*-wal +# ./scripts/test-instance.sh status # 查看运行状态 +# +# 可用环境变量覆盖默认值: +# ZHANLU_DB_FILE 数据库路径 (默认 <项目根>/zhanlu.db) +# ZHANLU_LISTEN_ADDR 监听地址 (默认 127.0.0.1:8080) +set -euo pipefail + +# 项目根目录(脚本位于 scripts/ 下) +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKDIR="$ROOT/tmp" # 二进制/pid/log 都放这里(已 gitignore) +BIN="$WORKDIR/zhanlu-proxy-test" +PIDFILE="$WORKDIR/test-instance.pid" +LOGFILE="$WORKDIR/test-instance.log" +DB="${ZHANLU_DB_FILE:-$ROOT/zhanlu.db}" +ADDR="${ZHANLU_LISTEN_ADDR:-127.0.0.1:8080}" + +start() { + mkdir -p "$WORKDIR" + if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo "已有实例在运行 (pid $(cat "$PIDFILE")),请先执行: $0 stop" >&2 + exit 1 + fi + echo "==> 编译二进制..." + (cd "$ROOT" && go build -o "$BIN" ./cmd/zhanlu-proxy) + echo "==> 启动 (db=$DB addr=$ADDR)" + ZHANLU_DB_FILE="$DB" ZHANLU_LISTEN_ADDR="$ADDR" \ + nohup "$BIN" > "$LOGFILE" 2>&1 & + echo $! > "$PIDFILE" + sleep 1 + if ! kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo "启动失败,日志:" >&2 + cat "$LOGFILE" >&2 + rm -f "$PIDFILE" + exit 1 + fi + port="${ADDR##*:}" + echo "==> 已启动 pid=$(cat "$PIDFILE")" + echo " 管理后台: http://127.0.0.1:$port/admin" + echo " 日志: $LOGFILE" + echo " 测试完成后执行: $0 stop" +} + +stop() { + pid="" + [[ -f "$PIDFILE" ]] && pid="$(cat "$PIDFILE")" + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + # 优雅等待退出(给 SQLite checkpoint WAL 的时间) + for _ in $(seq 1 40); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.25 + done + if kill -0 "$pid" 2>/dev/null; then + echo "==> 优雅退出超时,强制结束 pid=$pid" + kill -9 "$pid" 2>/dev/null || true + else + echo "==> 已停止进程 pid=$pid" + fi + else + echo "==> 无运行中的进程" + fi + rm -f "$PIDFILE" + # 清理 wal/shm(与 DB 同目录同名) + removed=0 + for ext in db-shm db-wal; do + f="${DB%.*}.$ext" + if [[ -f "$f" ]]; then + rm -f "$f" + echo "==> 已清理 $f" + removed=1 + fi + done + [[ $removed -eq 0 ]] && echo "==> 无 shm/wal 需清理" + rm -f "$BIN" + echo "==> 完成" +} + +status() { + if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo "运行中 pid=$(cat "$PIDFILE") addr=$ADDR db=$DB" + else + echo "未运行" + fi +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + status) status ;; + *) echo "用法: $0 {start|stop|status}" >&2; exit 1 ;; +esac