diff --git a/src/components/NetworkChart.tsx b/src/components/NetworkChart.tsx index f376d84..c7743b4 100644 --- a/src/components/NetworkChart.tsx +++ b/src/components/NetworkChart.tsx @@ -28,6 +28,12 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { fetchLoginUser, fetchMonitor, @@ -533,41 +539,55 @@ export const NetworkChartClient = React.memo(function NetworkChart({ return (
-
- {TIME_RANGE_OPTIONS.map((option) => { - const isLocked = !isLogin && option.value !== "1d"; - return ( -
{ - if (!isLocked) { - onPeriodChange(option.value); - } - }} - className={cn( - "relative cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300", - period === option.value - ? "text-foreground" - : "text-muted-foreground hover:text-foreground", - isLocked && "cursor-not-allowed opacity-40 grayscale", - )} - > - {period === option.value && ( - - )} - {option.label} -
- ); - })} -
+ +
+ {TIME_RANGE_OPTIONS.map((option) => { + const isLocked = !isLogin && option.value !== "1d"; + const optionItem = ( +
{ + if (!isLocked) { + onPeriodChange(option.value); + } + }} + className={cn( + "relative cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300", + period === option.value + ? "text-foreground" + : "text-muted-foreground hover:text-foreground", + isLocked && "cursor-not-allowed opacity-40 grayscale", + )} + > + {period === option.value && ( + + )} + {option.label} +
+ ); + + if (isLocked) { + return ( + + {optionItem} + + {t("monitor.loginRequired", "Please login to view")} + + + ); + } + + return
{optionItem}
; + })} +
+
+ new Promise((resolve) => { + setTimeout(resolve, ms); + }); + function PeriodSelector({ selectedPeriod, onPeriodChange, isLogin, + isTsdbEnabled, }: { selectedPeriod: ChartPeriod; onPeriodChange: (period: ChartPeriod) => void; isLogin: boolean; + isTsdbEnabled: boolean; }) { const { t } = useTranslation(); @@ -96,44 +114,73 @@ function PeriodSelector({ ]; return ( -
- {periods.map((period) => { - // Only realtime and 1d are available for non-logged-in users - const isLocked = - !isLogin && period.value !== "realtime" && period.value !== "1d"; - return ( -
{ - if (!isLocked) { - onPeriodChange(period.value); - } - }} - className={cn( - "relative cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300", - selectedPeriod === period.value - ? "text-foreground" - : "text-muted-foreground hover:text-foreground", - isLocked && "cursor-not-allowed opacity-40 grayscale", - )} - > - {selectedPeriod === period.value && ( - - )} -
- {period.value === "realtime" && ( - + +
+ {periods.map((period) => { + const isHistoryPeriod = period.value !== "realtime"; + const isLockedByTsdb = !isTsdbEnabled && isHistoryPeriod; + // Only realtime and 1d are available for non-logged-in users + const isLockedByLogin = + !isLockedByTsdb && + !isLogin && + period.value !== "realtime" && + period.value !== "1d"; + const isLocked = isLockedByTsdb || isLockedByLogin; + + const periodItem = ( +
{ + if (!isLocked) { + onPeriodChange(period.value); + } + }} + className={cn( + "relative cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300", + selectedPeriod === period.value + ? "text-foreground" + : "text-muted-foreground hover:text-foreground", + isLocked && "cursor-not-allowed opacity-40 grayscale", )} - {period.label} + > + {selectedPeriod === period.value && ( + + )} +
+ {period.value === "realtime" && ( + + )} + {period.label} +
-
- ); - })} -
+ ); + + if (isLockedByTsdb || isLockedByLogin) { + return ( + + {periodItem} + + {isLockedByTsdb + ? t( + "serverDetailChart.tsdbRequired", + "Enable TSDB to use historical data", + ) + : t( + "serverDetailChart.loginRequired", + "Please login to view", + )} + + + ); + } + + return
{periodItem}
; + })} +
+ ); } @@ -161,12 +208,31 @@ export default function ServerDetailChart({ ? !!userData?.data?.id && !!document.cookie : false; + const { data: settingData } = useQuery({ + queryKey: ["setting"], + queryFn: () => fetchSetting(), + refetchOnMount: true, + refetchOnWindowFocus: true, + }); + const isTsdbEnabled = settingData?.data?.tsdb_enabled ?? true; + + useEffect(() => { + if (!isTsdbEnabled && selectedPeriod !== "realtime") { + setSelectedPeriod("realtime"); + } + }, [isTsdbEnabled, selectedPeriod]); + // Reset period if user is not logged in and selected period is restricted useEffect(() => { - if (!isLogin && selectedPeriod !== "realtime" && selectedPeriod !== "1d") { + if ( + isTsdbEnabled && + !isLogin && + selectedPeriod !== "realtime" && + selectedPeriod !== "1d" + ) { setSelectedPeriod("1d"); } - }, [isLogin, selectedPeriod]); + }, [isLogin, isTsdbEnabled, selectedPeriod]); if (!connected && !lastMessage) { return ; @@ -195,6 +261,7 @@ export default function ServerDetailChart({ selectedPeriod={selectedPeriod} onPeriodChange={setSelectedPeriod} isLogin={isLogin} + isTsdbEnabled={isTsdbEnabled} />
( const [historicalData, setHistoricalData] = useState([]); const [isLoading, setIsLoading] = useState(false); const [displayData, setDisplayData] = useState([]); - const loadingTimerRef = useRef(null); + const [loadedPeriod, setLoadedPeriod] = useState("realtime"); useEffect(() => { + let cancelled = false; + if (period === "realtime") { setHistoricalData([]); setDisplayData([]); - if (loadingTimerRef.current) { - clearTimeout(loadingTimerRef.current); - } - return; + setIsLoading(false); + setLoadedPeriod("realtime"); + return () => { + cancelled = true; + }; } const fetchData = async () => { - loadingTimerRef.current = setTimeout(() => { - setIsLoading(true); - }, 200); + const loadingStartedAt = Date.now(); + setIsLoading(true); try { const response = await fetchServerMetrics( @@ -301,24 +370,35 @@ function useHistoricalData( const transformedData = response.data.data_points.map((point) => transformData(point.ts, point.value), ); - setHistoricalData(transformedData); - setDisplayData(transformedData); + if (!cancelled) { + setHistoricalData(transformedData); + setDisplayData(transformedData); + } } } catch (error) { console.error(`Failed to fetch ${metricName} metrics:`, error); } finally { - if (loadingTimerRef.current) { - clearTimeout(loadingTimerRef.current); - loadingTimerRef.current = null; + const elapsed = Date.now() - loadingStartedAt; + if (elapsed < MIN_HISTORY_LOADING_MS) { + await sleep(MIN_HISTORY_LOADING_MS - elapsed); + } + if (!cancelled) { + setIsLoading(false); + setLoadedPeriod(period); } - setIsLoading(false); } }; fetchData(); + return () => { + cancelled = true; + }; }, [serverId, metricName, period, transformData]); - return { historicalData, displayData, isLoading }; + const isHistoricalLoading = + period !== "realtime" && (isLoading || loadedPeriod !== period); + + return { historicalData, displayData, isLoading: isHistoricalLoading }; } function GpuChart({ @@ -938,14 +1018,23 @@ function MemChart({ [], ); const [isLoadingMem, setIsLoadingMem] = useState(false); + const [loadedPeriodMem, setLoadedPeriodMem] = + useState("realtime"); useEffect(() => { + let cancelled = false; + if (period === "realtime") { setMemHistoricalData([]); - return; + setIsLoadingMem(false); + setLoadedPeriodMem("realtime"); + return () => { + cancelled = true; + }; } const fetchMemData = async () => { + const loadingStartedAt = Date.now(); setIsLoadingMem(true); try { const [memResponse, swapResponse] = await Promise.all([ @@ -978,16 +1067,28 @@ function MemChart({ swap: swapMap.get(point.ts) || 0, }; }); - setMemHistoricalData(combinedData); + if (!cancelled) { + setMemHistoricalData(combinedData); + } } } catch (error) { console.error("Failed to fetch memory metrics:", error); } finally { - setIsLoadingMem(false); + const elapsed = Date.now() - loadingStartedAt; + if (elapsed < MIN_HISTORY_LOADING_MS) { + await sleep(MIN_HISTORY_LOADING_MS - elapsed); + } + if (!cancelled) { + setIsLoadingMem(false); + setLoadedPeriodMem(period); + } } }; fetchMemData(); + return () => { + cancelled = true; + }; }, [data.id, period, data.host.mem_total, data.host.swap_total]); // 初始化历史数据 @@ -1058,6 +1159,8 @@ function MemChart({ } satisfies ChartConfig; const displayData = period === "realtime" ? memChartData : memHistoricalData; + const isMemLoading = + period !== "realtime" && (isLoadingMem || loadedPeriodMem !== period); return ( - {isLoadingMem ? ( + {isMemLoading ? ( ) : ( ([]); const [isLoadingNetwork, setIsLoadingNetwork] = useState(false); + const [loadedPeriodNetwork, setLoadedPeriodNetwork] = + useState("realtime"); useEffect(() => { + let cancelled = false; + if (period === "realtime") { setNetworkHistoricalData([]); - return; + setIsLoadingNetwork(false); + setLoadedPeriodNetwork("realtime"); + return () => { + cancelled = true; + }; } const fetchNetworkData = async () => { + const loadingStartedAt = Date.now(); setIsLoadingNetwork(true); try { const [uploadResponse, downloadResponse] = await Promise.all([ @@ -1479,16 +1591,28 @@ function NetworkChart({ upload: point.value / 1024 / 1024, // Convert bytes to MB download: downloadMap.get(point.ts) || 0, })); - setNetworkHistoricalData(combinedData); + if (!cancelled) { + setNetworkHistoricalData(combinedData); + } } } catch (error) { console.error("Failed to fetch network metrics:", error); } finally { - setIsLoadingNetwork(false); + const elapsed = Date.now() - loadingStartedAt; + if (elapsed < MIN_HISTORY_LOADING_MS) { + await sleep(MIN_HISTORY_LOADING_MS - elapsed); + } + if (!cancelled) { + setIsLoadingNetwork(false); + setLoadedPeriodNetwork(period); + } } }; fetchNetworkData(); + return () => { + cancelled = true; + }; }, [data.id, period]); // 初始化历史数据 @@ -1554,6 +1678,9 @@ function NetworkChart({ const displayData = period === "realtime" ? networkChartData : networkHistoricalData; + const isNetworkLoading = + period !== "realtime" && + (isLoadingNetwork || loadedPeriodNetwork !== period); let maxDownload = Math.max(...displayData.map((item) => item.download)); maxDownload = Math.ceil(maxDownload); @@ -1616,7 +1743,7 @@ function NetworkChart({ config={chartConfig} className="aspect-auto h-[130px] w-full" > - {isLoadingNetwork ? ( + {isNetworkLoading ? ( ) : ( ([]); const [isLoadingConnect, setIsLoadingConnect] = useState(false); + const [loadedPeriodConnect, setLoadedPeriodConnect] = + useState("realtime"); useEffect(() => { + let cancelled = false; + if (period === "realtime") { setConnectHistoricalData([]); - return; + setIsLoadingConnect(false); + setLoadedPeriodConnect("realtime"); + return () => { + cancelled = true; + }; } const fetchConnectData = async () => { + const loadingStartedAt = Date.now(); setIsLoadingConnect(true); try { const [tcpResponse, udpResponse] = await Promise.all([ @@ -1761,16 +1897,28 @@ function ConnectChart({ tcp: point.value, udp: udpMap.get(point.ts) || 0, })); - setConnectHistoricalData(combinedData); + if (!cancelled) { + setConnectHistoricalData(combinedData); + } } } catch (error) { console.error("Failed to fetch connection metrics:", error); } finally { - setIsLoadingConnect(false); + const elapsed = Date.now() - loadingStartedAt; + if (elapsed < MIN_HISTORY_LOADING_MS) { + await sleep(MIN_HISTORY_LOADING_MS - elapsed); + } + if (!cancelled) { + setIsLoadingConnect(false); + setLoadedPeriodConnect(period); + } } }; fetchConnectData(); + return () => { + cancelled = true; + }; }, [data.id, period]); // 初始化历史数据 @@ -1842,6 +1990,9 @@ function ConnectChart({ const displayData = period === "realtime" ? connectChartData : connectHistoricalData; + const isConnectLoading = + period !== "realtime" && + (isLoadingConnect || loadedPeriodConnect !== period); return ( - {isLoadingConnect ? ( + {isConnectLoading ? ( ) : ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 441abe7..e2d4f4d 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -81,7 +81,9 @@ "realtime": "Realtime", "period1d": "1 Day", "period7d": "7 Days", - "period30d": "30 Days" + "period30d": "30 Days", + "tsdbRequired": "Enable TSDB to use historical data", + "loginRequired": "Please login to view" }, "footer": { "themeBy": "Theme by " @@ -115,6 +117,7 @@ "packetLoss": "Packet Loss", "clearSelections": "Clear", "peakCut": "Peak cut", + "loginRequired": "Please login to view", "period1d": "1 Day", "period7d": "7 Days", "period30d": "30 Days" diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 307c915..8a71686 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -81,7 +81,9 @@ "realtime": "实时", "period1d": "1 天", "period7d": "7 天", - "period30d": "30 天" + "period30d": "30 天", + "tsdbRequired": "需要开启 TSDB 才能启用历史记录功能", + "loginRequired": "请登录后查看" }, "footer": { "themeBy": "主题-" @@ -116,6 +118,7 @@ "packetLoss": "丢包率", "clearSelections": "清除", "peakCut": "削峰", + "loginRequired": "请登录后查看", "period1d": "1 天", "period7d": "7 天", "period30d": "30 天" diff --git a/src/types/nezha-api.ts b/src/types/nezha-api.ts index de348c6..c49a203 100644 --- a/src/types/nezha-api.ts +++ b/src/types/nezha-api.ts @@ -156,6 +156,7 @@ export interface SettingResponse { data: { config: SettingConfig; version: string; + tsdb_enabled?: boolean; }; }