Add 1d/7d/all time-range filter to admin by-model stats
build / build (push) Successful in 2m32s

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:
2026-08-20 17:08:39 +08:00
parent 9aba511abe
commit a0a2049440
3 changed files with 230 additions and 5 deletions
+92 -5
View File
@@ -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 @@
</div>
</div>
<div class="panel" style="margin-top:20px">
<h2>按模型</h2>
{{if .Stats.PerModel}}
<div class="scroll"><table>
<div class="panel-head">
<h2>按模型</h2>
<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>
<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}}
</tbody>
</table></div>
{{else}}<p class="empty">暂无数据</p>{{end}}
<p class="empty" id="model-empty" style="display:none">暂无数据</p>
</div>
<div class="panel" style="margin-top:20px">
<h2>按日</h2>
@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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)
(function () {
var tbody = document.querySelector('#recent tbody');