Polish diagnostics animations and device alias sizing
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-07 22:19:06 +03:00
parent e9433e754f
commit 2151ff51f0
6 changed files with 106 additions and 29 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.17.6', macClient: '0.17.8',
gatewayClient: '0.18.6', gatewayClient: '0.18.8',
gatewayBackend: '0.18.1', gatewayBackend: '0.18.1',
}); });
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { api } from '../api.js'; import { api } from '../api.js';
import { import {
assessConnectivity, assessConnectivity,
@@ -38,7 +38,7 @@ function readCustomServices() {
function resultStatus(site, pending, available = true) { function resultStatus(site, pending, available = true) {
if (!available) return ['is-muted', '—']; if (!available) return ['is-muted', '—'];
if (pending) return ['is-running', 'Проверяем']; if (pending) return ['is-running', 'Тестируем'];
if (!site) return ['is-muted', '—']; if (!site) return ['is-muted', '—'];
if (site.status === 'unavailable') return ['is-error', 'Нет доступа']; if (site.status === 'unavailable') return ['is-error', 'Нет доступа'];
if (site.status === 'responded') return ['is-warning', `HTTP ${site.httpStatus}`]; if (site.status === 'responded') return ['is-warning', `HTTP ${site.httpStatus}`];
@@ -47,7 +47,10 @@ function resultStatus(site, pending, available = true) {
function Status({ value, route }) { function Status({ value, route }) {
const [className, label] = value; const [className, label] = value;
return <span className={`client-diagnostics-status ${className}`} aria-label={`${route}: ${label}`}>{label}</span>; return <span className={`client-diagnostics-status ${className}`} aria-label={`${route}: ${label}`}>
{label}
{className === 'is-running' && <span className="client-diagnostics-dots" aria-hidden="true">...</span>}
</span>;
} }
function ipResult(path, source) { function ipResult(path, source) {
@@ -60,7 +63,7 @@ function ipResult(path, source) {
function IpCell({ path, source, pending, route }) { function IpCell({ path, source, pending, route }) {
const value = ipResult(path, source); const value = ipResult(path, source);
if (path?.available === false) return <Status value={['is-muted', '—']} route={route} />; if (path?.available === false) return <Status value={['is-muted', '—']} route={route} />;
if (pending) return <Status value={['is-running', 'Проверяем']} route={route} />; if (pending) return <Status value={['is-running', 'Тестируем']} route={route} />;
if (!path?.available) return <Status value={['is-muted', '—']} route={route} />; if (!path?.available) return <Status value={['is-muted', '—']} route={route} />;
if (!value?.address) return <Status value={['is-error', 'Нет ответа']} route={route} />; if (!value?.address) return <Status value={['is-error', 'Нет ответа']} route={route} />;
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>; return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
@@ -111,6 +114,9 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
const [serviceName, setServiceName] = useState(''); const [serviceName, setServiceName] = useState('');
const [serviceUrl, setServiceUrl] = useState(''); const [serviceUrl, setServiceUrl] = useState('');
const [formError, setFormError] = useState(''); const [formError, setFormError] = useState('');
const sheetRef = useRef(null);
const runnerRef = useRef(null);
const previousTargetRef = useRef(null);
useEffect(() => { useEffect(() => {
try { try {
@@ -120,6 +126,30 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
} }
}, [customServices]); }, [customServices]);
useLayoutEffect(() => {
const sheet = sheetRef.current;
const runner = runnerRef.current;
if (!sheet || !runner) return;
if (!activeTarget) {
runner.classList.remove('is-visible', 'is-moving');
previousTargetRef.current = null;
return;
}
const row = [...sheet.querySelectorAll('[data-diagnostic-target]')]
.find((item) => item.dataset.diagnosticTarget === activeTarget);
if (!row) return;
const rowRect = row.getBoundingClientRect();
const sheetRect = sheet.getBoundingClientRect();
runner.style.setProperty('--diagnostics-runner-x', `${rowRect.left - sheetRect.left}px`);
runner.style.setProperty('--diagnostics-runner-y', `${rowRect.top - sheetRect.top}px`);
runner.style.width = `${rowRect.width}px`;
runner.style.height = `${rowRect.height}px`;
runner.classList.toggle('is-moving', previousTargetRef.current !== null);
runner.classList.add('is-visible');
previousTargetRef.current = activeTarget;
}, [activeTarget]);
async function run() { async function run() {
setStatus('running'); setStatus('running');
setError(null); setError(null);
@@ -182,7 +212,8 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
aria-hidden={!open} aria-hidden={!open}
inert={!open ? true : undefined} inert={!open ? true : undefined}
> >
<div className="client-drawer-sheet client-instructions-sheet client-diagnostics-sheet"> <div ref={sheetRef} className="client-drawer-sheet client-instructions-sheet client-diagnostics-sheet">
<span ref={runnerRef} className="client-diagnostics-active-marker" aria-hidden="true" />
<button <button
ref={closeRef} ref={closeRef}
className="client-drawer-close" className="client-drawer-close"
@@ -235,7 +266,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
<tbody>{CONNECTIVITY_IP_SOURCES.map((source) => { <tbody>{CONNECTIVITY_IP_SOURCES.map((source) => {
const target = `ip:${source.id}`; const target = `ip:${source.id}`;
const running = activeTarget === target; const running = activeTarget === target;
return <tr key={source.id} className={running ? 'is-running' : undefined}> return <tr key={source.id} data-diagnostic-target={target} className={running ? 'is-running' : undefined}>
<th scope="row">{source.label}</th> <th scope="row">{source.label}</th>
<td><IpCell path={result?.direct} source={source} pending={running} route={`Напрямую, ${source.label}`} /></td> <td><IpCell path={result?.direct} source={source} pending={running} route={`Напрямую, ${source.label}`} /></td>
<td><IpCell path={result?.vpn} source={source} pending={running} route={`VPN, ${source.label}`} /></td> <td><IpCell path={result?.vpn} source={source} pending={running} route={`VPN, ${source.label}`} /></td>
@@ -293,7 +324,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
const vpn = result?.vpn?.sites?.find((item) => item.id === site.id); const vpn = result?.vpn?.sites?.find((item) => item.id === site.id);
const custom = site.id.startsWith('custom-'); const custom = site.id.startsWith('custom-');
const running = activeTarget === `site:${site.id}`; const running = activeTarget === `site:${site.id}`;
return <tr key={site.id} className={running ? 'is-running' : undefined}> return <tr key={site.id} data-diagnostic-target={`site:${site.id}`} className={running ? 'is-running' : undefined}>
<th scope="row"> <th scope="row">
<span className="client-diagnostics-service-name">{site.label}</span> <span className="client-diagnostics-service-name">{site.label}</span>
{custom && <button {custom && <button
+3 -5
View File
@@ -211,7 +211,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const movementAnimations = useRef(new Map()); const movementAnimations = useRef(new Map());
const previousTraffic = useRef(new Map()); const previousTraffic = useRef(new Map());
const aliasBaseline = useRef({ id: '', value: '' }); const aliasBaseline = useRef({ id: '', value: '' });
const aliasWidth = useRef('1px');
const copyTimer = useRef(null); const copyTimer = useRef(null);
const trafficDeltaTimer = useRef(null); const trafficDeltaTimer = useRef(null);
const trafficOrder = useRef({ direction: sortDirection, ids: [] }); const trafficOrder = useRef({ direction: sortDirection, ids: [] });
@@ -411,10 +410,9 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS); copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
} }
function startEditing(device, width = 1) { function startEditing(device) {
const value = device.alias || device.hostname || ''; const value = device.alias || device.hostname || '';
aliasBaseline.current = { id: device.id, value }; aliasBaseline.current = { id: device.id, value };
aliasWidth.current = `${width}px`;
setEditingId(device.id); setEditingId(device.id);
setAlias(value); setAlias(value);
} }
@@ -584,7 +582,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
<input <input
className="client-device-alias-input" className="client-device-alias-input"
value={alias} value={alias}
style={{ '--alias-width': aliasWidth.current }} style={{ '--alias-width': `${Math.max(1, alias.length)}ch` }}
maxLength="64" maxLength="64"
autoFocus autoFocus
aria-label="Название устройства" aria-label="Название устройства"
@@ -600,7 +598,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
className="client-device-alias-trigger" className="client-device-alias-trigger"
type="button" type="button"
aria-label={`Изменить название ${title}`} aria-label={`Изменить название ${title}`}
onClick={(event) => startEditing(device, event.currentTarget.getBoundingClientRect().width)} onClick={() => startEditing(device)}
>{title}</button>} >{title}</button>}
{(hasName || (editing && Boolean(alias))) && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>} {(hasName || (editing && Boolean(alias))) && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
{device.ip ? <button {device.ip ? <button
+55 -13
View File
@@ -4731,13 +4731,35 @@ p {
.client-diagnostics-table tbody tr { .client-diagnostics-table tbody tr {
border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent); border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent);
transition: background-color 420ms ease, box-shadow 520ms cubic-bezier(0.16, 1, 0.3, 1);
} }
.client-diagnostics-table tbody tr.is-running { .client-diagnostics-active-marker {
position: absolute;
top: 0;
left: 0;
z-index: 0;
background-color: color-mix(in oklch, var(--client-accent) 8%, transparent); background-color: color-mix(in oklch, var(--client-accent) 8%, transparent);
box-shadow: inset 2px 0 var(--client-accent); box-shadow: inset 2px 0 var(--client-accent);
animation: client-diagnostics-row-pulse 1200ms ease-in-out infinite alternate; opacity: 0;
pointer-events: none;
transform: translate3d(var(--diagnostics-runner-x, 0), var(--diagnostics-runner-y, 0), 0);
transition: opacity 220ms ease;
will-change: transform;
}
.client-diagnostics-active-marker.is-visible {
opacity: 1;
}
.client-diagnostics-active-marker.is-moving {
transition: transform 680ms cubic-bezier(0.16, 1, 0.3, 1), opacity 220ms ease;
}
.client-diagnostics-header,
.client-diagnostics-feedback,
.client-diagnostics-section {
position: relative;
z-index: 1;
} }
.client-diagnostics-table tbody tr.is-running th { .client-diagnostics-table tbody tr.is-running th {
@@ -4745,17 +4767,11 @@ p {
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent); text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent);
} }
@keyframes client-diagnostics-row-pulse {
to {
background-color: color-mix(in oklch, var(--client-accent) 13%, transparent);
box-shadow: inset 2px 0 var(--client-accent), 0 0 16px color-mix(in oklch, var(--client-accent) 8%, transparent);
}
}
.client-diagnostics-table tbody th { .client-diagnostics-table tbody th {
color: var(--client-text); color: var(--client-text);
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
transition: color 420ms ease, text-shadow 520ms ease;
} }
.client-diagnostics-table code, .client-diagnostics-table code,
@@ -4789,7 +4805,29 @@ p {
.client-diagnostics-status.is-running { .client-diagnostics-status.is-running {
color: var(--client-accent); color: var(--client-accent);
animation: client-operation-pulse 900ms ease-in-out infinite alternate; white-space: nowrap;
}
.client-diagnostics-dots {
position: relative;
display: inline-block;
margin-left: 0.15em;
color: color-mix(in oklch, var(--client-accent) 24%, transparent);
}
.client-diagnostics-dots::after {
position: absolute;
inset: 0;
color: var(--client-accent);
clip-path: inset(0 100% 0 0);
content: '...';
animation: client-diagnostics-dots-fill 1200ms steps(3, end) infinite;
}
@keyframes client-diagnostics-dots-fill {
0%, 12% { clip-path: inset(0 100% 0 0); }
82%, 94% { clip-path: inset(0); }
100% { clip-path: inset(0 100% 0 0); }
} }
.client-diagnostics-summary { .client-diagnostics-summary {
@@ -4960,12 +4998,16 @@ p {
.client-diagnostics-refresh, .client-diagnostics-refresh,
.client-diagnostics-refresh svg, .client-diagnostics-refresh svg,
.client-diagnostics-table, .client-diagnostics-table,
.client-diagnostics-table tbody tr.is-running, .client-diagnostics-active-marker,
.client-diagnostics-status.is-running { .client-diagnostics-dots::after {
transition: none; transition: none;
animation: none; animation: none;
} }
.client-diagnostics-dots::after {
clip-path: inset(0);
}
.client-devices-refresh-ring circle { .client-devices-refresh-ring circle {
stroke-dashoffset: 0; stroke-dashoffset: 0;
} }
+2 -1
View File
@@ -46,7 +46,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /client-device-name-heading\$\{hasName \? '' : ' is-address-only'\}\$\{editing \? ' is-editing' : ''\}/); assert.match(panel, /client-device-name-heading\$\{hasName \? '' : ' is-address-only'\}\$\{editing \? ' is-editing' : ''\}/);
assert.match(panel, /className="client-device-alias-input"[\s\S]*onBlur=\{\(\) => saveAlias\(device\)\}[\s\S]*event\.key === 'Enter'[\s\S]*event\.currentTarget\.blur\(\)/); assert.match(panel, /className="client-device-alias-input"[\s\S]*onBlur=\{\(\) => saveAlias\(device\)\}[\s\S]*event\.key === 'Enter'[\s\S]*event\.currentTarget\.blur\(\)/);
assert.match(panel, /aliasBaseline\.current = \{ id: device\.id, value \}/); assert.match(panel, /aliasBaseline\.current = \{ id: device\.id, value \}/);
assert.match(panel, /aliasWidth\.current = `\$\{width\}px`[\s\S]*startEditing\(device, event\.currentTarget\.getBoundingClientRect\(\)\.width\)/); assert.match(panel, /style=\{\{ '--alias-width': `\$\{Math\.max\(1, alias\.length\)\}ch` \}\}/);
assert.doesNotMatch(panel, /aliasWidth|getBoundingClientRect\(\)\.width/);
assert.match(panel, /nextAlias === aliasBaseline\.current\.value\.trim\(\)[\s\S]*setEditingId\(\(current\) => current === device\.id \? '' : current\)/); assert.match(panel, /nextAlias === aliasBaseline\.current\.value\.trim\(\)[\s\S]*setEditingId\(\(current\) => current === device\.id \? '' : current\)/);
assert.doesNotMatch(panel, /client-device-alias"|Сохранить название|Отменить изменение/); assert.doesNotMatch(panel, /client-device-alias"|Сохранить название|Отменить изменение/);
assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/); assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/);
+6 -1
View File
@@ -141,7 +141,12 @@ test('connectivity diagnostics render stable compact tables before the first run
assert.match(diagnostics, /api\.diagnostics\.connectivity\(customServices, target\)/); assert.match(diagnostics, /api\.diagnostics\.connectivity\(customServices, target\)/);
assert.match(diagnostics, /const target = `ip:\$\{source\.id\}`;[\s\S]*activeTarget === target/); assert.match(diagnostics, /const target = `ip:\$\{source\.id\}`;[\s\S]*activeTarget === target/);
assert.match(diagnostics, /activeTarget === `site:\$\{site\.id\}`/); assert.match(diagnostics, /activeTarget === `site:\$\{site\.id\}`/);
assert.match(styles, /\.client-diagnostics-table tbody tr\.is-running \{[\s\S]*client-diagnostics-row-pulse/); assert.match(diagnostics, /data-diagnostic-target=\{target\}/);
assert.match(diagnostics, /client-diagnostics-active-marker/);
assert.match(styles, /\.client-diagnostics-active-marker\.is-moving \{[\s\S]*transform 680ms/);
assert.match(diagnostics, />\.{3}<\/span>/);
assert.doesNotMatch(styles, /client-diagnostics-row-pulse/);
assert.doesNotMatch(rule('.client-diagnostics-status.is-running'), /animation:|transform:|opacity:/);
}); });
test('duration and Gateway access keep stable geometry without tabs', () => { test('duration and Gateway access keep stable geometry without tabs', () => {