Polish device traffic charts and bump Harbor client versions
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.14.2',
|
||||
gatewayClient: '0.15.2',
|
||||
macClient: '0.14.3',
|
||||
gatewayClient: '0.15.3',
|
||||
gatewayBackend: '0.15.0',
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formatLastSeen,
|
||||
positiveByteDelta,
|
||||
sortDevicesByTraffic,
|
||||
trafficAxisMid,
|
||||
trafficSampleMetrics,
|
||||
} from '../utils/format.js';
|
||||
|
||||
@@ -46,8 +47,9 @@ function TrafficChart({ samples, scale, capacity, routeLabel }) {
|
||||
const total = byteString(sample.gatewayBytes) + byteString(sample.proxyBytes);
|
||||
return total > largest ? total : largest;
|
||||
}, 0n);
|
||||
const mid = trafficAxisMid(max, scale);
|
||||
const latest = samples.at(-1)?.observedAt || 'empty';
|
||||
return <span className="client-device-traffic-chart" role="img" aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}`}>
|
||||
return <span className="client-device-traffic-chart" role="img" aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}>
|
||||
<span className="client-device-traffic-track" key={latest} style={{ '--sample-count': Math.max(1, capacity) }}>
|
||||
{samples.map((sample, index) => {
|
||||
const gateway = byteString(sample.gatewayBytes);
|
||||
@@ -57,8 +59,9 @@ function TrafficChart({ samples, scale, capacity, routeLabel }) {
|
||||
const slot = capacity - samples.length + index;
|
||||
const edge = slot < 28 ? ' is-edge-left' : slot >= capacity - 28 ? ' is-edge-right' : '';
|
||||
const { height, gatewayShare } = trafficSampleMetrics(gateway, proxy, max, scale);
|
||||
const newest = index === samples.length - 1;
|
||||
return <i
|
||||
className={`client-device-traffic-bar${edge}`}
|
||||
className={`client-device-traffic-bar${edge}${newest ? ' is-new' : ''}`}
|
||||
key={`${sample.observedAt}-${index}`}
|
||||
style={{ height: `${height}%`, gridColumnStart: slot + 1 }}
|
||||
>
|
||||
@@ -75,6 +78,11 @@ function TrafficChart({ samples, scale, capacity, routeLabel }) {
|
||||
</i>;
|
||||
})}
|
||||
</span>
|
||||
{max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
|
||||
<span className="is-max">{formatByteString(max)}</span>
|
||||
<span className="is-mid">{formatByteString(mid)}</span>
|
||||
<span className="is-zero">0</span>
|
||||
</span>}
|
||||
{samples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
|
||||
<time dateTime={samples[0].observedAt}>{chartTime(samples[0].observedAt)}</time>
|
||||
<span>15 с</span>
|
||||
@@ -98,6 +106,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
const [trafficDeltas, setTrafficDeltas] = useState({});
|
||||
const deviceNodes = useRef(new Map());
|
||||
const previousPositions = useRef(new Map());
|
||||
const movementAnimations = useRef(new Map());
|
||||
const previousTraffic = useRef(new Map());
|
||||
const copyTimer = useRef(null);
|
||||
const trafficDeltaTimer = useRef(null);
|
||||
@@ -172,11 +181,13 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
previousPositions.current.clear();
|
||||
for (const animation of movementAnimations.current.values()) animation.cancel();
|
||||
movementAnimations.current.clear();
|
||||
return;
|
||||
}
|
||||
const positions = new Map();
|
||||
for (const [id, node] of deviceNodes.current) {
|
||||
node.getAnimations().forEach((animation) => animation.cancel());
|
||||
movementAnimations.current.get(id)?.cancel();
|
||||
positions.set(id, node.getBoundingClientRect());
|
||||
}
|
||||
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
@@ -184,10 +195,14 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
const before = previousPositions.current.get(id);
|
||||
const deltaY = before ? before.top - after.top : 0;
|
||||
if (Math.abs(deltaY) < 1) continue;
|
||||
deviceNodes.current.get(id)?.animate([
|
||||
const animation = deviceNodes.current.get(id)?.animate([
|
||||
{ transform: `translateY(${deltaY}px)` },
|
||||
{ transform: 'translateY(0)' },
|
||||
], { duration: DEVICE_MOVE_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
|
||||
if (animation) {
|
||||
movementAnimations.current.set(id, animation);
|
||||
animation.onfinish = () => movementAnimations.current.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
previousPositions.current = positions;
|
||||
@@ -408,7 +423,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
if (node) deviceNodes.current.set(device.id, node);
|
||||
else deviceNodes.current.delete(device.id);
|
||||
}}
|
||||
className={`client-device is-${device.status}`}
|
||||
className={`client-device is-${device.status}${device.pinned ? ' is-pinned' : ''}`}
|
||||
key={device.id}
|
||||
>
|
||||
<span className="client-device-pin-wrap client-tooltip-anchor">
|
||||
|
||||
+88
-5
@@ -922,9 +922,10 @@ p {
|
||||
}
|
||||
|
||||
.client-device {
|
||||
--client-device-chart-height: 34px;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 112px 34px;
|
||||
grid-template-rows: 34px 42px;
|
||||
grid-template-rows: 34px var(--client-device-chart-height);
|
||||
align-items: start;
|
||||
column-gap: 8px;
|
||||
row-gap: 6px;
|
||||
@@ -932,6 +933,10 @@ p {
|
||||
border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
|
||||
}
|
||||
|
||||
.client-device.is-pinned {
|
||||
--client-device-chart-height: 72px;
|
||||
}
|
||||
|
||||
.client-device-main {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
@@ -1256,11 +1261,13 @@ p {
|
||||
}
|
||||
|
||||
.client-device-traffic-breakdown > span {
|
||||
color: var(--client-accent);
|
||||
opacity: 0;
|
||||
filter: blur(5px);
|
||||
transform: translateY(-12px);
|
||||
transform-origin: top right;
|
||||
transition: opacity 180ms ease, filter 300ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
text-shadow: 0 0 3px var(--client-bg), 0 0 8px var(--client-bg);
|
||||
}
|
||||
|
||||
.client-device-traffic-breakdown > span:nth-child(2) {
|
||||
@@ -1332,11 +1339,16 @@ p {
|
||||
}
|
||||
|
||||
.client-device-traffic-breakdown .is-proxy {
|
||||
color: color-mix(in oklch, var(--client-accent) 76%, var(--client-text));
|
||||
}
|
||||
|
||||
.client-device-traffic-breakdown b {
|
||||
color: var(--client-accent);
|
||||
text-shadow: 0 0 3px var(--client-bg), 0 0 8px var(--client-bg);
|
||||
}
|
||||
|
||||
.client-device-traffic-breakdown .is-proxy b {
|
||||
color: color-mix(in oklch, var(--client-accent) 72%, var(--client-muted));
|
||||
color: color-mix(in oklch, var(--client-accent) 76%, var(--client-text));
|
||||
}
|
||||
|
||||
.client-device-traffic:focus-visible {
|
||||
@@ -1348,7 +1360,7 @@ p {
|
||||
.client-device-traffic-chart {
|
||||
grid-column: 2 / 4;
|
||||
grid-row: 2;
|
||||
height: 42px;
|
||||
height: var(--client-device-chart-height);
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
overflow: visible;
|
||||
@@ -1358,6 +1370,11 @@ p {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.client-device.is-pinned .client-device-traffic-chart {
|
||||
transform-origin: bottom center;
|
||||
animation: client-device-chart-expand 620ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-device-traffic-chart::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@@ -1375,7 +1392,7 @@ p {
|
||||
grid-template-columns: repeat(var(--sample-count), minmax(1px, 1fr));
|
||||
align-items: flex-end;
|
||||
gap: 0;
|
||||
animation: client-device-traffic-shift 420ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation: client-device-traffic-shift 560ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-device-traffic-bar {
|
||||
@@ -1411,6 +1428,52 @@ p {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.client-device-traffic-bar.is-new .client-device-traffic-bar-fill {
|
||||
transform-origin: bottom center;
|
||||
animation: client-device-traffic-grow 620ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-device-traffic-axis {
|
||||
position: absolute;
|
||||
inset: 0 auto 14px 2px;
|
||||
z-index: 3;
|
||||
width: 1px;
|
||||
color: color-mix(in oklch, var(--client-text) 62%, var(--client-muted));
|
||||
font: 700 7.5px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 0 3px var(--client-bg), 0 0 7px var(--client-bg);
|
||||
}
|
||||
|
||||
.client-device-traffic-axis > span {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-device-traffic-axis > span::before {
|
||||
width: 5px;
|
||||
height: 1px;
|
||||
flex: 0 0 5px;
|
||||
background: currentColor;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.client-device-traffic-axis > .is-max {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.client-device-traffic-axis > .is-mid {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.client-device-traffic-axis > .is-zero {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.client-device-traffic-bar-fill > span {
|
||||
width: 100%;
|
||||
display: block;
|
||||
@@ -1489,10 +1552,28 @@ p {
|
||||
}
|
||||
|
||||
@keyframes client-device-traffic-shift {
|
||||
from { opacity: 0.52; transform: translateX(8px); }
|
||||
from { opacity: 0.72; transform: translateX(calc(100% / var(--sample-count))); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes client-device-traffic-grow {
|
||||
from { opacity: 0; transform: scaleY(0); }
|
||||
to { opacity: 1; transform: scaleY(1); }
|
||||
}
|
||||
|
||||
@keyframes client-device-chart-expand {
|
||||
from {
|
||||
opacity: 0.72;
|
||||
clip-path: inset(calc(100% - 34px) 0 0);
|
||||
transform: scaleY(0.48);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
clip-path: inset(0);
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
.client-device-policy-wrap {
|
||||
grid-column: 4;
|
||||
grid-row: 1;
|
||||
@@ -4557,6 +4638,8 @@ p {
|
||||
.client-device-traffic-breakdown,
|
||||
.client-device-traffic-breakdown > span,
|
||||
.client-device-traffic-bar-tooltip,
|
||||
.client-device-traffic-bar-fill,
|
||||
.client-device-traffic-chart,
|
||||
.client-device-traffic-track,
|
||||
.client-device-edit,
|
||||
.client-device-edit svg,
|
||||
|
||||
@@ -51,6 +51,13 @@ export function trafficSampleMetrics(gatewayValue, proxyValue, maxValue, scale =
|
||||
};
|
||||
}
|
||||
|
||||
export function trafficAxisMid(maxValue, scale = 'linear') {
|
||||
const max = byteString(maxValue);
|
||||
if (!max) return 0n;
|
||||
if (scale !== 'log') return max / 2n;
|
||||
return BigInt(Math.max(1, Math.round(Math.expm1(Math.log1p(Number(max)) / 2))));
|
||||
}
|
||||
|
||||
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,
|
||||
trafficAxisMid,
|
||||
trafficSampleMetrics,
|
||||
} from '../../src/web/utils/format.js';
|
||||
|
||||
@@ -25,6 +26,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
||||
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*api\.devices\.list\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*api\.devices\.update\(device\.id, patch, latest\.revision\)/);
|
||||
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
||||
assert.match(panel, /movementAnimations\.current\.get\(id\)\?\.cancel\(\)/);
|
||||
assert.match(panel, /prefers-reduced-motion: reduce/);
|
||||
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
||||
assert.match(api, /setPolicy: \(id, mode, expectedRevision\) => request\(`\/api\/devices\/\$\{id\}\/policy`/);
|
||||
@@ -57,6 +59,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
|
||||
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
|
||||
assert.match(panel, /client-device-traffic-bar-tooltip[\s\S]*<time dateTime=\{sample\.observedAt\}>\{time\}<\/time>[\s\S]*<strong>Всего \{formatByteString\(total\)\}<\/strong>/);
|
||||
assert.match(panel, /trafficAxisMid\(max, scale\)[\s\S]*client-device-traffic-axis[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
|
||||
assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/);
|
||||
assert.match(panel, /\{proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(proxy\)\}<\/span>\}/);
|
||||
assert.match(panel, /<time dateTime=\{samples\[0\]\.observedAt\}>[\s\S]*<span>15 с<\/span>/);
|
||||
@@ -77,18 +80,22 @@ 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]*grid-template-rows: 34px 42px;[\s\S]*padding: 10px 8px/);
|
||||
assert.match(styles, /\.client-device \{[\s\S]*--client-device-chart-height: 34px;[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*grid-template-rows: 34px var\(--client-device-chart-height\);[\s\S]*padding: 10px 8px/);
|
||||
assert.match(styles, /\.client-device\.is-pinned \{[\s\S]*--client-device-chart-height: 72px/);
|
||||
assert.match(styles, /\.client-device\.is-pinned \.client-device-traffic-chart \{[\s\S]*client-device-chart-expand 620ms/);
|
||||
assert.match(styles, /\.client-device-main \{[\s\S]*display: flex;[\s\S]*align-items: center/);
|
||||
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-breakdown > span \{[\s\S]*translateY\(-12px\)[\s\S]*\.client-device-traffic:hover \.client-device-traffic-breakdown > span,[\s\S]*opacity: 1[\s\S]*translateY\(0\)/);
|
||||
assert.doesNotMatch(styles, /\.client-device-traffic-breakdown \{[^}]*background:|\.client-device-traffic-breakdown \{[^}]*box-shadow:/);
|
||||
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, /\.client-device-traffic-track \{[\s\S]*client-device-traffic-shift 560ms/);
|
||||
assert.match(styles, /\.client-device-traffic-track \{[^}]*gap: 0/);
|
||||
assert.match(styles, /\.client-device-traffic-bar-fill \{[\s\S]*inset: 0 -2px/);
|
||||
assert.match(styles, /\.client-device-traffic-bar:hover \.client-device-traffic-bar-tooltip \{[\s\S]*opacity: 1[\s\S]*visibility: visible/);
|
||||
assert.match(styles, /\.client-device-traffic-bar\.is-edge-left[\s\S]*\.client-device-traffic-bar\.is-edge-right/);
|
||||
assert.match(styles, /@keyframes client-device-traffic-shift[\s\S]*translateX\(8px\)[\s\S]*translateX\(0\)/);
|
||||
assert.match(styles, /@keyframes client-device-traffic-shift[\s\S]*translateX\(calc\(100% \/ var\(--sample-count\)\)\)[\s\S]*translateX\(0\)/);
|
||||
assert.match(styles, /@keyframes client-device-traffic-grow[\s\S]*scaleY\(0\)[\s\S]*scaleY\(1\)/);
|
||||
assert.match(styles, /@keyframes client-device-chart-expand[\s\S]*clip-path: inset\(calc\(100% - 34px\) 0 0\)[\s\S]*scaleY\(1\)/);
|
||||
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 14px\/1\.2/);
|
||||
assert.match(styles, /\.client-device-name-ip \{[\s\S]*font-size: 12px/);
|
||||
@@ -142,6 +149,8 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni
|
||||
assert.deepEqual(trafficSampleMetrics('1', '0', '1000'), { height: 10, gatewayShare: 100 });
|
||||
assert.deepEqual(trafficSampleMetrics('0', '0', '0'), { height: 0, gatewayShare: 0 });
|
||||
assert.deepEqual(trafficSampleMetrics('10', '0', '100', 'log'), { height: 52, gatewayShare: 100 });
|
||||
assert.equal(trafficAxisMid('100', 'linear'), 50n);
|
||||
assert.equal(trafficAxisMid('99', 'log'), 9n);
|
||||
|
||||
const devices = [
|
||||
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },
|
||||
|
||||
Reference in New Issue
Block a user