11import {
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'
1824import { GetUserUsageByRangeCommand } from '@remnawave/backend-contract'
19- import { TbChartBar as IconChartBar } from 'react-icons/tb '
25+ import { PiEmpty , PiListBullets } from 'react-icons/pi '
2026import { BarChart , Sparkline } from '@mantine/charts'
2127import { useEffect , useMemo , useState } from 'react'
2228import { useTranslation } from 'react-i18next'
23- import { PiEmpty } from 'react-icons/pi'
2429import ColorHash from 'color-hash'
2530import 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