mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-21 09:28:00 +00:00
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:
3
.gitignore
vendored
3
.gitignore
vendored
@ -17,3 +17,6 @@ cmd/gui/*.log
|
|||||||
.pi-glla/
|
.pi-glla/
|
||||||
.codegraph/
|
.codegraph/
|
||||||
/build/
|
/build/
|
||||||
|
|
||||||
|
# local ops scripts (machine-specific, not for sharing)
|
||||||
|
scripts/
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const {
|
|||||||
Tray,
|
Tray,
|
||||||
shell,
|
shell,
|
||||||
nativeImage,
|
nativeImage,
|
||||||
|
nativeTheme,
|
||||||
} = require("electron");
|
} = require("electron");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@ -357,17 +358,27 @@ function createTray() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
tray = new Tray(img);
|
tray = new Tray(img);
|
||||||
|
// Rebuild the context menu when the OS theme flips (dark/light) so menu
|
||||||
|
// text colors follow the desktop theme; nativeTheme is the Electron API
|
||||||
|
// that tracks GTK/appindicator dark mode on Linux.
|
||||||
|
const themeRebuild = () => {
|
||||||
|
try {
|
||||||
|
if (global.__rebuildTray) global.__rebuildTray();
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
nativeTheme.on("updated", themeRebuild);
|
||||||
|
} catch (e) {}
|
||||||
|
// Status rows used to be enabled:false, which GTK renders in a fixed light
|
||||||
|
// grey that never flips with the system dark/light theme — invisible on
|
||||||
|
// light desktops. They are now enabled (normal theme-aware fg) with a
|
||||||
|
// no-op click so they stay display-only but inherit the menu text color.
|
||||||
|
const statusRow = (text) => ({ label: text, enabled: true, click: () => {} });
|
||||||
const rebuild = () => {
|
const rebuild = () => {
|
||||||
const running = coreStarted();
|
const running = coreStarted();
|
||||||
const menu = Menu.buildFromTemplate([
|
const menu = Menu.buildFromTemplate([
|
||||||
{
|
statusRow("ModelRouter Desktop"),
|
||||||
label: "ModelRouter Desktop",
|
statusRow(running ? "● 内核运行中" : "○ 内核已停止"),
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: running ? "● 内核运行中" : "○ 内核已停止",
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: "显示主窗口",
|
label: "显示主窗口",
|
||||||
|
|||||||
@ -456,7 +456,15 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
"gateway_keys": ks,
|
"gateway_keys": ks,
|
||||||
}
|
}
|
||||||
if reqRole(r.Context()) == "admin" {
|
if reqRole(r.Context()) == "admin" {
|
||||||
resp["sources"] = g.core.Registry().Status()
|
sts := g.core.Registry().Status()
|
||||||
|
recent := g.stats.SourceRecent(300)
|
||||||
|
for i := range sts {
|
||||||
|
if v, ok := recent[sts[i].Name]; ok {
|
||||||
|
sts[i].RecentOK = v[0]
|
||||||
|
sts[i].RecentErr = v[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp["sources"] = sts
|
||||||
resp["adapters"] = g.core.ListAdapters()
|
resp["adapters"] = g.core.ListAdapters()
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
|||||||
@ -445,6 +445,33 @@ func (s *Stats) AuditRecords(from, to int64, key string) []Req {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SourceRecent counts real gateway requests per source within the last
|
||||||
|
// window (unix seconds). It reads only the in-memory ring, so it is cheap and
|
||||||
|
// reflects live traffic — used by /api/status so the source status column is
|
||||||
|
// driven by what actually happens, not just a probe.
|
||||||
|
func (s *Stats) SourceRecent(windowSec int64) map[string][2]int64 {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
cut := time.Now().Unix() - windowSec
|
||||||
|
out := map[string][2]int64{}
|
||||||
|
for _, r := range s.recs {
|
||||||
|
if r.Time/1000 < cut {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Source == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := out[r.Source]
|
||||||
|
if r.OK {
|
||||||
|
v[0]++
|
||||||
|
} else {
|
||||||
|
v[1]++
|
||||||
|
}
|
||||||
|
out[r.Source] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot returns the whole dashboard payload; when key != "" the records
|
// Snapshot returns the whole dashboard payload; when key != "" the records
|
||||||
// and aggregate views are restricted to that gateway key.
|
// and aggregate views are restricted to that gateway key.
|
||||||
func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||||
|
|||||||
@ -1393,6 +1393,63 @@
|
|||||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
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)
|
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() {
|
async function renderStatus() {
|
||||||
const s = await api("/api/status");
|
const s = await api("/api/status");
|
||||||
const base = (window._base = s.base_url || location.origin + "/v1");
|
const base = (window._base = s.base_url || location.origin + "/v1");
|
||||||
@ -1402,24 +1459,16 @@
|
|||||||
const pane = $("#tab-status");
|
const pane = $("#tab-status");
|
||||||
if (pane && pane.dataset.built) {
|
if (pane && pane.dataset.built) {
|
||||||
if (statsTimerId) clearInterval(statsTimerId);
|
if (statsTimerId) clearInterval(statsTimerId);
|
||||||
|
await paintSourceStatus();
|
||||||
await paintStats();
|
await paintStats();
|
||||||
statsTimerId = setInterval(() => {
|
statsTimerId = setInterval(() => {
|
||||||
if (!document.hidden) paintStats();
|
if (!document.hidden) {
|
||||||
|
paintStats();
|
||||||
|
paintSourceStatus();
|
||||||
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
return;
|
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 = `
|
pane.innerHTML = `
|
||||||
<div class="kpis" id="kpi-row"><div class="kpi-skeletons" aria-hidden="true">${Array(
|
<div class="kpis" id="kpi-row"><div class="kpi-skeletons" aria-hidden="true">${Array(
|
||||||
5,
|
5,
|
||||||
@ -1441,7 +1490,8 @@
|
|||||||
${
|
${
|
||||||
s.sources
|
s.sources
|
||||||
? `<div class="card"><h2>${t("srcTitle")} (${s.sources.length})</h2>
|
? `<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>`
|
</div>`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
@ -1475,9 +1525,13 @@
|
|||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
if (statsTimerId) clearInterval(statsTimerId);
|
if (statsTimerId) clearInterval(statsTimerId);
|
||||||
|
await paintSourceStatus();
|
||||||
await paintStats();
|
await paintStats();
|
||||||
statsTimerId = setInterval(() => {
|
statsTimerId = setInterval(() => {
|
||||||
if (!document.hidden) paintStats();
|
if (!document.hidden) {
|
||||||
|
paintStats();
|
||||||
|
paintSourceStatus();
|
||||||
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
function renderKeySelect(keys, keyNames) {
|
function renderKeySelect(keys, keyNames) {
|
||||||
|
|||||||
@ -211,6 +211,12 @@ type SourceStatus struct {
|
|||||||
FailCount int `json:"fail_count,omitempty"`
|
FailCount int `json:"fail_count,omitempty"`
|
||||||
BackoffUntil int64 `json:"backoff_until,omitempty"`
|
BackoffUntil int64 `json:"backoff_until,omitempty"`
|
||||||
Permanent bool `json:"permanent,omitempty"`
|
Permanent bool `json:"permanent,omitempty"`
|
||||||
|
// RecentOK / RecentErr count real gateway requests served by this source
|
||||||
|
// within the last 300s (drives the status column so a source that is
|
||||||
|
// actually serving traffic can never be shown as down just because a
|
||||||
|
// probe was rate-limited by the upstream).
|
||||||
|
RecentOK int64 `json:"recent_ok,omitempty"`
|
||||||
|
RecentErr int64 `json:"recent_err,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProbeAll runs a live reachability check for every provider (in parallel).
|
// ProbeAll runs a live reachability check for every provider (in parallel).
|
||||||
|
|||||||
Reference in New Issue
Block a user