440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
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,
|
||
type DeviceTag,
|
||
} 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>;
|
||
createDeviceTag: (name: string, expectedRevision: number) => Promise<unknown>;
|
||
renameDeviceTag: (id: string, name: string, expectedRevision: number) => Promise<unknown>;
|
||
deleteDeviceTag: (id: string, 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 };
|
||
}
|
||
|
||
const sameStringList = (left: string[], right: string[]) => (
|
||
left.length === right.length && left.every((value, index) => value === right[index])
|
||
);
|
||
|
||
const tagNameKey = (value: string) => value.trim().toLocaleLowerCase('ru-RU');
|
||
|
||
export function useDevicesFeature({
|
||
isGateway,
|
||
listDevices,
|
||
refreshDevices,
|
||
resetDeviceTraffic,
|
||
updateDevice: requestDeviceUpdate,
|
||
createDeviceTag,
|
||
renameDeviceTag,
|
||
deleteDeviceTag,
|
||
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 [tagSavingId, setTagSavingId] = useState('');
|
||
const [tagError, setTagError] = useState<unknown>(null);
|
||
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 updateDeviceTags(device: Device, tagIds: string[], baselineTagIds: string[]) {
|
||
if (!snapshot) return false;
|
||
setTagSavingId(device.id);
|
||
setTagError(null);
|
||
const currentDevice = snapshot.devices.find(({ id }) => id === device.id);
|
||
if (!currentDevice || !sameStringList(currentDevice.tagIds, baselineTagIds)) {
|
||
setTagError(new Error('Device tags changed'));
|
||
setTagSavingId('');
|
||
return false;
|
||
}
|
||
try {
|
||
let next: DeviceSnapshot;
|
||
try {
|
||
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, { tagIds }, 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);
|
||
const knownTagIds = new Set(latest.tags.map(({ id }) => id));
|
||
if (!latestDevice || !sameStringList(latestDevice.tagIds, baselineTagIds)
|
||
|| tagIds.some((tagId) => !knownTagIds.has(tagId))) throw caught;
|
||
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, { tagIds }, latest.revision));
|
||
}
|
||
publish(next);
|
||
return true;
|
||
} catch (caught) {
|
||
setTagError(caught);
|
||
return false;
|
||
} finally {
|
||
setTagSavingId('');
|
||
}
|
||
}
|
||
|
||
async function createTag(name: string) {
|
||
if (!snapshot) return false;
|
||
setTagSavingId('create');
|
||
setTagError(null);
|
||
try {
|
||
let next: DeviceSnapshot;
|
||
try {
|
||
next = parseDeviceSnapshot(await createDeviceTag(name, snapshot.revision));
|
||
} catch (caught) {
|
||
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||
const latest = parseDeviceSnapshot(await listDevices());
|
||
publish(latest);
|
||
const nameKey = tagNameKey(name);
|
||
if (latest.tags.length >= 32 || latest.tags.some((tag) => tagNameKey(tag.name) === nameKey)) throw caught;
|
||
next = parseDeviceSnapshot(await createDeviceTag(name, latest.revision));
|
||
}
|
||
publish(next);
|
||
return true;
|
||
} catch (caught) {
|
||
setTagError(caught);
|
||
return false;
|
||
} finally {
|
||
setTagSavingId('');
|
||
}
|
||
}
|
||
|
||
async function renameTag(tag: DeviceTag, name: string, baselineName: string) {
|
||
if (!snapshot) return false;
|
||
setTagSavingId(tag.id);
|
||
setTagError(null);
|
||
if (snapshot.tags.find(({ id }) => id === tag.id)?.name !== baselineName) {
|
||
setTagError(new Error('Device tag changed'));
|
||
setTagSavingId('');
|
||
return false;
|
||
}
|
||
try {
|
||
let next: DeviceSnapshot;
|
||
try {
|
||
next = parseDeviceSnapshot(await renameDeviceTag(tag.id, name, snapshot.revision));
|
||
} catch (caught) {
|
||
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||
const latest = parseDeviceSnapshot(await listDevices());
|
||
publish(latest);
|
||
const latestTag = latest.tags.find(({ id }) => id === tag.id);
|
||
if (!latestTag || latestTag.name !== baselineName) throw caught;
|
||
next = parseDeviceSnapshot(await renameDeviceTag(tag.id, name, latest.revision));
|
||
}
|
||
publish(next);
|
||
return true;
|
||
} catch (caught) {
|
||
setTagError(caught);
|
||
return false;
|
||
} finally {
|
||
setTagSavingId('');
|
||
}
|
||
}
|
||
|
||
async function deleteTag(tag: DeviceTag): Promise<'saved' | 'conflict' | 'failed'> {
|
||
if (!snapshot) return 'failed';
|
||
setTagSavingId(tag.id);
|
||
setTagError(null);
|
||
try {
|
||
publish(parseDeviceSnapshot(await deleteDeviceTag(tag.id, snapshot.revision)));
|
||
return 'saved';
|
||
} catch (caught) {
|
||
setTagError(caught);
|
||
if (requestError(caught).code === 'STATE_CONFLICT') {
|
||
try {
|
||
publish(parseDeviceSnapshot(await listDevices()));
|
||
} catch {
|
||
// Preserve the conflict as the actionable error.
|
||
}
|
||
return 'conflict';
|
||
}
|
||
return 'failed';
|
||
} finally {
|
||
setTagSavingId('');
|
||
}
|
||
}
|
||
|
||
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 (document.querySelector('.client-devices-rail.is-open, .client-device-tag-popover, .client-confirmation-popup.is-open')) 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,
|
||
tagSavingId,
|
||
tagError,
|
||
resetOpen,
|
||
resetting,
|
||
panelRef,
|
||
toggleRef,
|
||
closeRef,
|
||
load,
|
||
updateDevice,
|
||
updateDeviceTags,
|
||
createTag,
|
||
renameTag,
|
||
deleteTag,
|
||
clearTagError: () => setTagError(null),
|
||
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" />
|
||
</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>;
|
||
}
|