Polish device traffic charts and bump Harbor client versions
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-07 20:19:27 +03:00
parent ffd899033e
commit e6666558b7
5 changed files with 129 additions and 15 deletions
+2 -2
View File
@@ -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',
});
+20 -5
View File
@@ -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
View File
@@ -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,
+7
View File
@@ -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 : [])