feat: add traffic analytics page with per-device/host/proxy breakdown

This commit is contained in:
Memory
2026-04-17 21:09:00 +08:00
committed by GitHub
parent 2979a5a00b
commit e006871c9a
17 changed files with 1375 additions and 7 deletions

View File

@@ -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 (

View File

@@ -19,7 +19,8 @@ const titleMap: Record<string, string> = {
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<string, string> = {
@@ -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 (

View File

@@ -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> = (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 (
<div className={`${usageCardStatus} flex justify-center`}>
<Tooltip content={t('sider.cards.traffic')} placement="right">
<Button
size="sm"
isIconOnly
color={match ? 'primary' : 'default'}
variant={match ? 'solid' : 'light'}
onPress={() => navigate('/traffic')}
>
<MdOutlineDataUsage className="text-[20px]" />
</Button>
</Tooltip>
</div>
)
}
return (
<div
style={{
position: 'relative',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 'calc(infinity)' : undefined
}}
className={`${usageCardStatus} usage-card`}
>
{usageCardStatus === 'col-span-2' ? (
<Card
fullWidth
ref={setNodeRef}
{...attributes}
{...listeners}
className={`${match ? 'bg-primary' : 'hover:bg-primary/30'} ${disableAnimations ? '' : `motion-reduce:transition-transform-background ${isDragging ? 'scale-[0.95] tap-highlight-transparent' : ''}`}`}
>
<CardBody className="pb-1 pt-0 px-0">
<div className="flex justify-between">
<Button
isIconOnly
className="bg-transparent pointer-events-none"
variant="flat"
color="default"
>
<MdOutlineDataUsage
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px]`}
/>
</Button>
<div className={`p-2 w-full ${match ? 'text-primary-foreground' : 'text-foreground'}`}>
{providers.length === 0 ? (
<div className="text-xs text-foreground-400 leading-[32px]"></div>
) : (
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 (
<div key={provider.name} className="mb-2 last:mb-0">
<div className="flex justify-between text-xs mb-0.5">
<span className="font-medium text-ellipsis whitespace-nowrap overflow-hidden max-w-[55%]">
{provider.name}
</span>
<span className="shrink-0">
{calcTraffic(used)}&nbsp;/&nbsp;{calcTraffic(Total)}
</span>
</div>
<Progress
size="sm"
value={percent}
color={progressColor}
classNames={{
track: match ? 'bg-primary-foreground/30' : undefined
}}
/>
<div className="text-right text-xs mt-0.5 opacity-70">
{Expire
? (isExpired ? '⚠ ' : '') + dayjs.unix(Expire).format('YYYY-MM-DD')
: t('sider.cards.neverExpire')}
</div>
</div>
)
})
)}
</div>
</div>
</CardBody>
<CardFooter className="pt-1">
<h3
className={`text-md font-bold sider-card-title ${match ? 'text-primary-foreground' : 'text-foreground'}`}
>
{t('sider.cards.traffic')}
</h3>
</CardFooter>
</Card>
) : (
<Card
fullWidth
ref={setNodeRef}
{...attributes}
{...listeners}
className={`${match ? 'bg-primary' : 'hover:bg-primary/30'} ${disableAnimations ? '' : `motion-reduce:transition-transform-background ${isDragging ? 'scale-[0.95] tap-highlight-transparent' : ''}`}`}
>
<CardBody className="pb-1 pt-0 px-0">
<div className="flex justify-between">
<Button
isIconOnly
className="bg-transparent pointer-events-none"
variant="flat"
color="default"
>
<MdOutlineDataUsage
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`}
/>
</Button>
</div>
</CardBody>
<CardFooter className="pt-1">
<h3
className={`text-md font-bold text-ellipsis whitespace-nowrap overflow-hidden ${match ? 'text-primary-foreground' : 'text-foreground'}`}
>
{t('sider.cards.traffic')}
</h3>
</CardFooter>
</Card>
)}
</div>
)
}
export default UsageCard

View File

@@ -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<string, AggregatedData[]>
selectedSubRow: string | null
onSubRowClick: (parentLabel: string, subLabel: string) => void
}
type SortField = 'label' | 'upload' | 'download' | 'total'
const TrafficDetailsTable: React.FC<Props> = ({
selectedRow,
activeView,
subStats,
proxyStatsMap,
selectedSubRow,
onSubRowClick
}) => {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [sortField, setSortField] = useState<SortField>('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 }) => (
<span className={`ml-0.5 text-[10px] ${sortField === field ? 'text-primary' : 'opacity-30'}`}>
{sortField === field ? (sortAsc ? '▲' : '▼') : '⇅'}
</span>
)
return (
<div className="flex h-[520px] flex-col overflow-hidden rounded-xl border border-foreground/10 bg-content1 shadow-sm">
{/* Header */}
<div className="flex items-center gap-3 border-b border-foreground/5 bg-content2/50 px-4 py-2.5">
<span className="text-sm font-semibold text-foreground truncate max-w-[200px]" title={selectedRow}>
{selectedRow}
</span>
<div className="flex-1" />
<Input
size="sm"
className="w-52"
placeholder={t('traffic.search')}
value={search}
onValueChange={(v) => { setSearch(v); setPage(0) }}
startContent={<IoSearch className="text-foreground/40" size={14} />}
isClearable
/>
</div>
{/* Table */}
<div className="flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 z-10 bg-content2/80">
<tr>
<th
className="cursor-pointer px-4 py-2 text-left font-semibold text-foreground/60 uppercase tracking-wide hover:text-foreground"
onClick={() => handleSort('label')}
>
{subColLabel}<SortIcon field="label" />
</th>
<th
className="hidden cursor-pointer px-4 py-2 text-left font-semibold text-foreground/60 uppercase tracking-wide hover:text-foreground lg:table-cell"
onClick={() => handleSort('upload')}
>
{t('traffic.upload')}<SortIcon field="upload" />
</th>
<th
className="hidden cursor-pointer px-4 py-2 text-left font-semibold text-foreground/60 uppercase tracking-wide hover:text-foreground lg:table-cell"
onClick={() => handleSort('download')}
>
{t('traffic.download')}<SortIcon field="download" />
</th>
<th
className="cursor-pointer px-4 py-2 text-right font-semibold text-foreground/60 uppercase tracking-wide hover:text-foreground lg:text-left"
onClick={() => handleSort('total')}
>
{t('traffic.total')}<SortIcon field="total" />
</th>
</tr>
</thead>
<tbody>
{paged.map((sub, idx) => {
const compositeKey = `${selectedRow}:${sub.label}`
const isExpanded = selectedSubRow === compositeKey
return (
<React.Fragment key={sub.label}>
<tr
className={`cursor-pointer border-b border-foreground/5 transition-colors hover:bg-foreground/5 ${isExpanded ? 'bg-primary/10' : idx % 2 === 1 ? 'bg-foreground/[0.02]' : ''}`}
onClick={() => onSubRowClick(selectedRow, sub.label)}
>
<td className="px-4 py-2">
<div className="flex items-center gap-1.5 font-mono">
{isExpanded
? <IoChevronDown size={12} className="shrink-0 text-foreground/40" />
: <IoChevronForward size={12} className="shrink-0 text-foreground/40" />}
<span className="truncate max-w-[200px]" title={sub.label}>{sub.label}</span>
</div>
<div className="mt-0.5 flex gap-3 pl-5 text-[10px] text-foreground/40 lg:hidden">
<span> {calcTraffic(sub.upload)}</span>
<span> {calcTraffic(sub.download)}</span>
</div>
</td>
<td className="hidden px-4 py-2 text-foreground/70 lg:table-cell">{calcTraffic(sub.upload)}</td>
<td className="hidden px-4 py-2 text-foreground/70 lg:table-cell">{calcTraffic(sub.download)}</td>
<td className="px-4 py-2 text-right font-bold text-primary lg:text-left">{calcTraffic(sub.total)}</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={4} className="px-4 pb-3 pt-1">
{(proxyStatsMap[compositeKey] ?? []).length === 0 ? (
<div className="py-3 text-center text-xs text-foreground/40"></div>
) : (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{(proxyStatsMap[compositeKey] ?? []).map((item) => (
<div
key={item.label}
className="flex flex-col gap-1 rounded-lg border border-foreground/10 bg-content2/50 p-2.5 text-[10px]"
>
<span className="truncate font-mono font-bold text-secondary text-[10px]" title={item.label}>
{item.label}
</span>
<div className="flex items-center justify-between border-b border-foreground/5 pb-1">
<span className="text-foreground/40">×{item.count}</span>
<span className="font-black text-primary">{calcTraffic(item.total)}</span>
</div>
<div className="flex justify-between text-foreground/50">
<span> {calcTraffic(item.upload)}</span>
<span> {calcTraffic(item.download)}</span>
</div>
</div>
))}
</div>
)}
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
{paged.length === 0 && (
<div className="flex items-center justify-center py-12 text-sm text-foreground/40 italic">
{t('traffic.noData')}
</div>
)}
</div>
{/* Footer */}
<div className="flex shrink-0 items-center justify-between border-t border-foreground/10 bg-content2/50 px-4 py-2">
<span className="text-xs text-foreground/50">
{filtered.length > 0
? `${safePage * pageSize + 1}${Math.min((safePage + 1) * pageSize, filtered.length)} / ${filtered.length}`
: '0 / 0'}
</span>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="light"
isDisabled={safePage === 0}
onPress={() => setPage((p) => Math.max(0, p - 1))}
className="min-w-0 px-2"
>
</Button>
{Array.from({ length: totalPages }, (_, i) => i)
.filter((i) => i === 0 || i === totalPages - 1 || Math.abs(i - safePage) <= 1)
.map((i, arrIdx, arr) => (
<React.Fragment key={i}>
{arrIdx > 0 && arr[arrIdx - 1] !== i - 1 && (
<span className="text-xs text-foreground/30"></span>
)}
<Button
size="sm"
variant={safePage === i ? 'solid' : 'light'}
color={safePage === i ? 'primary' : 'default'}
onPress={() => setPage(i)}
className="min-w-0 px-2"
>
{i + 1}
</Button>
</React.Fragment>
))}
<Button
size="sm"
variant="light"
isDisabled={safePage >= totalPages - 1}
onPress={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
className="min-w-0 px-2"
>
</Button>
</div>
</div>
</div>
)
}
export default TrafficDetailsTable

View File

@@ -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<Props> = ({ title, data, selectedRow, onSelect }) => {
return (
<div className="flex h-full w-full flex-col">
<h3 className="mb-3 text-sm font-semibold text-foreground">{title}</h3>
<div className="flex-1 overflow-y-auto pr-1 custom-scrollbar">
{data.length === 0 ? (
<div className="py-8 text-center text-sm text-foreground/50"></div>
) : (
data.map((row) => (
<div
key={row.label}
onClick={() => 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' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span
className={`min-w-0 flex-1 truncate font-mono text-xs font-medium ${
selectedRow === row.label ? 'text-primary' : 'text-foreground'
}`}
>
{row.label}
</span>
<span
className={`shrink-0 text-xs font-bold ${
selectedRow === row.label ? 'text-primary' : 'text-foreground/80'
}`}
>
{calcTraffic(row.total)}
</span>
</div>
<div className="flex items-center gap-3 text-[10px] font-bold text-foreground/50">
<span> {calcTraffic(row.upload)}</span>
<span> {calcTraffic(row.download)}</span>
</div>
</div>
))
)}
</div>
</div>
)
}
export default TrafficRankings

View File

@@ -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<Props> = ({ 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 (
<div className="flex h-full items-center justify-center text-sm text-foreground/40"></div>
)
}
return <Line data={chartData} options={options} />
}
export default TrafficTrendChart

View File

@@ -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<string, { upload: number; download: number }>())
const logBufferRef = useRef<DataUsageLog[]>([])
const flushTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastTotalsRef = useRef({ upload: 0, download: 0 })
useEffect(() => {
const flushLogs = async (): Promise<void> => {
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()
}
}, [])
}

View File

@@ -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",

View File

@@ -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": "جهانی",

View File

@@ -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": "Глобальный",

View File

@@ -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": "全局",

View File

@@ -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": "全局",

View File

@@ -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<TimeRange, number> = {
'1h': 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000
}
const bucket: Record<TimeRange, number> = {
'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<DataUsageType>('host')
const [timeRange, setTimeRange] = useState<TimeRange>('24h')
const [rankings, setRankings] = useState<AggregatedData[]>([])
const [trendData, setTrendData] = useState<{ timestamp: number; upload: number; download: number }[]>([])
const [selectedRow, setSelectedRow] = useState<string | null>(null)
const [subStats, setSubStats] = useState<AggregatedData[]>([])
const [proxyStatsMap, setProxyStatsMap] = useState<Record<string, AggregatedData[]>>({})
const [selectedSubRow, setSelectedSubRow] = useState<string | null>(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<TimeRange, string> = {
'1h': t('traffic.timeRange.1h'),
'24h': t('traffic.timeRange.24h'),
'7d': t('traffic.timeRange.7d'),
'30d': t('traffic.timeRange.30d')
}
const viewLabels: Record<DataUsageType, string> = {
sourceIP: t('traffic.view.sourceIP'),
host: t('traffic.view.host'),
outbound: t('traffic.view.outbound'),
process: t('traffic.view.process')
}
return (
<BasePage
title={t('sider.cards.traffic')}
header={
<div className="app-nodrag flex items-center gap-2">
<Tabs
size="sm"
selectedKey={timeRange}
onSelectionChange={(k) => setTimeRange(k as TimeRange)}
>
{TIME_RANGES.map((r) => (
<Tab key={r} title={timeRangeLabel[r]} />
))}
</Tabs>
<Button
size="sm"
variant="light"
color="danger"
isIconOnly
title={t('traffic.clearAll')}
onPress={handleClearAll}
>
<CgTrash className="text-[16px]" />
</Button>
</div>
}
>
<div className="flex flex-col gap-3 p-2">
{/* Summary stats */}
<div className="grid grid-cols-4 gap-2">
{[
{ 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 }) => (
<div
key={label}
className="flex flex-col items-center rounded-xl border border-foreground/10 bg-content1 py-3 shadow-sm"
>
<span className="text-[11px] text-foreground/50 uppercase tracking-wide">{label}</span>
<span className="mt-0.5 text-sm font-bold text-foreground">{value}</span>
</div>
))}
</div>
{/* View tabs */}
<Tabs
size="sm"
selectedKey={activeView}
onSelectionChange={(k) => setActiveView(k as DataUsageType)}
>
{(Object.keys(viewLabels) as DataUsageType[]).map((v) => (
<Tab key={v} title={viewLabels[v]} />
))}
</Tabs>
{/* Rankings + Chart */}
<div className="grid grid-cols-4 gap-3">
<div className="col-span-1 h-52 overflow-hidden rounded-xl border border-foreground/10 bg-content1 p-3 shadow-sm">
<TrafficRankings
title={viewLabels[activeView]}
data={rankings}
selectedRow={selectedRow}
onSelect={handleSelectRow}
/>
</div>
<div className="col-span-3 h-52 overflow-hidden rounded-xl border border-foreground/10 bg-content1 p-3 shadow-sm">
<TrafficTrendChart data={trendData} bucketSizeMs={bucketSizeMs} />
</div>
</div>
{/* Detail table */}
{selectedRow && (
<TrafficDetailsTable
selectedRow={selectedRow}
activeView={activeView}
subStats={subStats}
proxyStatsMap={proxyStatsMap}
selectedSubRow={selectedSubRow}
onSubRowClick={handleSubRowClick}
/>
)}
</div>
</BasePage>
)
}
export default TrafficPage

View File

@@ -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: <SubStore />
},
{
path: '/traffic',
element: <Traffic />
},
{
path: '/',
element: <Navigate to="/proxies" />

View File

@@ -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<AggregatedData[]> {
const logs = await db.query(startTime, endTime)
const map = new Map<string, AggregatedData>()
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<DataUsageType, 'host'>,
label: string,
startTime: number,
endTime: number
): Promise<AggregatedData[]> {
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<string, AggregatedData>()
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<AggregatedData[]> {
const logs = await db.query(startTime, endTime)
const filtered = logs.filter((log) => log.host === host)
const map = new Map<string, AggregatedData>()
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<AggregatedData[]> {
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<string, AggregatedData>()
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<AggregatedData[]> {
const logs = await db.query(startTime, endTime)
const filtered = logs.filter((log) => log.outbound === proxy && log.host === host)
const map = new Map<string, AggregatedData>()
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<number, { upload: number; download: number }>()
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)
}

View File

@@ -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<IDBDatabase> {
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<void> {
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<DataUsageLog[]> {
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<IDBCursorWithValue>).result
if (cursor) {
results.push(cursor.value)
cursor.continue()
} else {
resolve(results)
}
}
request.onerror = () => reject(request.error)
})
}
async clearAll(): Promise<void> {
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<void> {
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<IDBCursor>).result
if (cursor) {
store.delete(cursor.primaryKey)
cursor.continue()
} else {
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
}
export const db = new DataUsageDB()

View File

@@ -284,6 +284,7 @@ interface IAppConfig {
substoreCardStatus?: CardStatus
sysproxyCardStatus?: CardStatus
tunCardStatus?: CardStatus
usageCardStatus?: CardStatus
githubToken?: string
useSubStore: boolean
subStoreHost?: string