Polish diagnostics animations and device alias sizing
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.17.6',
|
||||
gatewayClient: '0.18.6',
|
||||
macClient: '0.17.8',
|
||||
gatewayClient: '0.18.8',
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user