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) <noreply@anthropic.com>
This commit is contained in:
sagitchu
2026-03-26 20:08:40 +08:00
parent d30363d164
commit e69082a596
5 changed files with 283 additions and 63 deletions
@@ -46,6 +46,8 @@ type Handler struct {
qualityProber *tunnelQualityProber qualityProber *tunnelQualityProber
} }
const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled"
type loginRequest struct { type loginRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
@@ -886,11 +888,32 @@ func normalizeAndValidateConfigValue(key, value string) (string, error) {
} }
return pngDataURLPrefix + payload, nil 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: default:
return value, nil 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) { func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败")) response.WriteJSON(w, response.ErrDefault("请求失败"))
+4 -2
View File
@@ -66,9 +66,11 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
func (h *Handler) runTunnelQualityProber(ctx context.Context) { func (h *Handler) runTunnelQualityProber(ctx context.Context) {
defer h.jobsWG.Done() defer h.jobsWG.Done()
if h.qualityProber != nil { if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() {
h.qualityProber.Start(ctx) return
} }
h.qualityProber.Start(ctx)
} }
func (h *Handler) runHourlyStatsLoop(ctx context.Context) { func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
@@ -48,11 +48,8 @@ type tunnelQualityProber struct {
// newTunnelQualityProber creates a new prober (not yet running). // newTunnelQualityProber creates a new prober (not yet running).
func newTunnelQualityProber(h *Handler) *tunnelQualityProber { func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
ctx, cancel := context.WithCancel(context.Background())
return &tunnelQualityProber{ return &tunnelQualityProber{
handler: h, handler: h,
ctx: ctx,
cancel: cancel,
interval: tunnelQualityProbeInterval, interval: tunnelQualityProbeInterval,
} }
} }
@@ -66,6 +63,10 @@ func (p *tunnelQualityProber) Start(ctx context.Context) {
// Stop halts the background probe loop. // Stop halts the background probe loop.
func (p *tunnelQualityProber) Stop() { func (p *tunnelQualityProber) Stop() {
if p == nil || p.cancel == nil {
return
}
p.cancel() 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). // maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
func (p *tunnelQualityProber) maybePrune() { func (p *tunnelQualityProber) maybePrune() {
if !p.isEnabled() {
return
}
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) { if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
return return
@@ -126,6 +139,10 @@ func (p *tunnelQualityProber) maybePrune() {
} }
func (p *tunnelQualityProber) probeAll() { func (p *tunnelQualityProber) probeAll() {
if !p.isEnabled() {
return
}
// Skip if previous probe round is still running (interval < timeout guard) // Skip if previous probe round is still running (interval < timeout guard)
if !atomic.CompareAndSwapInt32(&p.probing, 0, 1) { if !atomic.CompareAndSwapInt32(&p.probing, 0, 1) {
return return
@@ -31,6 +31,7 @@ import {
getTunnelMetrics, getTunnelMetrics,
getMonitorTunnelQuality, getMonitorTunnelQuality,
getMonitorTunnelQualityHistory, getMonitorTunnelQualityHistory,
getConfigByName,
} from "@/api"; } from "@/api";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
@@ -51,6 +52,9 @@ interface TunnelMonitorViewProps {
} }
const QUALITY_POLL_INTERVAL = 1_000; // 1 second 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 formatTimestamp = (ts: number, rangeMs?: number): string => {
const date = new Date(ts); 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 }) { function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
if (loading) { if (loading) {
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />; return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
@@ -90,7 +92,6 @@ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean
return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>; return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
} }
/** Animated pulse dot for live status */
function LiveDot() { function LiveDot() {
return ( return (
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
@@ -173,8 +174,6 @@ function UptimeHistoryBar({
); );
} }
/* ─── Module-level constants & memoized sub-components ────────────── */
const TIME_RANGE_OPTIONS = [ const TIME_RANGE_OPTIONS = [
{ key: String(15 * 60 * 1000), label: "15分钟" }, { key: String(15 * 60 * 1000), label: "15分钟" },
{ key: String(60 * 60 * 1000), label: "1小时" }, { key: String(60 * 60 * 1000), label: "1小时" },
@@ -340,6 +339,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
const initialHistoryFetched = useRef(false); const initialHistoryFetched = useRef(false);
const [qualityLoading, setQualityLoading] = useState(false); const [qualityLoading, setQualityLoading] = useState(false);
const qualityTimerRef = useRef<number | null>(null); const qualityTimerRef = useRef<number | null>(null);
const [monitorTunnelQualityEnabled, setMonitorTunnelQualityEnabled] =
useState(true);
// Detail view state // Detail view state
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null); const [detailTunnelId, setDetailTunnelId] = useState<number | null>(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(() => { useEffect(() => {
void loadTunnels(); void loadTunnels();
}, [loadTunnels]); void loadMonitorTunnelQualityEnabled();
}, [loadMonitorTunnelQualityEnabled, loadTunnels]);
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
@@ -399,7 +416,32 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [loadTunnels]); }, [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(() => { useEffect(() => {
if (tunnels.length > 0 && !initialHistoryFetched.current) { if (tunnels.length > 0 && !initialHistoryFetched.current) {
initialHistoryFetched.current = true; initialHistoryFetched.current = true;
@@ -472,10 +514,22 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!monitorTunnelQualityEnabled) {
return;
}
void loadQuality(); void loadQuality();
}, [loadQuality]); }, [loadQuality, monitorTunnelQualityEnabled]);
useEffect(() => { useEffect(() => {
if (!monitorTunnelQualityEnabled) {
if (qualityTimerRef.current) {
window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null;
}
return;
}
qualityTimerRef.current = window.setInterval(() => { qualityTimerRef.current = window.setInterval(() => {
void loadQuality({ silent: true }); void loadQuality({ silent: true });
}, QUALITY_POLL_INTERVAL); }, QUALITY_POLL_INTERVAL);
@@ -483,9 +537,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return () => { return () => {
if (qualityTimerRef.current) { if (qualityTimerRef.current) {
window.clearInterval(qualityTimerRef.current); window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null;
} }
}; };
}, [loadQuality]); }, [loadQuality, monitorTunnelQualityEnabled]);
// --- Load quality history for detail chart --- // --- Load quality history for detail chart ---
const loadQualityHistory = useCallback( const loadQualityHistory = useCallback(
@@ -549,21 +604,35 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
useEffect(() => { useEffect(() => {
if (detailTunnelId) { if (detailTunnelId) {
void loadQualityHistory(detailTunnelId);
void loadTunnelMetrics(detailTunnelId); void loadTunnelMetrics(detailTunnelId);
if (monitorTunnelQualityEnabled) {
void loadQualityHistory(detailTunnelId);
}
} }
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]); }, [
detailTunnelId,
loadQualityHistory,
loadTunnelMetrics,
monitorTunnelQualityEnabled,
]);
// Auto-refresh detail charts // Auto-refresh detail charts
useEffect(() => { useEffect(() => {
if (!detailTunnelId) return; if (!detailTunnelId) return;
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
void loadQualityHistory(detailTunnelId, { silent: true }); if (monitorTunnelQualityEnabled) {
void loadQualityHistory(detailTunnelId, { silent: true });
}
void loadTunnelMetrics(detailTunnelId, { silent: true }); void loadTunnelMetrics(detailTunnelId, { silent: true });
}, 30_000); }, 30_000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]); }, [
detailTunnelId,
loadQualityHistory,
loadTunnelMetrics,
monitorTunnelQualityEnabled,
]);
// Memoize chart data so React.memo sub-components see stable references // Memoize chart data so React.memo sub-components see stable references
const qualityChartData = useMemo( const qualityChartData = useMemo(
@@ -607,10 +676,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null; return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
}, [qualityMap]); }, [qualityMap]);
// =====================
// RENDER
// =====================
if (accessDenied) { if (accessDenied) {
return ( return (
<Card> <Card>
@@ -628,7 +693,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
); );
} }
// ===== DETAIL VIEW =====
if (detailTunnelId && detailTunnel) { if (detailTunnelId && detailTunnel) {
const quality = qualityMap[detailTunnelId]; const quality = qualityMap[detailTunnelId];
@@ -693,8 +757,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
{/* Auto-probe status */} {/* Auto-probe status */}
<div className="flex items-center gap-2 text-xs text-default-500"> <div className="flex items-center gap-2 text-xs text-default-500">
<LiveDot /> {monitorTunnelQualityEnabled ? (
<span>自动探测中(每秒测试,30秒上报)</span> <>
<LiveDot />
<span>自动探测中(每秒测试,30秒上报)</span>
</>
) : (
<>
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时隧道质量检测已关闭</span>
</>
)}
{quality?.timestamp && ( {quality?.timestamp && (
<span className="text-default-400"> <span className="text-default-400">
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} · 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
@@ -735,12 +808,26 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 mb-1"> <div className="flex flex-wrap items-center gap-3 mb-1">
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip> <Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
{lastQualityUpdate && ( {lastQualityUpdate ? (
<div className="flex items-center gap-1.5 text-xs text-default-500"> <div className="flex items-center gap-1.5 text-xs text-default-500">
<LiveDot /> {monitorTunnelQualityEnabled ? (
<span>每秒探测 · 更新于 {lastQualityUpdate}</span> <>
<LiveDot />
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
</>
) : (
<>
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时质量检测已关闭 · 最近更新于 {lastQualityUpdate}</span>
</>
)}
</div> </div>
)} ) : !monitorTunnelQualityEnabled ? (
<div className="flex items-center gap-1.5 text-xs text-default-500">
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时质量检测已关闭</span>
</div>
) : null}
<div className="ml-auto"> <div className="ml-auto">
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}> <Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
<RefreshCw className="w-4 h-4 mr-1" /> <RefreshCw className="w-4 h-4 mr-1" />
@@ -812,11 +899,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<span className="text-[11px] text-danger truncate">{quality.errorMessage}</span> <span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
) : quality?.timestamp ? ( ) : quality?.timestamp ? (
<span className="text-[11px] text-default-500 flex items-center gap-1"> <span className="text-[11px] text-default-500 flex items-center gap-1">
<LiveDot /> {monitorTunnelQualityEnabled ? (
<LiveDot />
) : (
<WifiOff className="w-3 h-3 text-warning" />
)}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")} {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span> </span>
) : ( ) : (
<span className="text-[11px] text-default-400">等待探测...</span> <span className="text-[11px] text-default-400">
{monitorTunnelQualityEnabled ? "等待探测..." : "实时检测已关闭"}
</span>
)} )}
</div> </div>
</CardBody> </CardBody>
@@ -870,11 +963,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<TableCell> <TableCell>
{quality?.timestamp ? ( {quality?.timestamp ? (
<span className="text-xs text-default-500 flex items-center gap-1 whitespace-nowrap"> <span className="text-xs text-default-500 flex items-center gap-1 whitespace-nowrap">
<LiveDot /> {monitorTunnelQualityEnabled ? (
<LiveDot />
) : (
<WifiOff className="w-3.5 h-3.5 text-warning" />
)}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")} {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span> </span>
) : ( ) : (
<span className="text-xs text-default-400">-</span> <span className="text-xs text-default-400">
{monitorTunnelQualityEnabled ? "-" : "实时检测已关闭"}
</span>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
+109 -30
View File
@@ -31,6 +31,12 @@ interface PanelAddress {
} }
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode"; 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 = () => { export const SettingsPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -43,6 +49,10 @@ export const SettingsPage = () => {
const [forwardCompactMode, setForwardCompactMode] = useState(false); const [forwardCompactMode, setForwardCompactMode] = useState(false);
const [forwardCompactModeSaving, setForwardCompactModeSaving] = const [forwardCompactModeSaving, setForwardCompactModeSaving] =
useState(false); useState(false);
const [monitorTunnelQualityEnabled, setMonitorTunnelQualityEnabled] =
useState(true);
const [monitorTunnelQualitySaving, setMonitorTunnelQualitySaving] =
useState(false);
const admin = isAdmin(); const admin = isAdmin();
@@ -50,9 +60,16 @@ export const SettingsPage = () => {
setPanelAddresses(newAddress); setPanelAddresses(newAddress);
}; };
useEffect(() => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
return () => {
delete (window as any).setPanelAddresses;
};
}, []);
// 加载面板地址列表 // 加载面板地址列表
const loadPanelAddresses = async () => { const loadPanelAddresses = async () => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
getPanelAddresses(); getPanelAddresses();
}; };
@@ -72,7 +89,6 @@ export const SettingsPage = () => {
return; return;
} }
(window as any).setPanelAddresses = setPanelAddressesFunc;
savePanelAddress(newName.trim(), newAddress.trim()); savePanelAddress(newName.trim(), newAddress.trim());
setNewName(""); setNewName("");
setNewAddress(""); setNewAddress("");
@@ -81,14 +97,12 @@ export const SettingsPage = () => {
// 设置当前面板地址 // 设置当前面板地址
const setCurrentPanel = async (name: string) => { const setCurrentPanel = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
setCurrentPanelAddress(name); setCurrentPanelAddress(name);
reinitializeBaseURL(); reinitializeBaseURL();
}; };
// 删除面板地址 // 删除面板地址
const handleDeletePanelAddress = async (name: string) => { const handleDeletePanelAddress = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
deletePanelAddress(name); deletePanelAddress(name);
reinitializeBaseURL(); reinitializeBaseURL();
toast.success("删除成功"); toast.success("删除成功");
@@ -98,22 +112,27 @@ export const SettingsPage = () => {
useEffect(() => { useEffect(() => {
loadPanelAddresses(); loadPanelAddresses();
loadForwardCompactMode(); loadForwardCompactMode();
loadMonitorTunnelQualityEnabled();
}, []); }, []);
const loadForwardCompactMode = async () => { const loadForwardCompactMode = async () => {
try { try {
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY); const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
const enabled = setForwardCompactMode(parseBooleanConfig(res.data?.value, false));
res.code === 0 &&
typeof res.data?.value === "string" &&
res.data.value === "true";
setForwardCompactMode(enabled);
} catch { } catch {
setForwardCompactMode(false); 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) => { const handleForwardCompactModeChange = async (enabled: boolean) => {
if (!admin || forwardCompactModeSaving) { if (!admin || forwardCompactModeSaving) {
return; 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) => { const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel); setUpdateChannel(channel);
setUpdateReleaseChannel(channel); setUpdateReleaseChannel(channel);
@@ -216,28 +269,54 @@ export const SettingsPage = () => {
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4"> <h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
显示设置 显示设置
</h2> </h2>
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3"> <div className="space-y-3">
<div className="flex items-center justify-between gap-4"> <div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div> <div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium text-gray-900 dark:text-white"> <div>
规则页面精简模式 <p className="text-sm font-medium text-gray-900 dark:text-white">
</p> 规则页面精简模式
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400"> </p>
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "} <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
</p> 开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
</p>
</div>
<Switch
color="primary"
isDisabled={!admin || forwardCompactModeSaving}
isSelected={forwardCompactMode}
onValueChange={handleForwardCompactModeChange}
/>
</div> </div>
<Switch {!admin && (
color="primary" <p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
isDisabled={!admin || forwardCompactModeSaving} 仅管理员可修改该全局配置。
isSelected={forwardCompactMode} </p>
onValueChange={handleForwardCompactModeChange} )}
/> </div>
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
实时隧道质量检测
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
关闭后,前端停止自动刷新,后端停止实时隧道质量探测。
</p>
</div>
<Switch
color="primary"
isDisabled={!admin || monitorTunnelQualitySaving}
isSelected={monitorTunnelQualityEnabled}
onValueChange={handleMonitorTunnelQualityEnabledChange}
/>
</div>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div> </div>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div> </div>
</CardBody> </CardBody>
</Card> </Card>