mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
fix(gateway): aggregate the full audit history, repair record paging
Two problems reported after the on-demand log work landed.
1. Dashboard totals were wrong. LoadAudit only replayed the last 4 MB of the
audit file, so requests/tokens/per-key rows reflected a window instead of all
time — a regression in reported numbers, not just in presentation.
The aggregates are now built by streaming EVERY audit file (oldest first, so
the hourly quota buckets keep their intended trailing window) and keeping
nothing per record: aggregate maps are keyed by key/model/source, so their
size is bounded by cardinality. Measured on the production host: 29 MB /
221k lines / 37k requests in ~260 ms at startup.
What stays bounded is the RAW-record ring: a fixed-size reqRing keeps only the
newest maxRecs records, so the ~25 MB that used to be spent appending every
record into a slice is still saved. auditReplayBytes is gone, and
replayPartial now means "an audit file could not be read", which is the only
remaining way for the totals to be incomplete.
2. Scrolling to the bottom stopped loading more records. Two independent causes:
* paintRecords rebuilt the entire table on every 5s poll whenever the row
count did not exceed the first screen — the "is a paged view live?" test
compared row counts and matched exactly on the first refresh — wiping loaded
pages and resetting scroll position.
* IntersectionObserver only fires on TRANSITIONS. With a short list, or after a
page whose rows all duplicated the first screen, the sentinel stayed visible
and never fired again.
paintRecords now builds once (recsState.built) and later polls PREPEND only
genuinely new rows; attachRecsObserver adds a scroll-position fallback;
fillRecordsViewport loads until the list actually overflows; and
loadMoreRecords chains (bounded) when a page yields no new rows, since the
first fetch necessarily overlaps the first screen.
TestUIRecordsPagingWiring pins all four mechanisms structurally, since none of
them is reachable from Go. Test names/comments referring to bounded replay are
updated to describe the bounded RING instead, and both READMEs now state that
totals come from the full history while records are paged.
This commit is contained in:
@ -927,7 +927,7 @@
|
||||
recsRotated: "审计日志已轮转,已从最新记录重新加载",
|
||||
recsNewest: "回到最新",
|
||||
recsPartial:
|
||||
"统计基于最近一段审计日志(启动时只回放尾部);完整历史请导出 CSV",
|
||||
"部分审计日志无法读取,统计可能不完整;完整历史请导出 CSV",
|
||||
thTokens: "Tokens",
|
||||
noUsage: "暂无用量记录",
|
||||
},
|
||||
@ -1153,7 +1153,7 @@
|
||||
recsRotated: "The audit log rotated; reloaded from the newest record",
|
||||
recsNewest: "Back to newest",
|
||||
recsPartial:
|
||||
"Aggregates cover the recent audit tail replayed at startup; export CSV for the full history",
|
||||
"Some audit files could not be read, so these totals may be incomplete; export CSV for the full history",
|
||||
thTokens: "Tokens",
|
||||
noUsage: "No usage data yet",
|
||||
},
|
||||
@ -1406,7 +1406,9 @@
|
||||
cursor: "", // next-page cursor
|
||||
hasMore: false,
|
||||
loading: false,
|
||||
built: false, // table DOM exists (polls prepend instead of rebuilding)
|
||||
observer: null, // IntersectionObserver on the bottom sentinel
|
||||
onScroll: null, // scroll fallback (observers only fire on transitions)
|
||||
abort: null, // AbortController for the in-flight page fetch
|
||||
keyNames: {},
|
||||
};
|
||||
@ -1418,6 +1420,10 @@
|
||||
recsState.observer.disconnect();
|
||||
recsState.observer = null;
|
||||
}
|
||||
const root = $("#tb-recs");
|
||||
if (root && recsState.onScroll)
|
||||
root.removeEventListener("scroll", recsState.onScroll);
|
||||
recsState.onScroll = null;
|
||||
if (recsState.abort) {
|
||||
recsState.abort.abort();
|
||||
recsState.abort = null;
|
||||
@ -1426,11 +1432,9 @@
|
||||
recsState.cursor = "";
|
||||
recsState.hasMore = false;
|
||||
recsState.loading = false;
|
||||
recsState.built = false;
|
||||
recsState.keyNames = {};
|
||||
if (clearDom) {
|
||||
const el = $("#tb-recs");
|
||||
if (el) el.innerHTML = "";
|
||||
}
|
||||
if (clearDom && root) root.innerHTML = "";
|
||||
}
|
||||
|
||||
// ---- Source status column (refreshable) ----
|
||||
@ -2101,36 +2105,95 @@
|
||||
<th class="num">${t("thPrompt")}</th><th class="num">${t("thCompl")}</th><th class="num">${t("thCache") || "缓存"}</th><th class="num">${t("thLatMs")}</th></tr>`;
|
||||
}
|
||||
|
||||
// paintRecords renders the FIRST SCREEN from the dashboard poll. Rows are
|
||||
// newest-first. Once the user has scrolled (extra pages loaded) the poll no
|
||||
// longer rebuilds the table, otherwise every refresh would throw the loaded
|
||||
// history away.
|
||||
// paintRecords renders the record table from the dashboard poll.
|
||||
//
|
||||
// It builds the table ONCE. Later polls only PREPEND records that arrived
|
||||
// since the last paint: rebuilding on every 5s refresh used to wipe the
|
||||
// pages the user had scrolled in and reset the scroll position (which made
|
||||
// paging look broken), because the "is the paged view live?" check compared
|
||||
// row counts and matched exactly on the first refresh.
|
||||
function paintRecords(records, keyNames) {
|
||||
const el = $("#tb-recs");
|
||||
if (!el) return;
|
||||
recsState.keyNames = keyNames || {};
|
||||
const first = (records || []).slice().reverse(); // API sends oldest-first
|
||||
if (recsState.rows.length > first.length) {
|
||||
// paged view is live: only refresh the key names, keep the loaded rows
|
||||
const incoming = (records || []).slice().reverse(); // API sends oldest-first
|
||||
|
||||
if (!recsState.built) {
|
||||
if (!incoming.length) {
|
||||
recsState.rows = [];
|
||||
el.innerHTML = `<div class="muted">${t("noUsage")}</div>`;
|
||||
return;
|
||||
}
|
||||
recsState.rows = incoming;
|
||||
recsState.cursor = "";
|
||||
recsState.hasMore = true; // probed on the first page fetch
|
||||
el.innerHTML =
|
||||
`<table id="recs-tbl">${recHeadHtml()}<tbody id="recs-body">` +
|
||||
incoming.map((r) => recRowHtml(r, recsState.keyNames)).join("") +
|
||||
`</tbody></table><div id="recs-sentinel" class="recs-sentinel"></div>`;
|
||||
recsState.built = true;
|
||||
attachRecsObserver();
|
||||
fillRecordsViewport();
|
||||
return;
|
||||
}
|
||||
if (!first.length) {
|
||||
recsState.rows = [];
|
||||
el.innerHTML = `<div class="muted">${t("noUsage")}</div>`;
|
||||
return;
|
||||
|
||||
// Already built: splice in anything newer than our newest row.
|
||||
const body = $("#recs-body");
|
||||
if (!body || !incoming.length) return;
|
||||
const seen = new Set(recsState.rows.map(recId));
|
||||
const fresh = incoming.filter((r) => !seen.has(recId(r)));
|
||||
if (!fresh.length) return;
|
||||
body.insertAdjacentHTML(
|
||||
"afterbegin",
|
||||
fresh.map((r) => recRowHtml(r, recsState.keyNames)).join(""),
|
||||
);
|
||||
recsState.rows = fresh.concat(recsState.rows);
|
||||
trimRecordsDom(body);
|
||||
}
|
||||
|
||||
// recId identifies a record for de-duplication across the snapshot and the
|
||||
// paged endpoint (both can return the same row).
|
||||
function recId(r) {
|
||||
return (
|
||||
r.time + "|" + r.key + "|" + r.model + "|" + r.type + "|" + r.status
|
||||
);
|
||||
}
|
||||
|
||||
// trimRecordsDom enforces the sliding window: drop the oldest rendered rows
|
||||
// (bottom of the table) so a long scroll cannot grow the DOM without bound.
|
||||
function trimRecordsDom(body) {
|
||||
const over = recsState.rows.length - RECS_MAX_DOM;
|
||||
if (over <= 0) return;
|
||||
for (let i = 0; i < over && body.lastElementChild; i++)
|
||||
body.removeChild(body.lastElementChild);
|
||||
recsState.rows = recsState.rows.slice(0, RECS_MAX_DOM);
|
||||
}
|
||||
|
||||
// fillRecordsViewport loads pages until the list actually overflows its
|
||||
// container. Without this, a short table leaves the sentinel permanently
|
||||
// visible: IntersectionObserver only fires on TRANSITIONS, so it would
|
||||
// never fire again and scrolling could never trigger a load.
|
||||
async function fillRecordsViewport() {
|
||||
const el = $("#tb-recs");
|
||||
if (!el) return;
|
||||
let guard = 0;
|
||||
while (
|
||||
recsState.built &&
|
||||
recsState.hasMore !== false &&
|
||||
el.scrollHeight <= el.clientHeight + 8 &&
|
||||
guard++ < 5
|
||||
) {
|
||||
const before = recsState.rows.length;
|
||||
await loadMoreRecords();
|
||||
if (recsState.rows.length === before) break; // no progress: stop
|
||||
}
|
||||
recsState.rows = first;
|
||||
recsState.cursor = "";
|
||||
recsState.hasMore = true; // probed on the first scroll
|
||||
el.innerHTML =
|
||||
`<table id="recs-tbl">${recHeadHtml()}<tbody id="recs-body">` +
|
||||
first.map((r) => recRowHtml(r, recsState.keyNames)).join("") +
|
||||
`</tbody></table><div id="recs-sentinel" class="recs-sentinel"></div>`;
|
||||
attachRecsObserver();
|
||||
}
|
||||
|
||||
// attachRecsObserver watches a sentinel below the last row; entering the
|
||||
// viewport loads the next page. The observer is torn down by
|
||||
// viewport loads the next page. A scroll listener backs the observer up:
|
||||
// IntersectionObserver only fires on transitions, so a sentinel that is
|
||||
// already visible (short list, or a page that added no new rows) would
|
||||
// otherwise never trigger another load. Both are torn down by
|
||||
// releaseRecords when the view goes away.
|
||||
function attachRecsObserver() {
|
||||
if (recsState.observer) {
|
||||
@ -2139,26 +2202,39 @@
|
||||
}
|
||||
const root = $("#tb-recs");
|
||||
const sentinel = $("#recs-sentinel");
|
||||
if (!root || !sentinel || typeof IntersectionObserver === "undefined")
|
||||
return;
|
||||
recsState.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) loadMoreRecords();
|
||||
},
|
||||
{ root, rootMargin: "120px" },
|
||||
);
|
||||
recsState.observer.observe(sentinel);
|
||||
if (!root || !sentinel) return;
|
||||
if (typeof IntersectionObserver !== "undefined") {
|
||||
recsState.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) loadMoreRecords();
|
||||
},
|
||||
{ root, rootMargin: "120px" },
|
||||
);
|
||||
recsState.observer.observe(sentinel);
|
||||
}
|
||||
if (recsState.onScroll) root.removeEventListener("scroll", recsState.onScroll);
|
||||
recsState.onScroll = () => {
|
||||
if (root.scrollTop + root.clientHeight >= root.scrollHeight - 120)
|
||||
loadMoreRecords();
|
||||
};
|
||||
root.addEventListener("scroll", recsState.onScroll, { passive: true });
|
||||
}
|
||||
|
||||
// loadMoreRecords fetches the next page and appends it. Concurrent calls
|
||||
// are ignored, and a stale response (view released meanwhile) is dropped.
|
||||
async function loadMoreRecords() {
|
||||
//
|
||||
// A page that yields no NEW rows (the first fetch overlaps the dashboard's
|
||||
// first screen) chains straight into the following page, bounded by
|
||||
// maxChain, so the user never has to scroll twice for one page of data.
|
||||
async function loadMoreRecords(depth) {
|
||||
const maxChain = 3;
|
||||
if (recsState.loading || recsState.hasMore === false) return;
|
||||
recsState.loading = true;
|
||||
const sentinel = $("#recs-sentinel");
|
||||
if (sentinel) sentinel.textContent = t("recsLoading");
|
||||
const ctrl = new AbortController();
|
||||
recsState.abort = ctrl;
|
||||
let added = 0;
|
||||
try {
|
||||
let q =
|
||||
"/api/stats/records?limit=" +
|
||||
@ -2168,7 +2244,7 @@
|
||||
q += "&before=" + encodeURIComponent(recsState.cursor);
|
||||
const page = await api(q, { signal: ctrl.signal });
|
||||
if (recsState.abort !== ctrl) return; // released while in flight
|
||||
appendRecords(page);
|
||||
added = appendRecords(page);
|
||||
} catch (e) {
|
||||
if (e && e.name === "AbortError") return;
|
||||
console.error("[records]", e);
|
||||
@ -2180,39 +2256,35 @@
|
||||
if (s)
|
||||
s.textContent = recsState.hasMore === false ? t("recsEnd") : "";
|
||||
}
|
||||
if (added === 0 && recsState.hasMore && (depth || 0) < maxChain)
|
||||
await loadMoreRecords((depth || 0) + 1);
|
||||
}
|
||||
|
||||
// appendRecords merges one page into the table, skipping records already on
|
||||
// screen (the first page overlaps the dashboard's first screen) and
|
||||
// trimming the oldest DOM rows past RECS_MAX_DOM so the table cannot grow
|
||||
// without bound during a long scroll.
|
||||
// screen and trimming the oldest DOM rows past RECS_MAX_DOM.
|
||||
//
|
||||
// The first page fetch necessarily overlaps the dashboard's first screen, so
|
||||
// it can legitimately yield ZERO new rows. When that happens the cursor has
|
||||
// still advanced, so we immediately pull the next page instead of waiting
|
||||
// for another scroll event that may never come.
|
||||
function appendRecords(page) {
|
||||
const body = $("#recs-body");
|
||||
if (!body) return;
|
||||
if (!body) return 0;
|
||||
if (page.key_names) recsState.keyNames = page.key_names;
|
||||
const seen = new Set(
|
||||
recsState.rows.map((r) => r.time + "|" + r.key + "|" + r.model),
|
||||
);
|
||||
const fresh = (page.records || []).filter(
|
||||
(r) => !seen.has(r.time + "|" + r.key + "|" + r.model),
|
||||
);
|
||||
const seen = new Set(recsState.rows.map(recId));
|
||||
const fresh = (page.records || []).filter((r) => !seen.has(recId(r)));
|
||||
if (fresh.length) {
|
||||
body.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
fresh.map((r) => recRowHtml(r, recsState.keyNames)).join(""),
|
||||
);
|
||||
recsState.rows = recsState.rows.concat(fresh);
|
||||
// sliding window: drop the oldest rendered rows (bottom of the table)
|
||||
const over = recsState.rows.length - RECS_MAX_DOM;
|
||||
if (over > 0) {
|
||||
for (let i = 0; i < over && body.lastElementChild; i++)
|
||||
body.removeChild(body.lastElementChild);
|
||||
recsState.rows = recsState.rows.slice(0, RECS_MAX_DOM);
|
||||
}
|
||||
trimRecordsDom(body);
|
||||
}
|
||||
recsState.cursor = page.next_cursor || "";
|
||||
recsState.hasMore = !!page.has_more && !!page.next_cursor;
|
||||
if (page.rotated) toast(t("recsRotated"));
|
||||
return fresh.length;
|
||||
}
|
||||
|
||||
// cacheCell renders the per-request cache-hit column: a percentage when
|
||||
|
||||
Reference in New Issue
Block a user