Skip to content

Commit b3db6c3

Browse files
committed
feat: add configuration status indication to node cards
- Implemented a visual indicator for missing configuration in `NodeCardWidget` and `NodeDetailsCardWidget`. - Added a "DANGLING" badge for nodes with incomplete configuration profiles. - Enhanced node status management with new hooks for enabling and disabling nodes. - Improved user interaction with tooltips for action buttons based on node status.
1 parent 6285f77 commit b3db6c3

File tree

2 files changed

+198
-47
lines changed

2 files changed

+198
-47
lines changed

src/widgets/dashboard/nodes/node-card/node-card.widget.tsx

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useClipboard, useMediaQuery } from '@mantine/hooks'
1010
import { notifications } from '@mantine/notifications'
1111
import ReactCountryFlag from 'react-country-flag'
1212
import { useSortable } from '@dnd-kit/sortable'
13+
import { TbAlertCircle } from 'react-icons/tb'
1314
import { useTranslation } from 'react-i18next'
1415
import { CSS } from '@dnd-kit/utilities'
1516
import clsx from 'clsx'
@@ -113,7 +114,14 @@ export const NodeCardWidget = memo((props: IProps) => {
113114
}
114115

115116
return { backgroundColor, borderColor, boxShadow }
116-
}, [node.isConnected, node.isConnecting, node.isDisabled])
117+
}, [node.isConnected, node.isConnecting, node.isDisabled, node.configProfile])
118+
119+
const isConfigMissing = useMemo(() => {
120+
return (
121+
node.configProfile.activeConfigProfileUuid === null ||
122+
node.configProfile.activeInbounds.length === 0
123+
)
124+
}, [node.configProfile])
117125

118126
return (
119127
<Box
@@ -148,18 +156,31 @@ export const NodeCardWidget = memo((props: IProps) => {
148156
<Grid align="center" className={classes.desktopGrid} gutter="md">
149157
<Grid.Col span={{ base: 12, sm: 5.5 }}>
150158
<Flex align="center" gap="sm">
151-
<NodeStatusBadgeWidget node={node} withText={false} />
152-
153-
<Badge
154-
color={node.usersOnline! > 0 ? 'teal' : 'gray'}
155-
leftSection={<PiUsersDuotone size={14} />}
156-
miw={'7ch'}
157-
radius="md"
158-
size="lg"
159-
variant="outline"
160-
>
161-
{node.usersOnline}
162-
</Badge>
159+
{isConfigMissing ? (
160+
<Badge
161+
color="red"
162+
leftSection={<TbAlertCircle size={14} />}
163+
radius="md"
164+
size="lg"
165+
variant="light"
166+
>
167+
DANGLING
168+
</Badge>
169+
) : (
170+
<>
171+
<NodeStatusBadgeWidget node={node} withText={false} />
172+
<Badge
173+
color={node.usersOnline! > 0 ? 'teal' : 'gray'}
174+
leftSection={<PiUsersDuotone size={14} />}
175+
miw={'7ch'}
176+
radius="md"
177+
size="lg"
178+
variant="outline"
179+
>
180+
{node.usersOnline}
181+
</Badge>
182+
</>
183+
)}
163184

164185
<Flex align="center" className={classes.nameContainer} gap="xs">
165186
{node.countryCode && node.countryCode !== 'XX' && (
@@ -281,18 +302,32 @@ export const NodeCardWidget = memo((props: IProps) => {
281302
{isMobile && (
282303
<Box>
283304
<Flex align="center" gap="sm" mb="xs">
284-
<NodeStatusBadgeWidget node={node} withText={false} />
285-
286-
<Badge
287-
color={node.usersOnline! > 0 ? 'teal' : 'gray'}
288-
leftSection={<PiUsersDuotone size={14} />}
289-
miw={'7ch'}
290-
radius="md"
291-
size="lg"
292-
variant="outline"
293-
>
294-
{node.usersOnline}
295-
</Badge>
305+
{isConfigMissing && (
306+
<Badge
307+
color="red"
308+
leftSection={<TbAlertCircle size={14} />}
309+
radius="md"
310+
size="lg"
311+
variant="light"
312+
>
313+
DANGLING
314+
</Badge>
315+
)}
316+
317+
{!isConfigMissing && <NodeStatusBadgeWidget node={node} withText={false} />}
318+
319+
{!isConfigMissing && (
320+
<Badge
321+
color={node.usersOnline! > 0 ? 'teal' : 'gray'}
322+
leftSection={<PiUsersDuotone size={14} />}
323+
miw={'7ch'}
324+
radius="md"
325+
size="lg"
326+
variant="outline"
327+
>
328+
{node.usersOnline}
329+
</Badge>
330+
)}
296331

297332
<Flex align="center" gap="xs" style={{ flex: 1, minWidth: 0 }}>
298333
{node.countryCode && node.countryCode !== 'XX' && (

src/widgets/dashboard/nodes/node-details-card/node-details-card.widget.tsx

Lines changed: 138 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
2+
ActionIcon,
23
Box,
34
Card,
45
Group,
6+
Loader,
57
Paper,
68
SimpleGrid,
79
Stack,
@@ -11,13 +13,16 @@ import {
1113
Tooltip
1214
} from '@mantine/core'
1315
import { PiCloudArrowUpDuotone, PiUsersDuotone, PiWarningCircle } from 'react-icons/pi'
14-
import { TbWifi, TbWifiOff } from 'react-icons/tb'
16+
import { UpdateNodeCommand } from '@remnawave/backend-contract'
17+
import { TbPower, TbWifi, TbWifiOff } from 'react-icons/tb'
1518
import { HiOutlineServer } from 'react-icons/hi'
1619
import { useTranslation } from 'react-i18next'
1720
import { motion } from 'framer-motion'
1821
import { memo, useMemo } from 'react'
1922

23+
import { QueryKeys, useDisableNode, useEnableNode } from '@shared/api/hooks'
2024
import { XtlsLogo } from '@shared/ui/logos/xtls-logo'
25+
import { queryClient } from '@shared/api'
2126
import { Logo } from '@shared/ui'
2227

2328
import { IProps } from './interface'
@@ -27,13 +32,47 @@ export const NodeDetailsCardWidget = memo(({ node, fetchedNode }: IProps) => {
2732

2833
const nodeData = fetchedNode || node
2934

35+
const mutationParams = {
36+
route: {
37+
uuid: nodeData.uuid
38+
},
39+
mutationFns: {
40+
onSuccess: async (nodeData: UpdateNodeCommand.Response['response']) => {
41+
await queryClient.setQueryData(
42+
QueryKeys.nodes.getNode({ uuid: nodeData.uuid }).queryKey,
43+
nodeData
44+
)
45+
}
46+
}
47+
}
48+
49+
const { mutate: disableNode, isPending: isDisableNodePending } = useDisableNode(mutationParams)
50+
const { mutate: enableNode, isPending: isEnableNodePending } = useEnableNode(mutationParams)
51+
52+
const isConfigMissing = useMemo(() => {
53+
return (
54+
node.configProfile.activeConfigProfileUuid === null ||
55+
node.configProfile.activeInbounds.length === 0
56+
)
57+
}, [node.configProfile])
58+
3059
const { icon, color, backgroundColor, borderColor, boxShadow } = useMemo(() => {
3160
let icon: React.ReactNode
3261
let color = 'red'
3362
let backgroundColor = 'rgba(239, 68, 68, 0.15)'
3463
let borderColor = 'rgba(239, 68, 68, 0.3)'
3564
let boxShadow = 'rgba(239, 68, 68, 0.2)'
3665

66+
if (isConfigMissing) {
67+
icon = <PiWarningCircle size={18} style={{ color: 'var(--mantine-color-red-6)' }} />
68+
color = 'red'
69+
backgroundColor = 'rgba(239, 68, 68, 0.15)'
70+
borderColor = 'rgba(239, 68, 68, 0.3)'
71+
boxShadow = 'rgba(239, 68, 68, 0.2)'
72+
73+
return { icon, color, backgroundColor, borderColor, boxShadow }
74+
}
75+
3776
if (nodeData.isConnected) {
3877
icon = <TbWifi size={18} style={{ color: 'var(--mantine-color-teal-6)' }} />
3978
color = 'teal'
@@ -68,6 +107,14 @@ export const NodeDetailsCardWidget = memo(({ node, fetchedNode }: IProps) => {
68107
return { icon, color, backgroundColor, borderColor, boxShadow }
69108
}, [nodeData.isConnected, nodeData.isConnecting, nodeData.isDisabled, t])
70109

110+
const handleToggleNodeStatus = () => {
111+
if (nodeData.isDisabled) {
112+
enableNode({})
113+
} else {
114+
disableNode({})
115+
}
116+
}
117+
71118
return (
72119
<Card
73120
p="lg"
@@ -115,29 +162,98 @@ export const NodeDetailsCardWidget = memo(({ node, fetchedNode }: IProps) => {
115162
</Box>
116163
</Group>
117164

118-
<motion.div
119-
animate={{
120-
scale: nodeData?.isConnected ? [1, 1.1, 1] : 1,
121-
opacity: nodeData?.isConnected ? [1, 0.8, 1] : 0.6
122-
}}
123-
transition={{
124-
duration: nodeData?.isConnected ? 2 : 0,
125-
repeat: nodeData?.isConnected ? Infinity : 0
126-
}}
127-
>
128-
<ThemeIcon
129-
color={color}
130-
size="lg"
131-
style={{
132-
backgroundColor,
133-
border: `1px solid ${borderColor}`,
134-
boxShadow: `0 0 15px ${boxShadow}`
165+
<Group gap="xs">
166+
{!isConfigMissing && (
167+
<Tooltip label={nodeData.isDisabled ? 'Enable Node' : 'Disable Node'}>
168+
<ActionIcon
169+
color={nodeData.isDisabled ? 'teal' : 'red'}
170+
disabled={isDisableNodePending || isEnableNodePending}
171+
onClick={handleToggleNodeStatus}
172+
size="md"
173+
style={{
174+
backgroundColor: nodeData.isDisabled
175+
? 'rgba(45, 212, 191, 0.15)'
176+
: 'rgba(239, 68, 68, 0.15)',
177+
border: `1px solid ${
178+
nodeData.isDisabled
179+
? 'rgba(45, 212, 191, 0.3)'
180+
: 'rgba(239, 68, 68, 0.3)'
181+
}`,
182+
boxShadow: `0 0 10px ${
183+
nodeData.isDisabled
184+
? 'rgba(45, 212, 191, 0.2)'
185+
: 'rgba(239, 68, 68, 0.2)'
186+
}`
187+
}}
188+
variant="light"
189+
>
190+
{isDisableNodePending || isEnableNodePending ? (
191+
<Loader
192+
color={nodeData.isDisabled ? 'teal' : 'red'}
193+
size="xs"
194+
/>
195+
) : (
196+
<TbPower
197+
size={16}
198+
style={{
199+
color: nodeData.isDisabled
200+
? 'var(--mantine-color-teal-4)'
201+
: 'var(--mantine-color-red-4)'
202+
}}
203+
/>
204+
)}
205+
</ActionIcon>
206+
</Tooltip>
207+
)}
208+
209+
{isConfigMissing && (
210+
<Tooltip label="Config profile or inbounds is missing">
211+
<ActionIcon
212+
color="gray"
213+
disabled
214+
size="md"
215+
style={{
216+
backgroundColor: 'rgba(107, 114, 128, 0.15)',
217+
border: `1px solid rgba(107, 114, 128, 0.3)`,
218+
boxShadow: `0 0 10px rgba(107, 114, 128, 0.2)`,
219+
opacity: 0.7
220+
}}
221+
variant="light"
222+
>
223+
<TbPower
224+
size={16}
225+
style={{
226+
color: 'var(--mantine-color-teal-4)'
227+
}}
228+
/>
229+
</ActionIcon>
230+
</Tooltip>
231+
)}
232+
233+
<motion.div
234+
animate={{
235+
scale: nodeData?.isConnected ? [1, 1.1, 1] : 1,
236+
opacity: nodeData?.isConnected ? [1, 0.8, 1] : 0.6
237+
}}
238+
transition={{
239+
duration: nodeData?.isConnected ? 2 : 0,
240+
repeat: nodeData?.isConnected ? Infinity : 0
135241
}}
136-
variant="light"
137242
>
138-
{icon}
139-
</ThemeIcon>
140-
</motion.div>
243+
<ThemeIcon
244+
color={color}
245+
size="lg"
246+
style={{
247+
backgroundColor,
248+
border: `1px solid ${borderColor}`,
249+
boxShadow: `0 0 15px ${boxShadow}`
250+
}}
251+
variant="light"
252+
>
253+
{icon}
254+
</ThemeIcon>
255+
</motion.div>
256+
</Group>
141257
</Group>
142258

143259
{nodeData.isConnected && (

0 commit comments

Comments
 (0)