Add recent API request recorder with admin tab and /api/recent endpoint
build / build (push) Successful in 2m31s

Record the last 10 /v1/ API requests (including errors) in an in-memory
ring buffer. Each entry captures request headers (Authorization masked),
request body, response status, and response body — all up to 1 MB.

- recorder.go: RecentRecorder ring buffer, recordingResponseWriter,
  withRecording middleware, getRecent handler
- server.go: add recorder to Server, wrap /v1/ routes, add /api/recent
- admin.html: new 最近请求 tab with lazy-load and expandable cards
- recorder_test.go: 5 tests (ring buffer, ordering, capture, errors, API)
This commit is contained in:
2026-08-23 14:52:33 +08:00
parent f988c47fec
commit 22d78c4576
4 changed files with 411 additions and 4 deletions
+83
View File
@@ -101,6 +101,16 @@
@media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}}
.footer{margin-top:22px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7}
.footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all}
.recent-card{border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;background:var(--bg)}
.recent-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px}
.recent-time{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
.recent-method{font-size:11.5px;font-weight:700;color:var(--accent);background:var(--accent-soft);padding:2px 8px;border-radius:999px}
.recent-path{font-size:13px;color:var(--ink);font-family:"SF Mono",ui-monospace,Consolas,monospace;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.recent-duration{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
.recent-card details{margin-top:6px}
.recent-card summary{cursor:pointer;font-size:12.5px;font-weight:600;color:var(--body);padding:4px 0;user-select:none}
.recent-card summary:hover{color:var(--accent)}
.recent-card pre{background:#1e1e2e;color:#cdd6f4;padding:12px;border-radius:8px;font-size:12px;overflow-x:auto;max-height:400px;overflow-y:auto;font-family:"SF Mono",ui-monospace,Consolas,monospace;line-height:1.5;margin:8px 0 0;white-space:pre-wrap;word-break:break-all}
</style>
</head>
<body>
@@ -116,6 +126,7 @@
<div class="tabs" role="tablist">
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
<button class="tab" role="tab" data-tab="models" aria-selected="false">可用模型</button>
<button class="tab" role="tab" data-tab="recent" aria-selected="false">最近请求</button>
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
</div>
@@ -191,6 +202,17 @@
</div>
</section>
<section class="tabpanel" data-tab="recent" role="tabpanel">
<div class="sub-actions">
<button class="btn btn-ghost" id="recent-refresh" type="button">刷新</button>
</div>
<div class="panel">
<h2>最近 API 请求</h2>
<p class="muted" style="margin:0 0 16px">记录最近 10 条 /v1/ 请求的请求头和返回结果(含报错)。</p>
<div id="recent-list"></div>
</div>
</section>
<section class="tabpanel" data-tab="login" role="tabpanel">
<div class="login-card">
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
@@ -225,6 +247,7 @@
});
var hash = location.hash.replace('#', '');
if (hash === 'models') activate('models');
else if (hash === 'recent') activate('recent');
else if (hash === 'login') activate('login');
var rows = document.querySelectorAll('#daily .bar');
@@ -521,6 +544,66 @@
if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
loadIfActive();
})();
(function () {
var recentList = document.getElementById('recent-list');
if (!recentList) return;
var loaded = false;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function formatTime(ts) {
var d = new Date(ts);
return d.toLocaleString('zh-CN', { hour12: false });
}
async function loadRecent() {
recentList.innerHTML = '<p class="muted">加载中…</p>';
try {
var res = await fetch('/api/recent');
var data = await res.json();
if (!data.ok || !data.requests || data.requests.length === 0) {
recentList.innerHTML = '<p class="empty">暂无数据</p>';
return;
}
var html = '';
for (var i = 0; i < data.requests.length; i++) {
var r = data.requests[i];
var statusClass = r.status >= 200 && r.status < 300 ? 'ok' : 'err';
var headers = r.request_headers || {};
var headerStr = Object.keys(headers).map(function (k) {
return k + ': ' + headers[k];
}).join('\n');
html += '<div class="recent-card">' +
'<div class="recent-head">' +
'<span class="recent-time">' + escapeHtml(formatTime(r.timestamp)) + '</span>' +
'<span class="recent-method">' + escapeHtml(r.method) + '</span>' +
'<span class="recent-path">' + escapeHtml(r.path) + '</span>' +
'<span class="badge ' + statusClass + '">' + r.status + '</span>' +
'<span class="recent-duration">' + r.duration_ms + 'ms</span>' +
'</div>' +
(headerStr ? '<details><summary>请求头</summary><pre>' + escapeHtml(headerStr) + '</pre></details>' : '') +
'<details><summary>请求体</summary><pre>' + escapeHtml(r.request_body || '(空)') + '</pre></details>' +
'<details><summary>响应体</summary><pre>' + escapeHtml(r.response_body || '(空)') + '</pre></details>' +
'</div>';
}
recentList.innerHTML = html;
} catch (e) {
recentList.innerHTML = '<p class="empty">加载失败: ' + escapeHtml(e.message) + '</p>';
}
}
var recentTab = document.querySelector('.tab[data-tab="recent"]');
if (recentTab) recentTab.addEventListener('click', function () {
if (!loaded) { loaded = true; loadRecent(); }
});
var refreshBtn = document.getElementById('recent-refresh');
if (refreshBtn) refreshBtn.addEventListener('click', loadRecent);
// auto-load if the tab is active on page load
var panel = document.querySelector('.tabpanel[data-tab="recent"]');
if (panel && panel.classList.contains('active')) { loaded = true; loadRecent(); }
})();
</script>
</body>
</html>