Skip to content

Commit 91041d1

Browse files
committed
feat: add multi-select nodes feature and view mode toggle in Nodes page
1 parent 181b9af commit 91041d1

File tree

17 files changed

+603
-14
lines changed

17 files changed

+603
-14
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"@monaco-editor/react": "^4.7.0",
6060
"@noble/post-quantum": "^0.5.2",
6161
"@paralleldrive/cuid2": "2.2.2",
62-
"@remnawave/backend-contract": "2.3.24",
62+
"@remnawave/backend-contract": "2.3.26",
6363
"@simplewebauthn/browser": "^13.2.2",
6464
"@stablelib/base64": "^2.0.1",
6565
"@stablelib/x25519": "^2.0.1",

public/locales/en/remnawave.json

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1834,5 +1834,24 @@
18341834
"empty-hosts": "Empty Hosts",
18351835
"empty-internal-squads": "Empty Internal Squads"
18361836
}
1837+
},
1838+
"multi-select-nodes": {
1839+
"feature": {
1840+
"profile-and-inbounds": "Profile & Inbounds"
1841+
}
1842+
},
1843+
"use-nodes-table-widget": {
1844+
"online": "Online",
1845+
"name": "Name",
1846+
"address": "Address",
1847+
"traffic-used": "Traffic Used",
1848+
"config-profile": "Config Profile",
1849+
"inbounds": "Inbounds",
1850+
"xray-v": "Xray V.",
1851+
"node-v": "Node V.",
1852+
"provider": "Provider",
1853+
"tags": "Tags",
1854+
"total-ram": "Total RAM",
1855+
"cpu-model": "CPU Model"
18371856
}
1838-
}
1857+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { Affix, Badge, Button, Group, Paper, Stack, Transition } from '@mantine/core'
2+
import { GetAllNodesCommand } from '@remnawave/backend-contract'
3+
import { useDisclosure } from '@mantine/hooks'
4+
import { useTranslation } from 'react-i18next'
5+
6+
import { ConfigProfilesDrawer } from '@widgets/dashboard/nodes/config-profiles-drawer'
7+
import { QueryKeys, useBulkNodesProfileModification } from '@shared/api/hooks'
8+
import { XrayLogo } from '@shared/ui/logos'
9+
import { queryClient } from '@shared/api'
10+
11+
interface IProps {
12+
selectedRecords: GetAllNodesCommand.Response['response'][number][]
13+
setSelectedRecords: (records: GetAllNodesCommand.Response['response'][number][]) => void
14+
}
15+
16+
export const MultiSelectNodesFeature = (props: IProps) => {
17+
const { selectedRecords, setSelectedRecords } = props
18+
const { t } = useTranslation()
19+
20+
const [opened, handlers] = useDisclosure(false)
21+
22+
const hasSelection = selectedRecords.length > 0
23+
24+
const { mutate: bulkNodesProfileModification } = useBulkNodesProfileModification({
25+
mutationFns: {
26+
onSuccess: () => {
27+
setSelectedRecords([])
28+
29+
queryClient.refetchQueries({ queryKey: QueryKeys.nodes.getAllNodes.queryKey })
30+
}
31+
}
32+
})
33+
34+
const handleProfileModification = (
35+
configProfileUuid: string,
36+
configProfileInboundUuids: string[]
37+
) => {
38+
bulkNodesProfileModification({
39+
variables: {
40+
uuids: selectedRecords.map((record) => record.uuid),
41+
configProfile: {
42+
activeConfigProfileUuid: configProfileUuid,
43+
activeInbounds: configProfileInboundUuids
44+
}
45+
}
46+
})
47+
}
48+
49+
return (
50+
<Affix position={{ bottom: 80, right: 20 }} zIndex={100}>
51+
<Transition mounted={hasSelection} transition="slide-up">
52+
{(styles) => (
53+
<Paper
54+
p={4}
55+
shadow="md"
56+
style={{
57+
...styles,
58+
width: '300px',
59+
maxWidth: '1200px',
60+
margin: '0 auto'
61+
}}
62+
withBorder
63+
>
64+
<Paper
65+
p="md"
66+
style={{
67+
borderRadius: 'calc(var(--mantine-radius-default) - 4px)',
68+
border: '1px solid var(--mantine-color-dark-5)'
69+
}}
70+
>
71+
<Stack gap="sm">
72+
<Group justify="center">
73+
<Badge color="gray" size="lg" variant="filled">
74+
{t('internal-squads.drawer.widget.selected')}:{' '}
75+
{selectedRecords.length}
76+
</Badge>
77+
<Group
78+
grow
79+
justify="apart"
80+
preventGrowOverflow={false}
81+
wrap="wrap"
82+
>
83+
<Button
84+
onClick={() => setSelectedRecords([])}
85+
variant="subtle"
86+
>
87+
{t('multi-select-hosts.feature.clear-selection')}
88+
</Button>
89+
</Group>
90+
</Group>
91+
92+
<Button
93+
color="cyan"
94+
fullWidth
95+
leftSection={<XrayLogo size={18} />}
96+
onClick={handlers.open}
97+
size="md"
98+
variant="light"
99+
>
100+
{t('multi-select-nodes.feature.profile-and-inbounds')}
101+
</Button>
102+
</Stack>
103+
</Paper>
104+
</Paper>
105+
)}
106+
</Transition>
107+
108+
<ConfigProfilesDrawer
109+
activeConfigProfileInbounds={[]}
110+
activeConfigProfileUuid={undefined}
111+
onClose={handlers.close}
112+
onSaveInbounds={(inbounds, configProfileUuid) => {
113+
handleProfileModification(configProfileUuid, inbounds)
114+
}}
115+
opened={opened}
116+
/>
117+
</Affix>
118+
)
119+
}

src/features/ui/dashboard/nodes/nodes-header-action-buttons/nodes-header-action-buttons.feature.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
import {
2+
TbAlertCircle,
3+
TbCards,
4+
TbInfoCircle,
5+
TbPlus,
6+
TbRefresh,
7+
TbRocket,
8+
TbSearch,
9+
TbTable
10+
} from 'react-icons/tb'
111
import {
212
ActionIcon,
313
ActionIconGroup,
@@ -8,16 +18,23 @@ import {
818
Text,
919
Tooltip
1020
} from '@mantine/core'
11-
import { TbAlertCircle, TbInfoCircle, TbPlus, TbRefresh, TbRocket, TbSearch } from 'react-icons/tb'
1221
import { useTranslation } from 'react-i18next'
1322
import { spotlight } from '@mantine/spotlight'
1423
import { PiSpiral } from 'react-icons/pi'
1524
import { modals } from '@mantine/modals'
1625

1726
import { useNodesStoreActions } from '@entities/dashboard/nodes/nodes-store/nodes-store'
27+
import { NodesViewMode } from '@pages/dashboard/nodes/ui/components/interfaces'
1828
import { useGetNodes, useRestartAllNodes } from '@shared/api/hooks'
1929

20-
export const NodesHeaderActionButtonsFeature = () => {
30+
interface IProps {
31+
setViewMode: (viewMode: NodesViewMode) => void
32+
viewMode: NodesViewMode
33+
}
34+
35+
export const NodesHeaderActionButtonsFeature = (props: IProps) => {
36+
const { setViewMode, viewMode } = props
37+
2138
const { t } = useTranslation()
2239

2340
const actions = useNodesStoreActions()
@@ -111,6 +128,29 @@ export const NodesHeaderActionButtonsFeature = () => {
111128
</Tooltip>
112129
</ActionIconGroup>
113130

131+
<ActionIconGroup>
132+
<Tooltip label="Toggle view mode">
133+
<ActionIcon
134+
color="gray"
135+
onClick={() =>
136+
setViewMode(
137+
viewMode === NodesViewMode.TABLE
138+
? NodesViewMode.CARDS
139+
: NodesViewMode.TABLE
140+
)
141+
}
142+
size="input-md"
143+
variant="light"
144+
>
145+
{viewMode === NodesViewMode.CARDS ? (
146+
<TbTable size="24px" />
147+
) : (
148+
<TbCards size="24px" />
149+
)}
150+
</ActionIcon>
151+
</Tooltip>
152+
</ActionIconGroup>
153+
114154
<ActionIconGroup>
115155
<Tooltip
116156
label={t('nodes-header-action-buttons.feature.restart-all-nodes')}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export enum NodesViewMode {
2+
CARDS = 'cards',
3+
TABLE = 'table'
4+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from './enums'
12
export * from './props.interface'
Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
1+
/* eslint-disable no-nested-ternary */
2+
import { GetAllNodesCommand } from '@remnawave/backend-contract'
13
import { useTranslation } from 'react-i18next'
24
import { Grid, Stack } from '@mantine/core'
35
import { HiServer } from 'react-icons/hi'
46
import { motion } from 'motion/react'
7+
import { useState } from 'react'
58

9+
import { MultiSelectNodesFeature } from '@features/dashboard/nodes/multi-select-nodes/multi-select-nodes.feature'
610
import { LinkedHostsDrawer } from '@widgets/dashboard/nodes/linked-hosts-drawer/linked-hosts-drawer.widget'
711
import { NodesHeaderActionButtonsFeature } from '@features/ui/dashboard/nodes/nodes-header-action-buttons'
12+
import { NodesDataTableWidget } from '@widgets/dashboard/nodes/nodes-datatable/nodes-datatable.widget'
13+
import { EditNodeByUuidModalWidget } from '@widgets/dashboard/nodes/edit-node-by-uuid-modal'
814
import { NodesRealtimeUsageMetrics } from '@widgets/dashboard/nodes/nodes-realtime-metrics'
915
import { EditNodeModalConnectorWidget } from '@widgets/dashboard/nodes/edit-node-modal'
1016
import { NodeUsersUsageDrawer } from '@widgets/dashboard/nodes/node-users-usage-drawer'
1117
import { CreateNodeModalWidget } from '@widgets/dashboard/nodes/create-node-modal'
1218
import { NodesTableWidget } from '@widgets/dashboard/nodes/nodes-table'
1319
import { LoadingScreen, Page, PageHeaderShared } from '@shared/ui'
1420

15-
import { IProps } from './interfaces'
21+
import { IProps, NodesViewMode } from './interfaces'
1622

1723
export default function NodesPageComponent(props: IProps) {
24+
const { nodes, isLoading } = props
25+
1826
const { t } = useTranslation()
1927

20-
const { nodes, isLoading } = props
28+
const [viewMode, setViewMode] = useState<NodesViewMode>(NodesViewMode.CARDS)
29+
const [selectedRecords, setSelectedRecords] = useState<
30+
GetAllNodesCommand.Response['response'][number][]
31+
>([])
2132

2233
return (
2334
<Page title={t('constants.nodes')}>
@@ -26,32 +37,49 @@ export default function NodesPageComponent(props: IProps) {
2637
<Stack>
2738
<NodesRealtimeUsageMetrics />
2839
<PageHeaderShared
29-
actions={<NodesHeaderActionButtonsFeature />}
40+
actions={
41+
<NodesHeaderActionButtonsFeature
42+
setViewMode={setViewMode}
43+
viewMode={viewMode}
44+
/>
45+
}
3046
icon={<HiServer size={24} />}
3147
title={t('constants.nodes')}
3248
/>
3349
</Stack>
3450

3551
{isLoading ? (
3652
<LoadingScreen height="60vh" />
37-
) : (
53+
) : viewMode === NodesViewMode.TABLE ? (
3854
<motion.div
3955
animate={{ opacity: 1 }}
4056
initial={{ opacity: 0 }}
4157
transition={{
4258
duration: 0.5
4359
}}
4460
>
45-
<NodesTableWidget nodes={nodes} />
61+
<NodesDataTableWidget
62+
nodes={nodes}
63+
selectedRecords={selectedRecords}
64+
setSelectedRecords={setSelectedRecords}
65+
/>
4666
</motion.div>
67+
) : (
68+
<NodesTableWidget nodes={nodes} />
4769
)}
4870
</Grid.Col>
4971
</Grid>
5072

5173
<EditNodeModalConnectorWidget key="view-node-widget" />
74+
<EditNodeByUuidModalWidget key="edit-node-by-uuid-modal" />
5275
<CreateNodeModalWidget key="create-node-widget" />
5376
<NodeUsersUsageDrawer key="node-users-usage-drawer" />
5477
<LinkedHostsDrawer key="linked-hosts-drawer" />
78+
79+
<MultiSelectNodesFeature
80+
selectedRecords={selectedRecords}
81+
setSelectedRecords={setSelectedRecords}
82+
/>
5583
</Page>
5684
)
5785
}

src/shared/api/hooks/nodes/nodes.mutation.hooks.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
BulkNodesProfileModificationCommand,
23
CreateNodeCommand,
34
DeleteNodeCommand,
45
DisableNodeCommand,
@@ -220,3 +221,27 @@ export const useResetNodeTraffic = createMutationHook({
220221
}
221222
}
222223
})
224+
225+
export const useBulkNodesProfileModification = createMutationHook({
226+
endpoint: BulkNodesProfileModificationCommand.TSQ_url,
227+
responseSchema: BulkNodesProfileModificationCommand.ResponseSchema,
228+
bodySchema: BulkNodesProfileModificationCommand.RequestSchema,
229+
requestMethod: BulkNodesProfileModificationCommand.endpointDetails.REQUEST_METHOD,
230+
rMutationParams: {
231+
onSuccess: () => {
232+
notifications.show({
233+
title: 'Success',
234+
message: 'Task added to queue successfully.',
235+
color: 'teal'
236+
})
237+
},
238+
onError: (error) => {
239+
notifications.show({
240+
title: `Bulk Nodes Profile Modification`,
241+
message:
242+
error instanceof Error ? error.message : `Request failed with unknown error.`,
243+
color: 'red'
244+
})
245+
}
246+
}
247+
})

src/shared/utils/bytes/pretty-bytes/pretty-bytes.util.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function prettyBytesToAnyUtil(
1818
}
1919

2020
export function prettyBytesUtil(
21-
bytesInput: number | string | undefined,
21+
bytesInput: null | number | string | undefined,
2222
returnZero: boolean = false
2323
): string | undefined {
2424
if (!bytesInput) {

0 commit comments

Comments
 (0)