Files
harbor-net/src/web/features/devices/DevicesFeature.tsx
T
dokril 116686a138
Build and Deploy Gateway / build-and-push (push) Successful in 26s
Build and Deploy Gateway / deploy (push) Successful in 14s
Implement Harbor gateway device ecosystem support
2026-08-31 02:06:22 +03:00

440 lines
15 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,
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>;
}