Add outbound traffic breakdown to device charts
This commit is contained in:
@@ -141,6 +141,24 @@ interface TrafficCursor {
|
||||
download: bigint;
|
||||
}
|
||||
|
||||
interface OutboundTrafficSample {
|
||||
observedAt: string;
|
||||
vpnBytes: string;
|
||||
directTrackedBytes: string;
|
||||
directIpv4Bytes: string;
|
||||
unknownBytes: string;
|
||||
}
|
||||
|
||||
interface OutboundTrafficCursor {
|
||||
signature: string;
|
||||
routeEpoch: string;
|
||||
directEpoch: string;
|
||||
vpn: bigint;
|
||||
directTracked: bigint;
|
||||
directIpv4: bigint;
|
||||
unknown: bigint;
|
||||
}
|
||||
|
||||
interface DeviceObservation {
|
||||
mac: string;
|
||||
ip: string;
|
||||
@@ -689,6 +707,8 @@ export function createDeviceInventoryService({
|
||||
let policyQueue: Promise<unknown> = Promise.resolve();
|
||||
const trafficHistoryByMac = new Map<string, TrafficSample[]>();
|
||||
const trafficCursorByMac = new Map<string, TrafficCursor>();
|
||||
const outboundTrafficHistoryByDeviceId = new Map<string, OutboundTrafficSample[]>();
|
||||
const outboundTrafficCursorByDeviceId = new Map<string, OutboundTrafficCursor>();
|
||||
let globalTrafficHistory: TrafficSample[] = [];
|
||||
let globalTrafficCursor: TrafficCursor | null = null;
|
||||
const hostnameAttempts = new Map<string, number>();
|
||||
@@ -763,6 +783,79 @@ export function createDeviceInventoryService({
|
||||
}
|
||||
}
|
||||
|
||||
function captureOutboundTrafficHistory(state: InventoryState) {
|
||||
const routeEpoch = typeof domainTrafficSnapshot.epoch === 'string' ? domainTrafficSnapshot.epoch : '';
|
||||
const directEpoch = typeof directTrafficSnapshot.epoch === 'string' ? directTrafficSnapshot.epoch : '';
|
||||
const routeObservedAt = validTimestamp(domainTrafficSnapshot.observedAt)
|
||||
? String(domainTrafficSnapshot.observedAt)
|
||||
: '';
|
||||
const directObservedAt = validTimestamp(directTrafficSnapshot.observedAt)
|
||||
? String(directTrafficSnapshot.observedAt)
|
||||
: '';
|
||||
const signature = `${routeEpoch}|${routeObservedAt}|${directEpoch}|${directObservedAt}`;
|
||||
if (signature === '|||') return;
|
||||
|
||||
const totals = new Map<string, Omit<OutboundTrafficCursor, 'signature' | 'routeEpoch' | 'directEpoch'>>();
|
||||
const totalFor = (deviceId: string) => {
|
||||
const existing = totals.get(deviceId) || { vpn: 0n, directTracked: 0n, directIpv4: 0n, unknown: 0n };
|
||||
totals.set(deviceId, existing);
|
||||
return existing;
|
||||
};
|
||||
for (const value of Array.isArray(domainTrafficSnapshot.routes) ? domainTrafficSnapshot.routes : []) {
|
||||
const row = record(value);
|
||||
const deviceId = String(row.deviceId || '');
|
||||
const outbound = String(row.outbound || '');
|
||||
const uploadBytes = String(row.uploadBytes || '');
|
||||
const downloadBytes = String(row.downloadBytes || '');
|
||||
if (!DEVICE_ID_PATTERN.test(deviceId) || !['vpn', 'direct', 'unknown'].includes(outbound)
|
||||
|| !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) continue;
|
||||
const amount = BigInt(uploadBytes) + BigInt(downloadBytes);
|
||||
const total = totalFor(deviceId);
|
||||
if (outbound === 'vpn') total.vpn += amount;
|
||||
else if (outbound === 'direct') total.directTracked += amount;
|
||||
else total.unknown += amount;
|
||||
}
|
||||
for (const value of Array.isArray(directTrafficSnapshot.series) ? directTrafficSnapshot.series : []) {
|
||||
const row = record(value);
|
||||
const deviceId = String(row.deviceId || '');
|
||||
const uploadBytes = String(row.uploadBytes || '');
|
||||
const downloadBytes = String(row.downloadBytes || '');
|
||||
if (!DEVICE_ID_PATTERN.test(deviceId)
|
||||
|| !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) continue;
|
||||
totalFor(deviceId).directIpv4 += BigInt(uploadBytes) + BigInt(downloadBytes);
|
||||
}
|
||||
|
||||
const knownIds = new Set(state.devices.map(({ id }) => id));
|
||||
const observedAt = [routeObservedAt, directObservedAt].filter(Boolean).sort().at(-1) || '';
|
||||
for (const device of state.devices) {
|
||||
const total = totalFor(device.id);
|
||||
const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total };
|
||||
const previous = outboundTrafficCursorByDeviceId.get(device.id);
|
||||
outboundTrafficCursorByDeviceId.set(device.id, current);
|
||||
if (!previous || previous.signature === signature || !observedAt) continue;
|
||||
const routeDelta = (value: bigint, before: bigint) => (
|
||||
routeEpoch && routeEpoch === previous.routeEpoch && value > before ? value - before : 0n
|
||||
);
|
||||
const directDelta = directEpoch && directEpoch === previous.directEpoch && total.directIpv4 > previous.directIpv4
|
||||
? total.directIpv4 - previous.directIpv4
|
||||
: 0n;
|
||||
const samples = outboundTrafficHistoryByDeviceId.get(device.id) || [];
|
||||
outboundTrafficHistoryByDeviceId.set(device.id, [...samples, {
|
||||
observedAt,
|
||||
vpnBytes: routeDelta(total.vpn, previous.vpn).toString(),
|
||||
directTrackedBytes: routeDelta(total.directTracked, previous.directTracked).toString(),
|
||||
directIpv4Bytes: directDelta.toString(),
|
||||
unknownBytes: routeDelta(total.unknown, previous.unknown).toString(),
|
||||
}].slice(-TRAFFIC_HISTORY_LIMIT));
|
||||
}
|
||||
for (const deviceId of outboundTrafficCursorByDeviceId.keys()) {
|
||||
if (!knownIds.has(deviceId)) {
|
||||
outboundTrafficCursorByDeviceId.delete(deviceId);
|
||||
outboundTrafficHistoryByDeviceId.delete(deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializePolicy<T>(action: () => Promise<T> | T): Promise<T> {
|
||||
const result = policyQueue.then(() => action(), () => action());
|
||||
policyQueue = result.catch(() => {});
|
||||
@@ -826,6 +919,7 @@ export function createDeviceInventoryService({
|
||||
proxyDownloadBytes: proxyTraffic?.downloadBytes || '0',
|
||||
proxyTrafficObservedAt: proxyTraffic?.observedAt || null,
|
||||
trafficHistory: trafficHistoryByMac.get(device.mac) || [],
|
||||
outboundTrafficHistory: outboundTrafficHistoryByDeviceId.get(device.id) || [],
|
||||
desiredPolicy: policy.desired,
|
||||
appliedPolicy: policy.applied,
|
||||
policyStatus: policy.status,
|
||||
@@ -1375,6 +1469,7 @@ export function createDeviceInventoryService({
|
||||
};
|
||||
});
|
||||
captureTrafficHistory(nextState);
|
||||
captureOutboundTrafficHistory(nextState);
|
||||
if (typeof policyResult?.transportError === 'string') {
|
||||
commitPolicyFailure(new Error(policyResult.transportError));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.25.2',
|
||||
gatewayClient: '0.26.1',
|
||||
gatewayBackend: '0.26.2',
|
||||
macClient: '0.25.3',
|
||||
gatewayClient: '0.26.2',
|
||||
gatewayBackend: '0.26.3',
|
||||
});
|
||||
|
||||
export interface ParsedVersion {
|
||||
|
||||
@@ -79,6 +79,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
const [alias, setAlias] = useState('');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear');
|
||||
const [trafficView, setTrafficView] = useState<'outbound' | 'inbound'>('outbound');
|
||||
const [copyFeedback, setCopyFeedback] = useState<Record<string, { field: DeviceCopyField; failed: boolean }>>({});
|
||||
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
|
||||
const [pencilAnimationId, setPencilAnimationId] = useState('');
|
||||
@@ -324,6 +325,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
</button>
|
||||
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
|
||||
</span>
|
||||
<span className="client-devices-scale" role="group" aria-label="Разрез графика трафика">
|
||||
<button type="button" aria-pressed={trafficView === 'outbound'} onClick={() => setTrafficView('outbound')}>Выход</button>
|
||||
<button type="button" aria-pressed={trafficView === 'inbound'} onClick={() => setTrafficView('inbound')}>Вход</button>
|
||||
</span>
|
||||
<span className="client-devices-scale" role="group" aria-label="Масштаб графика трафика">
|
||||
<button type="button" aria-pressed={trafficScale === 'linear'} onClick={() => setTrafficScale('linear')}>Лин</button>
|
||||
<button type="button" aria-pressed={trafficScale === 'log'} onClick={() => setTrafficScale('log')}>Лог</button>
|
||||
@@ -581,10 +586,11 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
<Tooltip>{deprioritized ? 'Вернуть в основной список' : 'Убрать в фон'}</Tooltip>
|
||||
</span>
|
||||
{!compact && <TrafficChart
|
||||
samples={device.trafficHistory || []}
|
||||
samples={trafficView === 'outbound' ? device.outboundTrafficHistory || [] : device.trafficHistory || []}
|
||||
scale={trafficScale}
|
||||
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
|
||||
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
|
||||
series={trafficView}
|
||||
pinned={showDetails}
|
||||
collapsing={collapsing}
|
||||
onCollapseEnd={() => finishPinCollapse(device.id)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
trafficBytesPerSecond,
|
||||
trafficScaleRatio,
|
||||
} from '../../utils/format.js';
|
||||
import type { TrafficSample, TrafficScale } from './deviceSnapshot.js';
|
||||
import type { OutboundTrafficSample, TrafficSample, TrafficScale } from './deviceSnapshot.js';
|
||||
|
||||
const TRAFFIC_CHART_HEADROOM = 10;
|
||||
const trafficChartY = (ratio: number) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
|
||||
@@ -25,13 +25,21 @@ function chartTime(value: string) {
|
||||
});
|
||||
}
|
||||
|
||||
type ChartSample = TrafficSample | OutboundTrafficSample;
|
||||
type ChartValueKey = 'gateway' | 'proxy' | 'directIpv4' | 'unknown';
|
||||
type ChartYKey = 'gatewayY' | 'proxyY' | 'directIpv4Y' | 'unknownY';
|
||||
|
||||
interface ChartPoint {
|
||||
sample: TrafficSample;
|
||||
sample: ChartSample;
|
||||
x: number;
|
||||
gateway: bigint;
|
||||
proxy: bigint;
|
||||
directIpv4: bigint;
|
||||
unknown: bigint;
|
||||
gatewayY: number;
|
||||
proxyY: number;
|
||||
directIpv4Y: number;
|
||||
unknownY: number;
|
||||
}
|
||||
|
||||
interface HoveredPoint extends ChartPoint {
|
||||
@@ -39,7 +47,7 @@ interface HoveredPoint extends ChartPoint {
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY') {
|
||||
function smoothTrafficPath(points: ChartPoint[], valueKey: ChartYKey) {
|
||||
if (!points.length) return '';
|
||||
return points.slice(1).reduce((path, point, index) => {
|
||||
const previous = points[index];
|
||||
@@ -60,20 +68,22 @@ function trafficPathAnimationSource(points: ChartPoint[], previousPoints: ChartP
|
||||
x: source.x,
|
||||
gatewayY: source.gatewayY,
|
||||
proxyY: source.proxyY,
|
||||
directIpv4Y: source.directIpv4Y,
|
||||
unknownY: source.unknownY,
|
||||
} : {
|
||||
...point,
|
||||
gatewayY: 100,
|
||||
proxyY: 100,
|
||||
directIpv4Y: 100,
|
||||
unknownY: 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function trafficSeriesMax(values: Array<{ gateway: bigint; proxy: bigint }>) {
|
||||
return values.reduce((largest, { gateway, proxy }) => {
|
||||
return gateway > largest
|
||||
? (proxy > gateway ? proxy : gateway)
|
||||
: (proxy > largest ? proxy : largest);
|
||||
}, 0n);
|
||||
function trafficSeriesMax(values: Array<Record<ChartValueKey, bigint>>) {
|
||||
return values.reduce((largest, value) => (
|
||||
Object.values(value).reduce((current, item) => item > current ? item : current, largest)
|
||||
), 0n);
|
||||
}
|
||||
|
||||
function formatRate(value: bigint) {
|
||||
@@ -88,64 +98,101 @@ export function TrafficChart({
|
||||
pinned = true,
|
||||
collapsing = false,
|
||||
onCollapseEnd,
|
||||
series = 'routes',
|
||||
series = 'inbound',
|
||||
}: {
|
||||
samples: TrafficSample[];
|
||||
samples: ChartSample[];
|
||||
scale?: TrafficScale;
|
||||
capacity: number;
|
||||
routeLabel: string;
|
||||
pinned?: boolean;
|
||||
collapsing?: boolean;
|
||||
onCollapseEnd?: () => void;
|
||||
series?: 'routes' | 'speed';
|
||||
series?: 'inbound' | 'outbound' | 'speed';
|
||||
}) {
|
||||
const [hovered, setHovered] = useState<HoveredPoint | null>(null);
|
||||
const previousPoints = useRef<ChartPoint[]>([]);
|
||||
const previousSeries = useRef(series);
|
||||
const speedAvailable = samples.length > 1 && samples.every((sample) => (
|
||||
typeof sample.downloadBytes === 'string' && typeof sample.uploadBytes === 'string'
|
||||
'downloadBytes' in sample && typeof sample.downloadBytes === 'string'
|
||||
&& 'uploadBytes' in sample && typeof sample.uploadBytes === 'string'
|
||||
));
|
||||
const visibleSamples = series === 'speed' ? speedAvailable ? samples.slice(1) : [] : samples;
|
||||
const values = visibleSamples.map((sample, index) => {
|
||||
if (series === 'routes') {
|
||||
if (series === 'inbound') {
|
||||
const traffic = sample as TrafficSample;
|
||||
return {
|
||||
sample,
|
||||
gateway: byteString(sample.gatewayBytes),
|
||||
proxy: byteString(sample.proxyBytes),
|
||||
gateway: byteString(traffic.gatewayBytes),
|
||||
proxy: byteString(traffic.proxyBytes),
|
||||
directIpv4: 0n,
|
||||
unknown: 0n,
|
||||
};
|
||||
}
|
||||
const previous = samples[index];
|
||||
if (series === 'outbound') {
|
||||
const outbound = sample as OutboundTrafficSample;
|
||||
return {
|
||||
sample,
|
||||
gateway: byteString(outbound.vpnBytes),
|
||||
proxy: byteString(outbound.directTrackedBytes),
|
||||
directIpv4: byteString(outbound.directIpv4Bytes),
|
||||
unknown: byteString(outbound.unknownBytes),
|
||||
};
|
||||
}
|
||||
const traffic = sample as TrafficSample;
|
||||
const previous = samples[index] as TrafficSample;
|
||||
return {
|
||||
sample,
|
||||
gateway: trafficBytesPerSecond(sample.downloadBytes, previous.observedAt, sample.observedAt),
|
||||
proxy: trafficBytesPerSecond(sample.uploadBytes, previous.observedAt, sample.observedAt),
|
||||
gateway: trafficBytesPerSecond(traffic.downloadBytes, previous.observedAt, traffic.observedAt),
|
||||
proxy: trafficBytesPerSecond(traffic.uploadBytes, previous.observedAt, traffic.observedAt),
|
||||
directIpv4: 0n,
|
||||
unknown: 0n,
|
||||
};
|
||||
});
|
||||
const max = trafficSeriesMax(values);
|
||||
const max = trafficSeriesMax(values.map(({ gateway, proxy, directIpv4, unknown }) => ({
|
||||
gateway, proxy, directIpv4, unknown,
|
||||
})));
|
||||
const mid = trafficAxisMid(max, scale);
|
||||
const firstSlot = capacity - visibleSamples.length;
|
||||
const points = values.map(({ sample, gateway, proxy }, index) => {
|
||||
const points = values.map(({ sample, gateway, proxy, directIpv4, unknown }, index) => {
|
||||
return {
|
||||
sample,
|
||||
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
|
||||
gateway,
|
||||
proxy,
|
||||
directIpv4,
|
||||
unknown,
|
||||
gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)),
|
||||
proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)),
|
||||
directIpv4Y: trafficChartY(trafficScaleRatio(directIpv4, max, scale)),
|
||||
unknownY: trafficChartY(trafficScaleRatio(unknown, max, scale)),
|
||||
};
|
||||
});
|
||||
const previous = points.slice(0, -1);
|
||||
const penultimate = points.at(-2);
|
||||
const newest = points.at(-1);
|
||||
const hasSecondary = points.some(({ proxy }) => proxy > 0n);
|
||||
const lineDefinitions: Array<{
|
||||
valueKey: ChartValueKey;
|
||||
yKey: ChartYKey;
|
||||
}> = series === 'outbound'
|
||||
? [
|
||||
{ valueKey: 'gateway', yKey: 'gatewayY' },
|
||||
{ valueKey: 'proxy', yKey: 'proxyY' },
|
||||
{ valueKey: 'directIpv4', yKey: 'directIpv4Y' },
|
||||
{ valueKey: 'unknown', yKey: 'unknownY' },
|
||||
]
|
||||
: [
|
||||
{ valueKey: 'gateway', yKey: 'gatewayY' },
|
||||
{ valueKey: 'proxy', yKey: 'proxyY' },
|
||||
];
|
||||
const visibleLines = lineDefinitions.filter(({ valueKey }) => points.some((point) => point[valueKey] > 0n));
|
||||
const motionFrom = previousSeries.current === series ? previousPoints.current : [];
|
||||
const previousMotionFrom = trafficPathAnimationSource(previous, motionFrom);
|
||||
const newestMotionFrom = trafficPathAnimationSource(
|
||||
penultimate && newest ? [penultimate, newest] : [],
|
||||
motionFrom,
|
||||
);
|
||||
const motionKey = `${series}-${scale}-${points.map(({ sample, gateway, proxy }) => (
|
||||
`${sample.observedAt}:${gateway}:${proxy}`
|
||||
const motionKey = `${series}-${scale}-${points.map(({ sample, gateway, proxy, directIpv4, unknown }) => (
|
||||
`${sample.observedAt}:${gateway}:${proxy}:${directIpv4}:${unknown}`
|
||||
)).join('|')}`;
|
||||
const animatePaths = points.length > 0
|
||||
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
@@ -182,6 +229,12 @@ export function TrafficChart({
|
||||
<span className="is-interval">За интервал {formatByteString(byteString(hovered.sample.gatewayBytes) + byteString(hovered.sample.proxyBytes))}</span>
|
||||
<span className={routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct'}>{routeLabel} {formatByteString(hovered.sample.gatewayBytes)}</span>
|
||||
{byteString(hovered.sample.proxyBytes) > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.sample.proxyBytes)}</span>}
|
||||
</> : series === 'outbound' ? <>
|
||||
{hovered.gateway > 0n && <strong className="is-vpn">VPN · sing-box {formatByteString(hovered.gateway)}</strong>}
|
||||
{hovered.proxy > 0n && <span className="is-direct-tracked">Direct · sing-box {formatByteString(hovered.proxy)}</span>}
|
||||
{hovered.directIpv4 > 0n && <span className="is-direct-ipv4">Direct · IPv4 {formatByteString(hovered.directIpv4)}</span>}
|
||||
{hovered.unknown > 0n && <span className="is-unknown">Неизвестно · sing-box {formatByteString(hovered.unknown)}</span>}
|
||||
<span className="is-interval">Разные уровни учёта не складываются</span>
|
||||
</> : <>
|
||||
<strong className="is-total">Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
|
||||
<span className={routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct'}>{routeLabel} {formatByteString(hovered.gateway)}</span>
|
||||
@@ -194,7 +247,7 @@ export function TrafficChart({
|
||||
return <span
|
||||
className={`client-device-traffic-chart${collapsing ? ' is-collapsing' : ''}`}
|
||||
role="img"
|
||||
aria-label={`${series === 'speed' ? 'История скорости' : 'История трафика'}, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${series === 'speed' ? formatRate(max) : formatByteString(max)}`}
|
||||
aria-label={`${series === 'speed' ? 'История скорости' : series === 'outbound' ? 'Фактический выход трафика' : 'Источник входящего трафика'}, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${series === 'speed' ? formatRate(max) : formatByteString(max)}`}
|
||||
style={{
|
||||
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
|
||||
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
|
||||
@@ -216,32 +269,30 @@ export function TrafficChart({
|
||||
<line x1="0" x2="100" y1="100" y2="100" />
|
||||
</g>}
|
||||
<g className="client-device-traffic-lines">
|
||||
{previous.length > 0 && <path className={series === 'speed' ? 'is-download' : 'is-gateway'} d={smoothTrafficPath(previous, 'gatewayY')}>
|
||||
{animatePaths && <animate key={`gateway-${motionKey}`} attributeName="d" from={smoothTrafficPath(previousMotionFrom, 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||
</path>}
|
||||
{hasSecondary && previous.length > 0 && <path className={series === 'speed' ? 'is-upload' : 'is-proxy'} d={smoothTrafficPath(previous, 'proxyY')}>
|
||||
{animatePaths && <animate key={`proxy-${motionKey}`} attributeName="d" from={smoothTrafficPath(previousMotionFrom, 'proxyY')} to={smoothTrafficPath(previous, 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||
</path>}
|
||||
{penultimate && newest && <path className={series === 'speed' ? 'is-download' : 'is-gateway'} d={smoothTrafficPath([penultimate, newest], 'gatewayY')}>
|
||||
{animatePaths && <animate key={`gateway-new-${motionKey}`} attributeName="d" from={smoothTrafficPath(newestMotionFrom, 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||
</path>}
|
||||
{hasSecondary && penultimate && newest && <path className={series === 'speed' ? 'is-upload' : 'is-proxy'} d={smoothTrafficPath([penultimate, newest], 'proxyY')}>
|
||||
{animatePaths && <animate key={`proxy-new-${motionKey}`} attributeName="d" from={smoothTrafficPath(newestMotionFrom, 'proxyY')} to={smoothTrafficPath([penultimate, newest], 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||
</path>}
|
||||
{!penultimate && newest && <line className={`${series === 'speed' ? 'is-download' : 'is-gateway'} is-point`} x1={newest.x} x2={newest.x} y1={newest.gatewayY} y2={newest.gatewayY}>
|
||||
{animatePaths && <animate key={`point-${motionKey}`} attributeName="opacity" from="0" to="1" dur="220ms" fill="freeze" />}
|
||||
</line>}
|
||||
{visibleLines.map((line) => previous.length > 0 && <path key={`old-${line.valueKey}`} className={line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'} d={smoothTrafficPath(previous, line.yKey)}>
|
||||
{animatePaths && <animate key={`${line.valueKey}-${motionKey}`} attributeName="d" from={smoothTrafficPath(previousMotionFrom, line.yKey)} to={smoothTrafficPath(previous, line.yKey)} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||
</path>)}
|
||||
{visibleLines.map((line) => penultimate && newest && <path key={`new-${line.valueKey}`} className={line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'} d={smoothTrafficPath([penultimate, newest], line.yKey)}>
|
||||
{animatePaths && <animate key={`${line.valueKey}-new-${motionKey}`} attributeName="d" from={smoothTrafficPath(newestMotionFrom, line.yKey)} to={smoothTrafficPath([penultimate, newest], line.yKey)} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||
</path>)}
|
||||
{visibleLines.map((line) => !penultimate && newest && <line key={`point-${line.valueKey}`} className={`${line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'} is-point`} x1={newest.x} x2={newest.x} y1={newest[line.yKey]} y2={newest[line.yKey]}>
|
||||
{animatePaths && <animate key={`${line.valueKey}-point-${motionKey}`} attributeName="opacity" from="0" to="1" dur="220ms" fill="freeze" />}
|
||||
</line>)}
|
||||
</g>
|
||||
{hovered && <g className="client-device-traffic-cursor">
|
||||
<line className="is-guide" x1={hovered.x} x2={hovered.x} y1={TRAFFIC_CHART_HEADROOM} y2="100" />
|
||||
<line className={`is-point ${series === 'speed' ? 'is-download' : 'is-gateway'}`} x1={hovered.x} x2={hovered.x} y1={hovered.gatewayY} y2={hovered.gatewayY} />
|
||||
{hovered.proxy > 0n && <line className={`is-point ${series === 'speed' ? 'is-upload' : 'is-proxy'}`} x1={hovered.x} x2={hovered.x} y1={hovered.proxyY} y2={hovered.proxyY} />}
|
||||
{visibleLines.map((line) => hovered[line.valueKey] > 0n && <line key={line.valueKey} className={`is-point ${line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'}`} x1={hovered.x} x2={hovered.x} y1={hovered[line.yKey]} y2={hovered[line.yKey]} />)}
|
||||
</g>}
|
||||
</svg>
|
||||
</span>
|
||||
{visibleSamples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
|
||||
<time dateTime={visibleSamples[0].observedAt}>{chartTime(visibleSamples[0].observedAt)}</time>
|
||||
<span>{series === 'speed' ? '↓ / ↑' : '15 с'}</span>
|
||||
{series === 'outbound' ? <span className="client-device-traffic-legend">
|
||||
<span className="is-vpn">VPN</span>
|
||||
<span className="is-direct-tracked">Direct · sing-box</span>
|
||||
<span className="is-direct-ipv4">Direct · IPv4</span>
|
||||
<span className="is-unknown">?</span>
|
||||
</span> : <span>{series === 'speed' ? '↓ / ↑' : 'Вход'}</span>}
|
||||
<time dateTime={visibleSamples[visibleSamples.length - 1].observedAt}>{chartTime(visibleSamples[visibleSamples.length - 1].observedAt)}</time>
|
||||
</span>}
|
||||
{tooltip}
|
||||
|
||||
@@ -13,6 +13,14 @@ export interface TrafficSample extends Record<string, unknown> {
|
||||
downloadBytes?: ByteValue;
|
||||
}
|
||||
|
||||
export interface OutboundTrafficSample extends Record<string, unknown> {
|
||||
observedAt: string;
|
||||
vpnBytes: ByteValue;
|
||||
directTrackedBytes: ByteValue;
|
||||
directIpv4Bytes: ByteValue;
|
||||
unknownBytes: ByteValue;
|
||||
}
|
||||
|
||||
export interface Device extends Record<string, unknown> {
|
||||
id: string;
|
||||
alias: string | null;
|
||||
@@ -33,6 +41,7 @@ export interface Device extends Record<string, unknown> {
|
||||
appliedPolicy: DevicePolicy;
|
||||
confidence: DeviceConfidence;
|
||||
trafficHistory: TrafficSample[];
|
||||
outboundTrafficHistory?: OutboundTrafficSample[];
|
||||
}
|
||||
|
||||
interface SnapshotSource extends Record<string, unknown> {
|
||||
@@ -108,6 +117,19 @@ function validHistory(value: unknown): value is TrafficSample[] {
|
||||
return Array.isArray(value) && value.every(validTrafficSample);
|
||||
}
|
||||
|
||||
function validOutboundTrafficSample(value: unknown): value is OutboundTrafficSample {
|
||||
return record(value)
|
||||
&& timestamp(value.observedAt)
|
||||
&& bytes(value.vpnBytes)
|
||||
&& bytes(value.directTrackedBytes)
|
||||
&& bytes(value.directIpv4Bytes)
|
||||
&& bytes(value.unknownBytes);
|
||||
}
|
||||
|
||||
function validOutboundHistory(value: unknown): value is OutboundTrafficSample[] {
|
||||
return Array.isArray(value) && value.every(validOutboundTrafficSample);
|
||||
}
|
||||
|
||||
function validDevice(value: unknown): value is Device {
|
||||
return record(value)
|
||||
&& typeof value.id === 'string'
|
||||
@@ -132,7 +154,8 @@ function validDevice(value: unknown): value is Device {
|
||||
&& (value.desiredPolicy === 'vpn' || value.desiredPolicy === 'direct')
|
||||
&& (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct')
|
||||
&& (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'ambiguous')
|
||||
&& validHistory(value.trafficHistory);
|
||||
&& validHistory(value.trafficHistory)
|
||||
&& (value.outboundTrafficHistory === undefined || validOutboundHistory(value.outboundTrafficHistory));
|
||||
}
|
||||
|
||||
function validSource(value: unknown): value is SnapshotSource {
|
||||
|
||||
@@ -909,6 +909,25 @@
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
.client-device-traffic-lines .is-vpn {
|
||||
stroke: var(--harbor-connect);
|
||||
}
|
||||
|
||||
.client-device-traffic-lines .is-direct-tracked {
|
||||
stroke: var(--harbor-gateway);
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
.client-device-traffic-lines .is-direct-ipv4 {
|
||||
stroke: var(--harbor-word);
|
||||
stroke-dasharray: 2 3;
|
||||
}
|
||||
|
||||
.client-device-traffic-lines .is-unknown {
|
||||
stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
|
||||
stroke-dasharray: 1 4;
|
||||
}
|
||||
|
||||
.client-device-traffic-lines .is-point {
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
@@ -944,6 +963,22 @@
|
||||
stroke: var(--harbor-gateway);
|
||||
}
|
||||
|
||||
.client-device-traffic-cursor .is-point.is-vpn {
|
||||
stroke: var(--harbor-connect);
|
||||
}
|
||||
|
||||
.client-device-traffic-cursor .is-point.is-direct-tracked {
|
||||
stroke: var(--harbor-gateway);
|
||||
}
|
||||
|
||||
.client-device-traffic-cursor .is-point.is-direct-ipv4 {
|
||||
stroke: var(--harbor-word);
|
||||
}
|
||||
|
||||
.client-device-traffic-cursor .is-point.is-unknown {
|
||||
stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
|
||||
}
|
||||
|
||||
.client-device-traffic-time {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
@@ -956,6 +991,27 @@
|
||||
text-transform: var(--type-micro-transform);
|
||||
}
|
||||
|
||||
.client-device-traffic-legend {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.client-device-traffic-legend .is-vpn {
|
||||
color: var(--harbor-connect);
|
||||
}
|
||||
|
||||
.client-device-traffic-legend .is-direct-tracked {
|
||||
color: var(--harbor-gateway);
|
||||
}
|
||||
|
||||
.client-device-traffic-legend .is-direct-ipv4 {
|
||||
color: var(--harbor-word);
|
||||
}
|
||||
|
||||
.client-device-traffic-legend .is-unknown {
|
||||
color: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
|
||||
}
|
||||
|
||||
.client-device-traffic-point-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1002;
|
||||
@@ -1012,6 +1068,22 @@
|
||||
color: oklch(0.79 0.11 72);
|
||||
}
|
||||
|
||||
.client-device-traffic-point-tooltip .is-vpn {
|
||||
color: oklch(0.75 0.1 185);
|
||||
}
|
||||
|
||||
.client-device-traffic-point-tooltip .is-direct-tracked {
|
||||
color: oklch(0.79 0.11 72);
|
||||
}
|
||||
|
||||
.client-device-traffic-point-tooltip .is-direct-ipv4 {
|
||||
color: oklch(0.78 0.07 232);
|
||||
}
|
||||
|
||||
.client-device-traffic-point-tooltip .is-unknown {
|
||||
color: oklch(0.68 0.012 145);
|
||||
}
|
||||
|
||||
.client-device-traffic-point-tooltip .is-interval,
|
||||
.client-device-traffic-point-tooltip .is-direct {
|
||||
color: oklch(0.72 0.012 145);
|
||||
|
||||
Reference in New Issue
Block a user