Skip to content

Commit c045a9a

Browse files
committed
feat: enhance user usage modal with detailed node statistics and navigation
1 parent 322afb5 commit c045a9a

File tree

5 files changed

+248
-16
lines changed

5 files changed

+248
-16
lines changed

public/locales/en/remnawave.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,9 @@
793793
"stacked": "Stacked",
794794
"grouped": "Grouped",
795795
"filter-nodes": "Filter nodes",
796-
"bar-chart": "Bar chart"
796+
"bar-chart": "Bar chart",
797+
"show-nodes": "Show nodes",
798+
"click-to-see-all": "Click to see all"
797799
}
798800
},
799801
"get-user-usage": {

public/locales/fa/remnawave.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,9 @@
793793
"stacked": "Stacked",
794794
"grouped": "Grouped",
795795
"filter-nodes": "Filter nodes",
796-
"bar-chart": "Bar chart"
796+
"bar-chart": "Bar chart",
797+
"show-nodes": "Show nodes",
798+
"click-to-see-all": "Click to see all"
797799
}
798800
},
799801
"get-user-usage": {

public/locales/ru/remnawave.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,9 @@
793793
"stacked": "Сложить",
794794
"grouped": "Сгруппировать",
795795
"filter-nodes": "Фильтр по нодам",
796-
"bar-chart": "Столбчатая диаграмма"
796+
"bar-chart": "Столбчатая диаграмма",
797+
"show-nodes": "Show nodes",
798+
"click-to-see-all": "Показать все"
797799
}
798800
},
799801
"get-user-usage": {
@@ -900,4 +902,4 @@
900902
"show-custom-remarks": "Отображать кастомное примечание"
901903
}
902904
}
903-
}
905+
}

src/shared/ui/language-picker/language-picker.shared.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Menu, Text, useDirection } from '@mantine/core'
1+
import { ActionIcon, Menu, Text, useDirection } from '@mantine/core'
22
import { useTranslation } from 'react-i18next'
33
import { useEffect } from 'react'
44

@@ -52,9 +52,9 @@ export function LanguagePicker() {
5252
return (
5353
<Menu position="bottom-end" width={150} withinPortal>
5454
<Menu.Target>
55-
{/* <ActionIcon color="gray" size="xl"> */}
56-
<Text size="xl">{selected.emoji}</Text>
57-
{/* </ActionIcon> */}
55+
<ActionIcon color="gray" size="xl" style={{ borderColor: 'transparent' }}>
56+
<Text size="xl">{selected.emoji}</Text>
57+
</ActionIcon>
5858
</Menu.Target>
5959
<Menu.Dropdown>{items}</Menu.Dropdown>
6060
</Menu>

src/widgets/dashboard/users/user-usage-modal/user-usage-modal.widget.tsx

Lines changed: 234 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
11
import {
2+
Accordion,
3+
ActionIcon,
24
Box,
35
Card,
46
Center,
7+
Drawer,
58
Group,
69
Modal,
710
MultiSelect,
811
Paper,
12+
ScrollArea,
913
SegmentedControl,
1014
Select,
1115
SimpleGrid,
1216
Skeleton,
1317
Stack,
18+
Table,
1419
Tabs,
1520
Text,
1621
Tooltip
1722
} from '@mantine/core'
23+
import { TbChartBar as IconChartBar, TbChevronLeft, TbChevronRight } from 'react-icons/tb'
1824
import { GetUserUsageByRangeCommand } from '@remnawave/backend-contract'
19-
import { TbChartBar as IconChartBar } from 'react-icons/tb'
25+
import { PiEmpty, PiListBullets } from 'react-icons/pi'
2026
import { BarChart, Sparkline } from '@mantine/charts'
2127
import { useEffect, useMemo, useState } from 'react'
2228
import { useTranslation } from 'react-i18next'
23-
import { PiEmpty } from 'react-icons/pi'
2429
import ColorHash from 'color-hash'
2530
import dayjs from 'dayjs'
2631

@@ -41,6 +46,14 @@ export const UserUsageModalWidget = (props: IProps) => {
4146
const [selectedNodes, setSelectedNodes] = useState<string[]>([])
4247
const [viewType, setViewType] = useState<'grouped' | 'stacked'>('stacked')
4348
const [highlightedNode, setHighlightedNode] = useState<null | string>(null)
49+
const [nodeDetailsActive, setNodeDetailsActive] = useState<boolean>(false)
50+
const [selectedDate, setSelectedDate] = useState<null | string>(null)
51+
const [selectedDayData, setSelectedDayData] = useState<Array<{
52+
color: string
53+
name: string
54+
value: number
55+
}> | null>(null)
56+
const [currentDateIndex, setCurrentDateIndex] = useState<null | number>(null)
4457
const ch = new ColorHash({ lightness: 0.5, saturation: 0.7 })
4558

4659
useEffect(() => {
@@ -156,6 +169,90 @@ export const UserUsageModalWidget = (props: IProps) => {
156169

157170
const hasData = data.length > 0 && displaySeries.length > 0
158171

172+
const handleBarClick = (barData: Record<string, unknown>, clickIndex?: number) => {
173+
const date = barData.date as string
174+
if (!date) return
175+
176+
const technicalFields = [
177+
'width',
178+
'y',
179+
'x',
180+
'height',
181+
'value',
182+
'payload',
183+
'background',
184+
'fill',
185+
'tooltipPayload',
186+
'tooltipPosition',
187+
'cursor',
188+
'className',
189+
'index',
190+
'stroke',
191+
'strokeWidth',
192+
'strokeDasharray',
193+
'stackedData',
194+
'dataKey',
195+
'layout'
196+
]
197+
198+
const dayData = Object.entries(barData)
199+
.filter(([key]) => key !== 'date' && !technicalFields.includes(key))
200+
.map(([name, value]) => {
201+
const nodeInfo = series.find((s) => s.name === name)
202+
return {
203+
name,
204+
value: Number(value) || 0,
205+
color: nodeInfo?.color || '#ccc'
206+
}
207+
})
208+
.sort((a, b) => b.value - a.value)
209+
210+
if (dayData.length === 0) return
211+
212+
let dateIndex: null | number = null
213+
214+
if (typeof clickIndex === 'number') {
215+
dateIndex = clickIndex
216+
} else {
217+
for (let i = 0; i < data.length; i++) {
218+
if (data[i].date === date) {
219+
dateIndex = i
220+
break
221+
}
222+
}
223+
}
224+
225+
setSelectedDate(date)
226+
setSelectedDayData(dayData)
227+
setCurrentDateIndex(dateIndex)
228+
setNodeDetailsActive(true)
229+
}
230+
231+
const goToPreviousDay = () => {
232+
if (currentDateIndex === null || currentDateIndex <= 0) return
233+
234+
const previousIndex = currentDateIndex - 1
235+
const previousData = data[previousIndex]
236+
237+
if (!previousData) return
238+
239+
handleBarClick(previousData, previousIndex)
240+
}
241+
242+
const goToNextDay = () => {
243+
if (currentDateIndex === null || currentDateIndex >= data.length - 1) return
244+
245+
const nextIndex = currentDateIndex + 1
246+
const nextData = data[nextIndex]
247+
248+
if (!nextData) return
249+
250+
handleBarClick(nextData, nextIndex)
251+
}
252+
253+
const hasPreviousDay = currentDateIndex !== null && currentDateIndex > 0
254+
const hasNextDay = currentDateIndex !== null && currentDateIndex < data.length - 1
255+
159256
const renderBarChart = () => {
160257
if (isLoading) {
161258
return <Skeleton height={400} mt="md" />
@@ -178,7 +275,12 @@ export const UserUsageModalWidget = (props: IProps) => {
178275
<Box mt="md" style={{ width: '100%', height: 400 }}>
179276
<BarChart
180277
barProps={{
181-
radius: 3
278+
radius: 3,
279+
cursor: 'pointer',
280+
onClick: (barData, index) => {
281+
const barIndex = typeof index === 'number' ? index : -1
282+
handleBarClick(barData, barIndex)
283+
}
182284
}}
183285
data={data}
184286
dataKey="date"
@@ -206,6 +308,10 @@ export const UserUsageModalWidget = (props: IProps) => {
206308
return sum + (Number(entry.value) || 0)
207309
}, 0)
208310

311+
const filteredPayload = sortedPayload.filter(
312+
(entry) => entry.value > 50_000
313+
)
314+
209315
return (
210316
<Paper px="md" py="sm" radius="md" shadow="md" withBorder>
211317
<Group justify="space-between" mb={8}>
@@ -214,7 +320,7 @@ export const UserUsageModalWidget = (props: IProps) => {
214320
{${prettyBytesToAnyUtil(totalForDay)}`}
215321
</Text>
216322
</Group>
217-
{sortedPayload.map((entry) => (
323+
{filteredPayload.slice(0, 10).map((entry) => (
218324
<Stack
219325
gap={4}
220326
key={entry.dataKey || `${entry.name}-${Math.random()}`}
@@ -237,6 +343,14 @@ export const UserUsageModalWidget = (props: IProps) => {
237343
</Group>
238344
</Stack>
239345
))}
346+
{filteredPayload.length > 10 && (
347+
<Text c="dimmed" fz="xs" mt={8} ta="center">
348+
{`+ ${filteredPayload.length - 10} more`}
349+
<Text c="dimmed" fz="xs" mt={8} ta="center">
350+
{t('user-usage-modal.widget.click-to-see-all')}
351+
</Text>
352+
</Text>
353+
)}
240354
</Paper>
241355
)
242356
}
@@ -248,12 +362,16 @@ export const UserUsageModalWidget = (props: IProps) => {
248362
withLegend={false}
249363
withXAxis
250364
/>
365+
366+
<Text c="dimmed" mt={8} size="sm" ta="center">
367+
{t('user-usage-modal.widget.show-nodes')}
368+
</Text>
251369
</Box>
252370
)
253371
}
254372

255373
const renderLegend = () => {
256-
return (
374+
const content = (
257375
<SimpleGrid
258376
cols={{ base: 1, xs: 2, sm: 3, md: 4 }}
259377
spacing={'xs'}
@@ -352,13 +470,29 @@ export const UserUsageModalWidget = (props: IProps) => {
352470
))}
353471
</SimpleGrid>
354472
)
473+
474+
return (
475+
<Accordion defaultValue="closed" variant="default">
476+
<Accordion.Item value="legend">
477+
<Accordion.Control
478+
icon={<PiListBullets color="var(--mantine-color-gray-7)" size={18} />}
479+
>
480+
{t('user-usage-modal.widget.show-nodes')}
481+
</Accordion.Control>
482+
<Accordion.Panel>{content}</Accordion.Panel>
483+
</Accordion.Item>
484+
</Accordion>
485+
)
355486
}
356487

357488
return (
358-
<Modal
359-
centered
489+
<Drawer
490+
keepMounted={false}
360491
onClose={onClose}
361492
opened={opened}
493+
overlayProps={{ backgroundOpacity: 0.6, blur: 0 }}
494+
padding="lg"
495+
position="right"
362496
size="900px"
363497
title={t('user-usage-modal.widget.traffic-statistics')}
364498
>
@@ -535,6 +669,98 @@ export const UserUsageModalWidget = (props: IProps) => {
535669
<Tabs.Panel value="bar">{renderBarChart()}</Tabs.Panel>
536670
</Tabs>
537671
</Stack>
538-
</Modal>
672+
673+
{/* Node details modal */}
674+
<Modal
675+
centered
676+
onClose={() => {
677+
setNodeDetailsActive(false)
678+
setCurrentDateIndex(null)
679+
}}
680+
opened={nodeDetailsActive}
681+
size="600px"
682+
title={
683+
<Group align="center" justify="space-between" wrap="nowrap">
684+
<Group gap="md">
685+
<ActionIcon
686+
disabled={!hasPreviousDay}
687+
onClick={goToPreviousDay}
688+
title={t('user-usage-modal.widget.show-nodes')}
689+
variant="subtle"
690+
>
691+
<TbChevronLeft size={16} />
692+
</ActionIcon>
693+
<Text>{selectedDate}</Text>
694+
<ActionIcon
695+
disabled={!hasNextDay}
696+
onClick={goToNextDay}
697+
title={t('user-usage-modal.widget.show-nodes')}
698+
variant="subtle"
699+
>
700+
<TbChevronRight size={16} />
701+
</ActionIcon>
702+
</Group>
703+
<Text c="dimmed" fz="sm">
704+
{/* eslint-disable */}
705+
{`${t('user-usage-modal.widget.total-traffic')}: ${
706+
selectedDayData
707+
? // prettier-ignore
708+
prettyBytesToAnyUtil(selectedDayData.reduce((sum, item) => sum + (item.value || 0), 0))
709+
: ''
710+
}`}
711+
{/* eslint-enable */}
712+
</Text>
713+
</Group>
714+
}
715+
>
716+
{selectedDayData && (
717+
<Stack>
718+
<Text c="dimmed" fz="sm">
719+
{`${t('user-usage-modal.widget.total-traffic')}: ${prettyBytesToAnyUtil(
720+
selectedDayData.reduce((sum, item) => sum + (item.value || 0), 0)
721+
)}`}
722+
</Text>
723+
<ScrollArea h={400} offsetScrollbars type="always">
724+
<Table highlightOnHover striped withTableBorder>
725+
<Table.Thead>
726+
<Table.Tr>
727+
<Table.Th>
728+
{t('user-usage-modal.widget.show-nodes')}
729+
</Table.Th>
730+
<Table.Th style={{ textAlign: 'right' }}>
731+
{t('user-usage-modal.widget.total-traffic')}
732+
</Table.Th>
733+
</Table.Tr>
734+
</Table.Thead>
735+
<Table.Tbody>
736+
{selectedDayData.map((entry) => (
737+
<Table.Tr key={entry.name}>
738+
<Table.Td>
739+
<Group gap={8}>
740+
<Box
741+
h={12}
742+
style={{
743+
backgroundColor: entry.color,
744+
borderRadius: '50%'
745+
}}
746+
w={12}
747+
/>
748+
<Text>{entry.name}</Text>
749+
</Group>
750+
</Table.Td>
751+
<Table.Td style={{ textAlign: 'right' }}>
752+
<Text fw={500}>
753+
{prettyBytesToAnyUtil(entry.value)}
754+
</Text>
755+
</Table.Td>
756+
</Table.Tr>
757+
))}
758+
</Table.Tbody>
759+
</Table>
760+
</ScrollArea>
761+
</Stack>
762+
)}
763+
</Modal>
764+
</Drawer>
539765
)
540766
}

0 commit comments

Comments
 (0)