fix(status): source column reflects real traffic + theme-aware tray menu

- api/status sources now carry recent_ok/recent_err (last 300s real gateway
  requests via Stats.SourceRecent) so a source actually serving traffic is
  never shown as down just because probe /models got rate-limited
- WebUI source status column repaints every 5s (no more frozen-at-first-
  render) with a manual refresh button; shows success rate + probe + cooldown
- tray menu status rows were enabled:false (GTK fixed light-grey, invisible
  on light themes) — now enabled with no-op click and nativeTheme listener
  rebuilds the menu on dark/light switches
- ignore local ops scripts (scripts/, machine-specific)
This commit is contained in:
2026-08-17 17:37:04 +08:00
parent 23bd9e5552
commit 8c5279b416
6 changed files with 141 additions and 32 deletions

View File

@ -1393,6 +1393,63 @@
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
let statsKeyF = ""; // active key filter for records ('' = all)
// ---- 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
// because /models probing got rate-limited by the upstream (common with
// free pools). Priority: real recent traffic > live probe > scheduling
// cooldown.
function srcStatusCell(x) {
const ok = x.recent_ok || 0;
const err = x.recent_err || 0;
const cooled = x.backoff_until && x.backoff_until * 1000 > Date.now();
const probeOK = !!x.live_available;
const lastErr = x.last_error ? esc(x.last_error) : "";
let cell;
if (ok + err > 0) {
// real traffic: show a success rate, probe only annotates
const rate = Math.round((ok * 100) / (ok + err));
const cls =
rate >= 95 ? "tag-green" : rate >= 50 ? "tag-amber" : "tag-red";
const tip = `最近流量 ${ok} 成功 / ${err} 失败 (探测: ${probeOK ? "可达" : "探测失败"} ${cooled ? "· 冷却中" : ""})`;
cell = `<span class="tag ${cls}" title="${escAttr(tip)}"><i class="net-dot"></i>${rate}%(${ok}/${ok + err})${cooled ? "·冷却" : ""}</span>`;
} else if (probeOK) {
// no recent traffic but probe alive
const tip = `探测正常 ${cooled ? "· 调度冷却中(可自动恢复)" : ""}`;
cell = `<span class="tag tag-green" title="${escAttr(tip)}"><i class="net-dot"></i>${t("online")}${cooled ? "·冷却" : ""}</span>`;
} else if (cooled) {
cell = `<span class="tag tag-amber" title="${escAttr("调度冷却中(不影响探测)" + (lastErr ? " " + lastErr : ""))}"><i class="net-dot"></i>冷却中</span>`;
} else {
cell = `<span class="tag tag-red" title="${escAttr(lastErr || "探测失败")}"><i class="net-dot"></i>${t("offline")}</span>`;
}
return cell;
}
// Re-fetch /api/status and repaint only the source status column. Called
// from the 5s stats loop and the manual refresh button so the column is
// never frozen at the first render.
async function paintSourceStatus() {
const tb = $("#src-body");
if (!tb) return;
try {
const s = await api("/api/status");
const srcs = s.sources || [];
tb.innerHTML = srcs
.map(
(x) =>
`<tr><td>${srcStatusCell(x)}</td>
<td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
<td><div class="src-models">${(x.models || []).map((m) => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join("")}</div></td>
<td><span class="muted">${esc(x.base_url || "")}</span></td>
<td>${x.max_concurrent}</td></tr>`,
)
.join("");
} catch (e) {
// status fetch failing (e.g. session) is surfaced by other loops
}
}
async function renderStatus() {
const s = await api("/api/status");
const base = (window._base = s.base_url || location.origin + "/v1");
@ -1402,24 +1459,16 @@
const pane = $("#tab-status");
if (pane && pane.dataset.built) {
if (statsTimerId) clearInterval(statsTimerId);
await paintSourceStatus();
await paintStats();
statsTimerId = setInterval(() => {
if (!document.hidden) paintStats();
if (!document.hidden) {
paintStats();
paintSourceStatus();
}
}, 5000);
return;
}
const srcRows = s.sources
? s.sources
.map(
(x) =>
`<tr><td>${x.live_available ? `<span class="tag tag-green"><i class="net-dot"></i>${t("online")}</span>` : `<span class="tag tag-red" title="${esc(x.last_error || "")}"><i class="net-dot"></i>${t("offline")}</span>`}</td>
<td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
<td><div class="src-models">${x.models.map((m) => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join("")}</div></td>
<td><span class="muted">${esc(x.base_url || "")}</span></td>
<td>${x.max_concurrent}</td></tr>`,
)
.join("")
: "";
pane.innerHTML = `
<div class="kpis" id="kpi-row"><div class="kpi-skeletons" aria-hidden="true">${Array(
5,
@ -1441,7 +1490,8 @@
${
s.sources
? `<div class="card"><h2>${t("srcTitle")} (${s.sources.length})</h2>
<div class="tbl-wrap"><table><tr><th>${t("tConn")}</th><th>${t("tName")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th>${t("tURL")}</th><th>${t("tConc")}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t("srcEmpty")}</td></tr>`}</table></div>
<div class="tbl-wrap"><table><tr><th>${t("tConn")}</th><th>${t("tName")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th>${t("tURL")}</th><th>${t("tConc")}</th></tr><tbody id="src-body"></tbody></table></div>
<p class="muted" style="padding:6px 2px 0"><button class="ghost small" onclick="paintSourceStatus()">${t("refreshSrc") || "刷新源状态"}</button> 状态列每 5 秒自动刷新</p>
</div>`
: ""
}
@ -1475,9 +1525,13 @@
)
.join("");
if (statsTimerId) clearInterval(statsTimerId);
await paintSourceStatus();
await paintStats();
statsTimerId = setInterval(() => {
if (!document.hidden) paintStats();
if (!document.hidden) {
paintStats();
paintSourceStatus();
}
}, 5000);
}
function renderKeySelect(keys, keyNames) {