perf(traffic): optimize traffic data handling and improve performance

This commit is contained in:
Tunglies
2025-12-24 13:40:03 +08:00
parent 0992556b4a
commit 4ffb8b415f
11 changed files with 289 additions and 95 deletions

View File

@@ -23,5 +23,6 @@
<summary><strong> 🚀 优化改进 </strong></summary>
- 应用内更新日志支持解析并渲染 HTML 标签
- 性能优化前后端在渲染流量图时的资源
</details>

View File

@@ -3,13 +3,13 @@ import { Box, IconButton, Tooltip } from "@mui/material";
import {
ColumnDef,
ColumnSizingState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
Updater,
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import dayjs from "dayjs";
@@ -43,6 +43,50 @@ const reconcileColumnOrder = (
return [...filtered, ...missing];
};
const createConnectionRow = (each: IConnectionsItem) => {
const { metadata, rulePayload } = each;
const chains = [...each.chains].reverse().join(" / ");
const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
const destination = metadata.destinationIP
? `${metadata.destinationIP}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
return {
id: each.id,
host: metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`,
download: each.download,
upload: each.upload,
dlSpeed: each.curDownload,
ulSpeed: each.curUpload,
chains,
rule,
process: truncateStr(metadata.process || metadata.processPath),
time: each.start,
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
remoteDestination: destination,
type: `${metadata.type}(${metadata.network})`,
connectionData: each,
};
};
type ConnectionRow = ReturnType<typeof createConnectionRow>;
const areRowsEqual = (a: ConnectionRow, b: ConnectionRow) =>
a.host === b.host &&
a.download === b.download &&
a.upload === b.upload &&
a.dlSpeed === b.dlSpeed &&
a.ulSpeed === b.ulSpeed &&
a.chains === b.chains &&
a.rule === b.rule &&
a.process === b.process &&
a.time === b.time &&
a.source === b.source &&
a.remoteDestination === b.remoteDestination &&
a.type === b.type;
interface Props {
connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void;
@@ -105,35 +149,6 @@ export const ConnectionTable = (props: Props) => {
},
);
const createConnectionRow = (each: IConnectionsItem) => {
const { metadata, rulePayload } = each;
const chains = [...each.chains].reverse().join(" / ");
const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
const Destination = metadata.destinationIP
? `${metadata.destinationIP}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
return {
id: each.id,
host: metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`,
download: each.download,
upload: each.upload,
dlSpeed: each.curDownload,
ulSpeed: each.curUpload,
chains,
rule,
process: truncateStr(metadata.process || metadata.processPath),
time: each.start,
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
remoteDestination: Destination,
type: `${metadata.type}(${metadata.network})`,
connectionData: each,
};
};
type ConnectionRow = ReturnType<typeof createConnectionRow>;
type ColumnField = Exclude<keyof ConnectionRow, "connectionData">;
interface BaseColumn {
@@ -209,7 +224,7 @@ export const ConnectionTable = (props: Props) => {
width: 100,
minWidth: 80,
align: "right",
cell: (row) => dayjs(row.time).fromNow(),
// cell filled later with shared relativeNow ticker
},
{
field: "source",
@@ -366,30 +381,68 @@ export const ConnectionTable = (props: Props) => {
}));
}, [columns, columnVisibilityModel]);
const connRows = useMemo<ConnectionRow[]>(
() => connections.map((each) => createConnectionRow(each)),
[connections],
);
const prevRowsRef = useRef<Map<string, ConnectionRow>>(new Map());
const columnDefs = useMemo<ColumnDef<ConnectionRow>[]>(() => {
return columns.map((column) => ({
id: column.field,
accessorKey: column.field,
header: column.headerName,
size: column.width,
minSize: column.minWidth ?? 80,
enableResizing: true,
meta: {
align: column.align ?? "left",
field: column.field,
},
cell: column.cell
? ({ row }) => column.cell?.(row.original)
: (info) => info.getValue(),
}));
}, [columns]);
const connRows = useMemo<ConnectionRow[]>(() => {
const prevMap = prevRowsRef.current;
const nextMap = new Map<string, ConnectionRow>();
const nextRows = connections.map((each) => {
const nextRow = createConnectionRow(each);
const prevRow = prevMap.get(each.id);
if (prevRow && areRowsEqual(prevRow, nextRow)) {
nextMap.set(each.id, prevRow);
return prevRow;
}
nextMap.set(each.id, nextRow);
return nextRow;
});
prevRowsRef.current = nextMap;
return nextRows;
}, [connections]);
const [sorting, setSorting] = useState<SortingState>([]);
const [relativeNow, setRelativeNow] = useState(() => Date.now());
const columnDefs = useMemo<ColumnDef<ConnectionRow>[]>(() => {
return columns.map((column) => {
const baseCell: ColumnDef<ConnectionRow>["cell"] = column.cell
? (ctx) => column.cell?.(ctx.row.original)
: (ctx) => ctx.getValue() as ReactNode;
const cell: ColumnDef<ConnectionRow>["cell"] =
column.field === "time"
? (ctx) => dayjs(ctx.row.original.time).from(relativeNow)
: baseCell;
return {
id: column.field,
accessorKey: column.field,
header: column.headerName,
size: column.width,
minSize: column.minWidth ?? 80,
enableResizing: true,
meta: {
align: column.align ?? "left",
field: column.field,
},
cell,
} satisfies ColumnDef<ConnectionRow>;
});
}, [columns, relativeNow]);
useEffect(() => {
if (typeof window === "undefined") return undefined;
const timer = window.setInterval(() => {
setRelativeNow(Date.now());
}, 5000);
return () => window.clearInterval(timer);
}, []);
const handleColumnSizingChange = useCallback(
(updater: Updater<ColumnSizingState>) => {
@@ -411,7 +464,6 @@ export const ConnectionTable = (props: Props) => {
const table = useReactTable({
data: connRows,
columns: columnDefs,
state: {
columnVisibility: columnVisibilityState,
columnSizing: columnWidths,
@@ -420,10 +472,11 @@ export const ConnectionTable = (props: Props) => {
columnResizeMode: "onChange",
enableSortingRemoval: true,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getSortedRowModel: sorting.length ? getSortedRowModel() : undefined,
onSortingChange: setSorting,
onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: handleColumnVisibilityChange,
columns: columnDefs,
});
const rows = table.getRowModel().rows;
@@ -432,7 +485,7 @@ export const ConnectionTable = (props: Props) => {
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
overscan: 4,
});
const virtualRows = rowVirtualizer.getVirtualItems();

View File

@@ -147,6 +147,7 @@ export const EnhancedCanvasTrafficGraph = memo(
});
const lastDataTimestampRef = useRef<number>(0);
const resumeCooldownRef = useRef<number>(0);
const dataStaleRef = useRef<boolean>(false);
// 当前显示的数据缓存
const [displayData, dispatchDisplayData] = useReducer(
@@ -197,6 +198,7 @@ export const EnhancedCanvasTrafficGraph = memo(
useEffect(() => {
if (displayData.length === 0) {
lastDataTimestampRef.current = 0;
dataStaleRef.current = false;
fpsControllerRef.current.target = GRAPH_CONFIG.targetFPS;
fpsControllerRef.current.samples = [];
fpsControllerRef.current.lastAdjustTime = 0;
@@ -209,6 +211,11 @@ export const EnhancedCanvasTrafficGraph = memo(
displayData[displayData.length - 1]?.timestamp ?? null;
if (latestTimestamp) {
lastDataTimestampRef.current = latestTimestamp;
const age = Date.now() - latestTimestamp;
const stale = age > STALE_DATA_THRESHOLD;
dataStaleRef.current = stale;
} else {
dataStaleRef.current = false;
}
}, [displayData]);
@@ -986,7 +993,11 @@ export const EnhancedCanvasTrafficGraph = memo(
// 受控的动画循环
useEffect(() => {
if (!isWindowFocused || displayData.length === 0) {
if (
!isWindowFocused ||
displayData.length === 0 ||
dataStaleRef.current
) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = undefined;
@@ -1002,9 +1013,25 @@ export const EnhancedCanvasTrafficGraph = memo(
return;
}
const lastDataAge =
lastDataTimestampRef.current > 0
? Date.now() - lastDataTimestampRef.current
: null;
const targetFPS = fpsControllerRef.current.target;
const frameBudget = 1000 / targetFPS;
if (
typeof lastDataAge === "number" &&
lastDataAge > STALE_DATA_THRESHOLD
) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = undefined;
}
dataStaleRef.current = true;
return;
}
if (
currentTime - lastRenderTimeRef.current >= frameBudget ||
!isInitializedRef.current

View File

@@ -14,8 +14,7 @@ import {
alpha,
useTheme,
} from "@mui/material";
import { useRef, memo, useMemo } from "react";
import { ReactNode } from "react";
import { ReactNode, memo, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { TrafficErrorBoundary } from "@/components/common/traffic-error-boundary";
@@ -147,9 +146,12 @@ export const EnhancedTrafficStats = () => {
const trafficRef = useRef<EnhancedCanvasTrafficGraphRef>(null);
const pageVisible = useVisibility();
// 是否显示流量图表
const trafficGraph = verge?.traffic_graph ?? true;
const {
response: { data: traffic },
} = useTrafficData();
} = useTrafficData({ enabled: trafficGraph && pageVisible });
const {
response: { data: memory },
@@ -159,9 +161,6 @@ export const EnhancedTrafficStats = () => {
response: { data: connections },
} = useConnectionData();
// 是否显示流量图表
const trafficGraph = verge?.traffic_graph ?? true;
// Canvas组件现在直接从全局Hook获取数据无需手动添加数据点
// 使用useMemo计算解析后的流量数据

View File

@@ -29,7 +29,7 @@ export const LayoutTraffic = () => {
const {
response: { data: traffic },
} = useTrafficData();
} = useTrafficData({ enabled: trafficGraph && pageVisible });
const {
response: { data: memory },
} = useMemoryData();

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useReducer } from "react";
import { useEffect, useMemo, useReducer, useRef } from "react";
import { useVerge } from "@/hooks/use-verge";
import delayManager from "@/services/delay";
@@ -22,6 +22,10 @@ export default function useFilterSort(
) {
const { verge } = useVerge();
const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0);
const lastInputRef = useRef<{ text: string; sort: ProxySortType } | null>(
null,
);
const debounceTimer = useRef<number | null>(null);
useEffect(() => {
let last = 0;
@@ -40,7 +44,7 @@ export default function useFilterSort(
};
}, [groupName]);
return useMemo(() => {
const compute = useMemo(() => {
const fp = filterProxies(proxies, groupName, filterText, searchState);
const sp = sortProxies(
fp,
@@ -57,6 +61,39 @@ export default function useFilterSort(
searchState,
verge?.default_latency_timeout,
]);
const [result, setResult] = useReducer(
(_prev: IProxyItem[], next: IProxyItem[]) => next,
compute,
);
useEffect(() => {
if (debounceTimer.current !== null) {
window.clearTimeout(debounceTimer.current);
debounceTimer.current = null;
}
const prev = lastInputRef.current;
const stableInputs =
prev && prev.text === filterText && prev.sort === sortType;
lastInputRef.current = { text: filterText, sort: sortType };
const delay = stableInputs ? 0 : 150;
debounceTimer.current = window.setTimeout(() => {
setResult(compute);
debounceTimer.current = null;
}, delay);
return () => {
if (debounceTimer.current !== null) {
window.clearTimeout(debounceTimer.current);
debounceTimer.current = null;
}
};
}, [compute, filterText, sortType]);
return result;
}
export function filterSort(

View File

@@ -6,6 +6,7 @@ type SharedPollerEntry = {
timer: number | null;
interval: number;
callback: (() => void) | null;
lastFired: number;
refreshWhenHidden: boolean;
refreshWhenOffline: boolean;
};
@@ -32,6 +33,12 @@ const ensureTimer = (key: string, entry: SharedPollerEntry) => {
entry.timer = window.setInterval(() => {
if (!entry.refreshWhenHidden && isDocumentHidden()) return;
if (!entry.refreshWhenOffline && isOffline()) return;
const now = Date.now();
if (entry.lastFired && now - entry.lastFired < entry.interval / 2) {
// Skip duplicate fire within half interval to coalesce concurrent consumers
return;
}
entry.lastFired = now;
entry.callback?.();
}, entry.interval);
};
@@ -50,6 +57,7 @@ const registerSharedPoller = (
timer: null,
interval,
callback,
lastFired: 0,
refreshWhenHidden: options.refreshWhenHidden,
refreshWhenOffline: options.refreshWhenOffline,
};

View File

@@ -4,6 +4,7 @@ import useSWR from "swr";
import { getRunningMode, isAdmin, isServiceAvailable } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import { useSharedSWRPoller } from "./use-shared-swr-poller";
import { useVerge } from "./use-verge";
export interface SystemState {
@@ -43,11 +44,20 @@ export function useSystemState() {
},
{
suspense: true,
refreshInterval: 30000,
refreshInterval: 0,
fallback: defaultSystemState,
},
);
useSharedSWRPoller(
"getSystemState",
30000,
() => {
void mutateSystemState();
},
{ refreshWhenHidden: false, refreshWhenOffline: false },
);
const isSidecarMode = systemState.runningMode === "Sidecar";
const isServiceMode = systemState.runningMode === "Service";
const isTunModeAvailable = systemState.isAdminMode || systemState.isServiceOk;

View File

@@ -5,10 +5,12 @@ import { useTrafficMonitorEnhanced } from "./use-traffic-monitor";
const FALLBACK_TRAFFIC: Traffic = { up: 0, down: 0 };
export const useTrafficData = () => {
export const useTrafficData = (options?: { enabled?: boolean }) => {
const enabled = options?.enabled ?? true;
const {
graphData: { appendData },
} = useTrafficMonitorEnhanced({ subscribe: false });
} = useTrafficMonitorEnhanced({ subscribe: false, enabled });
const { response, refresh } = useMihomoWsSubscription<ITrafficItem>({
storageKey: "mihomo_traffic_date",
buildSubscriptKey: (date) => `getClashTraffic-${date}`,

View File

@@ -343,8 +343,10 @@ const EMPTY_STATS: ISamplerStats = {
*/
export const useTrafficMonitorEnhanced = (options?: {
subscribe?: boolean;
enabled?: boolean;
}) => {
const subscribeToSnapshots = options?.subscribe ?? true;
const enabled = options?.enabled ?? true;
const [latestSnapshot, setLatestSnapshot] = useState<{
availableDataPoints: ITrafficDataPoint[];
samplerStats: ISamplerStats;
@@ -365,6 +367,8 @@ export const useTrafficMonitorEnhanced = (options?: {
// 注册引用计数与Worker生命周期
useEffect(() => {
if (!enabled) return;
const client = getWorkerClient();
clientRef.current = client;
@@ -396,43 +400,53 @@ export const useTrafficMonitorEnhanced = (options?: {
client.stop();
}
};
}, [subscribeToSnapshots]);
}, [enabled, subscribeToSnapshots]);
// Periodically refresh "now" so idle streams age out of the selected window when subscribed
useEffect(() => {
if (!subscribeToSnapshots) return;
if (!enabled || !subscribeToSnapshots) return;
const timer = window.setInterval(() => {
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, [subscribeToSnapshots]);
}, [enabled, subscribeToSnapshots]);
// 添加流量数据
const appendData = useCallback((traffic: Traffic) => {
clientRef.current?.appendData(traffic);
}, []);
const appendData = useCallback(
(traffic: Traffic) => {
if (!enabled) return;
clientRef.current?.appendData(traffic);
},
[enabled],
);
// 请求不同时间范围的数据
const requestRange = useCallback((minutes: number) => {
currentRangeRef.current = minutes;
setRangeMinutes(minutes);
clientRef.current?.setRange(minutes);
}, []);
const requestRange = useCallback(
(minutes: number) => {
if (!enabled) return;
currentRangeRef.current = minutes;
setRangeMinutes(minutes);
clientRef.current?.setRange(minutes);
},
[enabled],
);
// 清空数据
const clearData = useCallback(() => {
if (!enabled) return;
clientRef.current?.clearData();
}, []);
}, [enabled]);
const filteredDataPoints = useMemo(() => {
if (!enabled) return [];
const sourceData = latestSnapshot.availableDataPoints;
if (sourceData.length === 0) return [];
const cutoff = now - rangeMinutes * 60 * 1000;
return sourceData.filter((point) => point.timestamp > cutoff);
}, [latestSnapshot.availableDataPoints, rangeMinutes, now]);
}, [enabled, latestSnapshot.availableDataPoints, rangeMinutes, now]);
return {
graphData: {

View File

@@ -15,7 +15,9 @@ export const formatTrafficName = (timestamp: number) =>
export class TrafficDataSampler {
private rawBuffer: ITrafficDataPoint[] = [];
private rawHead = 0;
private compressedBuffer: ICompressedDataPoint[] = [];
private compressedHead = 0;
private compressionQueue: ITrafficDataPoint[] = [];
constructor(private config: ISamplingConfig) {}
@@ -24,7 +26,17 @@ export class TrafficDataSampler {
this.rawBuffer.push(point);
const rawCutoff = Date.now() - this.config.rawDataMinutes * 60 * 1000;
this.rawBuffer = this.rawBuffer.filter((p) => p.timestamp > rawCutoff);
// O(1) amortized trimming using moving head; compact occasionally
while (
this.rawHead < this.rawBuffer.length &&
this.rawBuffer[this.rawHead]?.timestamp <= rawCutoff
) {
this.rawHead++;
}
if (this.rawHead > 512 && this.rawHead > this.rawBuffer.length / 2) {
this.rawBuffer = this.rawBuffer.slice(this.rawHead);
this.rawHead = 0;
}
this.compressionQueue.push(point);
if (this.compressionQueue.length >= this.config.compressionRatio) {
@@ -33,9 +45,19 @@ export class TrafficDataSampler {
const compressedCutoff =
Date.now() - this.config.compressedDataMinutes * 60 * 1000;
this.compressedBuffer = this.compressedBuffer.filter(
(p) => p.timestamp > compressedCutoff,
);
while (
this.compressedHead < this.compressedBuffer.length &&
this.compressedBuffer[this.compressedHead]?.timestamp <= compressedCutoff
) {
this.compressedHead++;
}
if (
this.compressedHead > 256 &&
this.compressedHead > this.compressedBuffer.length / 2
) {
this.compressedBuffer = this.compressedBuffer.slice(this.compressedHead);
this.compressedHead = 0;
}
}
private compressData() {
@@ -60,18 +82,34 @@ export class TrafficDataSampler {
getDataForTimeRange(minutes: number): ITrafficDataPoint[] {
const cutoff = Date.now() - minutes * 60 * 1000;
const rawData = this.rawBuffer.filter((p) => p.timestamp > cutoff);
let rawStart = this.rawHead;
while (
rawStart < this.rawBuffer.length &&
this.rawBuffer[rawStart]?.timestamp <= cutoff
) {
rawStart++;
}
const rawData = this.rawBuffer.slice(rawStart);
if (minutes <= this.config.rawDataMinutes) {
return rawData;
}
const compressedCutoffUpper =
Date.now() - this.config.rawDataMinutes * 60 * 1000;
let compressedStart = this.compressedHead;
while (
compressedStart < this.compressedBuffer.length &&
this.compressedBuffer[compressedStart]?.timestamp <= cutoff
) {
compressedStart++;
}
const compressedData = this.compressedBuffer
.filter(
(p) =>
p.timestamp > cutoff &&
p.timestamp <= Date.now() - this.config.rawDataMinutes * 60 * 1000,
)
.slice(compressedStart)
.filter((p) => p.timestamp <= compressedCutoffUpper)
.map((p) => ({
up: p.up,
down: p.down,
@@ -86,16 +124,21 @@ export class TrafficDataSampler {
getStats(): ISamplerStats {
return {
rawBufferSize: this.rawBuffer.length,
compressedBufferSize: this.compressedBuffer.length,
rawBufferSize: this.rawBuffer.length - this.rawHead,
compressedBufferSize: this.compressedBuffer.length - this.compressedHead,
compressionQueueSize: this.compressionQueue.length,
totalMemoryPoints: this.rawBuffer.length + this.compressedBuffer.length,
totalMemoryPoints:
this.rawBuffer.length -
this.rawHead +
(this.compressedBuffer.length - this.compressedHead),
};
}
clear() {
this.rawBuffer = [];
this.rawHead = 0;
this.compressedBuffer = [];
this.compressedHead = 0;
this.compressionQueue = [];
}
}