Add device traffic history chart and total breakdown
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.13.5',
|
||||
gatewayClient: '0.14.5',
|
||||
macClient: '0.13.6',
|
||||
gatewayClient: '0.14.6',
|
||||
gatewayBackend: '0.14.0',
|
||||
});
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
formatLastSeen,
|
||||
positiveByteDelta,
|
||||
sortDevicesByTraffic,
|
||||
trafficSampleMetrics,
|
||||
} from '../utils/format.js';
|
||||
|
||||
const AUTO_REFRESH_MS = 15_000;
|
||||
const DEVICE_MOVE_MS = 520;
|
||||
const COPY_FEEDBACK_MS = 5_000;
|
||||
const TRAFFIC_DELTA_MS = 2_200;
|
||||
const TRAFFIC_HISTORY_LIMIT = 18;
|
||||
|
||||
function Tooltip({ children }) {
|
||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||
@@ -34,6 +36,27 @@ function TrafficValue({ value, delta }) {
|
||||
</strong>;
|
||||
}
|
||||
|
||||
function TrafficChart({ samples }) {
|
||||
const max = samples.reduce((largest, sample) => {
|
||||
const total = byteString(sample.gateway) + byteString(sample.proxy);
|
||||
return total > largest ? total : largest;
|
||||
}, 0n);
|
||||
const latest = samples.at(-1)?.id || 'empty';
|
||||
return <span className="client-device-traffic-chart" aria-hidden="true">
|
||||
<span className="client-device-traffic-track" key={latest}>
|
||||
{samples.map((sample) => {
|
||||
const gateway = byteString(sample.gateway);
|
||||
const proxy = byteString(sample.proxy);
|
||||
const { height, gatewayShare } = trafficSampleMetrics(gateway, proxy, max);
|
||||
return <i className="client-device-traffic-bar" key={sample.id} style={{ height: `${height}%` }}>
|
||||
<span className="is-gateway" style={{ height: `${gatewayShare}%` }} />
|
||||
<span className="is-proxy" style={{ height: `${100 - gatewayShare}%` }} />
|
||||
</i>;
|
||||
})}
|
||||
</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
const [snapshot, setSnapshot] = useState(null);
|
||||
const [status, setStatus] = useState('idle');
|
||||
@@ -46,11 +69,13 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
const [sortDirection, setSortDirection] = useState('desc');
|
||||
const [copyFeedback, setCopyFeedback] = useState(null);
|
||||
const [trafficDeltas, setTrafficDeltas] = useState({});
|
||||
const [trafficHistory, setTrafficHistory] = useState({});
|
||||
const deviceNodes = useRef(new Map());
|
||||
const previousPositions = useRef(new Map());
|
||||
const previousTraffic = useRef(new Map());
|
||||
const copyTimer = useRef(null);
|
||||
const trafficDeltaTimer = useRef(null);
|
||||
const trafficSequence = useRef(0);
|
||||
const devices = useMemo(
|
||||
() => sortDevicesByTraffic(snapshot?.devices, sortDirection),
|
||||
[snapshot?.devices, sortDirection],
|
||||
@@ -100,6 +125,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
|
||||
const next = new Map();
|
||||
const deltas = {};
|
||||
const historyChanges = {};
|
||||
for (const device of snapshot?.devices || []) {
|
||||
const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
|
||||
const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
|
||||
@@ -108,11 +134,25 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
if (!previous) continue;
|
||||
const gatewayDelta = positiveByteDelta(previous.gateway, gateway);
|
||||
const proxyDelta = positiveByteDelta(previous.proxy, proxy);
|
||||
if (gatewayDelta || proxyDelta) deltas[device.id] = { gateway: gatewayDelta, proxy: proxyDelta };
|
||||
if (!gatewayDelta && !proxyDelta) continue;
|
||||
const totalDelta = positiveByteDelta(previous.gateway + previous.proxy, gateway + proxy);
|
||||
deltas[device.id] = { gateway: gatewayDelta, proxy: proxyDelta, total: totalDelta };
|
||||
historyChanges[device.id] = {
|
||||
id: ++trafficSequence.current,
|
||||
gateway: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0',
|
||||
proxy: proxy > previous.proxy ? (proxy - previous.proxy).toString() : '0',
|
||||
};
|
||||
}
|
||||
previousTraffic.current = next;
|
||||
if (!Object.keys(deltas).length) return;
|
||||
setTrafficDeltas(deltas);
|
||||
setTrafficHistory((current) => {
|
||||
const updated = { ...current };
|
||||
for (const [id, change] of Object.entries(historyChanges)) {
|
||||
updated[id] = [...(current[id] || []), change].slice(-TRAFFIC_HISTORY_LIMIT);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
clearTimeout(trafficDeltaTimer.current);
|
||||
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
|
||||
}, [snapshot?.devices, open]);
|
||||
@@ -427,11 +467,13 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="client-device-traffic" role="group" aria-label={`Gateway ${gatewayTraffic}, Прокси ${proxyTraffic}`}>
|
||||
<span className="client-device-traffic-sources" aria-hidden="true">
|
||||
<span className="client-device-traffic" role="group" tabIndex="0" aria-label={`Всего ${totalTraffic}. Gateway ${gatewayTraffic}, Прокси ${proxyTraffic}`}>
|
||||
<span className="client-device-traffic-total" aria-hidden="true">
|
||||
<b>Всего</b><TrafficValue value={totalTraffic} delta={trafficDelta.total} />
|
||||
</span>
|
||||
<span className="client-device-traffic-breakdown" aria-hidden="true">
|
||||
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
|
||||
<span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>
|
||||
<span className="is-total"><b>Всего</b><strong>{totalTraffic}</strong></span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -454,6 +496,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
</button>
|
||||
<Tooltip>{policyTooltip}</Tooltip>
|
||||
</span>
|
||||
<TrafficChart samples={trafficHistory[device.id] || []} />
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
+114
-20
@@ -902,13 +902,17 @@ p {
|
||||
.client-device {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 112px 34px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
grid-template-rows: 34px 28px;
|
||||
align-items: start;
|
||||
column-gap: 8px;
|
||||
row-gap: 6px;
|
||||
padding: 10px 8px;
|
||||
border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
|
||||
}
|
||||
|
||||
.client-device-main {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
height: 34px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -1035,6 +1039,8 @@ p {
|
||||
}
|
||||
|
||||
.client-device-pin-wrap {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
@@ -1178,33 +1184,64 @@ p {
|
||||
}
|
||||
|
||||
.client-device-traffic {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
width: 112px;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
color: var(--client-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.client-device-traffic-sources {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.client-device-traffic-sources > span {
|
||||
.client-device-traffic-total,
|
||||
.client-device-traffic-breakdown > span {
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-device-traffic-sources b {
|
||||
.client-device-traffic-total {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.client-device-traffic-breakdown {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 0;
|
||||
width: 112px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 7px 0 6px;
|
||||
background: color-mix(in oklch, var(--client-bg) 98%, var(--client-panel));
|
||||
box-shadow: 0 10px 22px oklch(0.08 0.015 145 / 0.12);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
filter: blur(5px);
|
||||
pointer-events: none;
|
||||
transform: translateY(-7px);
|
||||
transform-origin: top center;
|
||||
transition: opacity 220ms ease, filter 300ms ease, transform 360ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 360ms;
|
||||
}
|
||||
|
||||
.client-device-traffic:hover .client-device-traffic-breakdown,
|
||||
.client-device-traffic:focus .client-device-traffic-breakdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.client-device-traffic b {
|
||||
color: var(--client-muted);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.client-device-traffic-sources strong {
|
||||
.client-device-traffic strong {
|
||||
overflow: hidden;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
@@ -1248,24 +1285,79 @@ p {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-device-traffic-sources .is-proxy {
|
||||
.client-device-traffic-breakdown .is-proxy {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-device-traffic-sources .is-proxy b {
|
||||
.client-device-traffic-breakdown .is-proxy b {
|
||||
color: color-mix(in oklch, var(--client-accent) 72%, var(--client-muted));
|
||||
}
|
||||
|
||||
.client-device-traffic-sources > .is-total {
|
||||
margin-top: 2px;
|
||||
color: var(--client-text);
|
||||
.client-device-traffic:focus-visible {
|
||||
border-radius: 3px;
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.client-device-traffic-sources > .is-total b {
|
||||
color: var(--client-muted);
|
||||
.client-device-traffic-chart {
|
||||
grid-column: 2 / 4;
|
||||
grid-row: 2;
|
||||
height: 28px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.client-device-traffic-chart::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
background: color-mix(in oklch, var(--client-border) 58%, transparent);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.client-device-traffic-track {
|
||||
position: absolute;
|
||||
inset: 0 0 1px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
gap: 3px;
|
||||
animation: client-device-traffic-shift 420ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-device-traffic-bar {
|
||||
width: 5px;
|
||||
min-height: 2px;
|
||||
flex: 0 0 5px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border-radius: 2px 2px 0 0;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.client-device-traffic-bar > span {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.client-device-traffic-bar > .is-gateway {
|
||||
background: color-mix(in oklch, var(--client-text) 58%, var(--client-muted));
|
||||
}
|
||||
|
||||
.client-device-traffic-bar > .is-proxy {
|
||||
background: var(--client-accent);
|
||||
}
|
||||
|
||||
@keyframes client-device-traffic-shift {
|
||||
from { opacity: 0.52; transform: translateX(8px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.client-device-policy-wrap {
|
||||
grid-column: 4;
|
||||
grid-row: 1;
|
||||
width: 34px;
|
||||
position: relative;
|
||||
}
|
||||
@@ -4324,6 +4416,8 @@ p {
|
||||
.client-device-policy svg,
|
||||
.client-device-name > span,
|
||||
.client-device-traffic-value > span,
|
||||
.client-device-traffic-breakdown,
|
||||
.client-device-traffic-track,
|
||||
.client-device-edit,
|
||||
.client-device-edit svg,
|
||||
.client-device-edit-wrap,
|
||||
|
||||
@@ -37,6 +37,17 @@ export function positiveByteDelta(previous, current) {
|
||||
return after > before ? formatByteString((after - before).toString()) : '';
|
||||
}
|
||||
|
||||
export function trafficSampleMetrics(gatewayValue, proxyValue, maxValue) {
|
||||
const gateway = byteString(gatewayValue);
|
||||
const proxy = byteString(proxyValue);
|
||||
const total = gateway + proxy;
|
||||
const max = byteString(maxValue);
|
||||
return {
|
||||
height: max && total ? Math.max(10, Math.min(100, Number(total * 100n / max))) : 0,
|
||||
gatewayShare: total ? Number(gateway * 100n / total) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function sortDevicesByTraffic(devices, direction = 'desc') {
|
||||
const factor = direction === 'asc' ? 1 : -1;
|
||||
return (Array.isArray(devices) ? devices : [])
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
formatLastSeen,
|
||||
positiveByteDelta,
|
||||
sortDevicesByTraffic,
|
||||
trafficSampleMetrics,
|
||||
} from '../../src/web/utils/format.js';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
@@ -48,9 +49,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/);
|
||||
assert.match(panel, /Трафик временно не обновляется/);
|
||||
assert.match(panel, /Учитывается только трафик, который прошёл через Harbor/);
|
||||
assert.match(panel, /<b>Gateway<\/b><TrafficValue value=\{gatewayTraffic\} delta=\{trafficDelta\.gateway\}/);
|
||||
assert.match(panel, /className="is-proxy"><b>Прокси<\/b><TrafficValue value=\{proxyTraffic\} delta=\{trafficDelta\.proxy\}/);
|
||||
assert.match(panel, /className="is-total"><b>Всего<\/b><strong>\{totalTraffic\}<\/strong>/);
|
||||
assert.match(panel, /client-device-traffic-total[\s\S]*<b>Всего<\/b><TrafficValue value=\{totalTraffic\} delta=\{trafficDelta\.total\}/);
|
||||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*<b>Gateway<\/b><TrafficValue value=\{gatewayTraffic\} delta=\{trafficDelta\.gateway\}/);
|
||||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*className="is-proxy"><b>Прокси<\/b><TrafficValue value=\{proxyTraffic\} delta=\{trafficDelta\.proxy\}/);
|
||||
assert.match(panel, /TRAFFIC_HISTORY_LIMIT = 18[\s\S]*slice\(-TRAFFIC_HISTORY_LIMIT\)/);
|
||||
assert.match(panel, /TrafficChart samples=\{trafficHistory\[device\.id\] \|\| \[\]\}/);
|
||||
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
|
||||
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
|
||||
assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|<Tooltip>\{trafficLabel\}<\/Tooltip>/);
|
||||
@@ -68,14 +71,17 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
|
||||
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
||||
assert.match(styles, /\.client-devices \{\s*width: min\(580px, 100vw\)/);
|
||||
assert.match(styles, /\.client-device \{[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*padding: 10px 8px/);
|
||||
assert.match(styles, /\.client-device \{[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*grid-template-rows: 34px 28px;[\s\S]*padding: 10px 8px/);
|
||||
assert.match(styles, /\.client-device-main \{[\s\S]*display: flex;[\s\S]*align-items: center/);
|
||||
assert.match(styles, /\.client-device-traffic-sources \{[\s\S]*display: grid/);
|
||||
assert.match(styles, /\.client-device-traffic-sources > span \{[\s\S]*grid-template-columns: 46px minmax\(0, 1fr\)/);
|
||||
assert.match(styles, /\.client-device-traffic-total,[\s\S]*\.client-device-traffic-breakdown > span \{[\s\S]*grid-template-columns: 46px minmax\(0, 1fr\)/);
|
||||
assert.match(styles, /\.client-device-traffic:hover \.client-device-traffic-breakdown,[\s\S]*opacity: 1[\s\S]*translateY\(0\)/);
|
||||
assert.match(styles, /\.client-device-traffic-chart \{[\s\S]*grid-column: 2 \/ 4;[\s\S]*grid-row: 2/);
|
||||
assert.match(styles, /\.client-device-traffic-track \{[\s\S]*client-device-traffic-shift 420ms/);
|
||||
assert.match(styles, /@keyframes client-device-traffic-shift[\s\S]*translateX\(8px\)[\s\S]*translateX\(0\)/);
|
||||
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
|
||||
assert.match(styles, /\.client-device-name \{[\s\S]*font: 700 15px\/1\.2/);
|
||||
assert.match(styles, /\.client-device-last-seen \{[\s\S]*font-size: 10px/);
|
||||
assert.match(styles, /\.client-device-traffic-sources strong \{[\s\S]*font-size: 10px/);
|
||||
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font-size: 10px/);
|
||||
assert.match(styles, /\.client-device-name > span \{[^}]*opacity: 0/);
|
||||
assert.match(styles, /\.client-device-name > \.client-device-name-primary \{[^}]*opacity: 1/);
|
||||
assert.match(styles, /\.client-device-name > span \{[^}]*transition: opacity 360ms[^}]*transform 480ms/);
|
||||
@@ -95,7 +101,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(styles, /\.client-device-edit:hover svg[\s\S]*translate\(1px, -1px\) rotate\(-4deg\)/);
|
||||
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
|
||||
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-name > span[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-text-morph-value/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-name > span[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-track[\s\S]*\.client-text-morph-value/);
|
||||
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
|
||||
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
||||
});
|
||||
@@ -120,6 +126,9 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni
|
||||
assert.equal(positiveByteDelta('1048576', '3145728'), '2,0 МБ');
|
||||
assert.equal(positiveByteDelta('3145728', '3145728'), '');
|
||||
assert.equal(positiveByteDelta('3145728', '1048576'), '');
|
||||
assert.deepEqual(trafficSampleMetrics('75', '25', '200'), { height: 50, gatewayShare: 75 });
|
||||
assert.deepEqual(trafficSampleMetrics('1', '0', '1000'), { height: 10, gatewayShare: 100 });
|
||||
assert.deepEqual(trafficSampleMetrics('0', '0', '0'), { height: 0, gatewayShare: 0 });
|
||||
|
||||
const devices = [
|
||||
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },
|
||||
|
||||
Reference in New Issue
Block a user