Skip to content

Commit 34bfc40

Browse files
committed
feat: Add subscription client details modal and enhance statistics display
1 parent 34937ff commit 34bfc40

6 files changed

Lines changed: 253 additions & 6 deletions

File tree

dashboard/public/statics/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,6 +1896,7 @@
18961896
"statistics.historicalDescription": "Historical server performance data for the selected time range",
18971897
"statistics.subscriptions": "Subscriptions",
18981898
"statistics.subscriptionDistribution": "Subscription Client Distribution",
1899+
"statistics.subscriptionDistributionDetails": "Subscription Client Details",
18991900
"statistics.subscriptionDistributionDescription": "Share of subscription updates across agents and applications.",
19001901
"statistics.totalSubscriptions": "Total subscription updates",
19011902
"statistics.percentage": "Percentage",

dashboard/public/statics/locales/fa.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1696,6 +1696,7 @@
16961696
"statistics.historicalDescription": "داده‌های عملکرد تاریخی سرور برای بازه زمانی انتخاب شده",
16971697
"statistics.subscriptions": "اشتراک‌ها",
16981698
"statistics.subscriptionDistribution": "توزیع کلاینت‌های اشتراک",
1699+
"statistics.subscriptionDistributionDetails": "جزئیات کلاینت‌های اشتراک",
16991700
"statistics.subscriptionDistributionDescription": "سهم به‌روزرسانی‌های لینک اشتراک بین عامل‌ها و برنامه‌ها.",
17001701
"statistics.totalSubscriptions": "مجموع به‌روزرسانی‌ها",
17011702
"statistics.percentage": "درصد",

dashboard/public/statics/locales/ru.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"statistics.historicalDescription": "Исторические данные о производительности сервера за выбранный период времени",
2626
"statistics.subscriptions": "Подписки",
2727
"statistics.subscriptionDistribution": "Распределение клиентов подписки",
28+
"statistics.subscriptionDistributionDetails": "Детали клиентов подписки",
2829
"statistics.subscriptionDistributionDescription": "Доля обновлений подписки по агентам и приложениям.",
2930
"statistics.totalSubscriptions": "Всего обновлений",
3031
"statistics.percentage": "Процент",

dashboard/public/statics/locales/zh.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1846,6 +1846,7 @@
18461846
"statistics.historicalDescription": "所选时间范围内的历史服务器性能数据",
18471847
"statistics.subscriptions": "订阅",
18481848
"statistics.subscriptionDistribution": "订阅客户端分布",
1849+
"statistics.subscriptionDistributionDetails": "订阅客户端详情",
18491850
"statistics.subscriptionDistributionDescription": "不同代理和应用的订阅更新占比。",
18501851
"statistics.totalSubscriptions": "总更新次数",
18511852
"statistics.percentage": "百分比",

dashboard/src/components/charts/user-sub-update-pie-chart.tsx

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge'
1212
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
1313
import { type ChartConfig, ChartContainer, ChartTooltip } from '@/components/ui/chart'
1414
import { Skeleton } from '@/components/ui/skeleton'
15+
import UserSubUpdateStatsModal from '@/features/users/dialogs/user-sub-update-stats-modal'
1516
import useDirDetection from '@/hooks/use-dir-detection'
1617
import { useChartViewType } from '@/hooks/use-chart-view-type'
1718
import {
@@ -208,7 +209,7 @@ function SeriesTooltip({
208209
</span>
209210
</div>
210211
))}
211-
{rows.length > visibleRows.length && <div className="text-muted-foreground pt-1 text-center text-[10px]">{t('statistics.clickForMore', { defaultValue: 'Click for more details' })}</div>}
212+
{rows.length > 0 && <div className="text-muted-foreground pt-1 text-center text-[10px]">{t('statistics.clickForMore', { defaultValue: 'Click for more details' })}</div>}
212213
</div>
213214
</div>
214215
)
@@ -223,10 +224,13 @@ function UserSubUpdatePieChart({ username, adminId }: UserSubUpdatePieChartProps
223224
const [chartView, setChartView] = useState<'bar' | 'pie'>('pie')
224225
const [selectedAdmin, setSelectedAdmin] = useState<string>('all')
225226
const [selectedAdminId, setSelectedAdminId] = useState<number | null>(() => (adminId != null ? adminId : null))
226-
const [selectedTime, setSelectedTime] = useState<TrafficShortcutKey>('1w')
227+
const [selectedTime, setSelectedTime] = useState<TrafficShortcutKey>('all')
227228
const [showCustomRange, setShowCustomRange] = useState(false)
228229
const [customRange, setCustomRange] = useState<DateRange | undefined>(undefined)
229230
const [windowWidth, setWindowWidth] = useState<number>(() => (typeof window !== 'undefined' ? window.innerWidth : 1024))
231+
const [modalOpen, setModalOpen] = useState(false)
232+
const [selectedData, setSelectedData] = useState<ChartDataPoint | null>(null)
233+
const [currentDataIndex, setCurrentDataIndex] = useState(0)
230234

231235
useEffect(() => {
232236
if (adminId != null) {
@@ -458,8 +462,36 @@ function UserSubUpdatePieChart({ username, adminId }: UserSubUpdatePieChartProps
458462
}
459463
}, [])
460464

465+
const handleModalNavigate = useCallback(
466+
(index: number) => {
467+
if (!timeSeriesData[index]) return
468+
setCurrentDataIndex(index)
469+
setSelectedData(timeSeriesData[index])
470+
},
471+
[timeSeriesData],
472+
)
473+
474+
const handleChartPointClick = useCallback(
475+
(data: unknown) => {
476+
const chartClick = data as { activeTooltipIndex?: unknown; activePayload?: Array<{ payload?: unknown }> } | null
477+
const clickedIndex = typeof chartClick?.activeTooltipIndex === 'number' ? chartClick.activeTooltipIndex : -1
478+
const clickedData = (chartClick?.activePayload?.[0]?.payload ?? (clickedIndex >= 0 ? timeSeriesData[clickedIndex] : undefined)) as ChartDataPoint | undefined
479+
if (!clickedData) return
480+
481+
const activeSeriesCount = series.filter(item => Number(clickedData[item.key] || 0) > 0).length
482+
if (activeSeriesCount <= 0) return
483+
484+
const resolvedIndex = clickedIndex >= 0 ? clickedIndex : timeSeriesData.findIndex(item => item._period_start === clickedData._period_start)
485+
setCurrentDataIndex(resolvedIndex >= 0 ? resolvedIndex : 0)
486+
setSelectedData(clickedData)
487+
setModalOpen(true)
488+
},
489+
[series, timeSeriesData],
490+
)
491+
461492
return (
462-
<Card>
493+
<>
494+
<Card>
463495
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 xl:flex-row">
464496
<div className="flex flex-1 flex-col gap-2 border-b px-4 py-3 xl:px-6 xl:py-4">
465497
<div className="flex min-w-0 flex-col justify-center gap-1 pt-2">
@@ -602,7 +634,7 @@ function UserSubUpdatePieChart({ username, adminId }: UserSubUpdatePieChartProps
602634
<div className="mx-auto w-full">
603635
<ChartContainer dir="ltr" config={chartConfig} className="h-[200px] w-full sm:h-[320px] lg:h-[400px]">
604636
{chartViewType === 'area' ? (
605-
<AreaChart accessibilityLayer data={timeSeriesData} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
637+
<AreaChart accessibilityLayer data={timeSeriesData} margin={{ top: 5, right: 10, left: 10, bottom: 5 }} onClick={handleChartPointClick}>
606638
<defs>
607639
{series.map(item => (
608640
<linearGradient key={item.key} id={`sub-update-area-gradient-${item.key}`} x1="0" y1="0" x2="0" y2="1">
@@ -635,11 +667,12 @@ function UserSubUpdatePieChart({ username, adminId }: UserSubUpdatePieChartProps
635667
strokeWidth={1.5}
636668
dot={false}
637669
activeDot={{ r: 4 }}
670+
cursor="pointer"
638671
/>
639672
))}
640673
</AreaChart>
641674
) : (
642-
<BarChart accessibilityLayer data={timeSeriesData} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
675+
<BarChart accessibilityLayer data={timeSeriesData} margin={{ top: 5, right: 10, left: 10, bottom: 5 }} onClick={handleChartPointClick}>
643676
<CartesianGrid direction="ltr" vertical={false} />
644677
<XAxis direction="ltr" dataKey="time" tickLine={false} tickMargin={10} axisLine={false} minTickGap={5} interval={xAxisInterval} />
645678
<YAxis
@@ -653,7 +686,7 @@ function UserSubUpdatePieChart({ username, adminId }: UserSubUpdatePieChartProps
653686
/>
654687
<ChartTooltip cursor={false} content={props => <SeriesTooltip {...(props as TooltipProps<number, string>)} period={activePeriod} seriesByKey={seriesByKey} />} />
655688
{series.map(item => (
656-
<Bar key={item.key} dataKey={item.key} stackId="a" fill={item.color} radius={SQUARE_RADIUS}>
689+
<Bar key={item.key} dataKey={item.key} stackId="a" fill={item.color} radius={SQUARE_RADIUS} cursor="pointer">
657690
{timeSeriesData.map(row => (
658691
<Cell key={`${item.key}-${row._period_start}`} {...getCellRadiusProps(getStackedBarRadius(row, item.key, series))} />
659692
))}
@@ -692,6 +725,18 @@ function UserSubUpdatePieChart({ username, adminId }: UserSubUpdatePieChartProps
692725
</CardFooter>
693726
)}
694727
</Card>
728+
729+
<UserSubUpdateStatsModal
730+
open={modalOpen}
731+
onClose={() => setModalOpen(false)}
732+
data={selectedData}
733+
period={activePeriod}
734+
series={series}
735+
allChartData={timeSeriesData}
736+
currentIndex={currentDataIndex}
737+
onNavigate={handleModalNavigate}
738+
/>
739+
</>
695740
)
696741
}
697742

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { Cell, Pie, PieChart, TooltipProps } from 'recharts'
2+
import { useTranslation } from 'react-i18next'
3+
import { Calendar, ChevronLeft, ChevronRight, Users } from 'lucide-react'
4+
5+
import useDirDetection from '@/hooks/use-dir-detection'
6+
import { Period } from '@/service/api'
7+
import { formatTooltipDate } from '@/utils/chart-period-utils'
8+
import { Badge } from '@/components/ui/badge'
9+
import { Button } from '@/components/ui/button'
10+
import { type ChartConfig, ChartContainer, ChartTooltip } from '@/components/ui/chart'
11+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
12+
13+
export type UserSubUpdateStatsModalData = {
14+
time: string
15+
_period_start: string
16+
[key: string]: string | number
17+
}
18+
19+
export type UserSubUpdateStatsModalSeries = {
20+
key: string
21+
label: string
22+
color: string
23+
}
24+
25+
type UserSubUpdateStatsModalProps = {
26+
open: boolean
27+
onClose: () => void
28+
data: UserSubUpdateStatsModalData | null
29+
period: Period
30+
series: UserSubUpdateStatsModalSeries[]
31+
allChartData?: UserSubUpdateStatsModalData[]
32+
currentIndex?: number
33+
onNavigate?: (index: number) => void
34+
}
35+
36+
type PieDataPoint = {
37+
name: string
38+
count: number
39+
percentage: number
40+
fill: string
41+
}
42+
43+
function ClientDistributionTooltip({ active, payload }: TooltipProps<number, string>) {
44+
const { t } = useTranslation()
45+
46+
if (!active || !payload || !payload.length) return null
47+
48+
const data = payload[0].payload as PieDataPoint
49+
50+
return (
51+
<div className="border-border bg-background/95 rounded-lg border p-2 text-xs shadow-sm backdrop-blur-sm">
52+
<div className="mb-1 flex items-center gap-1.5">
53+
<div className="border-border/20 h-2.5 w-2.5 rounded-full border" style={{ backgroundColor: data.fill }} />
54+
<span className="text-foreground font-medium">{data.name}</span>
55+
</div>
56+
<div className="flex items-center justify-between gap-3">
57+
<span className="text-muted-foreground">{t('statistics.subscriptions', { defaultValue: 'Subscriptions' })}</span>
58+
<span dir="ltr" className="text-foreground font-mono font-semibold">
59+
{data.count.toLocaleString()}
60+
</span>
61+
</div>
62+
<div className="mt-1 flex items-center justify-between gap-3">
63+
<span className="text-muted-foreground">{t('statistics.percentage', { defaultValue: 'Percentage' })}</span>
64+
<span dir="ltr" className="text-foreground font-mono">{`${data.percentage.toFixed(1)}%`}</span>
65+
</div>
66+
</div>
67+
)
68+
}
69+
70+
export default function UserSubUpdateStatsModal({ open, onClose, data, period, series, allChartData = [], currentIndex = 0, onNavigate }: UserSubUpdateStatsModalProps) {
71+
const { t, i18n } = useTranslation()
72+
const dir = useDirDetection()
73+
74+
if (!data) return null
75+
76+
const isRTL = dir === 'rtl'
77+
const formattedDate = data._period_start ? formatTooltipDate(data._period_start, period, i18n.language) : data.time
78+
const rows = series
79+
.map(item => ({
80+
...item,
81+
count: Number(data[item.key] || 0),
82+
}))
83+
.filter(item => item.count > 0)
84+
.sort((a, b) => b.count - a.count)
85+
const total = rows.reduce((sum, item) => sum + item.count, 0)
86+
const pieData: PieDataPoint[] = rows.map(item => ({
87+
name: item.label,
88+
count: item.count,
89+
percentage: total > 0 ? (item.count * 100) / total : 0,
90+
fill: item.color,
91+
}))
92+
const pieChartConfig = pieData.reduce<ChartConfig>((acc, item) => {
93+
acc[item.name] = { label: item.name, color: item.fill }
94+
return acc
95+
}, {})
96+
const piePaddingAngle = pieData.length > 1 ? 1 : 0
97+
98+
const hasNavigation = allChartData.length > 1
99+
const canGoPrevious = hasNavigation && currentIndex > 0
100+
const canGoNext = hasNavigation && currentIndex < allChartData.length - 1
101+
102+
const handlePrevious = () => {
103+
if (canGoPrevious) onNavigate?.(currentIndex - 1)
104+
}
105+
106+
const handleNext = () => {
107+
if (canGoNext) onNavigate?.(currentIndex + 1)
108+
}
109+
110+
const handleLeftButton = isRTL ? handleNext : handlePrevious
111+
const handleRightButton = isRTL ? handlePrevious : handleNext
112+
const canGoLeft = isRTL ? canGoNext : canGoPrevious
113+
const canGoRight = isRTL ? canGoPrevious : canGoNext
114+
115+
return (
116+
<Dialog open={open} onOpenChange={onClose}>
117+
<DialogContent className="flex max-h-[95dvh] w-[96vw] max-w-md flex-col overflow-hidden sm:max-w-2xl md:max-w-3xl" dir={dir}>
118+
<DialogHeader>
119+
<div className="flex flex-col items-start gap-3">
120+
<DialogTitle className="flex items-center gap-2">
121+
<Users className="h-4 w-4 sm:h-5 sm:w-5" />
122+
{t('statistics.subscriptionDistributionDetails', { defaultValue: 'Subscription Client Details' })}
123+
</DialogTitle>
124+
{hasNavigation && (
125+
<div className="flex w-full items-center justify-center gap-1">
126+
<Button variant="outline" size="sm" onClick={handleLeftButton} disabled={!canGoLeft} className="h-8 w-8 p-0">
127+
<ChevronLeft className={`h-4 w-4 ${isRTL ? 'rotate-180' : ''}`} />
128+
</Button>
129+
<span dir="ltr" className="text-muted-foreground min-w-[60px] text-center text-sm font-medium">
130+
{currentIndex + 1} / {allChartData.length}
131+
</span>
132+
<Button variant="outline" size="sm" onClick={handleRightButton} disabled={!canGoRight} className="h-8 w-8 p-0">
133+
<ChevronRight className={`h-4 w-4 ${isRTL ? 'rotate-180' : ''}`} />
134+
</Button>
135+
</div>
136+
)}
137+
</div>
138+
</DialogHeader>
139+
140+
<div className="min-h-0 flex-1 space-y-3 overflow-x-hidden overflow-y-auto pr-1 sm:space-y-4">
141+
<div className={`flex items-center justify-between gap-3 ${isRTL ? 'flex-row-reverse' : 'flex-row'}`}>
142+
<div className={`flex min-w-0 items-center gap-2 ${isRTL ? 'flex-row-reverse' : 'flex-row'}`}>
143+
<Calendar className="text-muted-foreground h-3 w-3 shrink-0 sm:h-4 sm:w-4" />
144+
<span className="truncate text-xs font-medium sm:text-sm" dir="ltr">
145+
{formattedDate}
146+
</span>
147+
</div>
148+
<Badge dir="ltr" variant="secondary" className="px-2 py-1 font-mono text-xs sm:text-sm">
149+
{total.toLocaleString()}
150+
</Badge>
151+
</div>
152+
153+
<div className="text-muted-foreground text-xs font-medium">{t('statistics.subscriptionDistribution', { defaultValue: 'Subscription Client Distribution' })}</div>
154+
155+
{pieData.length > 0 ? (
156+
<div className="flex flex-col gap-3 md:flex-row md:items-start">
157+
<div className="flex justify-center md:w-[200px] md:flex-shrink-0">
158+
<ChartContainer config={pieChartConfig} className="h-[132px] w-[132px] sm:h-[160px] sm:w-[160px] md:h-[180px] md:w-[180px] [&_.recharts-text]:fill-transparent">
159+
<PieChart>
160+
<ChartTooltip content={<ClientDistributionTooltip />} />
161+
<Pie data={pieData} dataKey="count" nameKey="name" innerRadius="58%" outerRadius="96%" paddingAngle={piePaddingAngle} strokeWidth={1.5}>
162+
{pieData.map(item => (
163+
<Cell key={item.name} fill={item.fill} />
164+
))}
165+
</Pie>
166+
</PieChart>
167+
</ChartContainer>
168+
</div>
169+
170+
<div dir="ltr" className="grid max-h-[36dvh] min-w-0 flex-1 gap-2 overflow-x-hidden overflow-y-auto sm:gap-3 md:max-h-80">
171+
{pieData.map(item => (
172+
<div key={item.name} className="flex items-center justify-between gap-3 rounded-lg border p-2 sm:p-3">
173+
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
174+
<div className="h-2 w-2 shrink-0 rounded-full sm:h-3 sm:w-3" style={{ backgroundColor: item.fill }} />
175+
<div className="min-w-0 flex-1">
176+
<div className="flex items-center gap-1.5">
177+
<span dir="ltr" className="text-foreground font-mono text-[10px] font-bold sm:text-xs">
178+
{item.percentage.toFixed(1)}%
179+
</span>
180+
<div className="truncate text-xs font-medium break-all sm:text-sm">{item.name}</div>
181+
</div>
182+
</div>
183+
</div>
184+
<span dir="ltr" className="shrink-0 font-mono text-xs font-semibold sm:text-sm">
185+
{item.count.toLocaleString()}
186+
</span>
187+
</div>
188+
))}
189+
</div>
190+
</div>
191+
) : (
192+
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-center text-xs">{t('statistics.noDataInRange', { defaultValue: 'No data in selected range' })}</div>
193+
)}
194+
</div>
195+
</DialogContent>
196+
</Dialog>
197+
)
198+
}

0 commit comments

Comments
 (0)