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,
},
})