Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions internal/panel/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ let refTimer = null;
let mdFilter = { q: '', realm: '', cap: '', effort: '', promo: '', sort: 'default' };
let mdAll = [], mdProbes = {}, mdProbeOf = () => undefined;
let mdLocks = [], mdRecent = [];
let reqFilter = { q: '', outcome: '' };
let reqFilter = { q: '', outcome: '', accountName: '' };
let reqEntries = [];
let usDim = 'account', usCreditDim = 'account', usSort = 'total';
let usageData = null;
Expand Down Expand Up @@ -525,6 +525,7 @@ async function loadOverview(quiet) {
const up = Math.floor(d.uptime_sec);
$('subMeta').textContent = '运行 ' + (up >= 86400 ? Math.floor(up / 86400) + ' 天 ' : '') + Math.floor(up % 86400 / 3600) + ' 时 ' + Math.floor(up % 3600 / 60) + ' 分';
renderAccounts(d.accounts || []);
ensureAccountSelects();
renderModelLocks(d.model_locks);
// 到期卡片跟随首页统计同步刷新(用同一份响应里的按日批次,不额外发请求)。
// 2 分钟内的实时明细最新且更全,保留;过期后由 state 快照接管,避免点过一次
Expand Down Expand Up @@ -865,12 +866,20 @@ async function loadLogs() {
// 「最近 N 条」里根本不会有那些记录,必须让服务端按时间取。
const rq = trangeQuery('reqRange', false);
rq.set('limit', limit);
// 账号筛选走服务端(account 参数):归档读取是先逐行过滤再取最近 N 条,
// 客户端过滤只能看到"最近 N 条里恰好属于该账号的",老账号会凭空变空。
const reqAccountUid = $('reqAccount') ? $('reqAccount').value : '';
if (reqAccountUid) rq.set('account', reqAccountUid);
// 客户端兜底:归档关闭时后端只能回内存里的最近记录(不带 account 过滤),
// 这里按昵称再筛一遍,保证下拉框在两种部署形态下行为一致。
const reqAccountChoice = accountChoices().find(a => a.uid === reqAccountUid);
reqFilter.accountName = reqAccountChoice ? reqAccountChoice.name : '';
try {
const [d, metrics, requestRows, credit] = await Promise.all([
api('logs'),
api('request_metrics').catch(() => ({})),
api('request_logs?' + rq.toString()).catch(() => ({ entries: [] })),
api('credit_history?limit=' + creditLimitValue()).catch(() => null),
api('credit_history?' + creditQuery().toString()).catch(() => null),
]);
// 归档开启时以归档为准——「区间内没有记录」是一个真实结果,不能回落成内存里
// 的最近 100 条(那会把筛选条件之外、时间范围之外的请求显示出来)。
Expand Down Expand Up @@ -981,9 +990,41 @@ function creditLimitValue() {
const n = el ? Number(el.value) : 100;
return n === 300 || n === 1000 ? n : 100;
}

/* accountChoices 账号下拉的数据源(overview 的账号快照,只取展示字段)。 */
function accountChoices() {
return ((overviewData && overviewData.accounts) || []).map(a => ({ uid: a.uid, name: a.nickname || a.uid }));
}

/* fillAccountSelect 重建账号下拉:保留当前选择;选中账号已删除时回退「全部账号」。 */
function fillAccountSelect(id) {
const el = $(id);
if (!el) return;
const choices = accountChoices();
const cur = el.value;
el.innerHTML = '<option value="">全部账号</option>' +
choices.map(a => '<option value="' + esc(a.uid) + '">' + esc(a.name) + '</option>').join('');
el.value = choices.some(a => a.uid === cur) ? cur : '';
}

/* ensureAccountSelects overview 刷新后同步两处账号下拉(覆盖账号增删与选中回退)。 */
function ensureAccountSelects() {
fillAccountSelect('reqAccount');
fillAccountSelect('creditAccount');
}

/* creditQuery 积分历史查询串:条数 + 账号 + 时间范围(默认「全部历史」,与改造前一致)。 */
function creditQuery() {
const q = trangeQuery('creditRange', false);
q.set('limit', String(creditLimitValue()));
const uid = $('creditAccount') ? $('creditAccount').value : '';
if (uid) q.set('uid', uid);
return q;
}

async function loadCreditHistory() {
let d = null;
try { d = await api('credit_history?limit=' + creditLimitValue()); } catch (e) { d = null; }
try { d = await api('credit_history?' + creditQuery().toString()); } catch (e) { d = null; }
renderCreditHistory(d);
}

Expand All @@ -992,6 +1033,7 @@ async function loadCreditHistory() {
function reqMatch(e, f) {
f = f || reqFilter;
if (f.outcome && String(e && e.outcome || '') !== f.outcome) return false;
if (f.accountName && String(e && e.account || '') !== f.accountName) return false;
if (f.q) {
const text = [e && e.client_ip, e && e.user_agent, e && e.model, e && e.account, e && e.request_id]
.filter(Boolean).join(' ').toLowerCase();
Expand Down Expand Up @@ -1072,11 +1114,15 @@ if ($('reqOutcome')) $('reqOutcome').onchange = () => {
renderRequestTable();
};
if ($('reqLimit')) $('reqLimit').onchange = loadLogs;
if ($('reqAccount')) $('reqAccount').onchange = loadLogs;
if ($('btnReqReload')) $('btnReqReload').onclick = loadLogs;
// 时间范围:默认「全部历史」——请求记录页的历史行为就是"取最近 N 条",
// 加一个默认收窄的区间会让打开页面时看到的条数凭空变少。
if ($('reqRange')) trangeBind('reqRange', loadLogs, '0');
if ($('creditLimit')) $('creditLimit').onchange = loadCreditHistory;
if ($('creditAccount')) $('creditAccount').onchange = loadCreditHistory;
// 积分历史同样默认「全部历史」:与改造前"最近 N 条"的口径一致。
if ($('creditRange')) trangeBind('creditRange', loadCreditHistory, '0');
if ($('btnCreditReload')) $('btnCreditReload').onclick = loadCreditHistory;

function requestLogText(e) {
Expand Down
44 changes: 44 additions & 0 deletions internal/panel/credit_history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package panel
import (
"encoding/json"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"

"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
"github.com/linguo2625469/workbuddy2api-panel/internal/credithist"
Expand Down Expand Up @@ -167,3 +169,45 @@ func TestCreditHistoryRequiresAuth(t *testing.T) {
t.Fatalf("无 key 应回 401,得到 %d", rr.Code)
}
}

// 时间与账号筛选:from/to 为秒级时间戳,闭区间;与 uid 组合时先并集筛选再截断。
func TestCreditHistoryTimeAndAccountFilter(t *testing.T) {
l := credithist.New("", 100)
l.Observe("u1", 1000) // 基线不记流水
l.Observe("u1", 1100) // +100(唯一一条流水)

now := time.Now()
past := now.Add(-time.Hour).Unix()
future := now.Add(time.Hour).Unix()

cases := []struct {
name string
query string
want int
}{
{"无筛选", "", 1},
{"起点在过去", "?from=" + strconv.FormatInt(past, 10), 1},
{"起点在未来", "?from=" + strconv.FormatInt(future, 10), 0},
{"终点在过去", "?to=" + strconv.FormatInt(past, 10), 0},
{"终点在未来", "?to=" + strconv.FormatInt(future, 10), 1},
{"区间覆盖", "?from=" + strconv.FormatInt(past, 10) + "&to=" + strconv.FormatInt(future, 10), 1},
{"区间不覆盖", "?from=" + strconv.FormatInt(future, 10) + "&to=" + strconv.FormatInt(future+60, 10), 0},
{"账号命中", "?uid=u1", 1},
{"账号不命中", "?uid=u2", 0},
{"账号+时间命中", "?uid=u1&from=" + strconv.FormatInt(past, 10), 1},
{"非法时间参数忽略", "?from=abc&to=xyz", 1},
}
for _, c := range cases {
code, body := creditGet(t, creditPanel(t, l), c.query)
if code != 200 {
t.Fatalf("%s: 状态 %d %s", c.name, code, body)
}
var out creditResp
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("%s: JSON 解析失败 %v", c.name, err)
}
if len(out.Entries) != c.want {
t.Fatalf("%s: 条目 %d,期望 %d(%s)", c.name, len(out.Entries), c.want, body)
}
}
}
7 changes: 7 additions & 0 deletions internal/panel/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,9 @@ <h3>请求记录 <span class="hint">调用来源:IP 取 X-Forwarded-For 首段
<option value="stream_error">流错误</option>
<option value="interrupted">中断</option>
</select>
<select id="reqAccount" aria-label="按账号筛选">
<option value="">全部账号</option>
</select>
<span class="trange" id="reqRange"></span>
<select id="reqLimit" aria-label="读取条数">
<option value="100" selected>最近 100 条</option>
Expand Down Expand Up @@ -1221,6 +1224,10 @@ <h3>积分历史 <span class="hint">每次真实查到余额与上次比对,
<span class="note" id="creditNote">—</span>
</header>
<div class="fbar">
<select id="creditAccount" aria-label="按账号筛选">
<option value="">全部账号</option>
</select>
<span class="trange" id="creditRange"></span>
<select id="creditLimit" aria-label="读取条数">
<option value="100" selected>最近 100 条</option>
<option value="300">最近 300 条</option>
Expand Down
24 changes: 21 additions & 3 deletions internal/panel/panel.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,9 +332,17 @@ func (p *Panel) requestLogs(w http.ResponseWriter, r *http.Request) {
limit = 1000
}
q := r.URL.Query()
// account 过滤:面板下拉传的是 uid,而归档里存的是昵称(展示口径),
// 这里做一次 uid→昵称翻译;传昵称/片段时保持原有包含匹配语义。
account := q.Get("account")
if account != "" {
if nk := p.nicknames()[account]; nk != "" {
account = nk
}
}
rows, err := p.cfg.RequestLog.ReadArchive(limit, reqlog.Filter{
Outcome: q.Get("outcome"),
Account: q.Get("account"),
Account: account,
Model: q.Get("model"),
ClientIP: q.Get("client_ip"),
UserAgent: q.Get("user_agent"),
Expand Down Expand Up @@ -372,11 +380,15 @@ func (p *Panel) creditHistory(w http.ResponseWriter, r *http.Request) {
limit = 1000
}
uid := r.URL.Query().Get("uid")
// 时间筛选用秒级时间戳(前端按浏览器本地时区算好区间再传,避免服务端时区
// 与浏览器不一致时把"今天"切错日子);空值表示不限。
from := parseTimeParam(r.URL.Query().Get("from"))
to := parseTimeParam(r.URL.Query().Get("to"))
nicks := p.nicknames()
// 有 uid 过滤时必须先全量取回(账本上限默认 2000 条):先截断再过滤会让
// 有筛选条件时必须先全量取回(账本上限默认 2000 条):先截断再过滤会让
// 筛选后的条数看起来像历史缺失。无过滤时只取 limit 条,不为一次展示拷贝整个账本。
readLimit := limit
if uid != "" {
if uid != "" || !from.IsZero() || !to.IsZero() {
readLimit = 0
}
all := p.cfg.CreditHistory.Read(readLimit)
Expand All @@ -385,6 +397,12 @@ func (p *Panel) creditHistory(w http.ResponseWriter, r *http.Request) {
if uid != "" && e.UID != uid {
continue
}
if !from.IsZero() && e.Time.Before(from) {
continue
}
if !to.IsZero() && e.Time.After(to) {
continue
}
out = append(out, map[string]any{
"time": e.Time,
"uid": e.UID,
Expand Down
43 changes: 43 additions & 0 deletions internal/panel/timerange_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"
"time"

"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
"github.com/linguo2625469/workbuddy2api-panel/internal/pool"
"github.com/linguo2625469/workbuddy2api-panel/internal/reqlog"
"github.com/linguo2625469/workbuddy2api-panel/internal/usage"
Expand Down Expand Up @@ -127,3 +128,45 @@ func TestRequestLogsEmptyRangeReturnsArray(t *testing.T) {
t.Fatalf("未来区间不该命中任何记录: %+v", out.Entries)
}
}

// 请求记录的账号筛选:下拉传 uid,归档存昵称——handler 必须做 uid→昵称翻译;
// 直接传昵称(老调用方/手工调试)保持包含匹配语义。
func TestRequestLogsAccountFilterByUID(t *testing.T) {
rec := reqlog.New(reqlog.Config{Enabled: true, Dir: t.TempDir(), MaxBytes: 1 << 20, RetentionDays: 7})
now := time.Now()
rec.Record(reqlog.Event{Time: now, Account: "小明", Model: "cn:hy3", Status: 200, OK: true, Outcome: reqlog.OutcomeSuccess})
rec.Record(reqlog.Event{Time: now.Add(-time.Second), Account: "小红", Model: "cn:hy3", Status: 200, OK: true, Outcome: reqlog.OutcomeSuccess})
rec.Close()

pl := pool.New("")
pl.Add(&auth.Auth{UID: "u1", Nickname: "小明"})
p := New(Config{Version: "test", APIKey: "k", Pool: pl, RequestLog: rec})

get := func(query string) []reqlog.Event {
t.Helper()
req := httptest.NewRequest("GET", "/panel/api/request_logs?limit=10"+query, nil)
req.Header.Set("Authorization", "Bearer k")
rr := httptest.NewRecorder()
p.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("%s -> %d %s", query, rr.Code, rr.Body)
}
var out struct {
Entries []reqlog.Event `json:"entries"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out.Entries
}

if got := get("&account=u1"); len(got) != 1 || got[0].Account != "小明" {
t.Fatalf("uid 过滤应命中 1 条小明的记录,得到 %+v", got)
}
if got := get("&account=小明"); len(got) != 1 || got[0].Account != "小明" {
t.Fatalf("昵称过滤应命中 1 条,得到 %+v", got)
}
if got := get("&account=陌生人"); len(got) != 0 {
t.Fatalf("不存在的账号应回 0 条,得到 %+v", got)
}
}
Loading