Files
harbor-net/src/web/features/devices/DevicesFeature.tsx
T
dokril 5a21a09b82
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 7s
Refine rail drawer switching and toggle animations
2026-08-14 15:52:56 +03:00

302 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">
<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="M7 15v4h3.5M17 15v4h-3.5" />
<path className="client-rail-device-bridge" d="M10.5 19h3" />
<path className="client-rail-device-pulse" pathLength="1" d="M7 19h10" />
</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>;
}