mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-07 02:06:38 +08:00
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:
@@ -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("请求失败"))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user