From 8c5279b4163d8bc80ea6dad98a763bd114b6d1d3 Mon Sep 17 00:00:00 2001 From: jianf <2198972886@qq.com> Date: Mon, 17 Aug 2026 17:37:04 +0800 Subject: [PATCH] fix(status): source column reflects real traffic + theme-aware tray menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 3 ++ cmd/gui/main.js | 27 +++++++---- internal/gateway/server.go | 20 +++++--- internal/gateway/stats.go | 31 ++++++++++++- internal/gateway/ui/index.html | 84 ++++++++++++++++++++++++++++------ internal/provider/registry.go | 8 +++- 6 files changed, 141 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index f981437..856bfa7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ cmd/gui/*.log .pi-glla/ .codegraph/ /build/ + +# local ops scripts (machine-specific, not for sharing) +scripts/ diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 833a391..3c9b954 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -10,6 +10,7 @@ const { Tray, shell, nativeImage, + nativeTheme, } = require("electron"); const path = require("path"); const fs = require("fs"); @@ -357,17 +358,27 @@ function createTray() { ); } 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 running = coreStarted(); const menu = Menu.buildFromTemplate([ - { - label: "ModelRouter Desktop", - enabled: false, - }, - { - label: running ? "● 内核运行中" : "○ 内核已停止", - enabled: false, - }, + statusRow("ModelRouter Desktop"), + statusRow(running ? "● 内核运行中" : "○ 内核已停止"), { type: "separator" }, { label: "显示主窗口", diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 1f54ce9..e3263bb 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -343,10 +343,10 @@ func (g *Gateway) handleLoginAPI(w http.ResponseWriter, r *http.Request) { return } http.SetCookie(w, &http.Cookie{ - Name: "gw_key", - Value: body.Key, - Path: "/", - MaxAge: 86400 * 30, + Name: "gw_key", + Value: body.Key, + Path: "/", + MaxAge: 86400 * 30, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) @@ -456,7 +456,15 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) { "gateway_keys": ks, } 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() } writeJSON(w, http.StatusOK, resp) @@ -491,4 +499,4 @@ func writeJSON(w http.ResponseWriter, code int, v interface{}) { if err := json.NewEncoder(w).Encode(v); err != nil { log.Printf("[gateway] write json: %v", err) } -} \ No newline at end of file +} diff --git a/internal/gateway/stats.go b/internal/gateway/stats.go index 7c2ebaf..2f509b8 100644 --- a/internal/gateway/stats.go +++ b/internal/gateway/stats.go @@ -26,7 +26,7 @@ type Req struct { Compl int64 `json:"completion_tokens"` // LatMs total handling time ms LatMs int64 `json:"latency_ms"` - OK bool `json:"ok"` + OK bool `json:"ok"` // Status http status code Status int `json:"status"` // Err short error message @@ -445,6 +445,33 @@ func (s *Stats) AuditRecords(from, to int64, key string) []Req { 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 // and aggregate views are restricted to that gateway key. func (s *Stats) Snapshot(limit int, key string) map[string]interface{} { @@ -506,4 +533,4 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} { "by_status": bs, "records": append([]Req(nil), recs...), } -} \ No newline at end of file +} diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index e724d66..bc40811 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -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 = `${rate}%(${ok}/${ok + err})${cooled ? "·冷却" : ""}`; + } else if (probeOK) { + // no recent traffic but probe alive + const tip = `探测正常 ${cooled ? "· 调度冷却中(可自动恢复)" : ""}`; + cell = `${t("online")}${cooled ? "·冷却" : ""}`; + } else if (cooled) { + cell = `冷却中`; + } else { + cell = `${t("offline")}`; + } + 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) => + `