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
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import {
assessConnectivity,
@@ -38,7 +38,7 @@ function readCustomServices() {
function resultStatus(site, pending, available = true) {
if (!available) return ['is-muted', '—'];
if (pending) return ['is-running', 'Проверяем'];
if (pending) return ['is-running', 'Тестируем'];
if (!site) return ['is-muted', '—'];
if (site.status === 'unavailable') return ['is-error', 'Нет доступа'];
if (site.status === 'responded') return ['is-warning', `HTTP ${site.httpStatus}`];
@@ -47,7 +47,10 @@ function resultStatus(site, pending, available = true) {
function Status({ value, route }) {
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) {
@@ -60,7 +63,7 @@ function ipResult(path, source) {
function IpCell({ path, source, pending, route }) {
const value = ipResult(path, source);
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 (!value?.address) return <Status value={['is-error', 'Нет ответа']} route={route} />;
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 [serviceUrl, setServiceUrl] = useState('');
const [formError, setFormError] = useState('');
const sheetRef = useRef(null);
const runnerRef = useRef(null);
const previousTargetRef = useRef(null);
useEffect(() => {
try {
@@ -120,6 +126,30 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
}
}, [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() {
setStatus('running');
setError(null);
@@ -182,7 +212,8 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
aria-hidden={!open}
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
ref={closeRef}
className="client-drawer-close"
@@ -235,7 +266,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
<tbody>{CONNECTIVITY_IP_SOURCES.map((source) => {
const target = `ip:${source.id}`;
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>
<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>
@@ -293,7 +324,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
const vpn = result?.vpn?.sites?.find((item) => item.id === site.id);
const custom = site.id.startsWith('custom-');
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">
<span className="client-diagnostics-service-name">{site.label}</span>
{custom && <button
+3 -5
View File
@@ -211,7 +211,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const movementAnimations = useRef(new Map());
const previousTraffic = useRef(new Map());
const aliasBaseline = useRef({ id: '', value: '' });
const aliasWidth = useRef('1px');
const copyTimer = useRef(null);
const trafficDeltaTimer = useRef(null);
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);
}
function startEditing(device, width = 1) {
function startEditing(device) {
const value = device.alias || device.hostname || '';
aliasBaseline.current = { id: device.id, value };
aliasWidth.current = `${width}px`;
setEditingId(device.id);
setAlias(value);
}
@@ -584,7 +582,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
<input
className="client-device-alias-input"
value={alias}
style={{ '--alias-width': aliasWidth.current }}
style={{ '--alias-width': `${Math.max(1, alias.length)}ch` }}
maxLength="64"
autoFocus
aria-label="Название устройства"
@@ -600,7 +598,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
className="client-device-alias-trigger"
type="button"
aria-label={`Изменить название ${title}`}
onClick={(event) => startEditing(device, event.currentTarget.getBoundingClientRect().width)}
onClick={() => startEditing(device)}
>{title}</button>}
{(hasName || (editing && Boolean(alias))) && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
{device.ip ? <button
+55 -13
View File
@@ -4731,13 +4731,35 @@ p {
.client-diagnostics-table tbody tr {
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);
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 {
@@ -4745,17 +4767,11 @@ p {
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 {
color: var(--client-text);
font-size: 9px;
font-weight: 600;
transition: color 420ms ease, text-shadow 520ms ease;
}
.client-diagnostics-table code,
@@ -4789,7 +4805,29 @@ p {
.client-diagnostics-status.is-running {
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 {
@@ -4960,12 +4998,16 @@ p {
.client-diagnostics-refresh,
.client-diagnostics-refresh svg,
.client-diagnostics-table,
.client-diagnostics-table tbody tr.is-running,
.client-diagnostics-status.is-running {
.client-diagnostics-active-marker,
.client-diagnostics-dots::after {
transition: none;
animation: none;
}
.client-diagnostics-dots::after {
clip-path: inset(0);
}
.client-devices-refresh-ring circle {
stroke-dashoffset: 0;
}