diff --git a/.gitignore b/.gitignore index 8e2d5f1bf..ace02b224 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,4 @@ deadlock-analysis.md # AI .AGENT graphify-out +coderabbitai.md diff --git a/dashboard/src/app/router.tsx b/dashboard/src/app/router.tsx index f2314b1b9..ac1a66dad 100644 --- a/dashboard/src/app/router.tsx +++ b/dashboard/src/app/router.tsx @@ -1,8 +1,8 @@ import { Suspense } from 'react' import { useAdmin } from '@/hooks/use-admin' -import { getCurrentAdmin } from '@/service/api' +import { getCurrentAdmin, type AdminDetails } from '@/service/api' import { hasPermission } from '@/utils/rbac' -import { createHashRouter, Navigate, RouteObject } from 'react-router' +import { createHashRouter, Navigate, type RouteObject } from 'react-router' import { LoadingSpinner } from '@/components/common/loading-spinner' import { TabbedRouteSuspenseFallback } from '@/components/layout/tabbed-route-suspense-fallback' import { lazyWithChunkRecovery } from '@/utils/chunk-recovery' @@ -59,7 +59,7 @@ function TemplatesIndex() { return } -const fetchAdminLoader = async (): Promise => { +const fetchAdminLoader = async (): Promise => { try { const response = await getCurrentAdmin() return response @@ -392,4 +392,4 @@ export const router = createHashRouter([ ), }, -] as RouteObject[]) +] satisfies RouteObject[]) diff --git a/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx b/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx index c2f1a5dc9..b2029693d 100644 --- a/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx +++ b/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { type ChartConfig, ChartContainer, ChartTooltip } from '@/components/ui/chart' import { useTranslation } from 'react-i18next' import useDirDetection from '@/hooks/use-dir-detection' +import { useDocumentVisibility } from '@/hooks/use-document-visibility' import { useChartViewType } from '@/hooks/use-chart-view-type' import { Period, type NodeUsageStat, type UserUsageStat, useGetAdminUsageById, useGetAdminUsageByUsername, useGetNodesSimple, type NodeSimple, useGetUsage } from '@/service/api' import { formatBytes, formatGigabytes } from '@/utils/formatByte' @@ -84,8 +85,15 @@ const getStackedNodeRadius = (row: NodeChartDataPoint, nodeName: string, nodeLis return [isTopSegment ? STACKED_BAR_RADIUS : 0, isTopSegment ? STACKED_BAR_RADIUS : 0, isBottomSegment ? STACKED_BAR_RADIUS : 0, isBottomSegment ? STACKED_BAR_RADIUS : 0] } -function CustomTooltip({ active, payload, chartConfig, dir, period }: TooltipProps & { chartConfig?: ChartConfig; dir: string; period: Period }) { - const { t, i18n } = useTranslation() +function CustomTooltip( + { active, payload, chartConfig, dir, period, t, locale }: TooltipProps & { + chartConfig?: ChartConfig + dir: string + period: Period + t: (key: string, options?: Record) => string + locale: string + }, +) { const [isMobile, setIsMobile] = useState(false) useEffect(() => { @@ -100,7 +108,7 @@ function CustomTooltip({ active, payload, chartConfig, dir, period }: TooltipPro if (!active || !payload || !payload.length) return null const data = payload[0].payload as NodeChartDataPoint - const formattedDate = data._period_start ? formatTooltipDate(data._period_start, period, i18n.language) : String(data.time || '') + const formattedDate = data._period_start ? formatTooltipDate(data._period_start, period, locale) : String(data.time || '') const getNodeColor = (nodeName: string) => chartConfig?.[nodeName]?.color || 'hsl(var(--chart-1))' const isRTL = dir === 'rtl' @@ -217,9 +225,11 @@ export function AllNodesStackedBarChart() { const { t, i18n } = useTranslation() const dir = useDirDetection() const chartViewType = useChartViewType() + const isTabVisible = useDocumentVisibility() const { data: nodesResponse } = useGetNodesSimple({ all: true }, { query: { enabled: true } }) const { resolvedTheme } = useTheme() const shouldUseNodeUsage = selectedAdmin === 'all' + const pollingInterval = isTabVisible ? 1000 * 60 * 15 : 1000 * 60 * 60 const handleModalNavigate = (index: number) => { if (!chartData[index]) return @@ -285,7 +295,7 @@ export function AllNodesStackedBarChart() { } = useGetUsage(usageParams, { query: { enabled: shouldUseNodeUsage, - refetchInterval: 1000 * 60 * 5, + refetchInterval: pollingInterval, }, }) @@ -296,7 +306,7 @@ export function AllNodesStackedBarChart() { } = useGetAdminUsageById(selectedAdminId ?? 0, usageParams, { query: { enabled: !shouldUseNodeUsage && selectedAdmin !== 'all' && selectedAdminId != null, - refetchInterval: 1000 * 60 * 5, + refetchInterval: pollingInterval, }, }) @@ -307,7 +317,7 @@ export function AllNodesStackedBarChart() { } = useGetAdminUsageByUsername(selectedAdmin, usageParams, { query: { enabled: !shouldUseNodeUsage && selectedAdmin !== 'all' && selectedAdminId == null, - refetchInterval: 1000 * 60 * 5, + refetchInterval: pollingInterval, }, }) @@ -623,7 +633,7 @@ export function AllNodesStackedBarChart() { width={32} tickMargin={2} /> - )} chartConfig={chartConfig} dir={dir} period={activePeriod} />} /> + )} chartConfig={chartConfig} dir={dir} period={activePeriod} t={t} locale={i18n.language} />} /> {nodeList.map((node, index) => ( - )} chartConfig={chartConfig} dir={dir} period={activePeriod} />} /> + )} chartConfig={chartConfig} dir={dir} period={activePeriod} t={t} locale={i18n.language} />} /> {nodeList.map((node, index) => ( {chartData.map(row => ( diff --git a/dashboard/src/components/charts/area-costume-chart.tsx b/dashboard/src/components/charts/area-costume-chart.tsx index 07e9b456b..6f3b840de 100644 --- a/dashboard/src/components/charts/area-costume-chart.tsx +++ b/dashboard/src/components/charts/area-costume-chart.tsx @@ -22,6 +22,7 @@ import { toChartQueryEndDate, } from '@/utils/chart-period-utils' import useDirDetection from '@/hooks/use-dir-detection' +import { useDocumentVisibility } from '@/hooks/use-document-visibility' type DataPoint = { time: string @@ -75,6 +76,7 @@ export function AreaCostumeChart({ nodeId, currentStats, realtimeStats, realtime const { t, i18n } = useTranslation() const { resolvedTheme } = useTheme() const dir = useDirDetection() + const isTabVisible = useDocumentVisibility() const [realtimeHistory, setRealtimeHistory] = useState([]) const [realtimeError, setRealtimeError] = useState(null) const [viewMode, setViewMode] = useState<'realtime' | 'historical'>(() => (realtimeAvailable ? 'realtime' : 'historical')) @@ -208,7 +210,7 @@ export function AreaCostumeChart({ nodeId, currentStats, realtimeStats, realtime } = useGetNodeStatsPeriodic(nodeId ?? 0, historicalParams, { query: { enabled: viewMode === 'historical' && nodeId !== undefined, - refetchInterval: 1000 * 60 * 5, + refetchInterval: isTabVisible ? 1000 * 60 * 15 : 1000 * 60 * 60, }, }) diff --git a/dashboard/src/components/charts/costume-bar-chart.tsx b/dashboard/src/components/charts/costume-bar-chart.tsx index e87783d5d..71757b44d 100644 --- a/dashboard/src/components/charts/costume-bar-chart.tsx +++ b/dashboard/src/components/charts/costume-bar-chart.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { type ChartConfig, ChartContainer, ChartTooltip } from '@/components/ui/chart' import { useTranslation } from 'react-i18next' import useDirDetection from '@/hooks/use-dir-detection' +import { useDocumentVisibility } from '@/hooks/use-document-visibility' import { useChartViewType } from '@/hooks/use-chart-view-type' import { Period, type NodeUsageStat, type UserUsageStat, useGetAdminUsageById, useGetAdminUsageByUsername, useGetUsage } from '@/service/api' import { formatBytes } from '@/utils/formatByte' @@ -118,8 +119,12 @@ export function CostumeBarChart({ nodeId }: CostumeBarChartProps) { const { t, i18n } = useTranslation() const dir = useDirDetection() const chartViewType = useChartViewType() + const isTabVisible = useDocumentVisibility() const shouldUseNodeUsage = selectedAdmin === 'all' + // When tab is hidden, reduce polling frequency to save VPS resources + const pollingInterval = isTabVisible ? 1000 * 60 * 15 : 1000 * 60 * 60 + const activeQueryRange = useMemo(() => { if (showCustomRange && customRange?.from && customRange?.to) { return getChartQueryRangeFromDateRange(customRange, selectedTime) @@ -157,7 +162,7 @@ export function CostumeBarChart({ nodeId }: CostumeBarChartProps) { } = useGetUsage(nodeUsageParams, { query: { enabled: shouldUseNodeUsage, - refetchInterval: 1000 * 60 * 5, + refetchInterval: pollingInterval, }, }) @@ -168,7 +173,7 @@ export function CostumeBarChart({ nodeId }: CostumeBarChartProps) { } = useGetAdminUsageById(selectedAdminId ?? 0, adminUsageParams, { query: { enabled: !shouldUseNodeUsage && selectedAdmin !== 'all' && selectedAdminId != null, - refetchInterval: 1000 * 60 * 5, + refetchInterval: pollingInterval, }, }) @@ -179,7 +184,7 @@ export function CostumeBarChart({ nodeId }: CostumeBarChartProps) { } = useGetAdminUsageByUsername(selectedAdmin, adminUsageParams, { query: { enabled: !shouldUseNodeUsage && selectedAdmin !== 'all' && selectedAdminId == null, - refetchInterval: 1000 * 60 * 5, + refetchInterval: pollingInterval, }, }) diff --git a/dashboard/src/components/charts/user-sub-update-pie-chart.tsx b/dashboard/src/components/charts/user-sub-update-pie-chart.tsx index 3e74d62f8..9ebfcebc6 100644 --- a/dashboard/src/components/charts/user-sub-update-pie-chart.tsx +++ b/dashboard/src/components/charts/user-sub-update-pie-chart.tsx @@ -4,6 +4,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' import useDirDetection from '@/hooks/use-dir-detection' +import { useDocumentVisibility } from '@/hooks/use-document-visibility' import { useTheme } from 'next-themes' import { type GetUsersSubUpdateChartParams, useGetAdminsSimple, useGetUsersSubUpdateChart, type UserSubscriptionUpdateChartSegment } from '@/service/api' import { numberWithCommas } from '@/utils/formatByte' @@ -136,6 +137,7 @@ function UserSubUpdatePieChart({ username, adminId }: UserSubUpdatePieChartProps const { t } = useTranslation() const dir = useDirDetection() const { resolvedTheme } = useTheme() + const isTabVisible = useDocumentVisibility() const [selectedAdmin, setSelectedAdmin] = useState(() => (adminId != null ? String(adminId) : 'all')) useEffect(() => { @@ -170,7 +172,7 @@ function UserSubUpdatePieChart({ username, adminId }: UserSubUpdatePieChartProps const { data, isLoading, error } = useGetUsersSubUpdateChart(params, { query: { - refetchInterval: 60_000, + refetchInterval: isTabVisible ? 1000 * 60 * 15 : 1000 * 60 * 60, }, }) diff --git a/dashboard/src/features/dashboard/components/admin-statistics-card.tsx b/dashboard/src/features/dashboard/components/admin-statistics-card.tsx index 07c3257c2..4f965c381 100644 --- a/dashboard/src/features/dashboard/components/admin-statistics-card.tsx +++ b/dashboard/src/features/dashboard/components/admin-statistics-card.tsx @@ -1,4 +1,5 @@ import { AdminDetails, SystemUsersStats, useGetSystemUsersStats } from '@/service/api' +import { useDocumentVisibility } from '@/hooks/use-document-visibility' import { UserCog, Users } from 'lucide-react' import { Suspense, lazy, useEffect, useState, type ComponentProps } from 'react' import { useTranslation } from 'react-i18next' @@ -49,6 +50,7 @@ const AdminStatisticsCard = ({ skipStatsFetch?: boolean }) => { const { t } = useTranslation() + const isTabVisible = useDocumentVisibility() if (!admin) return null // Send admin_username for specific admin stats, except for 'Total' which shows global stats @@ -59,7 +61,7 @@ const AdminStatisticsCard = ({ const { data: adminSystemStats } = useGetSystemUsersStats(systemStatsParams, { query: { enabled: shouldFetchStats, - refetchInterval: 5000, + refetchInterval: isTabVisible ? 5000 : 60000, }, }) diff --git a/dashboard/src/features/dashboard/components/workers-health-card.tsx b/dashboard/src/features/dashboard/components/workers-health-card.tsx index def4f6530..363113748 100644 --- a/dashboard/src/features/dashboard/components/workers-health-card.tsx +++ b/dashboard/src/features/dashboard/components/workers-health-card.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { DOCUMENTATION } from '@/constants/Project' import { cn } from '@/lib/utils' import useDirDetection from '@/hooks/use-dir-detection' +import { useDocumentVisibility } from '@/hooks/use-document-visibility' import { useGetWorkersHealth } from '@/service/api' import { ChevronDown, ChevronRight, Clock, HelpCircle, Server, ServerCog } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' @@ -44,11 +45,12 @@ const dotClassMap: Record = { const WorkersHealthCard = () => { const { t, i18n } = useTranslation() const dir = useDirDetection() + const isTabVisible = useDocumentVisibility() const [pauseRefetch, setPauseRefetch] = useState(false) const [isCollapsed, setIsCollapsed] = useState(true) const { data, isLoading, isError } = useGetWorkersHealth({ query: { - refetchInterval: pauseRefetch ? false : 5000, + refetchInterval: pauseRefetch ? false : isTabVisible ? 5000 : 60000, retry: false, }, }) diff --git a/dashboard/src/features/users/components/users-statistics.tsx b/dashboard/src/features/users/components/users-statistics.tsx index fc2e1a413..7422627f6 100644 --- a/dashboard/src/features/users/components/users-statistics.tsx +++ b/dashboard/src/features/users/components/users-statistics.tsx @@ -1,4 +1,5 @@ import useDirDetection from '@/hooks/use-dir-detection' +import { useDocumentVisibility } from '@/hooks/use-document-visibility' import { cn } from '@/lib/utils' import { type SystemUsersStats, useGetSystemUsersStats } from '@/service/api' import { UserCheck, Users } from 'lucide-react' @@ -10,12 +11,13 @@ import { CountUp } from '@/components/ui/count-up' const UsersStatistics = () => { const { t } = useTranslation() const dir = useDirDetection() + const isTabVisible = useDocumentVisibility() const [prevData, setPrevData] = useState(null) const [isIncreased, setIsIncreased] = useState>({}) const { data } = useGetSystemUsersStats(undefined, { query: { - refetchInterval: 5000, + refetchInterval: isTabVisible ? 5000 : 60000, }, }) diff --git a/dashboard/src/hooks/use-document-visibility.ts b/dashboard/src/hooks/use-document-visibility.ts new file mode 100644 index 000000000..262b39fb1 --- /dev/null +++ b/dashboard/src/hooks/use-document-visibility.ts @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react' + +/** + * Returns whether the current browser tab is visible to the user. + * Uses the Page Visibility API — returns true if the tab is visible, + * false if minimized, hidden, or in a background tab. + */ +export function useDocumentVisibility(): boolean { + const [isVisible, setIsVisible] = useState(() => { + if (typeof document === 'undefined') return true + return document.visibilityState === 'visible' + }) + + useEffect(() => { + const handleVisibilityChange = () => { + setIsVisible(document.visibilityState === 'visible') + } + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => document.removeEventListener('visibilitychange', handleVisibilityChange) + }, []) + + return isVisible +} diff --git a/dashboard/src/hooks/use-version-check.ts b/dashboard/src/hooks/use-version-check.ts index 64b06d1c7..f6393631a 100644 --- a/dashboard/src/hooks/use-version-check.ts +++ b/dashboard/src/hooks/use-version-check.ts @@ -92,7 +92,7 @@ export function useVersionCheck(currentVersion: string | null, options: UseVersi gcTime: CACHE_DURATION * 2, refetchOnWindowFocus: false, refetchOnMount: false, - refetchInterval: CACHE_DURATION, + refetchInterval: false, retry: 1, }) diff --git a/dashboard/src/pages/_dashboard._index.tsx b/dashboard/src/pages/_dashboard._index.tsx index 819ff22f2..338b03cdf 100644 --- a/dashboard/src/pages/_dashboard._index.tsx +++ b/dashboard/src/pages/_dashboard._index.tsx @@ -18,6 +18,7 @@ import { HostFormSchema, hostFormDefaultValues, type HostFormValues } from '@/fe import { Separator } from '@/components/ui/separator' import { useAdmin } from '@/hooks/use-admin' import { useClipboard } from '@/hooks/use-clipboard' +import { useDocumentVisibility } from '@/hooks/use-document-visibility' import type { AdminDetails, UserResponse } from '@/service/api' import { useGetSystemResourceStats, useGetSystemUsersStats } from '@/service/api' import { getInboundDetails } from '@/service/api' @@ -99,6 +100,8 @@ const Dashboard = () => { const queryClient = useQueryClient() const { copy } = useClipboard() + const isTabVisible = useDocumentVisibility() + const dashboardPollingInterval = isTabVisible ? 5000 : 60000 /** Match nodes list: delayed refetch so backend can settle after create/update (see nodes-list NodeModal onSuccess). */ const handleNodeModalSuccess = () => { @@ -214,13 +217,13 @@ const Dashboard = () => { const { data: systemResourceStatsData } = useGetSystemResourceStats({ query: { - refetchInterval: 5000, + refetchInterval: dashboardPollingInterval, }, }) const { data: systemUsersStatsData } = useGetSystemUsersStats(systemUsersStatsParams, { query: { - refetchInterval: 5000, + refetchInterval: dashboardPollingInterval, }, })