feat: add Sessions Explorer

This commit is contained in:
kastov 2026-03-22 04:39:40 +03:00
parent 8cce45c495
commit 8f7865897b
No known key found for this signature in database
GPG key ID: 1B27BE29057F4C90
17 changed files with 1182 additions and 7 deletions

View file

@ -27,7 +27,8 @@
"response-rules": "Response Rules",
"remnawave-settings": "Remnawave Settings",
"node-plugins": "Plugins",
"tb-reports": "Torrent Blocker Reports"
"tb-reports": "Torrent Blocker Reports",
"sessions-explorer": "Sessions Explorer"
},
"common": {
"delete": "Delete",
@ -2090,5 +2091,29 @@
"widget": {
"total-records": "Total records"
}
},
"sessions-explorer-idle": {
"description-1": "This operation may take a while depending on the number of nodes and users. Sessions are fetched sequentially to avoid overloading the backend.",
"start-exploring": "Start exploring"
},
"sessions-explorer-progress": {
"exploring-nodes": "Exploring nodes...",
"nodes-processed": "{{completed}} of {{total}} nodes processed",
"nodes-failed": "{{count}} failed"
},
"sessions-explorer": {
"widget": {
"all-nodes-failed-to-return-session-data": "All nodes failed to return session data.",
"total-ips": "Total IPs",
"unique-ips": "Unique IPs",
"nodes-explored": "Nodes Explored",
"total-users": "Total Users",
"no-active-sessions-found-on-any-node": "No active sessions found on any node.",
"restart-scan": "Restart scan",
"clear-results": "Clear results",
"description": "Scan all online nodes to collect active user sessions and IP addresses",
"search-by-ip": "Search by IP address...",
"no-results-for-ip": "No users found with IP matching \"{{ip}}\""
}
}
}

View file

@ -6,6 +6,7 @@ import {
TbFolder,
TbHexagon,
TbPackage,
TbRadar2,
TbReportAnalytics,
TbRoute,
TbWebhook
@ -236,6 +237,12 @@ export const useMenuSections = (): MenuItem[] => {
href: ROUTES.DASHBOARD.TOOLS.TORRENT_BLOCKER_REPORTS,
icon: TbFlame,
id: 'torrent-blocker-reports'
},
{
name: t('constants.sessions-explorer'),
href: ROUTES.DASHBOARD.TOOLS.SESSIONS_EXPLORER,
icon: TbRadar2,
id: 'sessions-explorer'
}
]
},

View file

@ -9,6 +9,7 @@ import {
import { SubpageConfigEditorPageConnector } from '@pages/dashboard/subpage-config/ui/connectors/subpage-config-editor-page.connector'
import { ConfigProfileByUuidPageConnector } from '@pages/dashboard/config-profiles/connectors/config-profile-by-uuid.page.connector'
import { SubpageConfigBasePageConnector } from '@pages/dashboard/subpage-config/ui/connectors/subpage-config-base-page.connector'
import { SessionsExplorerPageConnector } from '@pages/dashboard/sessions-explorer/ui/connectors/sessions-explorer.page.connector'
import { NodePluginEditorPageConnector } from '@pages/dashboard/node-plugins/ui/connectors/node-plugin-editor-page.connector'
import { NodePluginsBasePageConnector } from '@pages/dashboard/node-plugins/ui/connectors/node-plugins-base-page.connector'
import { InternalSquadsPageConnector } from '@pages/dashboard/internal-squads/connectors/internal-squads.page.connector'
@ -143,6 +144,10 @@ const router = createBrowserRouter(
element={<TorrentBlockerReportsPageConnector />}
path={ROUTES.DASHBOARD.TOOLS.TORRENT_BLOCKER_REPORTS}
/>
<Route
element={<SessionsExplorerPageConnector />}
path={ROUTES.DASHBOARD.TOOLS.SESSIONS_EXPLORER}
/>
</Route>
<Route path={ROUTES.DASHBOARD.TEMPLATES.ROOT}>

View file

@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next'
import { motion } from 'framer-motion'
import { Stack } from '@mantine/core'
import { UserSubscriptionRequestsDrawerWidget } from '@widgets/dashboard/users/user-subscription-requests-drawer/user-subscription-requests-drawer.widget'
import { UserTorrentBlockerReportsDrawerWidget } from '@widgets/dashboard/users/user-torrent-blocker-reports/user-torrent-blocker-reports.drawer.widget'
import { UserAccessibleNodesModalWidget } from '@widgets/dashboard/users/user-accessible-nodes-modal/user-accessible-nodes.modal.widget'
import { DetailedUserInfoDrawerWidget } from '@widgets/dashboard/users/detailed-user-info-drawer/detailed-user-info-drawer.widget'
import { UserHwidDevicesDrawerWidget } from '@widgets/dashboard/users/user-hwid-devices-drawer/user-hwid-devices.drawer.widget'
import { SessionsExplorerWidget } from '@widgets/dashboard/sessions-explorer/sessions-explorer-widget'
import { ViewUserModal } from '@widgets/dashboard/users/view-user-modal'
import { Page } from '@shared/ui'
export default function SessionsExplorerPageComponent() {
const { t } = useTranslation()
return (
<Page title={t('constants.sessions-explorer')}>
<Stack>
<motion.div
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0, 0.71, 0.2, 1.01] }}
>
<SessionsExplorerWidget />
</motion.div>
</Stack>
<ViewUserModal key="view-user-modal" />
<DetailedUserInfoDrawerWidget key="detailed-user-info-drawer" />
<UserAccessibleNodesModalWidget key="user-accessible-nodes-modal" />
<UserHwidDevicesDrawerWidget key="user-hwid-devices-drawer" />
<UserTorrentBlockerReportsDrawerWidget key="user-torrent-blocker-reports-drawer" />
<UserSubscriptionRequestsDrawerWidget key="user-subscription-requests-drawer" />
</Page>
)
}

View file

@ -0,0 +1 @@
export * from './sessions-explorer.page.connector'

View file

@ -0,0 +1,14 @@
import { useGetNodes } from '@shared/api/hooks'
import { LoadingScreen } from '@shared/ui'
import SessionsExplorerPageComponent from '../components/sessions-explorer.page.component'
export function SessionsExplorerPageConnector() {
const { isLoading: isNodesLoading } = useGetNodes()
if (isNodesLoading) {
return <LoadingScreen />
}
return <SessionsExplorerPageComponent />
}

View file

@ -1,7 +1,8 @@
import {
DropConnectionsCommand,
FetchIpsCommand,
FetchUsersIpsCommand
FetchUsersIpsCommand,
FetchUsersIpsResultCommand
} from '@remnawave/backend-contract'
import { createMutationHook } from '../../tsq-helpers'
@ -20,6 +21,13 @@ export const useFetchUsersIps = createMutationHook({
requestMethod: FetchUsersIpsCommand.endpointDetails.REQUEST_METHOD
})
export const useFetchUsersIpsResultMutation = createMutationHook({
endpoint: FetchUsersIpsResultCommand.TSQ_url,
responseSchema: FetchUsersIpsResultCommand.ResponseSchema,
routeParamsSchema: FetchUsersIpsResultCommand.RequestSchema,
requestMethod: FetchUsersIpsResultCommand.endpointDetails.REQUEST_METHOD
})
export const useDropConnections = createMutationHook({
endpoint: DropConnectionsCommand.TSQ_url,
bodySchema: DropConnectionsCommand.RequestSchema,

View file

@ -33,7 +33,8 @@ export const ROUTES = {
ROOT: '/dashboard/tools',
HWID_INSPECTOR: '/dashboard/tools/hwid-inspector',
SRH_INSPECTOR: '/dashboard/tools/srh-inspector',
TORRENT_BLOCKER_REPORTS: '/dashboard/tools/torrent-blocker-reports'
TORRENT_BLOCKER_REPORTS: '/dashboard/tools/torrent-blocker-reports',
SESSIONS_EXPLORER: '/dashboard/tools/sessions-explorer'
},
TEMPLATES: {
ROOT: '/dashboard/templates',

View file

@ -5,6 +5,7 @@ import { ReactNode } from 'react'
type IProps = {
countryCode?: string
hideIcon?: boolean
iconColor?: ThemeIconProps['color']
IconComponent: React.ComponentType<{ size: number }>
iconSize?: number
@ -27,16 +28,19 @@ export const BaseOverlayHeader = (props: IProps) => {
subtitle,
title,
titleOrder = 4,
withCopy = false
withCopy = false,
hideIcon = false
} = props
const { copy } = useClipboard()
return (
<Group gap="sm" wrap="nowrap">
<ThemeIcon color={iconColor} size="lg" variant={iconVariant} {...themeIconProps}>
<IconComponent size={iconSize} />
</ThemeIcon>
{!hideIcon && (
<ThemeIcon color={iconColor} size="lg" variant={iconVariant} {...themeIconProps}>
<IconComponent size={iconSize} />
</ThemeIcon>
)}
{countryCode && countryCode !== 'XX' && (
<ReactCountryFlag countryCode={countryCode} style={{ fontSize: '1.5em' }} />

View file

@ -0,0 +1,31 @@
import { SimpleGrid } from '@mantine/core'
import { forwardRef } from 'react'
export const SessionsExplorerVirtualizedGridComponents = {
List: forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(
({ style, children, ...props }, ref) => (
<SimpleGrid
{...props}
cols={{
base: 1,
'800px': 2,
'1000px': 3,
'1200px': 4,
'1800px': 5,
'2400px': 6,
'3000px': 7
}}
ref={ref}
style={{
...style
}}
type="container"
>
{children}
</SimpleGrid>
)
),
Item: ({ children, ...props }: React.HTMLProps<HTMLDivElement>) => (
<div {...props}>{children}</div>
)
}

View file

@ -0,0 +1 @@
export * from './sessions-explorer.widget'

View file

@ -0,0 +1,246 @@
import {
TbClockCheck,
TbClockExclamation,
TbClockPause,
TbExternalLink,
TbFingerprint,
TbId,
TbServer
} from 'react-icons/tb'
import {
ActionIcon,
Badge,
Box,
Divider,
Group,
ScrollArea,
Stack,
Text,
Tooltip
} from '@mantine/core'
import { createSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { PiUserCircle } from 'react-icons/pi'
import { memo } from 'react'
import clsx from 'clsx'
import { formatRelativeDateUtil, formatTimeUtil } from '@shared/utils/time-utils'
import { CopyableFieldShared } from '@shared/ui/copyable-field/copyable-field'
import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header'
import { SEARCH_PARAMS } from '@shared/constants/search-params'
import { SectionCard } from '@shared/ui/section-card'
import { useResolveUser } from '@shared/api/hooks'
import { ROUTES } from '@shared/constants'
import type { AggregatedUser } from './use-sessions-explorer'
import styles from './sessions-explorer.module.css'
interface IProps {
highThreshold: number
ipSearchQuery?: string
midThreshold: number
user: AggregatedUser
}
function getIpCountColor(count: number, mid: number, high: number): string {
if (count >= high) return 'red'
if (count >= mid) return 'yellow'
return 'teal'
}
const getLastSeenIndicator = (lastSeen: Date | string) => {
const diffMs = Date.now() - new Date(lastSeen).getTime()
const diffMinutes = diffMs / 60_000
if (diffMinutes <= 5) return { color: 'var(--mantine-color-teal-6)', Icon: TbClockCheck }
if (diffMinutes <= 60) return { color: 'var(--mantine-color-yellow-6)', Icon: TbClockPause }
return { color: 'var(--mantine-color-red-6)', Icon: TbClockExclamation }
}
export const SessionsExplorerCard = memo(
({ user, midThreshold, highThreshold, ipSearchQuery }: IProps) => {
const { t, i18n } = useTranslation()
const { mutateAsync: resolveUser, isPending: isLoading } = useResolveUser()
const handleViewUser = async () => {
const result = await resolveUser({
variables: {
id: Number(user.userId)
}
})
if (result.uuid) {
const searchParams = createSearchParams({
[SEARCH_PARAMS.USER]: String(result.uuid)
})
window.open(
`${ROUTES.DASHBOARD.MANAGEMENT.USERS}?${searchParams.toString()}`,
'_blank'
)
}
}
return (
<SectionCard.Root dividerOpacity={0} gap="xs">
<SectionCard.Section>
<Group gap="xs" justify="space-between">
<BaseOverlayHeader
iconColor="blue"
IconComponent={TbId}
iconVariant="soft"
title={user.userId}
/>
<Group gap="xs">
<Badge
color={getIpCountColor(user.totalIps, midThreshold, highThreshold)}
size="lg"
variant="soft"
>
{user.totalIps}
</Badge>
<Tooltip label={t('sessions-explorer.widget.unique-ips')}>
<Badge
color={getIpCountColor(
user.totalIps,
midThreshold,
highThreshold
)}
leftSection={<TbFingerprint size={16} />}
size="lg"
variant="default"
>
{user.uniqueIps}
</Badge>
</Tooltip>
<Tooltip label={t('node-active-session.item.widget.view-user')}>
<ActionIcon
color="cyan"
loading={isLoading}
onClick={handleViewUser}
size="lg"
variant="soft"
>
<PiUserCircle size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</SectionCard.Section>
<ScrollArea.Autosize mah={400} mih={400}>
{user.nodes.map((nodeData) => (
<SectionCard.Root
dividerOpacity={0}
gap={4}
key={nodeData.nodeUuid}
mb="xs"
>
<SectionCard.Section>
<Group justify="space-between">
<BaseOverlayHeader
countryCode={nodeData.countryCode}
hideIcon={true}
iconColor="blue"
IconComponent={TbServer}
iconVariant="soft"
title={nodeData.nodeName}
titleOrder={6}
/>
<Badge color="teal" size="lg" variant="default">
{nodeData.ips.length}
</Badge>
</Group>
</SectionCard.Section>
<Divider opacity={0.3} />
{nodeData.ips.map((item) => {
const isMatch = !!ipSearchQuery && item.ip.includes(ipSearchQuery)
return (
<SectionCard.Section
className={clsx(isMatch && styles.ipHighlight)}
key={`${nodeData.nodeUuid}-${item.ip}`}
>
<Group align="center" gap="xs" wrap="nowrap">
<ActionIcon
color="cyan"
component="a"
href={`https://ipinfo.io/${item.ip}`}
rel="noopener noreferrer"
size="input-sm"
target="_blank"
variant="soft"
>
<TbExternalLink size={18} />
</ActionIcon>
<Box style={{ flex: 1 }}>
<CopyableFieldShared
leftSection={
<Tooltip
label={
<Stack gap={2} p={4}>
<Text
c="white"
fw={600}
size="xs"
>
{formatRelativeDateUtil(
item.lastSeen,
t,
i18n.language
)}
</Text>
<Text
c="dimmed"
ff="monospace"
size="xs"
>
{formatTimeUtil({
time: item.lastSeen,
template:
'TIME_FIRST_DATETIME',
language: i18n.language
})}
</Text>
</Stack>
}
radius="md"
>
{(() => {
const { color, Icon } =
getLastSeenIndicator(
item.lastSeen
)
return (
<Box
style={{
display: 'flex',
cursor: 'help',
color
}}
>
<Icon size={16} />
</Box>
)
})()}
</Tooltip>
}
size="sm"
value={item.ip}
/>
</Box>
</Group>
</SectionCard.Section>
)
})}
</SectionCard.Root>
))}
</ScrollArea.Autosize>
</SectionCard.Root>
)
}
)

View file

@ -0,0 +1,95 @@
import { TbAlertTriangle, TbBrandDocker, TbClock, TbRadar, TbRadar2 } from 'react-icons/tb'
import { Button, Group, Stack, Text, ThemeIcon } from '@mantine/core'
import { CodeHighlight } from '@mantine/code-highlight'
import { Trans, useTranslation } from 'react-i18next'
import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header'
import { SectionCard } from '@shared/ui/section-card'
const DOCKER_SNIPPET = `
cap_add:
- NET_ADMIN
`
const HIGHLIGHT_SPAN = <Text c="white" component="span" fw={600} size="sm" />
interface IProps {
onlineNodesCount: number
onStart: () => void
}
export function SessionsExplorerIdle({ onlineNodesCount, onStart }: IProps) {
const { t } = useTranslation()
return (
<Stack gap="md">
<SectionCard.Root gap="md">
<SectionCard.Section>
<BaseOverlayHeader
iconColor="yellow"
IconComponent={TbAlertTriangle}
iconVariant="soft"
title={t('active-sessions-drawer.widget.requirements')}
/>
</SectionCard.Section>
<Stack gap="xs">
<Group gap="sm" wrap="nowrap">
<ThemeIcon color="violet" size="md" variant="soft">
<TbBrandDocker size={16} />
</ThemeIcon>
<Text c="dimmed" size="sm">
<Trans
components={{ highlight: HIGHLIGHT_SPAN }}
i18nKey="active-sessions-drawer.widget.warning-docker"
/>
</Text>
</Group>
<CodeHighlight
background="rgba(22, 27, 35)"
code={DOCKER_SNIPPET}
language="yaml"
radius="md"
style={{
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: 'var(--mantine-radius-md)'
}}
/>
</Stack>
<Group gap="sm" wrap="nowrap">
<ThemeIcon color="cyan" size="md" variant="soft">
<TbClock size={16} />
</ThemeIcon>
<Text c="dimmed" size="sm">
<Trans
components={{ highlight: HIGHLIGHT_SPAN }}
i18nKey="active-sessions-drawer.widget.warning-activity"
/>
</Text>
</Group>
<Group gap="sm" wrap="nowrap">
<ThemeIcon color="yellow" size="md" variant="soft">
<TbRadar size={16} />
</ThemeIcon>
<Text c="dimmed" size="sm">
{t('sessions-explorer-idle.description-1')}
</Text>
</Group>
</SectionCard.Root>
<Button
color="teal"
disabled={onlineNodesCount === 0}
fullWidth
leftSection={<TbRadar2 size={20} />}
onClick={onStart}
size="md"
variant="light"
>
{t('sessions-explorer-idle.start-exploring')}
</Button>
</Stack>
)
}

View file

@ -0,0 +1,92 @@
import { Badge, Box, Group, Progress, Stack, Text } from '@mantine/core'
import { AnimatePresence, motion } from 'motion/react'
import { useTranslation } from 'react-i18next'
import { LottieGlobeShared } from '@shared/ui/lotties/globe'
import { CountryFlag } from '@shared/ui/get-country-flag'
import { SectionCard } from '@shared/ui/section-card'
import type { ExplorerProgress } from './use-sessions-explorer'
import styles from './sessions-explorer.module.css'
interface IProps {
progress: ExplorerProgress
}
export function SessionsExplorerProgress({ progress }: IProps) {
const { t } = useTranslation()
const progressPercent =
progress.total > 0
? Math.round(((progress.completed + progress.failed) / progress.total) * 100)
: 0
return (
<SectionCard.Root gap="md">
<SectionCard.Section className={styles.progressCard}>
<Stack align="center" gap="lg" py="xl" w="100%">
<div style={{ height: 120, display: 'flex', alignItems: 'center' }}>
<LottieGlobeShared />
</div>
<Stack align="center" gap="xs" w="100%">
<Text c="white" fw={600} size="lg">
{t('sessions-explorer-progress.exploring-nodes')}
</Text>
<Text c="dimmed" size="sm">
{t('sessions-explorer-progress.nodes-processed', {
completed: progress.completed + progress.failed,
total: progress.total
})}
{progress.failed > 0 && (
<Text c="red.5" component="span" fw={500}>
{' '}
({t('sessions-explorer-progress.nodes-failed', { count: progress.failed })})
</Text>
)}
</Text>
</Stack>
<Box px="xl" w="100%">
<Progress
animated
color="teal"
radius="xl"
size="lg"
striped
value={progressPercent}
/>
</Box>
<Group gap="xs" justify="center" w="100%">
<AnimatePresence mode="popLayout">
{progress.activeNodes.map((node, i) => (
<motion.div
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
initial={{ opacity: 0, scale: 0.85 }}
key={node.uuid}
layout
transition={{
duration: 0.25,
delay: i * 0.06
}}
>
<Badge
color="teal"
leftSection={<CountryFlag countryCode={node.countryCode} />}
size="lg"
variant="soft"
>
{node.name}
</Badge>
</motion.div>
))}
</AnimatePresence>
</Group>
</Stack>
</SectionCard.Section>
</SectionCard.Root>
)
}

View file

@ -0,0 +1,14 @@
.progressCard {
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.ipHighlight {
background: rgba(45, 212, 191, 0.08);
border-radius: var(--mantine-radius-md);
padding: 4px;
margin: -4px;
outline: 1px solid rgba(45, 212, 191, 0.25);
}

View file

@ -0,0 +1,316 @@
import {
ActionIcon,
Affix,
Badge,
Button,
Center,
Group,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
Transition
} from '@mantine/core'
import {
TbAlertTriangle,
TbArrowUp,
TbFingerprint,
TbNetwork,
TbRadar2,
TbRefresh,
TbSearch,
TbServer,
TbTrash,
TbUser,
TbX
} from 'react-icons/tb'
import { useDebouncedValue, useWindowScroll } from '@mantine/hooks'
import { VirtuosoGrid, VirtuosoGridHandle } from 'react-virtuoso'
import { useMemo, useRef, useState } from 'react'
import { PiEmptyDuotone } from 'react-icons/pi'
import { useTranslation } from 'react-i18next'
import { MetricCardShared } from '@shared/ui/metrics/metric-card'
import { PageHeaderShared } from '@shared/ui/page-header'
import { SectionCard } from '@shared/ui/section-card'
import { useGetNodes } from '@shared/api/hooks'
import { SessionsExplorerVirtualizedGridComponents } from './grid-components'
import { SessionsExplorerProgress } from './sessions-explorer-progress'
import { SessionsExplorerIdle } from './sessions-explorer-idle'
import { SessionsExplorerCard } from './sessions-explorer-card'
import { useSessionsExplorer } from './use-sessions-explorer'
export function SessionsExplorerWidget() {
const { t } = useTranslation()
const { data: nodes } = useGetNodes()
const { phase, progress, aggregatedUsers, stats, onlineNodes, start, reset } =
useSessionsExplorer(nodes)
const [scroll] = useWindowScroll()
const virtuosoRef = useRef<VirtuosoGridHandle>(null)
const [ipSearch, setIpSearch] = useState('')
const [debouncedSearch] = useDebouncedValue(ipSearch, 200)
const filteredUsers = useMemo(() => {
const q = debouncedSearch.trim()
if (!q) return aggregatedUsers
return aggregatedUsers.filter((u) =>
u.nodes.some((n) => n.ips.some((ip) => ip.ip.includes(q)))
)
}, [aggregatedUsers, debouncedSearch])
const isSearchActive = debouncedSearch.trim().length > 0
const ipThresholds = useMemo(() => {
if (aggregatedUsers.length === 0) return { high: 0, mid: 0 }
const max = aggregatedUsers[0].totalIps
const min = aggregatedUsers[aggregatedUsers.length - 1].totalIps
const range = max - min
if (range === 0) return { high: max + 1, mid: max + 1 }
return {
high: min + range * 0.66,
mid: min + range * 0.33
}
}, [aggregatedUsers])
const itemContent = (index: number) => {
const item = filteredUsers[index]
if (!item) return null
return (
<div style={{ width: '100%' }}>
<SessionsExplorerCard
highThreshold={ipThresholds.high}
ipSearchQuery={debouncedSearch.trim() || undefined}
midThreshold={ipThresholds.mid}
user={item}
/>
</div>
)
}
const handleStart = () => {
setIpSearch('')
start()
}
const handleReset = () => {
setIpSearch('')
reset()
}
const renderFailedState = () => (
<SectionCard.Root gap="sm">
<SectionCard.Section>
<Center h="230">
<Stack align="center" gap="xs">
<ThemeIcon color="red" radius="md" size="xl" variant="soft">
<TbAlertTriangle size={24} />
</ThemeIcon>
<Text c="dimmed" size="md">
{t('sessions-explorer.widget.all-nodes-failed-to-return-session-data')}
</Text>
<Button
color="teal"
leftSection={<TbRefresh size={20} />}
onClick={handleReset}
size="sm"
variant="soft"
>
{t('active-sessions-drawer.widget.try-again')}
</Button>
</Stack>
</Center>
</SectionCard.Section>
</SectionCard.Root>
)
const renderCompletedState = () => (
<Stack gap="md">
{stats && (
<SimpleGrid cols={{ xs: 2, sm: 2, md: 4 }} spacing="xs">
<MetricCardShared
iconColor="blue"
IconComponent={TbNetwork}
iconVariant="soft"
title={t('sessions-explorer.widget.total-ips')}
value={stats.totalIps}
/>
<MetricCardShared
iconColor="teal"
IconComponent={TbFingerprint}
iconVariant="soft"
title={t('sessions-explorer.widget.unique-ips')}
value={stats.uniqueIps}
/>
<MetricCardShared
iconColor="indigo"
IconComponent={TbServer}
iconVariant="soft"
title={t('sessions-explorer.widget.nodes-explored')}
value={stats.nodesScanned}
/>
<MetricCardShared
iconColor="violet"
IconComponent={TbUser}
iconVariant="soft"
title={t('sessions-explorer.widget.total-users')}
value={stats.totalUsers}
/>
</SimpleGrid>
)}
{aggregatedUsers.length > 0 && (
<Group gap="xs">
<TextInput
leftSection={<TbSearch size={16} />}
onChange={(e) => setIpSearch(e.currentTarget.value)}
placeholder={t('sessions-explorer.widget.search-by-ip')}
rightSection={
ipSearch ? (
<ActionIcon
color="gray"
onClick={() => setIpSearch('')}
size="sm"
variant="subtle"
>
<TbX size={14} />
</ActionIcon>
) : null
}
style={{ flex: 1 }}
value={ipSearch}
/>
{isSearchActive && (
<Badge color="teal" size="xl" variant="soft">
{filteredUsers.length}
</Badge>
)}
</Group>
)}
{aggregatedUsers.length === 0 && (
<SectionCard.Root gap="sm">
<SectionCard.Section>
<Center h="230">
<Stack align="center" gap="xs">
<PiEmptyDuotone color="var(--mantine-color-gray-5)" size="3rem" />
<Text c="dimmed" size="sm">
{t(
'sessions-explorer.widget.no-active-sessions-found-on-any-node'
)}
</Text>
</Stack>
</Center>
</SectionCard.Section>
</SectionCard.Root>
)}
{isSearchActive && filteredUsers.length === 0 && (
<SectionCard.Root gap="sm">
<SectionCard.Section>
<Center h="160">
<Stack align="center" gap="xs">
<PiEmptyDuotone color="var(--mantine-color-gray-5)" size="2rem" />
<Text c="dimmed" size="sm">
{t('sessions-explorer.widget.no-results-for-ip', {
ip: debouncedSearch.trim()
})}
</Text>
</Stack>
</Center>
</SectionCard.Section>
</SectionCard.Root>
)}
{filteredUsers.length > 0 && (
<VirtuosoGrid
components={SessionsExplorerVirtualizedGridComponents}
itemContent={itemContent}
overscan={{
main: 4,
reverse: 4
}}
ref={virtuosoRef}
totalCount={filteredUsers.length}
useWindowScroll={true}
/>
)}
</Stack>
)
return (
<>
<PageHeaderShared
actions={
<Group>
{phase === 'completed' && (
<>
<Tooltip label={t('sessions-explorer.widget.restart-scan')}>
<ActionIcon
color="teal"
onClick={handleStart}
size="input-md"
variant="soft"
>
<TbRefresh size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label={t('sessions-explorer.widget.clear-results')}>
<ActionIcon
color="red"
onClick={handleReset}
size="input-md"
variant="soft"
>
<TbTrash size={20} />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
}
description={t('sessions-explorer.widget.description')}
icon={<TbRadar2 size={24} />}
title={t('constants.sessions-explorer')}
/>
{phase === 'idle' && (
<SessionsExplorerIdle onlineNodesCount={onlineNodes.length} onStart={handleStart} />
)}
{phase === 'running' && <SessionsExplorerProgress progress={progress} />}
{phase === 'failed' && renderFailedState()}
{phase === 'completed' && renderCompletedState()}
<Affix position={{ bottom: 20, right: 20 }}>
<Transition mounted={scroll.y > 300} transition="slide-up">
{(transitionStyles) => (
<ActionIcon
color="teal"
onClick={() => {
if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({
index: 0,
align: 'start',
behavior: 'auto'
})
}
}}
radius="xl"
size="xl"
style={transitionStyles}
variant="filled"
>
<TbArrowUp size={20} />
</ActionIcon>
)}
</Transition>
</Affix>
</>
)
}

View file

@ -0,0 +1,278 @@
import { FetchUsersIpsResultCommand, GetAllNodesCommand } from '@remnawave/backend-contract'
import { useEffect, useRef, useState } from 'react'
import { useFetchUsersIps, useFetchUsersIpsResultMutation } from '@shared/api/hooks'
type NodeType = GetAllNodesCommand.Response['response'][number]
type NodeIpEntry = NonNullable<
FetchUsersIpsResultCommand.Response['response']['result']
>['users'][number]['ips'][number]
type PollResult = NonNullable<FetchUsersIpsResultCommand.Response['response']['result']>
export interface AggregatedUserNode {
countryCode: string
ips: NodeIpEntry[]
nodeName: string
nodeUuid: string
}
export interface AggregatedUser {
nodes: AggregatedUserNode[]
totalIps: number
uniqueIps: number
userId: string
}
export interface ExplorerStats {
nodesFailed: number
nodesScanned: number
totalIps: number
totalUsers: number
uniqueIps: number
}
export interface ActiveNodeInfo {
countryCode: string
name: string
uuid: string
}
export interface ExplorerProgress {
activeNodes: ActiveNodeInfo[]
completed: number
failed: number
total: number
}
export type ExplorerPhase = 'completed' | 'failed' | 'idle' | 'running'
const MAX_CONCURRENT = 5
const POLL_INTERVAL = 1000
function delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms)
const onAbort = () => {
clearTimeout(timer)
reject(new DOMException('Aborted', 'AbortError'))
}
signal.addEventListener('abort', onAbort, { once: true })
})
}
async function runWithConcurrency<T>(
items: T[],
fn: (item: T) => Promise<void>,
maxConcurrent: number,
signal: AbortSignal
): Promise<void> {
const pool = new Set<Promise<void>>()
for (const item of items) {
if (signal.aborted) break
const p = fn(item).then(() => {
pool.delete(p)
})
pool.add(p)
if (pool.size >= maxConcurrent) {
await Promise.race(pool) // eslint-disable-line no-await-in-loop
}
}
await Promise.all(pool)
}
function isAbortError(err: unknown): boolean {
if (err instanceof DOMException && err.name === 'AbortError') return true
if (err && typeof err === 'object' && 'code' in err) {
return (err as { code: string }).code === 'ERR_CANCELED'
}
return false
}
interface NodeResult {
countryCode: string
nodeName: string
nodeUuid: string
users: Array<{ ips: NodeIpEntry[]; userId: string }>
}
function aggregateResults(results: NodeResult[]) {
const userMap = new Map<string, AggregatedUser>()
const globalIpSet = new Set<string>()
let totalIpCount = 0
for (const nr of results) {
for (const u of nr.users) {
let agg = userMap.get(u.userId)
if (!agg) {
agg = { userId: u.userId, totalIps: 0, uniqueIps: 0, nodes: [] }
userMap.set(u.userId, agg)
}
agg.nodes.push({
nodeUuid: nr.nodeUuid,
nodeName: nr.nodeName,
ips: u.ips,
countryCode: nr.countryCode
})
agg.totalIps += u.ips.length
totalIpCount += u.ips.length
for (const ip of u.ips) {
globalIpSet.add(ip.ip)
}
}
}
for (const agg of userMap.values()) {
const s = new Set<string>()
for (const n of agg.nodes) {
for (const ip of n.ips) {
s.add(ip.ip)
}
}
agg.uniqueIps = s.size
}
const sorted = [...userMap.values()].sort((a, b) => b.totalIps - a.totalIps)
return { sorted, totalIpCount, uniqueIpCount: globalIpSet.size }
}
export function useSessionsExplorer(nodes: NodeType[] | undefined) {
const [phase, setPhase] = useState<ExplorerPhase>('idle')
const [progress, setProgress] = useState<ExplorerProgress>({
completed: 0,
failed: 0,
total: 0,
activeNodes: []
})
const [aggregatedUsers, setAggregatedUsers] = useState<AggregatedUser[]>([])
const [stats, setStats] = useState<ExplorerStats | null>(null)
const { mutateAsync: createNodeJob } = useFetchUsersIps()
const { mutateAsync: fetchUsersIpsResult } = useFetchUsersIpsResultMutation()
const abortRef = useRef<AbortController | null>(null)
const nodesRef = useRef(nodes)
useEffect(() => {
nodesRef.current = nodes
}, [nodes])
useEffect(() => {
return () => {
abortRef.current?.abort()
}
}, [])
const onlineNodes = nodes?.filter((n) => n.isConnected && !n.isDisabled) ?? []
const start = async () => {
const targetNodes = nodesRef.current?.filter((n) => n.isConnected && !n.isDisabled) ?? []
if (targetNodes.length === 0) return
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
const { signal } = ac
const total = targetNodes.length
setPhase('running')
setProgress({ completed: 0, failed: 0, total, activeNodes: [] })
setAggregatedUsers([])
setStats(null)
const results: NodeResult[] = []
let completed = 0
let failed = 0
const activeNodesMap = new Map<string, ActiveNodeInfo>()
const syncProgress = () => {
setProgress({
completed,
failed,
total,
activeNodes: [...activeNodesMap.values()]
})
}
const pollUntilDone = async (jobId: string): Promise<null | PollResult> => {
await delay(POLL_INTERVAL, signal)
const resp = await fetchUsersIpsResult({ route: { jobId }, query: { signal } })
if (resp.isFailed || (resp.isCompleted && !resp.result?.success)) {
return null
}
if (resp.isCompleted && resp.result) {
return resp.result
}
if (signal.aborted) return null
return pollUntilDone(jobId)
}
const processNode = async (node: NodeType) => {
activeNodesMap.set(node.uuid, { name: node.name, countryCode: node.countryCode, uuid: node.uuid })
syncProgress()
try {
const { jobId } = await createNodeJob({ route: { nodeUuid: node.uuid } })
const pollResult = await pollUntilDone(jobId)
if (pollResult) {
results.push({
nodeUuid: node.uuid,
nodeName: node.name,
users: pollResult.users,
countryCode: node.countryCode
})
completed++
} else {
failed++
}
} catch (err: unknown) {
if (isAbortError(err)) throw err
failed++
} finally {
activeNodesMap.delete(node.uuid)
syncProgress()
}
}
try {
await runWithConcurrency(targetNodes, processNode, MAX_CONCURRENT, signal)
if (signal.aborted) return
const { sorted, totalIpCount, uniqueIpCount } = aggregateResults(results)
setAggregatedUsers(sorted)
setStats({
totalIps: totalIpCount,
uniqueIps: uniqueIpCount,
totalUsers: sorted.length,
nodesScanned: completed,
nodesFailed: failed
})
setPhase(failed === total ? 'failed' : 'completed')
} catch (err: unknown) {
if (isAbortError(err)) return
setPhase('failed')
}
}
const reset = () => {
abortRef.current?.abort()
abortRef.current = null
setPhase('idle')
setProgress({ completed: 0, failed: 0, total: 0, activeNodes: [] })
setAggregatedUsers([])
setStats(null)
}
return { phase, progress, aggregatedUsers, stats, onlineNodes, start, reset }
}