By-model panel gains a segmented 1天/7天/全部 control. Selecting a range fetches /api/stats?since=<RFC3339> and re-renders only that table client-side, replicating the human/rate template helpers in JS; the existing /api/stats since param already drives the server-side filter. - internal/server/templates/admin.html: filter UI + JS, empty/loading states - internal/server/server_test.go: render test for the filter controls - scripts/test-instance.sh: build→start→stop→clean test-instance helper
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
||||||
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
|
||||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
|
"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).
|
// 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)
|
||||||
|
|||||||
@@ -41,6 +41,13 @@
|
|||||||
.tabpanel{display:none;min-width:0}
|
.tabpanel{display:none;min-width:0}
|
||||||
.tabpanel.active{display:block;min-width:0}
|
.tabpanel.active{display:block;min-width:0}
|
||||||
.sub-actions{display:flex;justify-content:flex-end;gap:10px;margin-bottom:16px}
|
.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{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}
|
.panel.disabled{background:#eef0f3}
|
||||||
h2{margin:0 0 16px;font-size:16px;font-weight:600;letter-spacing:-.01em}
|
h2{margin:0 0 16px;font-size:16px;font-weight:600;letter-spacing:-.01em}
|
||||||
@@ -129,15 +136,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel" style="margin-top:20px">
|
<div class="panel" style="margin-top:20px">
|
||||||
<h2>按模型</h2>
|
<div class="panel-head">
|
||||||
{{if .Stats.PerModel}}
|
<h2>按模型</h2>
|
||||||
<div class="scroll"><table>
|
<div class="seg" id="model-range" role="group" aria-label="按模型统计时间范围">
|
||||||
|
<button class="seg-btn" type="button" data-range="1d">1 天</button>
|
||||||
|
<button class="seg-btn" type="button" data-range="7d">7 天</button>
|
||||||
|
<button class="seg-btn" type="button" data-range="all" aria-current="true">全部</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scroll" id="model-scroll"><table>
|
||||||
<thead><tr><th>模型</th><th class="num">请求数</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th class="num">命中率</th></tr></thead>
|
<thead><tr><th>模型</th><th class="num">请求数</th><th class="num">Prompt</th><th class="num">Comp</th><th class="num">Total</th><th class="num">缓存</th><th class="num">命中率</th></tr></thead>
|
||||||
<tbody>
|
<tbody id="model-tbody">
|
||||||
{{range .Stats.PerModel}}<tr><td>{{.Model}}</td><td class="num">{{.Requests}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td class="num">{{rate .CachedTokens .PromptTokens}}</td></tr>{{end}}
|
{{range .Stats.PerModel}}<tr><td>{{.Model}}</td><td class="num">{{.Requests}}</td><td class="num">{{human .PromptTokens}}</td><td class="num">{{human .CompletionTokens}}</td><td class="num">{{human .TotalTokens}}</td><td class="num">{{human .CachedTokens}}</td><td class="num">{{rate .CachedTokens .PromptTokens}}</td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
{{else}}<p class="empty">暂无数据</p>{{end}}
|
<p class="empty" id="model-empty" style="display:none">暂无数据</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel" style="margin-top:20px">
|
<div class="panel" style="margin-top:20px">
|
||||||
<h2>按日</h2>
|
<h2>按日</h2>
|
||||||
@@ -232,6 +245,80 @@
|
|||||||
fetch('/api/stats/reset', { method: 'POST' }).then(function () { location.reload(); });
|
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 += '<tr><td>' + escapeHtml(r.model) + '</td>' +
|
||||||
|
'<td class="num">' + (r.requests || 0) + '</td>' +
|
||||||
|
'<td class="num">' + human(r.prompt_tokens) + '</td>' +
|
||||||
|
'<td class="num">' + human(r.completion_tokens) + '</td>' +
|
||||||
|
'<td class="num">' + human(r.total_tokens) + '</td>' +
|
||||||
|
'<td class="num">' + human(r.cached_tokens) + '</td>' +
|
||||||
|
'<td class="num">' + rate(r.cached_tokens, r.prompt_tokens) + '</td></tr>';
|
||||||
|
}
|
||||||
|
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 = '<tr><td colspan="7" class="muted" style="text-align:center">加载中…</td></tr>';
|
||||||
|
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 = '<tr><td colspan="7" class="muted" style="text-align:center">加载失败</td></tr>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// paginate the recent-requests table (data is already rendered server-side)
|
// paginate the recent-requests table (data is already rendered server-side)
|
||||||
(function () {
|
(function () {
|
||||||
var tbody = document.querySelector('#recent tbody');
|
var tbody = document.querySelector('#recent tbody');
|
||||||
|
|||||||
Executable
+96
@@ -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
|
||||||
Reference in New Issue
Block a user