Files
harbor-net/src/web/features/devices/DevicesFeature.tsx
T
dokril 0a0a932057
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 6s
Refine secondary rail icon animations
2026-08-17 17:07:25 +03:00

305 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { RailAction } from '../../ui/RailAction.js';
import {
formatByteString,
formatLastSeen,
trafficBytesPerSecond,
} 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>;
resetDeviceTraffic: (expectedRevision: number) => 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,
resetDeviceTraffic,
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 [resetOpen, setResetOpen] = useState(false);
const [resetting, setResetting] = useState(false);
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('');
}
}
async function confirmResetTraffic() {
if (!snapshot) return;
setResetting(true);
try {
let next: DeviceSnapshot;
try {
next = parseDeviceSnapshot(await resetDeviceTraffic(snapshot.revision));
} catch (caught) {
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
const latest = parseDeviceSnapshot(await listDevices());
publish(latest);
next = parseDeviceSnapshot(await resetDeviceTraffic(latest.revision));
}
publish(next);
setError(null);
setResetOpen(false);
} catch (caught) {
setError(caught);
} finally {
setResetting(false);
}
}
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 (resetOpen) return;
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, resetOpen]);
return {
isOpen,
snapshot,
status,
error,
refreshing,
refreshCycle,
savingId,
resetOpen,
resetting,
panelRef,
toggleRef,
closeRef,
load,
updateDevice,
updatePolicy,
requestTrafficReset: () => setResetOpen(true),
cancelTrafficReset: () => setResetOpen(false),
confirmResetTraffic,
close: () => setIsOpen(false),
toggle: () => setIsOpen((open) => !open),
};
}
export type DevicesFeature = ReturnType<typeof useDevicesFeature>;
export function DevicesToggle({
feature,
open,
onToggle,
}: {
feature: DevicesFeature;
open: boolean;
onToggle: () => void;
}) {
return <RailAction
buttonRef={feature.toggleRef}
className="client-instructions-toggle client-devices-toggle"
open={open}
controls="client-devices"
ariaLabel={open ? 'Закрыть устройства' : 'Устройства Gateway'}
label="Устройства"
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<g className="client-rail-device-monitor">
<rect x="2.5" y="4.5" width="9" height="10.5" rx="1.5" />
<path d="M5 19h5.5M7 15v4" />
</g>
<rect className="client-rail-device-phone" x="16.5" y="7" width="5" height="9" rx="1.3" />
<path className="client-rail-device-link" d="M19 16v3h-5.5" />
<path className="client-rail-device-bridge" d="M10.5 19h3" />
<path className="client-rail-device-packet" pathLength="1" d="M7 19h12" />
</svg>
</RailAction>;
}
export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) {
const globalTraffic = feature.snapshot?.traffic;
const history = globalTraffic?.history || [];
const latest = history.at(-1);
const previous = history.at(-2);
const hasDirectionalRate = latest && previous
&& typeof latest.downloadBytes === 'string'
&& typeof latest.uploadBytes === 'string';
const downloadRate = hasDirectionalRate
? trafficBytesPerSecond(latest.downloadBytes, previous.observedAt, latest.observedAt)
: null;
const uploadRate = hasDirectionalRate
? trafficBytesPerSecond(latest.uploadBytes, previous.observedAt, latest.observedAt)
: null;
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 className="client-gateway-traffic-total">
<small>Учтено Harbor</small>
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
</span>
<span className="client-gateway-traffic-speed" aria-label="Текущая средняя скорость">
<span className="is-download"> {downloadRate === null ? '—' : `${formatByteString(downloadRate)}/с`}</span>
<span className="is-upload"> {uploadRate === null ? '—' : `${formatByteString(uploadRate)}/с`}</span>
</span>
</div>
<div className="client-gateway-traffic-chart">
<TrafficChart
samples={history}
capacity={feature.snapshot?.trafficHistoryCapacity || 120}
routeLabel="Gateway"
series="speed"
/>
</div>
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
{trafficSourceError
? `Трафик не обновляется · последние данные ${trafficFreshness}`
: globalTraffic?.observedAt ? `Обновлено ${trafficFreshness}` : 'Ожидаем первые данные трафика'}
</div>
</section>;
}