Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { formatByteString, formatLastSeen } from '../../utils/format.js';
|
||||
import { TrafficChart } from './TrafficChart.js';
|
||||
import {
|
||||
parseDeviceSnapshot,
|
||||
type Device,
|
||||
type DevicePolicy,
|
||||
type DeviceSnapshot,
|
||||
} from './deviceSnapshot.js';
|
||||
|
||||
const DEVICE_AUTO_REFRESH_MS = 15_000;
|
||||
|
||||
interface DevicesFeatureOptions {
|
||||
isGateway: boolean;
|
||||
listDevices: () => Promise<unknown>;
|
||||
refreshDevices: () => Promise<unknown>;
|
||||
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||
setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface RequestError {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requestError(value: unknown): RequestError {
|
||||
if (!record(value)) return {};
|
||||
return { code: typeof value.code === 'string' ? value.code : undefined };
|
||||
}
|
||||
|
||||
export function useDevicesFeature({
|
||||
isGateway,
|
||||
listDevices,
|
||||
refreshDevices,
|
||||
updateDevice: requestDeviceUpdate,
|
||||
setDevicePolicy,
|
||||
}: DevicesFeatureOptions) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [snapshot, setSnapshot] = useState<DeviceSnapshot | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'refreshing' | 'ready' | 'error'>('idle');
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshCycle, setRefreshCycle] = useState(0);
|
||||
const [savingId, setSavingId] = useState('');
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
function publish(value: unknown) {
|
||||
const next = parseDeviceSnapshot(value);
|
||||
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function load(quiet = false, discover = false) {
|
||||
if (!isGateway) return;
|
||||
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
|
||||
setRefreshing(true);
|
||||
try {
|
||||
publish(await (discover ? refreshDevices() : listDevices()));
|
||||
setError(null);
|
||||
setStatus('ready');
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
setStatus('error');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
setRefreshCycle((cycle) => cycle + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDevice(device: Device, patch: Record<string, unknown>) {
|
||||
if (!snapshot) return false;
|
||||
setSavingId(device.id);
|
||||
try {
|
||||
let next: DeviceSnapshot;
|
||||
try {
|
||||
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, patch, snapshot.revision));
|
||||
} catch (caught) {
|
||||
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||
const latest = parseDeviceSnapshot(await listDevices());
|
||||
publish(latest);
|
||||
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||
if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) {
|
||||
throw caught;
|
||||
}
|
||||
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, patch, latest.revision));
|
||||
}
|
||||
publish(next);
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(caught);
|
||||
return false;
|
||||
} finally {
|
||||
setSavingId('');
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePolicy(device: Device, mode: DevicePolicy) {
|
||||
if (!snapshot) return;
|
||||
setSavingId(device.id);
|
||||
try {
|
||||
let next: DeviceSnapshot;
|
||||
try {
|
||||
next = parseDeviceSnapshot(await setDevicePolicy(device.id, mode, snapshot.revision));
|
||||
} catch (caught) {
|
||||
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||
const latest = parseDeviceSnapshot(await listDevices());
|
||||
publish(latest);
|
||||
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw caught;
|
||||
next = parseDeviceSnapshot(await setDevicePolicy(device.id, mode, latest.revision));
|
||||
}
|
||||
publish(next);
|
||||
setError(null);
|
||||
} catch (caught) {
|
||||
if (requestError(caught).code === 'DEVICE_POLICY_APPLY_FAILED') {
|
||||
try {
|
||||
publish(parseDeviceSnapshot(await listDevices()));
|
||||
} catch {
|
||||
// Keep the policy error as the actionable result.
|
||||
}
|
||||
}
|
||||
setError(caught);
|
||||
} finally {
|
||||
setSavingId('');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGateway) return undefined;
|
||||
load();
|
||||
return undefined;
|
||||
}, [isGateway]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGateway || refreshing || status === 'loading') return undefined;
|
||||
const timer = setTimeout(() => load(true), DEVICE_AUTO_REFRESH_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isGateway, refreshCycle, refreshing, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||
const closeDevices = (event: PointerEvent | KeyboardEvent) => {
|
||||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||||
if (event.type !== 'keydown' && (
|
||||
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
||||
)) return;
|
||||
setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', closeDevices);
|
||||
document.addEventListener('keydown', closeDevices);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('pointerdown', closeDevices);
|
||||
document.removeEventListener('keydown', closeDevices);
|
||||
requestAnimationFrame(() => {
|
||||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
snapshot,
|
||||
status,
|
||||
error,
|
||||
refreshing,
|
||||
refreshCycle,
|
||||
savingId,
|
||||
panelRef,
|
||||
toggleRef,
|
||||
closeRef,
|
||||
load,
|
||||
updateDevice,
|
||||
updatePolicy,
|
||||
close: () => setIsOpen(false),
|
||||
toggle: () => setIsOpen((open) => !open),
|
||||
};
|
||||
}
|
||||
|
||||
export type DevicesFeature = ReturnType<typeof useDevicesFeature>;
|
||||
|
||||
export function DevicesToggle({ feature, onToggle }: { feature: DevicesFeature; onToggle: () => void }) {
|
||||
return <button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-instructions-toggle client-devices-toggle${feature.isOpen ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={feature.isOpen}
|
||||
aria-controls="client-devices"
|
||||
aria-label={feature.isOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect className="client-rail-device-primary" x="3.5" y="5" width="7" height="10" rx="1.5" />
|
||||
<rect className="client-rail-device-secondary" x="13.5" y="8" width="7" height="7" rx="1.5" />
|
||||
<path className="client-rail-device-link" d="M6 19h12M7 15v4M17 15v4" />
|
||||
</svg>
|
||||
<span>Устройства</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) {
|
||||
const globalTraffic = feature.snapshot?.traffic;
|
||||
const trafficSourceError = feature.snapshot?.source?.traffic?.error
|
||||
|| feature.snapshot?.source?.traffic?.proxy?.error
|
||||
|| (feature.status === 'error' ? feature.error : null);
|
||||
const trafficFreshness = globalTraffic?.observedAt
|
||||
? formatLastSeen(globalTraffic.observedAt, new Date(now)).relative
|
||||
: 'Нет данных';
|
||||
|
||||
return <section className="client-gateway-summary" aria-label="Общий трафик Harbor">
|
||||
<div className="client-gateway-traffic-heading">
|
||||
<span>Учтено Harbor</span>
|
||||
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
|
||||
</div>
|
||||
<div className="client-gateway-traffic-chart">
|
||||
<TrafficChart
|
||||
samples={globalTraffic?.history || []}
|
||||
capacity={feature.snapshot?.trafficHistoryCapacity || 120}
|
||||
routeLabel="Gateway"
|
||||
/>
|
||||
</div>
|
||||
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
|
||||
{trafficSourceError
|
||||
? `Трафик не обновляется · последние данные ${trafficFreshness}`
|
||||
: globalTraffic?.observedAt ? `Обновлено ${trafficFreshness}` : 'Ожидаем первые данные трафика'}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
Reference in New Issue
Block a user