mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-18 08:21:34 +08:00
perf(traffic): optimize traffic data handling and improve performance
This commit is contained in:
parent
0992556b4a
commit
4ffb8b415f
@ -23,5 +23,6 @@
|
|||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||||
|
|
||||||
- 应用内更新日志支持解析并渲染 HTML 标签
|
- 应用内更新日志支持解析并渲染 HTML 标签
|
||||||
|
- 性能优化前后端在渲染流量图时的资源
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@ -3,13 +3,13 @@ import { Box, IconButton, Tooltip } from "@mui/material";
|
|||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
ColumnSizingState,
|
ColumnSizingState,
|
||||||
SortingState,
|
|
||||||
VisibilityState,
|
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
|
SortingState,
|
||||||
Updater,
|
Updater,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
|
VisibilityState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@ -43,6 +43,50 @@ const reconcileColumnOrder = (
|
|||||||
return [...filtered, ...missing];
|
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 {
|
interface Props {
|
||||||
connections: IConnectionsItem[];
|
connections: IConnectionsItem[];
|
||||||
onShowDetail: (data: IConnectionsItem) => void;
|
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">;
|
type ColumnField = Exclude<keyof ConnectionRow, "connectionData">;
|
||||||
|
|
||||||
interface BaseColumn {
|
interface BaseColumn {
|
||||||
@ -209,7 +224,7 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
width: 100,
|
width: 100,
|
||||||
minWidth: 80,
|
minWidth: 80,
|
||||||
align: "right",
|
align: "right",
|
||||||
cell: (row) => dayjs(row.time).fromNow(),
|
// cell filled later with shared relativeNow ticker
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "source",
|
field: "source",
|
||||||
@ -366,13 +381,44 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
}));
|
}));
|
||||||
}, [columns, columnVisibilityModel]);
|
}, [columns, columnVisibilityModel]);
|
||||||
|
|
||||||
const connRows = useMemo<ConnectionRow[]>(
|
const prevRowsRef = useRef<Map<string, ConnectionRow>>(new Map());
|
||||||
() => connections.map((each) => createConnectionRow(each)),
|
|
||||||
[connections],
|
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>[]>(() => {
|
const columnDefs = useMemo<ColumnDef<ConnectionRow>[]>(() => {
|
||||||
return columns.map((column) => ({
|
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,
|
id: column.field,
|
||||||
accessorKey: column.field,
|
accessorKey: column.field,
|
||||||
header: column.headerName,
|
header: column.headerName,
|
||||||
@ -383,13 +429,20 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
align: column.align ?? "left",
|
align: column.align ?? "left",
|
||||||
field: column.field,
|
field: column.field,
|
||||||
},
|
},
|
||||||
cell: column.cell
|
cell,
|
||||||
? ({ row }) => column.cell?.(row.original)
|
} satisfies ColumnDef<ConnectionRow>;
|
||||||
: (info) => info.getValue(),
|
});
|
||||||
}));
|
}, [columns, relativeNow]);
|
||||||
}, [columns]);
|
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setRelativeNow(Date.now());
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleColumnSizingChange = useCallback(
|
const handleColumnSizingChange = useCallback(
|
||||||
(updater: Updater<ColumnSizingState>) => {
|
(updater: Updater<ColumnSizingState>) => {
|
||||||
@ -411,7 +464,6 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: connRows,
|
data: connRows,
|
||||||
columns: columnDefs,
|
|
||||||
state: {
|
state: {
|
||||||
columnVisibility: columnVisibilityState,
|
columnVisibility: columnVisibilityState,
|
||||||
columnSizing: columnWidths,
|
columnSizing: columnWidths,
|
||||||
@ -420,10 +472,11 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
columnResizeMode: "onChange",
|
columnResizeMode: "onChange",
|
||||||
enableSortingRemoval: true,
|
enableSortingRemoval: true,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: sorting.length ? getSortedRowModel() : undefined,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnSizingChange: handleColumnSizingChange,
|
onColumnSizingChange: handleColumnSizingChange,
|
||||||
onColumnVisibilityChange: handleColumnVisibilityChange,
|
onColumnVisibilityChange: handleColumnVisibilityChange,
|
||||||
|
columns: columnDefs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = table.getRowModel().rows;
|
const rows = table.getRowModel().rows;
|
||||||
@ -432,7 +485,7 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
count: rows.length,
|
count: rows.length,
|
||||||
getScrollElement: () => tableContainerRef.current,
|
getScrollElement: () => tableContainerRef.current,
|
||||||
estimateSize: () => ROW_HEIGHT,
|
estimateSize: () => ROW_HEIGHT,
|
||||||
overscan: 8,
|
overscan: 4,
|
||||||
});
|
});
|
||||||
|
|
||||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
|
|||||||
@ -147,6 +147,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
});
|
});
|
||||||
const lastDataTimestampRef = useRef<number>(0);
|
const lastDataTimestampRef = useRef<number>(0);
|
||||||
const resumeCooldownRef = useRef<number>(0);
|
const resumeCooldownRef = useRef<number>(0);
|
||||||
|
const dataStaleRef = useRef<boolean>(false);
|
||||||
|
|
||||||
// 当前显示的数据缓存
|
// 当前显示的数据缓存
|
||||||
const [displayData, dispatchDisplayData] = useReducer(
|
const [displayData, dispatchDisplayData] = useReducer(
|
||||||
@ -197,6 +198,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
lastDataTimestampRef.current = 0;
|
lastDataTimestampRef.current = 0;
|
||||||
|
dataStaleRef.current = false;
|
||||||
fpsControllerRef.current.target = GRAPH_CONFIG.targetFPS;
|
fpsControllerRef.current.target = GRAPH_CONFIG.targetFPS;
|
||||||
fpsControllerRef.current.samples = [];
|
fpsControllerRef.current.samples = [];
|
||||||
fpsControllerRef.current.lastAdjustTime = 0;
|
fpsControllerRef.current.lastAdjustTime = 0;
|
||||||
@ -209,6 +211,11 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
displayData[displayData.length - 1]?.timestamp ?? null;
|
displayData[displayData.length - 1]?.timestamp ?? null;
|
||||||
if (latestTimestamp) {
|
if (latestTimestamp) {
|
||||||
lastDataTimestampRef.current = latestTimestamp;
|
lastDataTimestampRef.current = latestTimestamp;
|
||||||
|
const age = Date.now() - latestTimestamp;
|
||||||
|
const stale = age > STALE_DATA_THRESHOLD;
|
||||||
|
dataStaleRef.current = stale;
|
||||||
|
} else {
|
||||||
|
dataStaleRef.current = false;
|
||||||
}
|
}
|
||||||
}, [displayData]);
|
}, [displayData]);
|
||||||
|
|
||||||
@ -986,7 +993,11 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
|
|
||||||
// 受控的动画循环
|
// 受控的动画循环
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWindowFocused || displayData.length === 0) {
|
if (
|
||||||
|
!isWindowFocused ||
|
||||||
|
displayData.length === 0 ||
|
||||||
|
dataStaleRef.current
|
||||||
|
) {
|
||||||
if (animationFrameRef.current) {
|
if (animationFrameRef.current) {
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
animationFrameRef.current = undefined;
|
animationFrameRef.current = undefined;
|
||||||
@ -1002,9 +1013,25 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastDataAge =
|
||||||
|
lastDataTimestampRef.current > 0
|
||||||
|
? Date.now() - lastDataTimestampRef.current
|
||||||
|
: null;
|
||||||
const targetFPS = fpsControllerRef.current.target;
|
const targetFPS = fpsControllerRef.current.target;
|
||||||
const frameBudget = 1000 / targetFPS;
|
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 (
|
if (
|
||||||
currentTime - lastRenderTimeRef.current >= frameBudget ||
|
currentTime - lastRenderTimeRef.current >= frameBudget ||
|
||||||
!isInitializedRef.current
|
!isInitializedRef.current
|
||||||
|
|||||||
@ -14,8 +14,7 @@ import {
|
|||||||
alpha,
|
alpha,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useRef, memo, useMemo } from "react";
|
import { ReactNode, memo, useMemo, useRef } from "react";
|
||||||
import { ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { TrafficErrorBoundary } from "@/components/common/traffic-error-boundary";
|
import { TrafficErrorBoundary } from "@/components/common/traffic-error-boundary";
|
||||||
@ -147,9 +146,12 @@ export const EnhancedTrafficStats = () => {
|
|||||||
const trafficRef = useRef<EnhancedCanvasTrafficGraphRef>(null);
|
const trafficRef = useRef<EnhancedCanvasTrafficGraphRef>(null);
|
||||||
const pageVisible = useVisibility();
|
const pageVisible = useVisibility();
|
||||||
|
|
||||||
|
// 是否显示流量图表
|
||||||
|
const trafficGraph = verge?.traffic_graph ?? true;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
response: { data: traffic },
|
response: { data: traffic },
|
||||||
} = useTrafficData();
|
} = useTrafficData({ enabled: trafficGraph && pageVisible });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
response: { data: memory },
|
response: { data: memory },
|
||||||
@ -159,9 +161,6 @@ export const EnhancedTrafficStats = () => {
|
|||||||
response: { data: connections },
|
response: { data: connections },
|
||||||
} = useConnectionData();
|
} = useConnectionData();
|
||||||
|
|
||||||
// 是否显示流量图表
|
|
||||||
const trafficGraph = verge?.traffic_graph ?? true;
|
|
||||||
|
|
||||||
// Canvas组件现在直接从全局Hook获取数据,无需手动添加数据点
|
// Canvas组件现在直接从全局Hook获取数据,无需手动添加数据点
|
||||||
|
|
||||||
// 使用useMemo计算解析后的流量数据
|
// 使用useMemo计算解析后的流量数据
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export const LayoutTraffic = () => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
response: { data: traffic },
|
response: { data: traffic },
|
||||||
} = useTrafficData();
|
} = useTrafficData({ enabled: trafficGraph && pageVisible });
|
||||||
const {
|
const {
|
||||||
response: { data: memory },
|
response: { data: memory },
|
||||||
} = useMemoryData();
|
} = useMemoryData();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useReducer } from "react";
|
import { useEffect, useMemo, useReducer, useRef } from "react";
|
||||||
|
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
import delayManager from "@/services/delay";
|
import delayManager from "@/services/delay";
|
||||||
@ -22,6 +22,10 @@ export default function useFilterSort(
|
|||||||
) {
|
) {
|
||||||
const { verge } = useVerge();
|
const { verge } = useVerge();
|
||||||
const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0);
|
const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0);
|
||||||
|
const lastInputRef = useRef<{ text: string; sort: ProxySortType } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const debounceTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let last = 0;
|
let last = 0;
|
||||||
@ -40,7 +44,7 @@ export default function useFilterSort(
|
|||||||
};
|
};
|
||||||
}, [groupName]);
|
}, [groupName]);
|
||||||
|
|
||||||
return useMemo(() => {
|
const compute = useMemo(() => {
|
||||||
const fp = filterProxies(proxies, groupName, filterText, searchState);
|
const fp = filterProxies(proxies, groupName, filterText, searchState);
|
||||||
const sp = sortProxies(
|
const sp = sortProxies(
|
||||||
fp,
|
fp,
|
||||||
@ -57,6 +61,39 @@ export default function useFilterSort(
|
|||||||
searchState,
|
searchState,
|
||||||
verge?.default_latency_timeout,
|
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(
|
export function filterSort(
|
||||||
|
|||||||
@ -6,6 +6,7 @@ type SharedPollerEntry = {
|
|||||||
timer: number | null;
|
timer: number | null;
|
||||||
interval: number;
|
interval: number;
|
||||||
callback: (() => void) | null;
|
callback: (() => void) | null;
|
||||||
|
lastFired: number;
|
||||||
refreshWhenHidden: boolean;
|
refreshWhenHidden: boolean;
|
||||||
refreshWhenOffline: boolean;
|
refreshWhenOffline: boolean;
|
||||||
};
|
};
|
||||||
@ -32,6 +33,12 @@ const ensureTimer = (key: string, entry: SharedPollerEntry) => {
|
|||||||
entry.timer = window.setInterval(() => {
|
entry.timer = window.setInterval(() => {
|
||||||
if (!entry.refreshWhenHidden && isDocumentHidden()) return;
|
if (!entry.refreshWhenHidden && isDocumentHidden()) return;
|
||||||
if (!entry.refreshWhenOffline && isOffline()) 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.callback?.();
|
||||||
}, entry.interval);
|
}, entry.interval);
|
||||||
};
|
};
|
||||||
@ -50,6 +57,7 @@ const registerSharedPoller = (
|
|||||||
timer: null,
|
timer: null,
|
||||||
interval,
|
interval,
|
||||||
callback,
|
callback,
|
||||||
|
lastFired: 0,
|
||||||
refreshWhenHidden: options.refreshWhenHidden,
|
refreshWhenHidden: options.refreshWhenHidden,
|
||||||
refreshWhenOffline: options.refreshWhenOffline,
|
refreshWhenOffline: options.refreshWhenOffline,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import useSWR from "swr";
|
|||||||
import { getRunningMode, isAdmin, isServiceAvailable } from "@/services/cmds";
|
import { getRunningMode, isAdmin, isServiceAvailable } from "@/services/cmds";
|
||||||
import { showNotice } from "@/services/notice-service";
|
import { showNotice } from "@/services/notice-service";
|
||||||
|
|
||||||
|
import { useSharedSWRPoller } from "./use-shared-swr-poller";
|
||||||
import { useVerge } from "./use-verge";
|
import { useVerge } from "./use-verge";
|
||||||
|
|
||||||
export interface SystemState {
|
export interface SystemState {
|
||||||
@ -43,11 +44,20 @@ export function useSystemState() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
suspense: true,
|
suspense: true,
|
||||||
refreshInterval: 30000,
|
refreshInterval: 0,
|
||||||
fallback: defaultSystemState,
|
fallback: defaultSystemState,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useSharedSWRPoller(
|
||||||
|
"getSystemState",
|
||||||
|
30000,
|
||||||
|
() => {
|
||||||
|
void mutateSystemState();
|
||||||
|
},
|
||||||
|
{ refreshWhenHidden: false, refreshWhenOffline: false },
|
||||||
|
);
|
||||||
|
|
||||||
const isSidecarMode = systemState.runningMode === "Sidecar";
|
const isSidecarMode = systemState.runningMode === "Sidecar";
|
||||||
const isServiceMode = systemState.runningMode === "Service";
|
const isServiceMode = systemState.runningMode === "Service";
|
||||||
const isTunModeAvailable = systemState.isAdminMode || systemState.isServiceOk;
|
const isTunModeAvailable = systemState.isAdminMode || systemState.isServiceOk;
|
||||||
|
|||||||
@ -5,10 +5,12 @@ import { useTrafficMonitorEnhanced } from "./use-traffic-monitor";
|
|||||||
|
|
||||||
const FALLBACK_TRAFFIC: Traffic = { up: 0, down: 0 };
|
const FALLBACK_TRAFFIC: Traffic = { up: 0, down: 0 };
|
||||||
|
|
||||||
export const useTrafficData = () => {
|
export const useTrafficData = (options?: { enabled?: boolean }) => {
|
||||||
|
const enabled = options?.enabled ?? true;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
graphData: { appendData },
|
graphData: { appendData },
|
||||||
} = useTrafficMonitorEnhanced({ subscribe: false });
|
} = useTrafficMonitorEnhanced({ subscribe: false, enabled });
|
||||||
const { response, refresh } = useMihomoWsSubscription<ITrafficItem>({
|
const { response, refresh } = useMihomoWsSubscription<ITrafficItem>({
|
||||||
storageKey: "mihomo_traffic_date",
|
storageKey: "mihomo_traffic_date",
|
||||||
buildSubscriptKey: (date) => `getClashTraffic-${date}`,
|
buildSubscriptKey: (date) => `getClashTraffic-${date}`,
|
||||||
|
|||||||
@ -343,8 +343,10 @@ const EMPTY_STATS: ISamplerStats = {
|
|||||||
*/
|
*/
|
||||||
export const useTrafficMonitorEnhanced = (options?: {
|
export const useTrafficMonitorEnhanced = (options?: {
|
||||||
subscribe?: boolean;
|
subscribe?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const subscribeToSnapshots = options?.subscribe ?? true;
|
const subscribeToSnapshots = options?.subscribe ?? true;
|
||||||
|
const enabled = options?.enabled ?? true;
|
||||||
const [latestSnapshot, setLatestSnapshot] = useState<{
|
const [latestSnapshot, setLatestSnapshot] = useState<{
|
||||||
availableDataPoints: ITrafficDataPoint[];
|
availableDataPoints: ITrafficDataPoint[];
|
||||||
samplerStats: ISamplerStats;
|
samplerStats: ISamplerStats;
|
||||||
@ -365,6 +367,8 @@ export const useTrafficMonitorEnhanced = (options?: {
|
|||||||
|
|
||||||
// 注册引用计数与Worker生命周期
|
// 注册引用计数与Worker生命周期
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
const client = getWorkerClient();
|
const client = getWorkerClient();
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
|
||||||
@ -396,43 +400,53 @@ export const useTrafficMonitorEnhanced = (options?: {
|
|||||||
client.stop();
|
client.stop();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [subscribeToSnapshots]);
|
}, [enabled, subscribeToSnapshots]);
|
||||||
|
|
||||||
// Periodically refresh "now" so idle streams age out of the selected window when subscribed
|
// Periodically refresh "now" so idle streams age out of the selected window when subscribed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subscribeToSnapshots) return;
|
if (!enabled || !subscribeToSnapshots) return;
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [subscribeToSnapshots]);
|
}, [enabled, subscribeToSnapshots]);
|
||||||
|
|
||||||
// 添加流量数据
|
// 添加流量数据
|
||||||
const appendData = useCallback((traffic: Traffic) => {
|
const appendData = useCallback(
|
||||||
|
(traffic: Traffic) => {
|
||||||
|
if (!enabled) return;
|
||||||
clientRef.current?.appendData(traffic);
|
clientRef.current?.appendData(traffic);
|
||||||
}, []);
|
},
|
||||||
|
[enabled],
|
||||||
|
);
|
||||||
|
|
||||||
// 请求不同时间范围的数据
|
// 请求不同时间范围的数据
|
||||||
const requestRange = useCallback((minutes: number) => {
|
const requestRange = useCallback(
|
||||||
|
(minutes: number) => {
|
||||||
|
if (!enabled) return;
|
||||||
currentRangeRef.current = minutes;
|
currentRangeRef.current = minutes;
|
||||||
setRangeMinutes(minutes);
|
setRangeMinutes(minutes);
|
||||||
clientRef.current?.setRange(minutes);
|
clientRef.current?.setRange(minutes);
|
||||||
}, []);
|
},
|
||||||
|
[enabled],
|
||||||
|
);
|
||||||
|
|
||||||
// 清空数据
|
// 清空数据
|
||||||
const clearData = useCallback(() => {
|
const clearData = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
clientRef.current?.clearData();
|
clientRef.current?.clearData();
|
||||||
}, []);
|
}, [enabled]);
|
||||||
|
|
||||||
const filteredDataPoints = useMemo(() => {
|
const filteredDataPoints = useMemo(() => {
|
||||||
|
if (!enabled) return [];
|
||||||
const sourceData = latestSnapshot.availableDataPoints;
|
const sourceData = latestSnapshot.availableDataPoints;
|
||||||
if (sourceData.length === 0) return [];
|
if (sourceData.length === 0) return [];
|
||||||
|
|
||||||
const cutoff = now - rangeMinutes * 60 * 1000;
|
const cutoff = now - rangeMinutes * 60 * 1000;
|
||||||
return sourceData.filter((point) => point.timestamp > cutoff);
|
return sourceData.filter((point) => point.timestamp > cutoff);
|
||||||
}, [latestSnapshot.availableDataPoints, rangeMinutes, now]);
|
}, [enabled, latestSnapshot.availableDataPoints, rangeMinutes, now]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
graphData: {
|
graphData: {
|
||||||
|
|||||||
@ -15,7 +15,9 @@ export const formatTrafficName = (timestamp: number) =>
|
|||||||
|
|
||||||
export class TrafficDataSampler {
|
export class TrafficDataSampler {
|
||||||
private rawBuffer: ITrafficDataPoint[] = [];
|
private rawBuffer: ITrafficDataPoint[] = [];
|
||||||
|
private rawHead = 0;
|
||||||
private compressedBuffer: ICompressedDataPoint[] = [];
|
private compressedBuffer: ICompressedDataPoint[] = [];
|
||||||
|
private compressedHead = 0;
|
||||||
private compressionQueue: ITrafficDataPoint[] = [];
|
private compressionQueue: ITrafficDataPoint[] = [];
|
||||||
|
|
||||||
constructor(private config: ISamplingConfig) {}
|
constructor(private config: ISamplingConfig) {}
|
||||||
@ -24,7 +26,17 @@ export class TrafficDataSampler {
|
|||||||
this.rawBuffer.push(point);
|
this.rawBuffer.push(point);
|
||||||
|
|
||||||
const rawCutoff = Date.now() - this.config.rawDataMinutes * 60 * 1000;
|
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);
|
this.compressionQueue.push(point);
|
||||||
if (this.compressionQueue.length >= this.config.compressionRatio) {
|
if (this.compressionQueue.length >= this.config.compressionRatio) {
|
||||||
@ -33,9 +45,19 @@ export class TrafficDataSampler {
|
|||||||
|
|
||||||
const compressedCutoff =
|
const compressedCutoff =
|
||||||
Date.now() - this.config.compressedDataMinutes * 60 * 1000;
|
Date.now() - this.config.compressedDataMinutes * 60 * 1000;
|
||||||
this.compressedBuffer = this.compressedBuffer.filter(
|
while (
|
||||||
(p) => p.timestamp > compressedCutoff,
|
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() {
|
private compressData() {
|
||||||
@ -60,18 +82,34 @@ export class TrafficDataSampler {
|
|||||||
|
|
||||||
getDataForTimeRange(minutes: number): ITrafficDataPoint[] {
|
getDataForTimeRange(minutes: number): ITrafficDataPoint[] {
|
||||||
const cutoff = Date.now() - minutes * 60 * 1000;
|
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) {
|
if (minutes <= this.config.rawDataMinutes) {
|
||||||
return rawData;
|
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
|
const compressedData = this.compressedBuffer
|
||||||
.filter(
|
.slice(compressedStart)
|
||||||
(p) =>
|
.filter((p) => p.timestamp <= compressedCutoffUpper)
|
||||||
p.timestamp > cutoff &&
|
|
||||||
p.timestamp <= Date.now() - this.config.rawDataMinutes * 60 * 1000,
|
|
||||||
)
|
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
up: p.up,
|
up: p.up,
|
||||||
down: p.down,
|
down: p.down,
|
||||||
@ -86,16 +124,21 @@ export class TrafficDataSampler {
|
|||||||
|
|
||||||
getStats(): ISamplerStats {
|
getStats(): ISamplerStats {
|
||||||
return {
|
return {
|
||||||
rawBufferSize: this.rawBuffer.length,
|
rawBufferSize: this.rawBuffer.length - this.rawHead,
|
||||||
compressedBufferSize: this.compressedBuffer.length,
|
compressedBufferSize: this.compressedBuffer.length - this.compressedHead,
|
||||||
compressionQueueSize: this.compressionQueue.length,
|
compressionQueueSize: this.compressionQueue.length,
|
||||||
totalMemoryPoints: this.rawBuffer.length + this.compressedBuffer.length,
|
totalMemoryPoints:
|
||||||
|
this.rawBuffer.length -
|
||||||
|
this.rawHead +
|
||||||
|
(this.compressedBuffer.length - this.compressedHead),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.rawBuffer = [];
|
this.rawBuffer = [];
|
||||||
|
this.rawHead = 0;
|
||||||
this.compressedBuffer = [];
|
this.compressedBuffer = [];
|
||||||
|
this.compressedHead = 0;
|
||||||
this.compressionQueue = [];
|
this.compressionQueue = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user