From e006871c9aef3aaf8570a49afabe9e7817b005e4 Mon Sep 17 00:00:00 2001 From: Memory <134070804+Memory2314@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:09:00 +0800 Subject: [PATCH] feat: add traffic analytics page with per-device/host/proxy breakdown --- src/renderer/src/App.tsx | 18 +- .../src/components/settings/sider-config.tsx | 6 +- .../src/components/sider/usage-card.tsx | 187 ++++++++++++++ .../traffic/traffic-details-table.tsx | 228 +++++++++++++++++ .../components/traffic/traffic-rankings.tsx | 56 +++++ .../traffic/traffic-trend-chart.tsx | 131 ++++++++++ src/renderer/src/hooks/use-traffic-logger.ts | 98 ++++++++ src/renderer/src/locales/en-US.json | 18 ++ src/renderer/src/locales/fa-IR.json | 18 ++ src/renderer/src/locales/ru-RU.json | 18 ++ src/renderer/src/locales/zh-CN.json | 18 ++ src/renderer/src/locales/zh-TW.json | 18 ++ src/renderer/src/pages/traffic.tsx | 238 ++++++++++++++++++ src/renderer/src/routes/index.tsx | 5 + src/renderer/src/utils/dataUsage.ts | 212 ++++++++++++++++ src/renderer/src/utils/db.ts | 112 +++++++++ src/shared/types.d.ts | 1 + 17 files changed, 1375 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/sider/usage-card.tsx create mode 100644 src/renderer/src/components/traffic/traffic-details-table.tsx create mode 100644 src/renderer/src/components/traffic/traffic-rankings.tsx create mode 100644 src/renderer/src/components/traffic/traffic-trend-chart.tsx create mode 100644 src/renderer/src/hooks/use-traffic-logger.ts create mode 100644 src/renderer/src/pages/traffic.tsx create mode 100644 src/renderer/src/utils/dataUsage.ts create mode 100644 src/renderer/src/utils/db.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d3cb8328..703154bc 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -33,6 +33,8 @@ import { platform } from '@renderer/utils/init' import { TitleBarOverlayOptions } from 'electron' import SubStoreCard from '@renderer/components/sider/substore-card' import NetworkCard from '@renderer/components/sider/network-card' +import UsageCard from '@renderer/components/sider/usage-card' +import { useTrafficLogger } from '@renderer/hooks/use-traffic-logger' import { createTourDriver, getDriver, startTourIfNeeded } from '@renderer/utils/tour' import 'driver.js/dist/driver.css' import { useTranslation } from 'react-i18next' @@ -56,7 +58,8 @@ const ALL_SIDER_KEYS = [ 'sniff', 'log', 'substore', - 'network' + 'network', + 'usage' ] function mergeSiderOrder(saved: string[]): string[] { @@ -67,6 +70,7 @@ function mergeSiderOrder(saved: string[]): string[] { const App: React.FC = () => { const { t } = useTranslation() + useTrafficLogger() const { appConfig, patchAppConfig } = useAppConfig() const { appTheme = 'system', @@ -87,7 +91,8 @@ const App: React.FC = () => { 'sniff', 'log', 'substore', - 'network' + 'network', + 'usage' ] } = appConfig || {} const narrowWidth = platform === 'darwin' ? 70 : 60 @@ -172,7 +177,8 @@ const App: React.FC = () => { return } } - navigate(navigateMap[active.id as string]) + const dest = navigateMap[active.id as string] + if (dest) navigate(dest) } const navigateMap = { @@ -189,7 +195,8 @@ const App: React.FC = () => { resource: 'resources', override: 'override', substore: 'substore', - network: 'network' + network: 'network', + usage: 'traffic' } const componentMap = { @@ -206,7 +213,8 @@ const App: React.FC = () => { resource: ResourceCard, override: OverrideCard, substore: SubStoreCard, - network: NetworkCard + network: NetworkCard, + usage: UsageCard } return ( diff --git a/src/renderer/src/components/settings/sider-config.tsx b/src/renderer/src/components/settings/sider-config.tsx index ebee2f83..fe10b8d1 100644 --- a/src/renderer/src/components/settings/sider-config.tsx +++ b/src/renderer/src/components/settings/sider-config.tsx @@ -19,7 +19,8 @@ const titleMap: Record = { sniffCardStatus: 'sider.cards.sniff', logCardStatus: 'sider.cards.logs', substoreCardStatus: 'sider.cards.substore', - networkCardStatus: 'sider.cards.network' + networkCardStatus: 'sider.cards.network', + usageCardStatus: 'sider.cards.traffic' } const sizeMap: Record = { @@ -46,7 +47,8 @@ const SiderConfig: FC = () => { sniffCardStatus: appConfig?.sniffCardStatus || 'col-span-1', logCardStatus: appConfig?.logCardStatus || 'col-span-1', substoreCardStatus: appConfig?.substoreCardStatus || 'col-span-1', - networkCardStatus: appConfig?.networkCardStatus || 'col-span-1' + networkCardStatus: appConfig?.networkCardStatus || 'col-span-1', + usageCardStatus: appConfig?.usageCardStatus || 'col-span-1' } return ( diff --git a/src/renderer/src/components/sider/usage-card.tsx b/src/renderer/src/components/sider/usage-card.tsx new file mode 100644 index 00000000..7510c717 --- /dev/null +++ b/src/renderer/src/components/sider/usage-card.tsx @@ -0,0 +1,187 @@ +import { Button, Card, CardBody, CardFooter, Progress, Tooltip } from '@heroui/react' +import { mihomoProxyProviders } from '@renderer/utils/ipc' +import { useLocation, useNavigate } from 'react-router-dom' +import { calcTraffic, calcPercent } from '@renderer/utils/calc' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import React, { useMemo } from 'react' +import { useAppConfig } from '@renderer/hooks/use-app-config' +import { MdOutlineDataUsage } from 'react-icons/md' +import { useTranslation } from 'react-i18next' +import useSWR from 'swr' +import dayjs from '@renderer/utils/dayjs' + +interface Props { + iconOnly?: boolean +} + +const UsageCard: React.FC = (props) => { + const { iconOnly } = props + const { t } = useTranslation() + const { appConfig } = useAppConfig() + const { usageCardStatus = 'col-span-2', disableAnimations = false } = appConfig || {} + const location = useLocation() + const navigate = useNavigate() + const match = location.pathname.includes('/traffic') + + const { data } = useSWR('mihomoProxyProviders', mihomoProxyProviders) + + const providers = useMemo(() => { + if (!data?.providers) return [] + return Object.values(data.providers).filter( + (p) => p.vehicleType !== 'Compatible' && p.subscriptionInfo && p.subscriptionInfo.Total > 0 + ) + }, [data]) + + const { + attributes, + listeners, + setNodeRef, + transform: tf, + transition, + isDragging + } = useSortable({ id: 'usage' }) + + const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null + + if (iconOnly) { + return ( +
+ + + +
+ ) + } + + return ( +
+ {usageCardStatus === 'col-span-2' ? ( + + +
+ +
+ {providers.length === 0 ? ( +
+ ) : ( + providers.map((provider) => { + const { Upload = 0, Download = 0, Total = 0, Expire } = + provider.subscriptionInfo ?? {} + const used = Upload + Download + const percent = calcPercent(Upload, Download, Total) + const isExpired = Expire ? dayjs.unix(Expire).isBefore(dayjs()) : false + const progressColor = + isExpired + ? 'danger' + : percent >= 90 + ? 'danger' + : percent >= 70 + ? 'warning' + : 'primary' + + return ( +
+
+ + {provider.name} + + + {calcTraffic(used)} / {calcTraffic(Total)} + +
+ +
+ {Expire + ? (isExpired ? '⚠ ' : '') + dayjs.unix(Expire).format('YYYY-MM-DD') + : t('sider.cards.neverExpire')} +
+
+ ) + }) + )} +
+
+
+ +

+ {t('sider.cards.traffic')} +

+
+
+ ) : ( + + +
+ +
+
+ +

+ {t('sider.cards.traffic')} +

+
+
+ )} +
+ ) +} + +export default UsageCard diff --git a/src/renderer/src/components/traffic/traffic-details-table.tsx b/src/renderer/src/components/traffic/traffic-details-table.tsx new file mode 100644 index 00000000..493ad04e --- /dev/null +++ b/src/renderer/src/components/traffic/traffic-details-table.tsx @@ -0,0 +1,228 @@ +import { calcTraffic } from '@renderer/utils/calc' +import type { AggregatedData, DataUsageType } from '@renderer/utils/dataUsage' +import { Button, Input } from '@heroui/react' +import { IoChevronDown, IoChevronForward, IoSearch } from 'react-icons/io5' +import React, { useState, useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +interface Props { + selectedRow: string + activeView: DataUsageType + subStats: AggregatedData[] + proxyStatsMap: Record + selectedSubRow: string | null + onSubRowClick: (parentLabel: string, subLabel: string) => void +} + +type SortField = 'label' | 'upload' | 'download' | 'total' + +const TrafficDetailsTable: React.FC = ({ + selectedRow, + activeView, + subStats, + proxyStatsMap, + selectedSubRow, + onSubRowClick +}) => { + const { t } = useTranslation() + const [search, setSearch] = useState('') + const [sortField, setSortField] = useState('total') + const [sortAsc, setSortAsc] = useState(false) + const [page, setPage] = useState(0) + const pageSize = 50 + + const subColLabel = activeView === 'host' ? t('traffic.devices') : t('traffic.host') + + const filtered = useMemo(() => { + const q = search.toLowerCase() + const list = q ? subStats.filter((s) => s.label.toLowerCase().includes(q)) : [...subStats] + return list.sort((a, b) => { + const cmp = sortField === 'label' ? a.label.localeCompare(b.label) : a[sortField] - b[sortField] + return sortAsc ? cmp : -cmp + }) + }, [subStats, search, sortField, sortAsc]) + + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)) + const safePage = Math.min(page, totalPages - 1) + const paged = filtered.slice(safePage * pageSize, (safePage + 1) * pageSize) + + const handleSort = (field: SortField): void => { + if (field === sortField) setSortAsc((v) => !v) + else { setSortField(field); setSortAsc(false) } + } + + const SortIcon: React.FC<{ field: SortField }> = ({ field }) => ( + + {sortField === field ? (sortAsc ? '▲' : '▼') : '⇅'} + + ) + + return ( +
+ {/* Header */} +
+ + {selectedRow} + +
+ { setSearch(v); setPage(0) }} + startContent={} + isClearable + /> +
+ + {/* Table */} +
+ + + + + + + + + + + {paged.map((sub, idx) => { + const compositeKey = `${selectedRow}:${sub.label}` + const isExpanded = selectedSubRow === compositeKey + return ( + + onSubRowClick(selectedRow, sub.label)} + > + + + + + + {isExpanded && ( + + + + )} + + ) + })} + +
handleSort('label')} + > + {subColLabel} + handleSort('upload')} + > + {t('traffic.upload')} + handleSort('download')} + > + {t('traffic.download')} + handleSort('total')} + > + {t('traffic.total')} +
+
+ {isExpanded + ? + : } + {sub.label} +
+
+ ↑ {calcTraffic(sub.upload)} + ↓ {calcTraffic(sub.download)} +
+
{calcTraffic(sub.upload)}{calcTraffic(sub.download)}{calcTraffic(sub.total)}
+ {(proxyStatsMap[compositeKey] ?? []).length === 0 ? ( +
+ ) : ( +
+ {(proxyStatsMap[compositeKey] ?? []).map((item) => ( +
+ + {item.label} + +
+ ×{item.count} + {calcTraffic(item.total)} +
+
+ ↑ {calcTraffic(item.upload)} + ↓ {calcTraffic(item.download)} +
+
+ ))} +
+ )} +
+ {paged.length === 0 && ( +
+ {t('traffic.noData')} +
+ )} +
+ + {/* Footer */} +
+ + {filtered.length > 0 + ? `${safePage * pageSize + 1}–${Math.min((safePage + 1) * pageSize, filtered.length)} / ${filtered.length}` + : '0 / 0'} + +
+ + {Array.from({ length: totalPages }, (_, i) => i) + .filter((i) => i === 0 || i === totalPages - 1 || Math.abs(i - safePage) <= 1) + .map((i, arrIdx, arr) => ( + + {arrIdx > 0 && arr[arrIdx - 1] !== i - 1 && ( + + )} + + + ))} + +
+
+
+ ) +} + +export default TrafficDetailsTable diff --git a/src/renderer/src/components/traffic/traffic-rankings.tsx b/src/renderer/src/components/traffic/traffic-rankings.tsx new file mode 100644 index 00000000..7c6f8878 --- /dev/null +++ b/src/renderer/src/components/traffic/traffic-rankings.tsx @@ -0,0 +1,56 @@ +import { calcTraffic } from '@renderer/utils/calc' +import type { AggregatedData } from '@renderer/utils/dataUsage' +import React from 'react' + +interface Props { + title: string + data: AggregatedData[] + selectedRow: string | null + onSelect: (label: string) => void +} + +const TrafficRankings: React.FC = ({ title, data, selectedRow, onSelect }) => { + return ( +
+

{title}

+
+ {data.length === 0 ? ( +
+ ) : ( + data.map((row) => ( +
onSelect(row.label)} + className={`mb-1.5 flex cursor-pointer flex-col gap-0.5 rounded-lg p-2 transition-colors hover:bg-foreground/5 ${ + selectedRow === row.label ? 'bg-primary/10' : '' + }`} + > +
+ + {row.label} + + + {calcTraffic(row.total)} + +
+
+ ↑ {calcTraffic(row.upload)} + ↓ {calcTraffic(row.download)} +
+
+ )) + )} +
+
+ ) +} + +export default TrafficRankings diff --git a/src/renderer/src/components/traffic/traffic-trend-chart.tsx b/src/renderer/src/components/traffic/traffic-trend-chart.tsx new file mode 100644 index 00000000..310d830c --- /dev/null +++ b/src/renderer/src/components/traffic/traffic-trend-chart.tsx @@ -0,0 +1,131 @@ +import { calcTraffic } from '@renderer/utils/calc' +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Filler, + Tooltip, + Legend, + type ChartOptions, + type ScriptableContext +} from 'chart.js' +import dayjs from '@renderer/utils/dayjs' +import React, { useMemo } from 'react' +import { Line } from 'react-chartjs-2' + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip, Legend) + +interface TrendPoint { + timestamp: number + upload: number + download: number +} + +interface Props { + data: TrendPoint[] + bucketSizeMs: number +} + +const TrafficTrendChart: React.FC = ({ data, bucketSizeMs }) => { + const labels = useMemo( + () => + data.map((d) => { + if (bucketSizeMs >= 86400000) return dayjs(d.timestamp).format('MM-DD') + if (bucketSizeMs >= 3600000) return dayjs(d.timestamp).format('MM-DD HH:mm') + return dayjs(d.timestamp).format('HH:mm') + }), + [data, bucketSizeMs] + ) + + const chartData = useMemo( + () => ({ + labels, + datasets: [ + { + label: '↑', + data: data.map((d) => d.upload), + fill: true, + backgroundColor: (ctx: ScriptableContext<'line'>) => { + const { ctx: c, chartArea } = ctx.chart + if (!chartArea) return 'transparent' + const g = c.createLinearGradient(0, chartArea.top, 0, chartArea.bottom) + g.addColorStop(0, 'rgba(34,197,94,0.4)') + g.addColorStop(1, 'rgba(34,197,94,0)') + return g + }, + borderColor: 'rgba(34,197,94,0.8)', + borderWidth: 1.5, + pointRadius: 0, + tension: 0.4 + }, + { + label: '↓', + data: data.map((d) => d.download), + fill: true, + backgroundColor: (ctx: ScriptableContext<'line'>) => { + const { ctx: c, chartArea } = ctx.chart + if (!chartArea) return 'transparent' + const g = c.createLinearGradient(0, chartArea.top, 0, chartArea.bottom) + g.addColorStop(0, 'rgba(59,130,246,0.4)') + g.addColorStop(1, 'rgba(59,130,246,0)') + return g + }, + borderColor: 'rgba(59,130,246,0.8)', + borderWidth: 1.5, + pointRadius: 0, + tension: 0.4 + } + ] + }), + [data, labels] + ) + + const options: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { + display: true, + position: 'top', + labels: { boxWidth: 12, font: { size: 11 } } + }, + tooltip: { + callbacks: { + label: (ctx) => ` ${ctx.dataset.label} ${calcTraffic(ctx.parsed.y)}` + } + } + }, + scales: { + x: { + ticks: { + maxTicksLimit: 8, + maxRotation: 0, + font: { size: 10 } + }, + grid: { display: false } + }, + y: { + ticks: { + callback: (v) => calcTraffic(Number(v)), + font: { size: 10 } + }, + grid: { color: 'rgba(128,128,128,0.1)' }, + min: 0 + } + }, + animation: { duration: 0 } + } + + if (data.length === 0) { + return ( +
+ ) + } + + return +} + +export default TrafficTrendChart diff --git a/src/renderer/src/hooks/use-traffic-logger.ts b/src/renderer/src/hooks/use-traffic-logger.ts new file mode 100644 index 00000000..cb22344f --- /dev/null +++ b/src/renderer/src/hooks/use-traffic-logger.ts @@ -0,0 +1,98 @@ +import { useEffect, useRef } from 'react' +import { db, type DataUsageLog } from '@renderer/utils/db' + +export function useTrafficLogger(): void { + const connectionLastDataRef = useRef(new Map()) + const logBufferRef = useRef([]) + const flushTimeoutRef = useRef | null>(null) + const lastTotalsRef = useRef({ upload: 0, download: 0 }) + + useEffect(() => { + const flushLogs = async (): Promise => { + if (logBufferRef.current.length === 0) return + const toFlush = [...logBufferRef.current] + logBufferRef.current = [] + try { + await db.addLogs(toFlush) + await db.cleanup(Date.now() - 30 * 24 * 60 * 60 * 1000) + } catch (e) { + console.error('[TrafficLogger] flush failed', e) + } + } + + const scheduleFlush = (): void => { + if (flushTimeoutRef.current) return + flushTimeoutRef.current = setTimeout(async () => { + await flushLogs() + flushTimeoutRef.current = null + }, 5000) + } + + const handler = (_e: unknown, ...args: unknown[]): void => { + const info = args[0] as IMihomoConnectionsInfo + if (!info.connections?.length) return + + const uploadTotal = info.uploadTotal || 0 + const downloadTotal = info.downloadTotal || 0 + + // Detect service restart (totals decreased) + if ( + uploadTotal < lastTotalsRef.current.upload || + downloadTotal < lastTotalsRef.current.download + ) { + connectionLastDataRef.current.clear() + logBufferRef.current = [] + db.clearAll().catch(console.error) + } + lastTotalsRef.current = { upload: uploadTotal, download: downloadTotal } + + const now = Date.now() + let hasDeltas = false + + for (const conn of info.connections) { + const currentUpload = conn.upload || 0 + const currentDownload = conn.download || 0 + const last = connectionLastDataRef.current.get(conn.id) + + let uploadDelta: number + let downloadDelta: number + + if (last) { + uploadDelta = Math.max(0, currentUpload - last.upload) + downloadDelta = Math.max(0, currentDownload - last.download) + } else { + uploadDelta = currentUpload + downloadDelta = currentDownload + } + + connectionLastDataRef.current.set(conn.id, { upload: currentUpload, download: currentDownload }) + + if (uploadDelta === 0 && downloadDelta === 0) continue + + hasDeltas = true + logBufferRef.current.push({ + timestamp: now, + sourceIP: conn.metadata.sourceIP || 'Inner', + host: conn.metadata.host || conn.metadata.destinationIP || 'Unknown', + process: conn.metadata.process || 'Unknown', + outbound: conn.chains?.[0] || 'DIRECT', + upload: uploadDelta, + download: downloadDelta + }) + } + + if (hasDeltas) scheduleFlush() + } + + window.electron.ipcRenderer.on('mihomoConnections', handler) + + return (): void => { + window.electron.ipcRenderer.removeListener('mihomoConnections', handler) + if (flushTimeoutRef.current) { + clearTimeout(flushTimeoutRef.current) + flushTimeoutRef.current = null + } + flushLogs() + } + }, []) +} diff --git a/src/renderer/src/locales/en-US.json b/src/renderer/src/locales/en-US.json index 0f47f80a..3e309d4f 100644 --- a/src/renderer/src/locales/en-US.json +++ b/src/renderer/src/locales/en-US.json @@ -344,7 +344,25 @@ "sider.cards.remote": "Remote", "sider.cards.local": "Local", "sider.cards.trafficUsage": "Traffic Usage Progress", + "sider.cards.traffic": "Traffic", "sider.cards.neverExpire": "Never Expire", + "traffic.view.sourceIP": "Devices", + "traffic.view.host": "Hosts", + "traffic.view.outbound": "Proxies", + "traffic.view.process": "Processes", + "traffic.timeRange.1h": "1h", + "traffic.timeRange.24h": "24h", + "traffic.timeRange.7d": "7d", + "traffic.timeRange.30d": "30d", + "traffic.sessions": "Sessions", + "traffic.upload": "Upload", + "traffic.download": "Download", + "traffic.total": "Total", + "traffic.devices": "Devices", + "traffic.host": "Host", + "traffic.search": "Search", + "traffic.noData": "No data", + "traffic.clearAll": "Clear All Data", "sider.cards.outbound.title": "Outbound Mode", "sider.cards.outbound.rule": "Rule", "sider.cards.outbound.global": "Global", diff --git a/src/renderer/src/locales/fa-IR.json b/src/renderer/src/locales/fa-IR.json index 1f7823b9..49b20fcb 100644 --- a/src/renderer/src/locales/fa-IR.json +++ b/src/renderer/src/locales/fa-IR.json @@ -319,7 +319,25 @@ "sider.cards.remote": "از راه دور", "sider.cards.local": "محلی", "sider.cards.trafficUsage": "پیشرفت مصرف ترافیک", + "sider.cards.traffic": "مصرف", "sider.cards.neverExpire": "بدون انقضا", + "traffic.view.sourceIP": "دستگاه‌ها", + "traffic.view.host": "هاست‌ها", + "traffic.view.outbound": "پروکسی‌ها", + "traffic.view.process": "فرآیندها", + "traffic.timeRange.1h": "۱ساعت", + "traffic.timeRange.24h": "۲۴ساعت", + "traffic.timeRange.7d": "۷روز", + "traffic.timeRange.30d": "۳۰روز", + "traffic.sessions": "جلسات", + "traffic.upload": "آپلود", + "traffic.download": "دانلود", + "traffic.total": "مجموع", + "traffic.devices": "دستگاه‌ها", + "traffic.host": "هاست", + "traffic.search": "جستجو", + "traffic.noData": "داده‌ای وجود ندارد", + "traffic.clearAll": "پاک کردن همه داده‌ها", "sider.cards.outbound.title": "حالت خروجی", "sider.cards.outbound.rule": "قانون", "sider.cards.outbound.global": "جهانی", diff --git a/src/renderer/src/locales/ru-RU.json b/src/renderer/src/locales/ru-RU.json index 9229b683..e89a5126 100644 --- a/src/renderer/src/locales/ru-RU.json +++ b/src/renderer/src/locales/ru-RU.json @@ -321,7 +321,25 @@ "sider.cards.remote": "Удаленный", "sider.cards.local": "Локальный", "sider.cards.trafficUsage": "Использование трафика", + "sider.cards.traffic": "Трафик", "sider.cards.neverExpire": "Бессрочно", + "traffic.view.sourceIP": "Устройства", + "traffic.view.host": "Хосты", + "traffic.view.outbound": "Прокси", + "traffic.view.process": "Процессы", + "traffic.timeRange.1h": "1ч", + "traffic.timeRange.24h": "24ч", + "traffic.timeRange.7d": "7д", + "traffic.timeRange.30d": "30д", + "traffic.sessions": "Сессии", + "traffic.upload": "Загрузка", + "traffic.download": "Скачивание", + "traffic.total": "Итого", + "traffic.devices": "Устройства", + "traffic.host": "Хост", + "traffic.search": "Поиск", + "traffic.noData": "Нет данных", + "traffic.clearAll": "Очистить все данные", "sider.cards.outbound.title": "Режим исходящего трафика", "sider.cards.outbound.rule": "Правило", "sider.cards.outbound.global": "Глобальный", diff --git a/src/renderer/src/locales/zh-CN.json b/src/renderer/src/locales/zh-CN.json index 17b99e73..b4744d1d 100644 --- a/src/renderer/src/locales/zh-CN.json +++ b/src/renderer/src/locales/zh-CN.json @@ -344,7 +344,25 @@ "sider.cards.remote": "远程", "sider.cards.local": "本地", "sider.cards.trafficUsage": "流量使用进度", + "sider.cards.traffic": "用量", "sider.cards.neverExpire": "长期有效", + "traffic.view.sourceIP": "设备", + "traffic.view.host": "域名", + "traffic.view.outbound": "代理", + "traffic.view.process": "进程", + "traffic.timeRange.1h": "1小时", + "traffic.timeRange.24h": "24小时", + "traffic.timeRange.7d": "7天", + "traffic.timeRange.30d": "30天", + "traffic.sessions": "会话", + "traffic.upload": "上传", + "traffic.download": "下载", + "traffic.total": "总计", + "traffic.devices": "设备", + "traffic.host": "域名", + "traffic.search": "搜索", + "traffic.noData": "暂无数据", + "traffic.clearAll": "清除所有数据", "sider.cards.outbound.title": "出站模式", "sider.cards.outbound.rule": "规则", "sider.cards.outbound.global": "全局", diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index a1be7ded..51a537c9 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -344,7 +344,25 @@ "sider.cards.remote": "遠程", "sider.cards.local": "本地", "sider.cards.trafficUsage": "流量使用進度", + "sider.cards.traffic": "用量", "sider.cards.neverExpire": "長期有效", + "traffic.view.sourceIP": "裝置", + "traffic.view.host": "網域", + "traffic.view.outbound": "代理", + "traffic.view.process": "程序", + "traffic.timeRange.1h": "1小時", + "traffic.timeRange.24h": "24小時", + "traffic.timeRange.7d": "7天", + "traffic.timeRange.30d": "30天", + "traffic.sessions": "會話", + "traffic.upload": "上傳", + "traffic.download": "下載", + "traffic.total": "總計", + "traffic.devices": "裝置", + "traffic.host": "網域", + "traffic.search": "搜尋", + "traffic.noData": "暫無資料", + "traffic.clearAll": "清除所有資料", "sider.cards.outbound.title": "出站模式", "sider.cards.outbound.rule": "規則", "sider.cards.outbound.global": "全局", diff --git a/src/renderer/src/pages/traffic.tsx b/src/renderer/src/pages/traffic.tsx new file mode 100644 index 00000000..6d1bad9a --- /dev/null +++ b/src/renderer/src/pages/traffic.tsx @@ -0,0 +1,238 @@ +import BasePage from '@renderer/components/base/base-page' +import TrafficRankings from '@renderer/components/traffic/traffic-rankings' +import TrafficTrendChart from '@renderer/components/traffic/traffic-trend-chart' +import TrafficDetailsTable from '@renderer/components/traffic/traffic-details-table' +import { + getAggregatedData, + getSubStatsByHost, + getDevicesByHost, + getProxyStatsByHost, + getTrafficTrend, + type AggregatedData, + type DataUsageType +} from '@renderer/utils/dataUsage' +import { db } from '@renderer/utils/db' +import { Button, Tab, Tabs } from '@heroui/react' +import dayjs from '@renderer/utils/dayjs' +import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { calcTraffic } from '@renderer/utils/calc' +import { CgTrash } from 'react-icons/cg' + +type TimeRange = '1h' | '24h' | '7d' | '30d' + +const TIME_RANGES: TimeRange[] = ['1h', '24h', '7d', '30d'] + +function getTimeRange(range: TimeRange): { start: number; end: number; bucketSizeMs: number } { + const end = Date.now() + const ms: Record = { + '1h': 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000 + } + const bucket: Record = { + '1h': 5 * 60 * 1000, + '24h': 60 * 60 * 1000, + '7d': 6 * 60 * 60 * 1000, + '30d': 24 * 60 * 60 * 1000 + } + return { start: end - ms[range], end, bucketSizeMs: bucket[range] } +} + +const TrafficPage: React.FC = () => { + const { t } = useTranslation() + const [activeView, setActiveView] = useState('host') + const [timeRange, setTimeRange] = useState('24h') + const [rankings, setRankings] = useState([]) + const [trendData, setTrendData] = useState<{ timestamp: number; upload: number; download: number }[]>([]) + const [selectedRow, setSelectedRow] = useState(null) + const [subStats, setSubStats] = useState([]) + const [proxyStatsMap, setProxyStatsMap] = useState>({}) + const [selectedSubRow, setSelectedSubRow] = useState(null) + const [totalStats, setTotalStats] = useState({ upload: 0, download: 0, total: 0, count: 0 }) + const [bucketSizeMs, setBucketSizeMs] = useState(60 * 60 * 1000) + + const load = useCallback(async () => { + const { start, end, bucketSizeMs: bms } = getTimeRange(timeRange) + setBucketSizeMs(bms) + + const [agg, trend] = await Promise.all([ + getAggregatedData(activeView, start, end), + getTrafficTrend(start, end, bms) + ]) + + setRankings(agg) + setTrendData(trend) + setTotalStats( + agg.reduce( + (acc, r) => ({ + upload: acc.upload + r.upload, + download: acc.download + r.download, + total: acc.total + r.total, + count: acc.count + r.count + }), + { upload: 0, download: 0, total: 0, count: 0 } + ) + ) + + setSelectedRow(null) + setSubStats([]) + setProxyStatsMap({}) + setSelectedSubRow(null) + }, [activeView, timeRange]) + + useEffect(() => { + load() + }, [load]) + + const handleSelectRow = useCallback( + async (label: string) => { + if (selectedRow === label) { + setSelectedRow(null) + setSubStats([]) + setProxyStatsMap({}) + setSelectedSubRow(null) + return + } + setSelectedRow(label) + setSelectedSubRow(null) + setProxyStatsMap({}) + + const { start, end } = getTimeRange(timeRange) + let subs: AggregatedData[] + if (activeView === 'host') { + subs = await getDevicesByHost(label, start, end) + } else { + subs = await getSubStatsByHost(activeView, label, start, end) + } + setSubStats(subs) + }, + [selectedRow, activeView, timeRange] + ) + + const handleSubRowClick = useCallback( + async (parentLabel: string, subLabel: string) => { + const compositeKey = `${parentLabel}:${subLabel}` + if (selectedSubRow === compositeKey) { + setSelectedSubRow(null) + return + } + setSelectedSubRow(compositeKey) + + if (proxyStatsMap[compositeKey]) return + const { start, end } = getTimeRange(timeRange) + const proxies = await getProxyStatsByHost(activeView, parentLabel, subLabel, start, end) + setProxyStatsMap((prev) => ({ ...prev, [compositeKey]: proxies })) + }, + [selectedSubRow, proxyStatsMap, activeView, timeRange] + ) + + const handleClearAll = useCallback(async () => { + await db.clearAll() + await load() + }, [load]) + + const timeRangeLabel: Record = { + '1h': t('traffic.timeRange.1h'), + '24h': t('traffic.timeRange.24h'), + '7d': t('traffic.timeRange.7d'), + '30d': t('traffic.timeRange.30d') + } + + const viewLabels: Record = { + sourceIP: t('traffic.view.sourceIP'), + host: t('traffic.view.host'), + outbound: t('traffic.view.outbound'), + process: t('traffic.view.process') + } + + return ( + + setTimeRange(k as TimeRange)} + > + {TIME_RANGES.map((r) => ( + + ))} + + +
+ } + > +
+ {/* Summary stats */} +
+ {[ + { label: t('traffic.sessions'), value: totalStats.count.toString() }, + { label: t('traffic.upload'), value: calcTraffic(totalStats.upload) }, + { label: t('traffic.download'), value: calcTraffic(totalStats.download) }, + { label: t('traffic.total'), value: calcTraffic(totalStats.total) } + ].map(({ label, value }) => ( +
+ {label} + {value} +
+ ))} +
+ + {/* View tabs */} + setActiveView(k as DataUsageType)} + > + {(Object.keys(viewLabels) as DataUsageType[]).map((v) => ( + + ))} + + + {/* Rankings + Chart */} +
+
+ +
+
+ +
+
+ + {/* Detail table */} + {selectedRow && ( + + )} +
+ + ) +} + +export default TrafficPage diff --git a/src/renderer/src/routes/index.tsx b/src/renderer/src/routes/index.tsx index 5b55a60c..99fb45af 100644 --- a/src/renderer/src/routes/index.tsx +++ b/src/renderer/src/routes/index.tsx @@ -14,6 +14,7 @@ import Resources from '@renderer/pages/resources' import DNS from '@renderer/pages/dns' import Sniffer from '@renderer/pages/sniffer' import SubStore from '@renderer/pages/substore' +import Traffic from '@renderer/pages/traffic' const routes = [ { path: '/network', @@ -75,6 +76,10 @@ const routes = [ path: '/substore', element: }, + { + path: '/traffic', + element: + }, { path: '/', element: diff --git a/src/renderer/src/utils/dataUsage.ts b/src/renderer/src/utils/dataUsage.ts new file mode 100644 index 00000000..3102d174 --- /dev/null +++ b/src/renderer/src/utils/dataUsage.ts @@ -0,0 +1,212 @@ +import { db } from '@renderer/utils/db' + +export type DataUsageType = 'sourceIP' | 'host' | 'outbound' | 'process' + +export interface AggregatedData { + label: string + upload: number + download: number + total: number + count: number +} + +export async function getAggregatedData( + type: DataUsageType, + startTime: number, + endTime: number +): Promise { + const logs = await db.query(startTime, endTime) + const map = new Map() + + for (const log of logs) { + const label = + type === 'sourceIP' + ? log.sourceIP + : type === 'host' + ? log.host + : type === 'outbound' + ? log.outbound + : log.process + + const existing = map.get(label) + if (existing) { + existing.upload += log.upload + existing.download += log.download + existing.total += log.upload + log.download + existing.count += 1 + } else { + map.set(label, { + label, + upload: log.upload, + download: log.download, + total: log.upload + log.download, + count: 1 + }) + } + } + + return Array.from(map.values()).sort((a, b) => b.total - a.total) +} + +export async function getSubStatsByHost( + dimension: Exclude, + label: string, + startTime: number, + endTime: number +): Promise { + const logs = await db.query(startTime, endTime) + const filtered = logs.filter((log) => + dimension === 'sourceIP' + ? log.sourceIP === label + : dimension === 'outbound' + ? log.outbound === label + : log.process === label + ) + + const map = new Map() + for (const log of filtered) { + const existing = map.get(log.host) + if (existing) { + existing.upload += log.upload + existing.download += log.download + existing.total += log.upload + log.download + existing.count += 1 + } else { + map.set(log.host, { + label: log.host, + upload: log.upload, + download: log.download, + total: log.upload + log.download, + count: 1 + }) + } + } + + return Array.from(map.values()).sort((a, b) => b.total - a.total) +} + +export async function getDevicesByHost( + host: string, + startTime: number, + endTime: number +): Promise { + const logs = await db.query(startTime, endTime) + const filtered = logs.filter((log) => log.host === host) + + const map = new Map() + for (const log of filtered) { + const existing = map.get(log.sourceIP) + if (existing) { + existing.upload += log.upload + existing.download += log.download + existing.total += log.upload + log.download + existing.count += 1 + } else { + map.set(log.sourceIP, { + label: log.sourceIP, + upload: log.upload, + download: log.download, + total: log.upload + log.download, + count: 1 + }) + } + } + + return Array.from(map.values()).sort((a, b) => b.total - a.total) +} + +export async function getProxyStatsByHost( + dimension: DataUsageType, + parentLabel: string, + host: string, + startTime: number, + endTime: number +): Promise { + const logs = await db.query(startTime, endTime) + const filtered = logs.filter((log) => { + if (log.host !== host) return false + return dimension === 'sourceIP' + ? log.sourceIP === parentLabel + : dimension === 'process' + ? log.process === parentLabel + : log.outbound === parentLabel + }) + + const map = new Map() + for (const log of filtered) { + const existing = map.get(log.outbound) + if (existing) { + existing.upload += log.upload + existing.download += log.download + existing.total += log.upload + log.download + existing.count += 1 + } else { + map.set(log.outbound, { + label: log.outbound, + upload: log.upload, + download: log.download, + total: log.upload + log.download, + count: 1 + }) + } + } + + return Array.from(map.values()).sort((a, b) => b.total - a.total) +} + +export async function getDevicesByProxyAndHost( + proxy: string, + host: string, + startTime: number, + endTime: number +): Promise { + const logs = await db.query(startTime, endTime) + const filtered = logs.filter((log) => log.outbound === proxy && log.host === host) + + const map = new Map() + for (const log of filtered) { + const existing = map.get(log.sourceIP) + if (existing) { + existing.upload += log.upload + existing.download += log.download + existing.total += log.upload + log.download + existing.count += 1 + } else { + map.set(log.sourceIP, { + label: log.sourceIP, + upload: log.upload, + download: log.download, + total: log.upload + log.download, + count: 1 + }) + } + } + + return Array.from(map.values()).sort((a, b) => b.total - a.total) +} + +export async function getTrafficTrend( + startTime: number, + endTime: number, + bucketSizeMs: number +): Promise<{ timestamp: number; upload: number; download: number }[]> { + const logs = await db.query(startTime, endTime) + const buckets = new Map() + + for (let t = startTime; t <= endTime; t += bucketSizeMs) { + buckets.set(Math.floor(t / bucketSizeMs) * bucketSizeMs, { upload: 0, download: 0 }) + } + + for (const log of logs) { + const key = Math.floor(log.timestamp / bucketSizeMs) * bucketSizeMs + const bucket = buckets.get(key) + if (bucket) { + bucket.upload += log.upload + bucket.download += log.download + } + } + + return Array.from(buckets.entries()) + .map(([timestamp, data]) => ({ timestamp, ...data })) + .sort((a, b) => a.timestamp - b.timestamp) +} diff --git a/src/renderer/src/utils/db.ts b/src/renderer/src/utils/db.ts new file mode 100644 index 00000000..13b654ce --- /dev/null +++ b/src/renderer/src/utils/db.ts @@ -0,0 +1,112 @@ +export interface DataUsageLog { + id?: number + timestamp: number + sourceIP: string + host: string + outbound: string + process: string + upload: number + download: number +} + +const DB_NAME = 'clashparty_db' +const STORE_NAME = 'data_usage_logs' +const DB_VERSION = 1 + +export class DataUsageDB { + private db: IDBDatabase | null = null + + async open(): Promise { + if (this.db) return this.db + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }) + store.createIndex('timestamp', 'timestamp', { unique: false }) + store.createIndex('sourceIP', 'sourceIP', { unique: false }) + store.createIndex('host', 'host', { unique: false }) + store.createIndex('outbound', 'outbound', { unique: false }) + store.createIndex('process', 'process', { unique: false }) + } + } + + request.onsuccess = (event) => { + this.db = (event.target as IDBOpenDBRequest).result + resolve(this.db) + } + + request.onerror = () => reject(request.error) + }) + } + + async addLogs(logs: DataUsageLog[]): Promise { + if (logs.length === 0) return + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction([STORE_NAME], 'readwrite') + const store = tx.objectStore(STORE_NAME) + logs.forEach((log) => store.add(log)) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } + + async query(startTime: number, endTime: number): Promise { + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction([STORE_NAME], 'readonly') + const index = tx.objectStore(STORE_NAME).index('timestamp') + const request = index.openCursor(IDBKeyRange.bound(startTime, endTime)) + const results: DataUsageLog[] = [] + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + results.push(cursor.value) + cursor.continue() + } else { + resolve(results) + } + } + + request.onerror = () => reject(request.error) + }) + } + + async clearAll(): Promise { + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction([STORE_NAME], 'readwrite') + const request = tx.objectStore(STORE_NAME).clear() + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) + } + + async cleanup(beforeTime: number): Promise { + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction([STORE_NAME], 'readwrite') + const store = tx.objectStore(STORE_NAME) + const request = store.index('timestamp').openKeyCursor(IDBKeyRange.upperBound(beforeTime)) + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + store.delete(cursor.primaryKey) + cursor.continue() + } else { + resolve() + } + } + + request.onerror = () => reject(request.error) + }) + } +} + +export const db = new DataUsageDB() diff --git a/src/shared/types.d.ts b/src/shared/types.d.ts index 991bd544..12a3de91 100644 --- a/src/shared/types.d.ts +++ b/src/shared/types.d.ts @@ -284,6 +284,7 @@ interface IAppConfig { substoreCardStatus?: CardStatus sysproxyCardStatus?: CardStatus tunCardStatus?: CardStatus + usageCardStatus?: CardStatus githubToken?: string useSubStore: boolean subStoreHost?: string