diff --git a/internal/core/core.go b/internal/core/core.go index 6997296..30252c5 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -26,12 +26,12 @@ import ( // request paths look keys up concurrently; the AUTO chain itself is swapped // atomically and needs no lock. type Core struct { - mu sync.Mutex - cfg *config.Config - vm *lua.VM - store *config.Store - scheduler *scheduler.Scheduler - registry *provider.Registry + mu sync.Mutex + cfg *config.Config + vm *lua.VM + store *config.Store + scheduler *scheduler.Scheduler + registry *provider.Registry autoChain atomic.Pointer[scheduler.Chain] // chat AUTO chain autoImageChain atomic.Pointer[scheduler.Chain] // image-generation AUTO chain } @@ -565,6 +565,16 @@ type AutoSlotState struct { FailCount int64 `json:"fail_count"` CooldownUntil int64 `json:"cooldown_until"` Cooling bool `json:"cooling"` + // CooldownFrom is when the current cooldown window opened and ProbeAfter is + // its midpoint: from ProbeAfter on, a single probe request is allowed + // through so a recovered upstream does not have to sit out the rest of the + // window. Probing reports whether such a probe is in flight right now. + CooldownFrom int64 `json:"cooldown_from"` + ProbeAfter int64 `json:"probe_after"` + Probing bool `json:"probing"` + // Probeable marks a cooling slot that is past its midpoint, i.e. the next + // AUTO request may use it as a probe. + Probeable bool `json:"probeable"` } // AutoSlotStates returns per-slot health for every slot of the current chain. @@ -582,6 +592,7 @@ func (c *Core) AutoSlotStates() []AutoSlotState { continue } pref, fail, until := pp.ModelHealthInfo(sl.Model) + from, probeAfter, probing := pp.ModelProbeInfo(sl.Model) out = append(out, AutoSlotState{ Model: sl.Model, Source: sl.Source, @@ -589,6 +600,10 @@ func (c *Core) AutoSlotStates() []AutoSlotState { FailCount: fail, CooldownUntil: until, Cooling: until > now, + CooldownFrom: from, + ProbeAfter: probeAfter, + Probing: probing, + Probeable: until > now && probeAfter > 0 && now >= probeAfter, }) } } @@ -740,4 +755,4 @@ func (c *Core) Close() { if c.vm != nil { c.vm.Stop() } -} \ No newline at end of file +} diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 4be0e70..970b5e5 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -250,6 +250,7 @@ .bar.ok i{background:var(--ok)} .bar.err i{background:var(--danger)} .recs-scroll{max-height:420px;overflow:auto} + .recs-sentinel{min-height:24px;padding:6px 2px;font-size:12px;color:var(--muted);text-align:center} .recs-scroll table th{position:sticky;top:0;background:var(--card);z-index:1} td.t-tag{white-space:nowrap} .tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch} @@ -400,6 +401,7 @@ .scr-block .scr-tag{flex:0 0 auto;font-family:'JetBrains Mono',ui-monospace,Consolas,monospace;font-size:10.5px;padding:2px 7px;border-radius:9px;background:rgba(0,0,0,.24);color:#ffe9a8;border:1px solid rgba(255,220,130,.35);cursor:pointer} .scr-block .scr-htag{flex:0 0 auto;display:flex;gap:4px;align-items:center;font-family:'JetBrains Mono',ui-monospace,Consolas,monospace;font-size:10px;font-weight:700;cursor:help} .scr-htag .ht-cool{padding:2px 6px;border-radius:9px;background:rgba(244,114,182,.32);color:#ffd9e5;border:1px solid rgba(255,127,172,.5)} + .scr-htag .ht-probe{padding:2px 6px;border-radius:9px;background:rgba(251,191,36,.32);color:#fff3d0;border:1px solid rgba(251,191,36,.55)} .scr-htag .ht-fail{padding:2px 6px;border-radius:9px;background:rgba(255,160,60,.22);color:#ffd9a8;border:1px solid rgba(255,180,90,.42)} .scr-htag .ht-pref{padding:2px 6px;border-radius:9px;background:rgba(136,192,208,.26);color:#d6f1fa;border:1px solid rgba(136,192,208,.42)} .scr-block .scr-grip{flex:0 0 auto;display:flex;flex-direction:column;gap:2px;padding:6px 4px;margin-left:2px;border-radius:6px;cursor:grab;background:rgba(255,255,255,.22);box-shadow:inset 0 1px 2px rgba(0,0,0,.2);transition:background .12s;touch-action:none} @@ -784,6 +786,14 @@ offline: "退避 / 不可用", adTitle: "已加载适配器", tVersion: "版本", + adPool: "Lua 状态池", + adPoolHint: + "池按需伸缩:扩容步长由最大并发决定,缩容步长由当前连接数决定(连接为 0 时回收到常驻下限)", + adPoolIdle: "空闲", + adPoolBusy: "占用", + adPoolMax: "上限", + adPoolPeak: "峰值", + adPoolSteps: "步长 (扩/缩)", cModel: "模型", cMsg: "Enter 发送,Shift+Enter 换行", send: "发送", @@ -865,8 +875,12 @@ sortSaved: "排序已保存并热重载", sortHintSave: "点击保存排序后生效", sortCooling: "冷却", + sortProbeReady: "待探测", + sortProbing: "探测中", sortFail: "失败", sortHealthTip: "冷却 / 失败次数 / 偏好分 实时状态", + sortProbeTip: + "冷却窗口 %s 开始,%s 起放行 1 个探测请求,%s 完全恢复(探测成功则立即恢复)", sortHealthReset: "链上冷却已复位", kpiActive: "活跃请求", kpiReqs: "总请求", @@ -908,6 +922,12 @@ thLatMs: "延迟", allKeys: "全部密钥", recFilter: "密钥筛选", + recsLoading: "加载更多…", + recsEnd: "已到最早记录", + recsRotated: "审计日志已轮转,已从最新记录重新加载", + recsNewest: "回到最新", + recsPartial: + "统计基于最近一段审计日志(启动时只回放尾部);完整历史请导出 CSV", thTokens: "Tokens", noUsage: "暂无用量记录", }, @@ -991,6 +1011,14 @@ offline: "backoff / down", adTitle: "Loaded adapters", tVersion: "Version", + adPool: "Lua state pool", + adPoolHint: + "Pools scale on demand: the grow step follows max concurrency, the shrink step follows the live connection count (idle pools collapse to the resident floor)", + adPoolIdle: "Idle", + adPoolBusy: "In use", + adPoolMax: "Max", + adPoolPeak: "Peak", + adPoolSteps: "Steps (grow/shrink)", cModel: "Model", cMsg: "Enter to send, Shift+Enter for newline", send: "Send", @@ -1073,8 +1101,12 @@ sortSaved: "Order saved & hot-reloaded", sortHintSave: "Click Save for it to take effect", sortCooling: "cooling", + sortProbeReady: "probe ready", + sortProbing: "probing", sortFail: "fail", sortHealthTip: "live cooldown / failures / preference score", + sortProbeTip: + "cooldown opened %s, one probe request is allowed from %s, fully clear at %s (a successful probe restores it immediately)", sortHealthReset: "chain cooldowns reset", kpiActive: "Active requests", kpiReqs: "Requests", @@ -1116,6 +1148,12 @@ thLatMs: "Latency", allKeys: "All keys", recFilter: "Key filter", + recsLoading: "Loading more…", + recsEnd: "Reached the oldest record", + 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", thTokens: "Tokens", noUsage: "No usage data yet", }, @@ -1205,6 +1243,9 @@ }); updateBreadcrumb(lastTab); function goTab(name) { + // leaving the dashboard releases the record buffer, its observer and any + // in-flight page fetch straight away + if (lastTab === "status" && name !== "status") releaseRecords(true); document .querySelectorAll(".sb-i") .forEach((x) => x.classList.toggle("active", x.dataset.tab === name)); @@ -1350,6 +1391,48 @@ }; let statsKeyF = ""; // active key filter for records ('' = all) + /* ---------- records: on-demand paging ---------- + * The records table holds only what is on screen. The first screen comes + * from /api/stats (its records field), and scrolling to the bottom pulls + * the next page from /api/stats/records?before=. Nothing is cached + * across page views: leaving the dashboard (tab switch or unload) drops the + * buffer, disconnects the observer and aborts any in-flight fetch, so both + * browser and server go back to holding nothing. + */ + const RECS_PAGE = 100; // rows fetched per scroll page + const RECS_MAX_DOM = 1000; // hard cap on rendered rows (sliding window) + const recsState = { + rows: [], // newest-first, mirrors the DOM + cursor: "", // next-page cursor + hasMore: false, + loading: false, + observer: null, // IntersectionObserver on the bottom sentinel + abort: null, // AbortController for the in-flight page fetch + keyNames: {}, + }; + + // releaseRecords drops every record-view resource. Called on tab switch, + // on key-filter change and on unload: "release when the user leaves". + function releaseRecords(clearDom) { + if (recsState.observer) { + recsState.observer.disconnect(); + recsState.observer = null; + } + if (recsState.abort) { + recsState.abort.abort(); + recsState.abort = null; + } + recsState.rows = []; + recsState.cursor = ""; + recsState.hasMore = false; + recsState.loading = false; + recsState.keyNames = {}; + if (clearDom) { + const el = $("#tb-recs"); + if (el) el.innerHTML = ""; + } + } + // ---- Source status column (refreshable) ---- // The column is driven by a combination of signals so a source that is // actually serving traffic can never be shown as "down/backoff" just @@ -1458,10 +1541,12 @@ ${s.sources ? `

${t("dashSrc")}

` : ""}

${t("dashKey")}

-

${t("dashRecs")}

+

${t("dashRecs")}

${t("recFilter")} + +
@@ -1507,6 +1592,8 @@ } function renderKeyF(v) { statsKeyF = statsKeyF === v ? "" : v; + // the record set changes entirely: drop the paged buffer and start over + releaseRecords(true); paintStats(); } function openExportModal() { @@ -1571,8 +1658,11 @@ } async function paintStats() { try { + // limit=0 -> the server ships one screen of records; the rest is paged + // on demand from /api/stats/records as the user scrolls. const q = - "/api/stats?limit=2000" + + "/api/stats?limit=" + + RECS_PAGE + (statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : ""); const st = await api(q); const tot = st.total || {}; @@ -1638,6 +1728,13 @@ paintStatusTable(st.by_status || []); paintKeyTable(st.by_key || [], st.key_names || {}); paintRecords(st.records || [], st.key_names || {}); + // aggregates built from a bounded audit tail: say so instead of + // implying the numbers are all-time totals + const rpart = $("#rec-partial"); + if (rpart) + rpart.innerHTML = st.replay_partial + ? `ⓘ ${esc(t("recsPartial"))}` + : ""; const rex = $("#rec-exit"); if (rex) rex.innerHTML = statsKeyF @@ -1981,29 +2078,141 @@ .join("") + "
"; } - function paintRecords(records, keyNames) { - const el = $("#tb-recs"); - if (!el) return; - if (!records.length) { - el.innerHTML = `
${t("noUsage")}
`; - return; - } - const slice = records.slice().reverse().slice(0, 300); - el.innerHTML = - ` - ` + - slice - .map( - (r) => ` + // resetRecords drops the paged history and returns to the newest screen, + // which is also how the user gets out of a long scroll cheaply. + function resetRecords() { + releaseRecords(true); + paintStats(); + } + + // recRowHtml renders one record row. + function recRowHtml(r, keyNames) { + return ` - `, - ) - .join("") + - "
${t("thTime")}${t("thStatus")}${t("thKey")}${t("thType")}${t("thModel")}${t("thSrc")}${t("thPrompt")}${t("thCompl")}${t("thCache") || "缓存"}${t("thLatMs")}
${fmtTime(r.time)} ${r.ok ? `${r.status || 200}` : `${r.status || 500}`} ${esc(keyNames[r.key] ? keyNames[r.key] + " · " + r.key : r.key)}${esc(r.type)}${esc(r.model)}${esc(r.source || "")} ${fmtTok(r.prompt_tokens)}${fmtTok(r.completion_tokens)} ${cacheCell(r)}${fmtMs(r.latency_ms)}
"; + ${fmtMs(r.latency_ms)}`; + } + + function recHeadHtml() { + return `${t("thTime")}${t("thStatus")}${t("thKey")}${t("thType")}${t("thModel")}${t("thSrc")} + ${t("thPrompt")}${t("thCompl")}${t("thCache") || "缓存"}${t("thLatMs")}`; + } + + // 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. + 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 + return; + } + if (!first.length) { + recsState.rows = []; + el.innerHTML = `
${t("noUsage")}
`; + return; + } + recsState.rows = first; + recsState.cursor = ""; + recsState.hasMore = true; // probed on the first scroll + el.innerHTML = + `${recHeadHtml()}` + + first.map((r) => recRowHtml(r, recsState.keyNames)).join("") + + `
`; + attachRecsObserver(); + } + + // attachRecsObserver watches a sentinel below the last row; entering the + // viewport loads the next page. The observer is torn down by + // releaseRecords when the view goes away. + function attachRecsObserver() { + if (recsState.observer) { + recsState.observer.disconnect(); + recsState.observer = null; + } + 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); + } + + // loadMoreRecords fetches the next page and appends it. Concurrent calls + // are ignored, and a stale response (view released meanwhile) is dropped. + async function loadMoreRecords() { + 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; + try { + let q = + "/api/stats/records?limit=" + + RECS_PAGE + + (statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : ""); + if (recsState.cursor) + q += "&before=" + encodeURIComponent(recsState.cursor); + const page = await api(q, { signal: ctrl.signal }); + if (recsState.abort !== ctrl) return; // released while in flight + appendRecords(page); + } catch (e) { + if (e && e.name === "AbortError") return; + console.error("[records]", e); + recsState.hasMore = false; + } finally { + if (recsState.abort === ctrl) recsState.abort = null; + recsState.loading = false; + const s = $("#recs-sentinel"); + if (s) + s.textContent = recsState.hasMore === false ? t("recsEnd") : ""; + } + } + + // 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. + function appendRecords(page) { + const body = $("#recs-body"); + if (!body) return; + 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), + ); + 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); + } + } + recsState.cursor = page.next_cursor || ""; + recsState.hasMore = !!page.has_more && !!page.next_cursor; + if (page.rotated) toast(t("recsRotated")); } // cacheCell renders the per-request cache-hit column: a percentage when @@ -2979,8 +3188,19 @@ const st = sortStateMap.get(it.id + "|" + (it.src || "*")); if (!st) return ""; const bits = []; - if (st.cooling) - bits.push(`${esc(t("sortCooling"))}`); + if (st.cooling) { + // A cooling slot past its window midpoint still takes one probe + // request, so show that instead of a flat "cooling". + if (st.probing) + bits.push( + `${esc(t("sortProbing"))}`, + ); + else if (st.probeable) + bits.push( + `${esc(t("sortProbeReady"))}`, + ); + else bits.push(`${esc(t("sortCooling"))}`); + } if (st.fail_count > 0) bits.push( `${esc(t("sortFail") + "×" + st.fail_count)}`, @@ -2990,7 +3210,26 @@ `${esc(st.pref > 0 ? "+" + st.pref : "" + st.pref)}`, ); if (!bits.length) return ""; - return `${bits.join("")}`; + return `${bits.join("")}`; + } + + // healthTip explains the cooldown/probe window in the tooltip: when the + // window opened, when probing starts and when it fully expires. + function healthTip(st) { + const base = t("sortHealthTip"); + if (!st.cooling || !st.cooldown_until) return base; + const hms = (ts) => + ts ? new Date(ts * 1000).toLocaleTimeString() : "—"; + return ( + base + + "\n" + + tFmt( + "sortProbeTip", + hms(st.cooldown_from), + hms(st.probe_after), + hms(st.cooldown_until), + ) + ); } function scrBlockHtml(it, isFirst, li, ji, extraClass) { const c = srcColor(it.src); @@ -3641,18 +3880,23 @@ /* ---------- adapters tab ---------- */ async function renderAdapters() { const j = await api("/api/status"); + const pools = {}; + (j.adapter_pools || []).forEach((p) => { + pools[p.name] = p; + }); const rows = j.adapters - .map( - ( - a, - ) => `${esc(a.name)}${esc(a.version || "")} - `, - ) + .map((a) => { + const p = pools[a.name]; + return `${esc(a.name)}${esc(a.version || "")} + ${poolCell(p)} + `; + }) .join(""); $("#tab-adapters").innerHTML = `

${t("adTitle")} (${j.adapters.length})

-
- ${rows || ``}
${t("tName")}${t("tVersion")}
${t("adEmpty")}
+
+ ${rows || ``}
${t("tName")}${t("tVersion")}${t("adPool")}
${t("adEmpty")}
+

${t("adPoolHint")}

${t("uploadTitle")}

${t("dropHint")}
@@ -3664,6 +3908,26 @@
`; bindDropzone(); } + + // poolCell renders one adapter's elastic Lua state pool: how many states + // exist vs the ceiling, how they split between idle and checked out, and + // the current grow/shrink steps. A pool that has never been used shows a + // dash: zero resident states is the healthy default, not an error. + function poolCell(p) { + if (!p) return ''; + if (!p.created) + return `0 / ${p.max}`; + const busy = p.in_use > 0; + const title = [ + `${t("adPoolIdle")} ${p.idle}`, + `${t("adPoolBusy")} ${p.in_use}`, + `${t("adPoolMax")} ${p.max}`, + `${t("adPoolPeak")} ${p.peak_in_use}`, + `${t("adPoolSteps")} +${p.grow_step} / -${p.shrink_step}`, + ].join(" · "); + return `${p.created} / ${p.max} + ${p.in_use}● ${p.idle}○ +${p.grow_step}/-${p.shrink_step}`; + } function bindDropzone() { const dz = $("#dz"), file = $("#adp-file"), @@ -4313,6 +4577,10 @@ } } catch (e) {} })(); + // page unload / navigation away: release the record view immediately + window.addEventListener("pagehide", () => releaseRecords(false)); + window.addEventListener("beforeunload", () => releaseRecords(false)); + refresh("status");