mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
feat: add traffic analytics page with per-device/host/proxy breakdown
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
187
src/renderer/src/components/sider/usage-card.tsx
Normal file
187
src/renderer/src/components/sider/usage-card.tsx
Normal 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)} / {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
|
||||
228
src/renderer/src/components/traffic/traffic-details-table.tsx
Normal file
228
src/renderer/src/components/traffic/traffic-details-table.tsx
Normal 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
|
||||
56
src/renderer/src/components/traffic/traffic-rankings.tsx
Normal file
56
src/renderer/src/components/traffic/traffic-rankings.tsx
Normal 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
|
||||
131
src/renderer/src/components/traffic/traffic-trend-chart.tsx
Normal file
131
src/renderer/src/components/traffic/traffic-trend-chart.tsx
Normal 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
|
||||
98
src/renderer/src/hooks/use-traffic-logger.ts
Normal file
98
src/renderer/src/hooks/use-traffic-logger.ts
Normal 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()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "جهانی",
|
||||
|
||||
@@ -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": "Глобальный",
|
||||
|
||||
@@ -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": "全局",
|
||||
|
||||
@@ -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": "全局",
|
||||
|
||||
238
src/renderer/src/pages/traffic.tsx
Normal file
238
src/renderer/src/pages/traffic.tsx
Normal 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
|
||||
@@ -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" />
|
||||
|
||||
212
src/renderer/src/utils/dataUsage.ts
Normal file
212
src/renderer/src/utils/dataUsage.ts
Normal 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)
|
||||
}
|
||||
112
src/renderer/src/utils/db.ts
Normal file
112
src/renderer/src/utils/db.ts
Normal 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()
|
||||
1
src/shared/types.d.ts
vendored
1
src/shared/types.d.ts
vendored
@@ -284,6 +284,7 @@ interface IAppConfig {
|
||||
substoreCardStatus?: CardStatus
|
||||
sysproxyCardStatus?: CardStatus
|
||||
tunCardStatus?: CardStatus
|
||||
usageCardStatus?: CardStatus
|
||||
githubToken?: string
|
||||
useSubStore: boolean
|
||||
subStoreHost?: string
|
||||
|
||||
Reference in New Issue
Block a user