Refine device traffic chart collapse animation
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-10 00:17:13 +03:00
parent 3a4173db43
commit ec68ba6a7b
7 changed files with 89 additions and 49 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.21.7',
gatewayClient: '0.22.2',
macClient: '0.21.8',
gatewayClient: '0.22.3',
gatewayBackend: '0.22.5',
});
+4 -3
View File
@@ -367,7 +367,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
const collapsing = Boolean(pinCollapses[device.id]);
const displayPinned = device.pinned || collapsing;
const expanded = device.pinned && !collapsing;
const showDetails = device.pinned || collapsing;
const hasName = Boolean(device.alias || device.hostname);
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
const editing = editingId === device.id;
@@ -409,7 +410,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
if (node) deviceNodes.current.set(device.id, node);
else deviceNodes.current.delete(device.id);
}}
className={`client-device is-${device.status}${displayPinned ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}`}
className={`client-device is-${device.status}${expanded ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}`}
key={device.id}
>
<span className="client-device-pin-wrap client-tooltip-anchor">
@@ -528,7 +529,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
scale={trafficScale}
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
pinned={displayPinned}
pinned={showDetails}
collapsing={collapsing}
onCollapseEnd={() => finishPinCollapse(device.id)}
/>
+1 -1
View File
@@ -200,7 +200,7 @@ export function TrafficChart({
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
} as CSSProperties}
onAnimationEnd={(event: AnimationEvent<HTMLSpanElement>) => {
if (collapsing && event.animationName === 'client-device-chart-collapse') onCollapseEnd?.();
if (collapsing && event.animationName === 'client-device-traffic-plot-collapse') onCollapseEnd?.();
}}
>
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
+56 -25
View File
@@ -670,13 +670,40 @@
overflow: visible;
}
.client-device.is-pinned .client-device-traffic-chart {
transform-origin: bottom center;
animation: client-device-chart-expand 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
.client-device.is-pinned .client-device-traffic-plot {
transform-origin: top center;
animation: client-device-traffic-plot-expand 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-device.is-pinned .client-device-traffic-chart.is-collapsing {
animation-name: client-device-chart-collapse;
.client-device.is-collapsing .client-device-traffic-plot {
transform-origin: top center;
animation: client-device-traffic-plot-collapse 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-device.is-pinned .client-device-traffic-axis,
.client-device.is-pinned .client-device-traffic-grid {
animation: client-device-traffic-detail-in 360ms 80ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-device.is-collapsing .client-device-traffic-axis {
position: absolute;
top: 0;
left: 0;
width: 34px;
height: 58px;
}
.client-device.is-collapsing .client-device-traffic-axis,
.client-device.is-collapsing .client-device-traffic-grid {
animation: client-device-traffic-detail-out 260ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-device.is-pinned .client-device-traffic-time {
animation: client-device-traffic-time-expand 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-device.is-collapsing .client-device-traffic-time {
animation: client-device-traffic-time-collapse 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-device-traffic-axis {
@@ -861,30 +888,34 @@
from { opacity: 0; filter: blur(3px); }
}
@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);
}
@keyframes client-device-traffic-plot-expand {
from { transform: scaleY(0.3448275862); }
to { transform: scaleY(1); }
}
@keyframes client-device-chart-collapse {
from {
opacity: 1;
clip-path: inset(0);
transform: scaleY(1);
@keyframes client-device-traffic-plot-collapse {
from { transform: scaleY(2.9); }
to { transform: scaleY(1); }
}
to {
opacity: 0.72;
clip-path: inset(calc(100% - 34px) 0 0);
transform: scaleY(0.48);
@keyframes client-device-traffic-time-expand {
from { transform: translateY(-38px); }
to { transform: translateY(0); }
}
@keyframes client-device-traffic-time-collapse {
from { transform: translateY(38px); }
to { transform: translateY(0); }
}
@keyframes client-device-traffic-detail-in {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes client-device-traffic-detail-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(5px); }
}
.client-device-policy-wrap {
+4
View File
@@ -75,6 +75,10 @@
.client-device-traffic-value > span,
.client-device-traffic-breakdown,
.client-device-traffic-breakdown > span,
.client-device-traffic-plot,
.client-device-traffic-axis,
.client-device-traffic-grid,
.client-device-traffic-time,
.client-device-traffic-lines,
.client-device-traffic-point-tooltip,
.client-device-traffic-chart,
+8 -4
View File
@@ -116,13 +116,15 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
assert.match(panel, /const \[pinCollapses, setPinCollapses\][\s\S]*animationDone[\s\S]*result: saved \? 'saved' : 'failed'/);
assert.match(panel, /const displayPinned = device\.pinned \|\| collapsing[\s\S]*collapsing=\{collapsing\}[\s\S]*onCollapseEnd=/);
assert.match(panel, /const expanded = device\.pinned && !collapsing[\s\S]*const showDetails = device\.pinned \|\| collapsing[\s\S]*pinned=\{showDetails\}[\s\S]*onCollapseEnd=/);
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]*--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 520ms/);
assert.match(styles, /\.client-device\.is-pinned \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-expand 520ms/);
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-collapse 520ms/);
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-axis \{[\s\S]*top: 0;[\s\S]*height: 58px/);
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\)/);
@@ -139,8 +141,10 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-traffic-point-tooltip \{[\s\S]*position: fixed;[\s\S]*z-index: 1002/);
assert.doesNotMatch(styles, /client-device-traffic-shift|client-device-traffic-line-draw/);
assert.match(styles, /\.client-device-traffic-lines \.is-upload \{[\s\S]*stroke-dasharray: 5 4/);
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, /@keyframes client-device-chart-collapse[\s\S]*clip-path: inset\(0\)[\s\S]*clip-path: inset\(calc\(100% - 34px\) 0 0\)[\s\S]*scaleY\(0\.48\)/);
assert.match(styles, /@keyframes client-device-traffic-plot-expand[\s\S]*scaleY\(0\.3448275862\)[\s\S]*scaleY\(1\)/);
assert.match(styles, /@keyframes client-device-traffic-plot-collapse[\s\S]*scaleY\(2\.9\)[\s\S]*scaleY\(1\)/);
assert.match(styles, /@keyframes client-device-traffic-detail-in[\s\S]*opacity: 0[\s\S]*opacity: 1/);
assert.match(styles, /@keyframes client-device-traffic-detail-out[\s\S]*opacity: 1[\s\S]*opacity: 0/);
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: 700 14px\/1\.2/);
assert.match(styles, /\.client-device-alias-input \{[\s\S]*width: var\(--alias-width\)[\s\S]*caret-color: var\(--client-accent\)[\s\S]*client-device-alias-edit-in 360ms/);
+12 -12
View File
@@ -36,24 +36,24 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 798,
cascadeEdges: 804,
customProperties: 31,
declarations: 2900,
declarations: 2914,
important: 0,
keyframes: 47,
keyframes: 51,
media: 10,
rules: 865,
rules: 878,
variableReferences: 336,
},
hashes: {
cascadeEdges: 'f177c44a1d610ba7c907ca3dc892e30e33dde390513139b5607f6a3cfe2d7825',
cascadeEdges: '18e5bdda435293145dbb52319f1e842cc8b7cc3e8beb60ec381c26d0410cfe44',
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
declarations: '9839eb3b27e0af3861942950de7fc8f01bd0ecf637a371803b616984ed3a61b7',
declarations: '01152af9f22aa7a7654356f81b8a6110488f9d70754b2517d1f6bafeff4b14fd',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47',
keyframes: 'ee0752081f8987d4b3ef09c0cdb5a34ebcc73e2a452976c509360bdaa5ce7e5c',
ruleDeclarationSequences: '56d2adf0cfd83dc14a798fc79bfed2a026ec658a0989926cb17cf0ad9794ffed',
selectors: '09042be79ac63f890fc98c78be92ca62ceca37443e4f4b808423243b06379ed8',
keyframes: '0bed7ec3cd3a86ee091cf9b07c081ab2364ac5a17e55e95bc9c4a6430419b019',
ruleDeclarationSequences: '7ffd551563a8d801bcd5e5f9886b9551940dd9561240f0f8a771a85d44fc19db',
selectors: '09962ebb3bc769f74ae785a44f8e032c0a83985ce14ea652135732b5af69042a',
variableReferences: '5988cb6c9b658d380c5b51c04621c0579e1cc5e8abcd764d78a9d42b802252c9',
witnesses: '68d7c48f20f7a8ae2c0a6ddabe743dfb84fd02fba303e3cc9d3b902efd0f8758',
},
@@ -293,8 +293,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-UzWB2BIJ.css']);
assert.deepEqual(assets, ['index-BJnEzGgg.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 107898);
assert.equal(sha256(built), 'df4965f0acfa4cf95e8ffa29c4942ce795d4cb903ad6914d54e61077b6ffd6f9');
assert.equal(built.byteLength, 109140);
assert.equal(sha256(built), '9e1f7811080177f366b693a77e92c615aff1a74537b34d597100c9707a8aea95');
});