Update VPN client connection flow
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-07 21:19:33 +03:00
parent 0c376c72aa
commit 0a4a6d9443
16 changed files with 771 additions and 342 deletions
+75 -26
View File
@@ -14,7 +14,7 @@ import {
const AUTO_REFRESH_MS = 15_000;
const DEVICE_MOVE_MS = 520;
const COPY_FEEDBACK_MS = 5_000;
const COPY_FEEDBACK_MS = 800;
const TRAFFIC_DELTA_MS = 2_200;
function Tooltip({ children }) {
@@ -45,13 +45,14 @@ function chartTime(value) {
function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
const [hovered, setHovered] = useState(null);
const previousPoints = useRef([]);
const previousScale = useRef(scale);
const max = samples.reduce((largest, sample) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
return gateway > largest ? gateway : proxy > largest ? proxy : largest;
}, 0n);
const mid = trafficAxisMid(max, scale);
const latest = samples.at(-1)?.observedAt || 'empty';
const firstSlot = capacity - samples.length;
const points = samples.map((sample, index) => {
const gateway = byteString(sample.gatewayBytes);
@@ -69,6 +70,15 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
const penultimate = points.at(-2);
const newest = points.at(-1);
const hasProxy = points.some(({ proxy }) => proxy > 0n);
const scaleFrom = previousPoints.current;
const animateScale = previousScale.current !== scale
&& scaleFrom.length === points.length
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
useLayoutEffect(() => {
previousPoints.current = points;
previousScale.current = scale;
}, [points, scale]);
function trackPointer(event) {
const bounds = event.currentTarget.getBoundingClientRect();
@@ -111,12 +121,48 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
<line x1="0" x2="100" y1="50" y2="50" />
<line x1="0" x2="100" y1="100" y2="100" />
</g>}
<g key={latest} className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
{previous.length > 0 && <polyline className="is-gateway" points={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')} />}
{hasProxy && previous.length > 0 && <polyline className="is-proxy" points={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')} />}
{penultimate && newest && <line className="is-gateway is-new" pathLength="1" x1={penultimate.x} y1={penultimate.gatewayY} x2={newest.x} y2={newest.gatewayY} />}
{hasProxy && penultimate && newest && <line className="is-proxy is-new" pathLength="1" x1={penultimate.x} y1={penultimate.proxyY} x2={newest.x} y2={newest.proxyY} />}
{!penultimate && newest && <circle className="is-gateway is-new" cx={newest.x} cy={newest.gatewayY} r="1.5" />}
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
{previous.length > 0 && <polyline className="is-gateway" points={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}>
{animateScale && <animate
key={`gateway-${scale}`}
attributeName="points"
from={scaleFrom.slice(0, -1).map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}
to={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}
dur="520ms"
calcMode="spline"
keyTimes="0;1"
keySplines="0.16 1 0.3 1"
fill="freeze"
/>}
</polyline>}
{hasProxy && previous.length > 0 && <polyline className="is-proxy" points={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}>
{animateScale && <animate
key={`proxy-${scale}`}
attributeName="points"
from={scaleFrom.slice(0, -1).map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}
to={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}
dur="520ms"
calcMode="spline"
keyTimes="0;1"
keySplines="0.16 1 0.3 1"
fill="freeze"
/>}
</polyline>}
{penultimate && newest && <line className="is-gateway is-new" pathLength="1" x1={penultimate.x} y1={penultimate.gatewayY} x2={newest.x} y2={newest.gatewayY}>
{animateScale && <>
<animate key={`gateway-y1-${scale}`} attributeName="y1" from={scaleFrom.at(-2).gatewayY} to={penultimate.gatewayY} dur="520ms" fill="freeze" />
<animate key={`gateway-y2-${scale}`} attributeName="y2" from={scaleFrom.at(-1).gatewayY} to={newest.gatewayY} dur="520ms" fill="freeze" />
</>}
</line>}
{hasProxy && penultimate && newest && <line className="is-proxy is-new" pathLength="1" x1={penultimate.x} y1={penultimate.proxyY} x2={newest.x} y2={newest.proxyY}>
{animateScale && <>
<animate key={`proxy-y1-${scale}`} attributeName="y1" from={scaleFrom.at(-2).proxyY} to={penultimate.proxyY} dur="520ms" fill="freeze" />
<animate key={`proxy-y2-${scale}`} attributeName="y2" from={scaleFrom.at(-1).proxyY} to={newest.proxyY} dur="520ms" fill="freeze" />
</>}
</line>}
{!penultimate && newest && <circle className="is-gateway is-new" cx={newest.x} cy={newest.gatewayY} r="1.5">
{animateScale && <animate key={`gateway-cy-${scale}`} attributeName="cy" from={scaleFrom[0].gatewayY} to={newest.gatewayY} dur="520ms" fill="freeze" />}
</circle>}
</g>
{hovered && <g className="client-device-traffic-cursor">
<line x1={hovered.x} x2={hovered.x} y1="0" y2="100" />
@@ -335,6 +381,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
}
function startEditing(device) {
setEditingId(device.id);
setAlias(device.alias || device.hostname || '');
}
return (
<aside
ref={panelRef}
@@ -431,7 +482,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
)}
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'copied!' : ''}
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать IP' : 'IP скопирован' : ''}
</div>
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
@@ -509,36 +560,34 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
</form>
) : (
<>
{device.ip ? <h3 className="client-device-name-heading">
<button
className={`client-device-name${hasName ? '' : ' is-address-only'}${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
<h3 className="client-device-name-heading">
{hasName && <button
className="client-device-alias-trigger"
type="button"
aria-label={`Изменить название ${title}`}
onClick={() => startEditing(device)}
>{title}</button>}
{hasName && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
{device.ip ? <button
className={`client-device-ip${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
onClick={() => copyDeviceIp(device)}
>
<span className="client-device-name-primary">{title}</span>
{hasName && <span className="client-device-name-ip" aria-hidden="true">{device.ip}</span>}
<span className="client-device-name-feedback" aria-hidden="true">
{copied && copyFeedback.failed ? 'Ошибка' : 'copied!'}
</span>
</button>
</h3> : <h3>{title}</h3>}
<span className="client-device-edit-wrap client-tooltip-anchor">
>{device.ip}</button> : !hasName && <span>Неизвестное устройство</span>}
</h3>
{!hasName && <span className="client-device-edit-wrap client-tooltip-anchor">
<button
className="client-device-edit"
type="button"
aria-label={`Изменить название ${title}`}
onClick={() => {
setEditingId(device.id);
setAlias(device.alias || '');
}}
onClick={() => startEditing(device)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" />
</svg>
</button>
<Tooltip>Изменить название</Tooltip>
</span>
</span>}
</>
)}
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex="0">