mirror of
https://github.com/remnawave/frontend.git
synced 2026-05-13 12:16:40 +00:00
feat: add Sessions Explorer
This commit is contained in:
parent
8cce45c495
commit
8f7865897b
17 changed files with 1182 additions and 7 deletions
|
|
@ -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}}\""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './sessions-explorer.page.connector'
|
||||
|
|
@ -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 />
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' }} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './sessions-explorer.widget'
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue