Update VPN proxy client behavior
Build and Deploy Gateway / build-and-push (push) Successful in 18s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-09 11:55:02 +03:00
parent 71ede44be0
commit 90433d7cd8
27 changed files with 770 additions and 254 deletions
+37 -10
View File
@@ -75,7 +75,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const [alias, setAlias] = useState('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear');
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
const [copyFeedback, setCopyFeedback] = useState<Record<string, { failed: boolean }>>({});
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
const [pencilAnimationId, setPencilAnimationId] = useState('');
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
const deviceNodes = useRef(new Map<string, HTMLElement>());
@@ -85,7 +86,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const movementAnimations = useRef(new Map<string, Animation>());
const previousTraffic = useRef(new Map<string, { gateway: bigint; proxy: bigint }>());
const aliasBaseline = useRef({ id: '', value: '' });
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const copyTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const copyAttempts = useRef(new Map<string, object>());
const trafficDeltaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] });
const devices = useMemo(
@@ -104,7 +106,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
);
useEffect(() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
for (const timer of copyTimers.current.values()) clearTimeout(timer);
copyTimers.current.clear();
copyAttempts.current.clear();
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
}, []);
@@ -190,14 +194,36 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
async function copyDeviceIp(device: Device) {
if (!device.ip) return;
if (copyTimer.current) clearTimeout(copyTimer.current);
const activeTimer = copyTimers.current.get(device.id);
if (activeTimer) clearTimeout(activeTimer);
const attempt = {};
copyAttempts.current.set(device.id, attempt);
let feedback: { failed: boolean };
try {
await copyText(device.ip);
setCopyFeedback({ id: device.id, failed: false });
feedback = { failed: false };
} catch {
setCopyFeedback({ id: device.id, failed: true });
feedback = { failed: true };
}
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
if (copyAttempts.current.get(device.id) !== attempt) return;
const announcement = {
id: device.id,
message: feedback.failed ? `Не удалось скопировать IP ${device.ip}` : `IP ${device.ip} скопирован`,
};
const pendingTimer = copyTimers.current.get(device.id);
if (pendingTimer) clearTimeout(pendingTimer);
setCopyFeedback((current) => ({ ...current, [device.id]: feedback }));
setCopyAnnouncement(announcement);
copyTimers.current.set(device.id, setTimeout(() => {
setCopyFeedback((current) => {
const next = { ...current };
delete next[device.id];
return next;
});
setCopyAnnouncement((current) => current?.id === device.id ? null : current);
copyTimers.current.delete(device.id);
copyAttempts.current.delete(device.id);
}, COPY_FEEDBACK_MS));
}
function startEditing(device: Device) {
@@ -303,7 +329,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
)}
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать IP' : 'IP скопирован' : ''}
{copyAnnouncement?.message || ''}
</div>
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
@@ -320,7 +346,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString());
const hasProxyTraffic = proxyTotal > 0n;
const trafficDelta = trafficDeltas[device.id] || {};
const copied = copyFeedback?.id === device.id;
const feedback = copyFeedback[device.id];
const copied = Boolean(feedback);
const policyBusy = device.policyStatus === 'applying';
const policyFailed = device.policyStatus === 'failed';
const policyPending = device.policyStatus === 'pending';
@@ -392,7 +419,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
>{title}</button>}
{(hasName || (editing && Boolean(alias))) && 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' : ''}`}
className={`client-device-ip${copied ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
onClick={() => copyDeviceIp(device)}