From e69082a596bd22def460d52d7365e486c6f03442 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Thu, 26 Mar 2026 20:08:40 +0800 Subject: [PATCH] fix(monitor): add tunnel quality detection toggle Allow admins to disable real-time tunnel quality probing from settings so the monitor UI and backend probe loop stop together. Co-Authored-By: Claude Opus 4.6 (1M context) --- go-backend/internal/http/handler/handler.go | 23 +++ go-backend/internal/http/handler/jobs.go | 6 +- .../http/handler/tunnel_quality_prober.go | 23 ++- .../src/pages/node/tunnel-monitor-view.tsx | 155 ++++++++++++++---- vite-frontend/src/pages/settings.tsx | 139 ++++++++++++---- 5 files changed, 283 insertions(+), 63 deletions(-) diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 15c6506..0aa93cd 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -46,6 +46,8 @@ type Handler struct { qualityProber *tunnelQualityProber } +const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled" + type loginRequest struct { Username string `json:"username"` Password string `json:"password"` @@ -886,11 +888,32 @@ func normalizeAndValidateConfigValue(key, value string) (string, error) { } return pngDataURLPrefix + payload, nil + case monitorTunnelQualityEnabledConfigKey: + normalized := strings.TrimSpace(strings.ToLower(value)) + switch normalized { + case "true", "false": + return normalized, nil + default: + return "", fmt.Errorf("隧道质量检测开关配置值无效") + } default: return value, nil } } +func (h *Handler) isTunnelQualityMonitoringEnabled() bool { + if h == nil || h.repo == nil { + return true + } + + cfg, err := h.repo.GetConfigByName(monitorTunnelQualityEnabledConfigKey) + if err != nil || cfg == nil { + return true + } + + return strings.TrimSpace(strings.ToLower(cfg.Value)) != "false" +} + func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("请求失败")) diff --git a/go-backend/internal/http/handler/jobs.go b/go-backend/internal/http/handler/jobs.go index 3225eeb..99f77fd 100644 --- a/go-backend/internal/http/handler/jobs.go +++ b/go-backend/internal/http/handler/jobs.go @@ -66,9 +66,11 @@ func (h *Handler) runHealthChecks(ctx context.Context) { func (h *Handler) runTunnelQualityProber(ctx context.Context) { defer h.jobsWG.Done() - if h.qualityProber != nil { - h.qualityProber.Start(ctx) + if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() { + return } + + h.qualityProber.Start(ctx) } func (h *Handler) runHourlyStatsLoop(ctx context.Context) { diff --git a/go-backend/internal/http/handler/tunnel_quality_prober.go b/go-backend/internal/http/handler/tunnel_quality_prober.go index b5e93df..9cf76d9 100644 --- a/go-backend/internal/http/handler/tunnel_quality_prober.go +++ b/go-backend/internal/http/handler/tunnel_quality_prober.go @@ -48,11 +48,8 @@ type tunnelQualityProber struct { // newTunnelQualityProber creates a new prober (not yet running). func newTunnelQualityProber(h *Handler) *tunnelQualityProber { - ctx, cancel := context.WithCancel(context.Background()) return &tunnelQualityProber{ handler: h, - ctx: ctx, - cancel: cancel, interval: tunnelQualityProbeInterval, } } @@ -66,6 +63,10 @@ func (p *tunnelQualityProber) Start(ctx context.Context) { // Stop halts the background probe loop. func (p *tunnelQualityProber) Stop() { + if p == nil || p.cancel == nil { + return + } + p.cancel() } @@ -106,8 +107,20 @@ func (p *tunnelQualityProber) loop() { } } +func (p *tunnelQualityProber) isEnabled() bool { + if p == nil || p.handler == nil { + return true + } + + return p.handler.isTunnelQualityMonitoringEnabled() +} + // maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults). func (p *tunnelQualityProber) maybePrune() { + if !p.isEnabled() { + return + } + now := time.Now().UnixMilli() if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) { return @@ -126,6 +139,10 @@ func (p *tunnelQualityProber) maybePrune() { } func (p *tunnelQualityProber) probeAll() { + if !p.isEnabled() { + return + } + // Skip if previous probe round is still running (interval < timeout guard) if !atomic.CompareAndSwapInt32(&p.probing, 0, 1) { return diff --git a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx index eebd329..fec11b4 100644 --- a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx +++ b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx @@ -31,6 +31,7 @@ import { getTunnelMetrics, getMonitorTunnelQuality, getMonitorTunnelQualityHistory, + getConfigByName, } from "@/api"; import { Button } from "@/shadcn-bridge/heroui/button"; @@ -51,6 +52,9 @@ interface TunnelMonitorViewProps { } const QUALITY_POLL_INTERVAL = 1_000; // 1 second +const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled"; +const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT = + "monitorTunnelQualityEnabledChanged"; const formatTimestamp = (ts: number, rangeMs?: number): string => { const date = new Date(ts); @@ -72,8 +76,6 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => { }); }; - -/** Render a colored latency value with appropriate visual cue */ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) { if (loading) { return ; @@ -90,7 +92,6 @@ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean return {ms}ms; } -/** Animated pulse dot for live status */ function LiveDot() { return ( @@ -173,8 +174,6 @@ function UptimeHistoryBar({ ); } -/* ─── Module-level constants & memoized sub-components ────────────── */ - const TIME_RANGE_OPTIONS = [ { key: String(15 * 60 * 1000), label: "15分钟" }, { key: String(60 * 60 * 1000), label: "1小时" }, @@ -340,6 +339,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) const initialHistoryFetched = useRef(false); const [qualityLoading, setQualityLoading] = useState(false); const qualityTimerRef = useRef(null); + const [monitorTunnelQualityEnabled, setMonitorTunnelQualityEnabled] = + useState(true); // Detail view state const [detailTunnelId, setDetailTunnelId] = useState(null); @@ -387,9 +388,25 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) } }, []); + const loadMonitorTunnelQualityEnabled = useCallback(async () => { + try { + const response = await getConfigByName( + MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY, + ); + setMonitorTunnelQualityEnabled( + typeof response.data?.value === "string" + ? response.data.value === "true" + : true, + ); + } catch { + setMonitorTunnelQualityEnabled(true); + } + }, []); + useEffect(() => { void loadTunnels(); - }, [loadTunnels]); + void loadMonitorTunnelQualityEnabled(); + }, [loadMonitorTunnelQualityEnabled, loadTunnels]); useEffect(() => { const timer = window.setInterval(() => { @@ -399,7 +416,32 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) return () => window.clearInterval(timer); }, [loadTunnels]); - // --- Initial history load --- + useEffect(() => { + const handleMonitorTunnelQualityEnabledChanged = (event: Event) => { + const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail?.enabled; + if (typeof enabled === "boolean") { + setMonitorTunnelQualityEnabled(enabled); + if (!enabled) { + setQualityLoading(false); + } + } else { + void loadMonitorTunnelQualityEnabled(); + } + }; + + window.addEventListener( + MONITOR_TUNNEL_QUALITY_ENABLED_EVENT, + handleMonitorTunnelQualityEnabledChanged as EventListener, + ); + + return () => { + window.removeEventListener( + MONITOR_TUNNEL_QUALITY_ENABLED_EVENT, + handleMonitorTunnelQualityEnabledChanged as EventListener, + ); + }; + }, [loadMonitorTunnelQualityEnabled]); + useEffect(() => { if (tunnels.length > 0 && !initialHistoryFetched.current) { initialHistoryFetched.current = true; @@ -472,10 +514,22 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) }, []); useEffect(() => { + if (!monitorTunnelQualityEnabled) { + return; + } + void loadQuality(); - }, [loadQuality]); + }, [loadQuality, monitorTunnelQualityEnabled]); useEffect(() => { + if (!monitorTunnelQualityEnabled) { + if (qualityTimerRef.current) { + window.clearInterval(qualityTimerRef.current); + qualityTimerRef.current = null; + } + return; + } + qualityTimerRef.current = window.setInterval(() => { void loadQuality({ silent: true }); }, QUALITY_POLL_INTERVAL); @@ -483,9 +537,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) return () => { if (qualityTimerRef.current) { window.clearInterval(qualityTimerRef.current); + qualityTimerRef.current = null; } }; - }, [loadQuality]); + }, [loadQuality, monitorTunnelQualityEnabled]); // --- Load quality history for detail chart --- const loadQualityHistory = useCallback( @@ -549,21 +604,35 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) useEffect(() => { if (detailTunnelId) { - void loadQualityHistory(detailTunnelId); void loadTunnelMetrics(detailTunnelId); + if (monitorTunnelQualityEnabled) { + void loadQualityHistory(detailTunnelId); + } } - }, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]); + }, [ + detailTunnelId, + loadQualityHistory, + loadTunnelMetrics, + monitorTunnelQualityEnabled, + ]); // Auto-refresh detail charts useEffect(() => { if (!detailTunnelId) return; const timer = window.setInterval(() => { - void loadQualityHistory(detailTunnelId, { silent: true }); + if (monitorTunnelQualityEnabled) { + void loadQualityHistory(detailTunnelId, { silent: true }); + } void loadTunnelMetrics(detailTunnelId, { silent: true }); }, 30_000); return () => window.clearInterval(timer); - }, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]); + }, [ + detailTunnelId, + loadQualityHistory, + loadTunnelMetrics, + monitorTunnelQualityEnabled, + ]); // Memoize chart data so React.memo sub-components see stable references const qualityChartData = useMemo( @@ -607,10 +676,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null; }, [qualityMap]); - // ===================== - // RENDER - // ===================== - if (accessDenied) { return ( @@ -628,7 +693,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) ); } - // ===== DETAIL VIEW ===== if (detailTunnelId && detailTunnel) { const quality = qualityMap[detailTunnelId]; @@ -693,8 +757,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {/* Auto-probe status */}
- - 自动探测中(每秒测试,30秒上报) + {monitorTunnelQualityEnabled ? ( + <> + + 自动探测中(每秒测试,30秒上报) + + ) : ( + <> + + 实时隧道质量检测已关闭 + + )} {quality?.timestamp && ( · 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} @@ -735,12 +808,26 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
隧道 {tunnelStats.enabled}/{tunnelStats.total} - {lastQualityUpdate && ( + {lastQualityUpdate ? (
- - 每秒探测 · 更新于 {lastQualityUpdate} + {monitorTunnelQualityEnabled ? ( + <> + + 每秒探测 · 更新于 {lastQualityUpdate} + + ) : ( + <> + + 实时质量检测已关闭 · 最近更新于 {lastQualityUpdate} + + )}
- )} + ) : !monitorTunnelQualityEnabled ? ( +
+ + 实时质量检测已关闭 +
+ ) : null}
@@ -870,11 +963,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {quality?.timestamp ? ( - + {monitorTunnelQualityEnabled ? ( + + ) : ( + + )} {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} ) : ( - - + + {monitorTunnelQualityEnabled ? "-" : "实时检测已关闭"} + )} diff --git a/vite-frontend/src/pages/settings.tsx b/vite-frontend/src/pages/settings.tsx index 55e3217..0f4d1ce 100644 --- a/vite-frontend/src/pages/settings.tsx +++ b/vite-frontend/src/pages/settings.tsx @@ -31,6 +31,12 @@ interface PanelAddress { } const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode"; +const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled"; +const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT = + "monitorTunnelQualityEnabledChanged"; + +const parseBooleanConfig = (value: unknown, defaultValue: boolean) => + typeof value === "string" ? value === "true" : defaultValue; export const SettingsPage = () => { const navigate = useNavigate(); @@ -43,6 +49,10 @@ export const SettingsPage = () => { const [forwardCompactMode, setForwardCompactMode] = useState(false); const [forwardCompactModeSaving, setForwardCompactModeSaving] = useState(false); + const [monitorTunnelQualityEnabled, setMonitorTunnelQualityEnabled] = + useState(true); + const [monitorTunnelQualitySaving, setMonitorTunnelQualitySaving] = + useState(false); const admin = isAdmin(); @@ -50,9 +60,16 @@ export const SettingsPage = () => { setPanelAddresses(newAddress); }; + useEffect(() => { + (window as any).setPanelAddresses = setPanelAddressesFunc; + + return () => { + delete (window as any).setPanelAddresses; + }; + }, []); + // 加载面板地址列表 const loadPanelAddresses = async () => { - (window as any).setPanelAddresses = setPanelAddressesFunc; getPanelAddresses(); }; @@ -72,7 +89,6 @@ export const SettingsPage = () => { return; } - (window as any).setPanelAddresses = setPanelAddressesFunc; savePanelAddress(newName.trim(), newAddress.trim()); setNewName(""); setNewAddress(""); @@ -81,14 +97,12 @@ export const SettingsPage = () => { // 设置当前面板地址 const setCurrentPanel = async (name: string) => { - (window as any).setPanelAddresses = setPanelAddressesFunc; setCurrentPanelAddress(name); reinitializeBaseURL(); }; // 删除面板地址 const handleDeletePanelAddress = async (name: string) => { - (window as any).setPanelAddresses = setPanelAddressesFunc; deletePanelAddress(name); reinitializeBaseURL(); toast.success("删除成功"); @@ -98,22 +112,27 @@ export const SettingsPage = () => { useEffect(() => { loadPanelAddresses(); loadForwardCompactMode(); + loadMonitorTunnelQualityEnabled(); }, []); const loadForwardCompactMode = async () => { try { const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY); - const enabled = - res.code === 0 && - typeof res.data?.value === "string" && - res.data.value === "true"; - - setForwardCompactMode(enabled); + setForwardCompactMode(parseBooleanConfig(res.data?.value, false)); } catch { setForwardCompactMode(false); } }; + const loadMonitorTunnelQualityEnabled = async () => { + try { + const res = await getConfigByName(MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY); + setMonitorTunnelQualityEnabled(parseBooleanConfig(res.data?.value, true)); + } catch { + setMonitorTunnelQualityEnabled(true); + } + }; + const handleForwardCompactModeChange = async (enabled: boolean) => { if (!admin || forwardCompactModeSaving) { return; @@ -148,6 +167,40 @@ export const SettingsPage = () => { } }; + const handleMonitorTunnelQualityEnabledChange = async (enabled: boolean) => { + if (!admin || monitorTunnelQualitySaving) { + return; + } + + const previous = monitorTunnelQualityEnabled; + + setMonitorTunnelQualityEnabled(enabled); + setMonitorTunnelQualitySaving(true); + try { + const response = await updateConfig( + MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY, + enabled ? "true" : "false", + ); + + if (response.code === 0) { + toast.success(`实时隧道质量检测已${enabled ? "开启" : "关闭"}`); + window.dispatchEvent( + new CustomEvent(MONITOR_TUNNEL_QUALITY_ENABLED_EVENT, { + detail: { enabled }, + }), + ); + } else { + setMonitorTunnelQualityEnabled(previous); + toast.error(response.msg || "保存隧道质量检测配置失败"); + } + } catch { + setMonitorTunnelQualityEnabled(previous); + toast.error("保存隧道质量检测配置失败"); + } finally { + setMonitorTunnelQualitySaving(false); + } + }; + const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => { setUpdateChannel(channel); setUpdateReleaseChannel(channel); @@ -216,28 +269,54 @@ export const SettingsPage = () => {

显示设置

-
-
-
-

- 规则页面精简模式 -

-

- 开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "} -

+
+
+
+
+

+ 规则页面精简模式 +

+

+ 开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "} +

+
+
- + {!admin && ( +

+ 仅管理员可修改该全局配置。 +

+ )} +
+ +
+
+
+

+ 实时隧道质量检测 +

+

+ 关闭后,前端停止自动刷新,后端停止实时隧道质量探测。 +

+
+ +
+ {!admin && ( +

+ 仅管理员可修改该全局配置。 +

+ )}
- {!admin && ( -

- 仅管理员可修改该全局配置。 -

- )}