Implement Harbor gateway device ecosystem support
Build and Deploy Gateway / build-and-push (push) Successful in 26s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-31 02:06:22 +03:00
parent 7e15cc199f
commit 116686a138
21 changed files with 1924 additions and 51 deletions
+137
View File
@@ -11,6 +11,7 @@ import {
type Device,
type DevicePolicy,
type DeviceSnapshot,
type DeviceTag,
} from './deviceSnapshot.js';
const DEVICE_AUTO_REFRESH_MS = 15_000;
@@ -21,6 +22,9 @@ interface DevicesFeatureOptions {
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>;
}
@@ -37,12 +41,21 @@ function requestError(value: unknown): RequestError {
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);
@@ -52,6 +65,8 @@ export function useDevicesFeature({
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);
@@ -140,6 +155,120 @@ export function useDevicesFeature({
}
}
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);
@@ -180,6 +309,7 @@ export function useDevicesFeature({
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)
@@ -206,6 +336,8 @@ export function useDevicesFeature({
refreshing,
refreshCycle,
savingId,
tagSavingId,
tagError,
resetOpen,
resetting,
panelRef,
@@ -213,6 +345,11 @@ export function useDevicesFeature({
closeRef,
load,
updateDevice,
updateDeviceTags,
createTag,
renameTag,
deleteTag,
clearTagError: () => setTagError(null),
updatePolicy,
requestTrafficReset: () => setResetOpen(true),
cancelTrafficReset: () => setResetOpen(false),
+628 -15
View File
@@ -5,26 +5,34 @@ import {
useRef,
useState,
type CSSProperties,
type FormEvent,
} from 'react';
import { createPortal } from 'react-dom';
import { Drawer } from '../../ui/Drawer.js';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { copyText } from '../../utils/clientControls.js';
import {
byteString,
deviceFilterCounts,
filterDevices,
formatByteString,
formatLastSeen,
isNewDevice,
positiveByteDelta,
stabilizeDevicesByTraffic,
type DeviceSystemFilter,
} from '../../utils/format.js';
import { TrafficChart } from './TrafficChart.js';
import { type Device } from './deviceSnapshot.js';
import { type Device, type DeviceTag } from './deviceSnapshot.js';
import type { DevicesFeature } from './DevicesFeature.js';
const DEVICE_MOVE_MS = 520;
const COPY_FEEDBACK_MS = 800;
const TRAFFIC_DELTA_MS = 2_200;
const FOCUSABLE = 'button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex="-1"])';
const tagTone = (id: string) => Number.parseInt(id.slice(-2), 16) % 4;
interface TrafficDelta {
gateway?: string;
@@ -51,6 +59,15 @@ interface PinCollapse {
type DeviceCopyField = 'IP' | 'MAC' | 'Hostname';
interface TagPopoverState {
deviceId: string;
title: string;
baseline: string[];
draft: string[];
catalogKey: string;
anchor: DOMRect;
}
function requestMessage(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const message: unknown = Reflect.get(value, 'message');
@@ -84,10 +101,17 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
refreshing,
refreshCycle,
savingId,
tagSavingId,
tagError,
resetOpen,
resetting,
load: onLoad,
updateDevice,
updateDeviceTags,
createTag,
renameTag,
deleteTag,
clearTagError,
updatePolicy,
requestTrafficReset,
cancelTrafficReset,
@@ -104,6 +128,20 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const [pencilAnimationId, setPencilAnimationId] = useState('');
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
const [pinCollapses, setPinCollapses] = useState<Record<string, PinCollapse>>({});
const [systemFilter, setSystemFilter] = useState<DeviceSystemFilter>('all');
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [deviceQuery, setDeviceQuery] = useState('');
const [mobileRailOpen, setMobileRailOpen] = useState(false);
const [compactRail, setCompactRail] = useState(() => window.matchMedia('(max-width: 640px)').matches);
const [railMode, setRailMode] = useState<'filters' | 'manager'>('filters');
const [tagPopover, setTagPopover] = useState<TagPopoverState | null>(null);
const [tagPopoverClosing, setTagPopoverClosing] = useState(false);
const [tagAnnouncement, setTagAnnouncement] = useState('');
const [newTagName, setNewTagName] = useState('');
const [editingTagId, setEditingTagId] = useState('');
const [editingTagName, setEditingTagName] = useState('');
const [deletingTagId, setDeletingTagId] = useState('');
const [tagErrorCopy, setTagErrorCopy] = useState('');
const deviceNodes = useRef(new Map<string, HTMLElement>());
const previousPositions = useRef(new Map<string, DOMRect>());
const previousScrollTop = useRef(0);
@@ -113,29 +151,190 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const copyTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const copyAttempts = useRef(new Map<string, object>());
const trafficDeltaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const tagPopoverCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const railModeAnimation = useRef<Animation | null>(null);
const railModeRequest = useRef(0);
const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] });
const filterButtonRef = useRef<HTMLButtonElement>(null);
const railRef = useRef<HTMLElement>(null);
const layoutRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const managerInputRef = useRef<HTMLInputElement>(null);
const tagTriggerRefs = useRef(new Map<string, HTMLButtonElement>());
const restoreTagTriggerFocus = useRef(true);
const editingTagBaseline = useRef('');
const allDevices = snapshot?.devices || [];
const tags = snapshot?.tags || [];
const counts = useMemo(
() => deviceFilterCounts(allDevices, tags),
[snapshot?.devices, snapshot?.tags],
);
const filteredDevices = useMemo(
() => filterDevices(allDevices, tags, {
system: systemFilter,
tagIds: selectedTagIds,
query: deviceQuery,
}),
[snapshot?.devices, snapshot?.tags, systemFilter, selectedTagIds, deviceQuery],
);
const devices = useMemo(
() => {
const previousIds = trafficOrder.current.direction === sortDirection
? trafficOrder.current.ids
: [];
const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds) as {
const result = stabilizeDevicesByTraffic(filteredDevices, sortDirection, previousIds) as {
ids: string[];
devices: Device[];
};
trafficOrder.current = { direction: sortDirection, ids: result.ids };
const canonical = stabilizeDevicesByTraffic(allDevices, sortDirection, previousIds) as {
ids: string[];
devices: Device[];
};
trafficOrder.current = { direction: sortDirection, ids: canonical.ids };
return result.devices;
},
[snapshot?.devices, sortDirection],
[allDevices, filteredDevices, sortDirection],
);
const filtersActive = systemFilter !== 'all' || selectedTagIds.length > 0 || deviceQuery.trim().length > 0;
useEffect(() => () => {
for (const timer of copyTimers.current.values()) clearTimeout(timer);
copyTimers.current.clear();
copyAttempts.current.clear();
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
if (tagPopoverCloseTimer.current) clearTimeout(tagPopoverCloseTimer.current);
railModeAnimation.current?.cancel();
}, []);
useEffect(() => {
const knownTagIds = new Set(tags.map(({ id }) => id));
setSelectedTagIds((current) => current.filter((id) => knownTagIds.has(id)));
if (!tagPopover) return;
const device = allDevices.find(({ id }) => id === tagPopover.deviceId);
const baselineChanged = !device || device.tagIds.length !== tagPopover.baseline.length
|| device.tagIds.some((id, index) => id !== tagPopover.baseline[index]);
const catalogChanged = tags.map(({ id, name }) => `${id}:${name}`).join('|') !== tagPopover.catalogKey;
if (!baselineChanged && !catalogChanged) return;
closeTagPopover();
setTagAnnouncement('Список тегов изменился. Откройте теги устройства снова.');
}, [snapshot?.tags, snapshot?.devices]);
useEffect(() => {
if (open) return;
restoreTagTriggerFocus.current = false;
setMobileRailOpen(false);
setRailMode('filters');
closeTagPopover(true, false);
}, [open]);
useEffect(() => {
if (railMode !== 'manager' || tagPopover || (compactRail && !mobileRailOpen)) return undefined;
const frame = requestAnimationFrame(() => managerInputRef.current?.focus());
return () => cancelAnimationFrame(frame);
}, [compactRail, mobileRailOpen, railMode, tagPopover]);
useEffect(() => {
const media = window.matchMedia('(max-width: 640px)');
const update = () => {
setCompactRail(media.matches);
if (!media.matches) setMobileRailOpen(false);
};
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, []);
useEffect(() => {
if (!mobileRailOpen) return undefined;
if (contentRef.current) contentRef.current.inert = true;
if (closeRef.current) closeRef.current.inert = true;
const frame = requestAnimationFrame(() => searchRef.current?.focus());
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
setMobileRailOpen(false);
return;
}
if (event.key !== 'Tab') return;
const controls = Array.from(railRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) || []);
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('keydown', onKeyDown);
if (contentRef.current) contentRef.current.inert = false;
if (closeRef.current) closeRef.current.inert = false;
requestAnimationFrame(() => filterButtonRef.current?.focus());
};
}, [mobileRailOpen]);
useEffect(() => {
if (!tagPopover) return undefined;
if (layoutRef.current) layoutRef.current.inert = true;
if (closeRef.current) closeRef.current.inert = true;
const frame = requestAnimationFrame(() => {
const assigned = popoverRef.current?.querySelector<HTMLInputElement>('input:checked');
const first = popoverRef.current?.querySelector<HTMLElement>(FOCUSABLE);
(assigned || first)?.focus();
});
const close = () => closeTagPopover();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
close();
return;
}
if (event.key !== 'Tab') return;
const controls = Array.from(popoverRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) || []);
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
window.addEventListener('resize', close);
panelRef.current?.addEventListener('scroll', close);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener('resize', close);
panelRef.current?.removeEventListener('scroll', close);
if (layoutRef.current) layoutRef.current.inert = false;
if (closeRef.current) closeRef.current.inert = false;
requestAnimationFrame(() => {
if (!restoreTagTriggerFocus.current) {
restoreTagTriggerFocus.current = true;
return;
}
const target = [
tagTriggerRefs.current.get(tagPopover.deviceId),
searchRef.current,
filterButtonRef.current,
closeRef.current,
].find((candidate) => candidate?.isConnected && !candidate.closest('[inert]'));
target?.focus();
});
};
}, [tagPopover?.deviceId]);
useEffect(() => {
if (!open) {
previousTraffic.current.clear();
@@ -193,23 +392,37 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
return;
}
const positions = new Map<string, DOMRect>();
const interruptedPositions = new Map<string, DOMRect>();
for (const [id, node] of deviceNodes.current) {
movementAnimations.current.get(id)?.cancel();
const activeAnimation = movementAnimations.current.get(id);
if (activeAnimation) {
activeAnimation.commitStyles();
activeAnimation.cancel();
movementAnimations.current.delete(id);
interruptedPositions.set(id, node.getBoundingClientRect());
node.style.removeProperty('opacity');
node.style.removeProperty('transform');
}
positions.set(id, node.getBoundingClientRect());
}
const currentScrollTop = panelRef.current?.scrollTop || 0;
if (previousPositions.current.size > 0
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
for (const [id, after] of positions) {
const before = previousPositions.current.get(id);
const interrupted = interruptedPositions.get(id);
const before = interrupted || previousPositions.current.get(id);
const deltaY = before
? before.top - after.top + previousScrollTop.current - currentScrollTop
? before.top - after.top + (interrupted ? 0 : previousScrollTop.current - currentScrollTop)
: 0;
if (Math.abs(deltaY) < 1) continue;
const animation = deviceNodes.current.get(id)?.animate([
{ transform: `translateY(${deltaY}px)` },
{ transform: 'translateY(0)' },
], { duration: DEVICE_MOVE_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
const animation = before
? Math.abs(deltaY) < 1 ? undefined : deviceNodes.current.get(id)?.animate([
{ transform: `translateY(${deltaY}px)` },
{ transform: 'translateY(0)' },
], { duration: DEVICE_MOVE_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' })
: deviceNodes.current.get(id)?.animate([
{ opacity: 0, transform: 'translateY(6px)' },
{ opacity: 1, transform: 'translateY(0)' },
], { duration: 160, easing: 'ease' });
if (animation) {
movementAnimations.current.set(id, animation);
animation.onfinish = () => movementAnimations.current.delete(id);
@@ -316,6 +529,164 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
});
}
function setSystem(value: DeviceSystemFilter) {
setSystemFilter(value);
if (value === 'untagged') setSelectedTagIds([]);
}
function toggleTagFilter(tagId: string) {
if (systemFilter === 'untagged') setSystemFilter('all');
setSelectedTagIds((current) => current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId]);
}
function resetFilters() {
setSystemFilter('all');
setSelectedTagIds([]);
setDeviceQuery('');
}
function closeTagPopover(immediate = false, restoreFocus = true) {
if (!tagPopover) return;
restoreTagTriggerFocus.current = restoreFocus;
if (tagPopoverCloseTimer.current) clearTimeout(tagPopoverCloseTimer.current);
if (immediate || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setTagPopoverClosing(false);
setTagPopover(null);
return;
}
setTagPopoverClosing(true);
tagPopoverCloseTimer.current = setTimeout(() => {
setTagPopover(null);
setTagPopoverClosing(false);
tagPopoverCloseTimer.current = null;
}, 160);
}
async function changeRailMode(next: 'filters' | 'manager') {
if (next === railMode) return;
const request = ++railModeRequest.current;
const activeAnimation = railModeAnimation.current;
if (activeAnimation) {
try { activeAnimation.commitStyles(); } catch { /* The previous view may already be detached. */ }
activeAnimation.cancel();
railModeAnimation.current = null;
}
const view = railRef.current?.querySelector<HTMLElement>('.client-devices-rail-view');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (view && !reducedMotion) {
const outgoing = view.animate(
[{ opacity: 0, transform: 'translateX(-6px)' }],
{ duration: 160, easing: 'ease', fill: 'forwards' },
);
railModeAnimation.current = outgoing;
try { await outgoing.finished; } catch { return; }
if (railModeAnimation.current === outgoing) railModeAnimation.current = null;
}
if (request !== railModeRequest.current) return;
setRailMode(next);
if (reducedMotion) return;
requestAnimationFrame(() => {
if (request !== railModeRequest.current) return;
const incoming = railRef.current?.querySelector<HTMLElement>('.client-devices-rail-view')?.animate([
{ opacity: 0, transform: 'translateX(6px)' },
{ opacity: 1, transform: 'translateX(0)' },
], { duration: 160, easing: 'ease' });
if (incoming) {
railModeAnimation.current = incoming;
incoming.onfinish = () => {
if (railModeAnimation.current === incoming) railModeAnimation.current = null;
};
}
});
}
function openTagPopover(device: Device, title: string, anchor: DOMRect) {
if (tagPopoverCloseTimer.current) clearTimeout(tagPopoverCloseTimer.current);
clearTagError();
setTagAnnouncement('');
restoreTagTriggerFocus.current = true;
setTagPopoverClosing(false);
setTagPopover({
deviceId: device.id,
title,
baseline: [...device.tagIds],
draft: [...device.tagIds],
catalogKey: tags.map(({ id, name }) => `${id}:${name}`).join('|'),
anchor,
});
}
async function saveDeviceTags() {
if (!tagPopover) return;
const device = allDevices.find(({ id }) => id === tagPopover.deviceId);
if (!device) return;
if (await updateDeviceTags(device, tagPopover.draft, tagPopover.baseline)) closeTagPopover();
}
function showTagManager() {
clearTagError();
setTagErrorCopy('');
restoreTagTriggerFocus.current = false;
void changeRailMode('manager');
if (compactRail) setMobileRailOpen(true);
closeTagPopover(false, false);
}
async function submitNewTag(event: FormEvent) {
event.preventDefault();
setTagErrorCopy('Не удалось создать тег.');
if (!newTagName.trim() || !await createTag(newTagName)) return;
setTagErrorCopy('');
setNewTagName('');
requestAnimationFrame(() => managerInputRef.current?.focus());
}
async function submitTagRename(tag: DeviceTag) {
setTagErrorCopy('Не удалось переименовать тег.');
if (!editingTagName.trim() || !await renameTag(tag, editingTagName, editingTagBaseline.current)) return;
setTagErrorCopy('');
setEditingTagId('');
setEditingTagName('');
}
const deletingTag = tags.find(({ id }) => id === deletingTagId) || null;
const deletingTagCount = deletingTag ? counts.byTag[deletingTag.id] || 0 : 0;
const deletingTagDescription = deletingTagCount === 0
? 'Тег больше не будет доступен для назначения. Устройства и маршруты не изменятся.'
: deletingTagCount === 1
? 'Тег исчезнет у одного устройства. Само устройство и его маршрут не изменятся.'
: `Тег исчезнет у ${deletingTagCount} устройств. Сами устройства и их маршруты не изменятся.`;
async function confirmTagDelete() {
if (!deletingTag) return;
setTagErrorCopy('Не удалось удалить тег.');
const result = await deleteTag(deletingTag);
if (result === 'saved') {
setDeletingTagId('');
setTagErrorCopy('');
} else if (result === 'conflict') {
setTagAnnouncement('Список тегов обновлён. Проверьте количество устройств и подтвердите удаление ещё раз.');
}
}
const systemFilters: Array<{ id: DeviceSystemFilter; label: string; count: number }> = [
{ id: 'all', label: 'Все', count: counts.all },
{ id: 'new', label: 'Новые', count: counts.new },
{ id: 'pinned', label: 'Закреплённые', count: counts.pinned },
{ id: 'background', label: 'Фоновые', count: counts.background },
{ id: 'untagged', label: 'Без тегов', count: counts.untagged },
];
const selectedTagNames = tags
.filter(({ id }) => selectedTagIds.includes(id))
.map(({ name }) => name);
const activeSummary = [
systemFilter === 'all' ? '' : systemFilters.find(({ id }) => id === systemFilter)?.label || '',
selectedTagNames.join(' или '),
deviceQuery.trim() ? `«${deviceQuery.trim()}»` : '',
].filter(Boolean).join(' · ');
return <>
<Drawer
panelRef={panelRef}
@@ -328,8 +699,147 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
closeLabel="Закрыть устройства"
onClose={onClose}
>
<div ref={layoutRef} className="client-devices-layout">
{compactRail && <button
className={`client-devices-rail-backdrop${mobileRailOpen ? ' is-open' : ''}`}
type="button"
aria-label="Закрыть фильтры"
aria-hidden={!mobileRailOpen}
inert={!mobileRailOpen ? true : undefined}
onClick={() => setMobileRailOpen(false)}
/>}
<aside
ref={railRef}
className={mobileRailOpen ? 'client-devices-rail is-open' : 'client-devices-rail'}
role={compactRail ? 'dialog' : 'navigation'}
aria-modal={compactRail && mobileRailOpen ? true : undefined}
aria-label={railMode === 'filters' ? 'Фильтры устройств' : 'Управление тегами'}
aria-hidden={compactRail && !mobileRailOpen ? true : undefined}
inert={compactRail && !mobileRailOpen ? true : undefined}
>
<div className="client-devices-rail-view" key={railMode}>
{railMode === 'filters' ? <>
<label className="client-devices-search">
<span>Найти устройство</span>
<input
ref={searchRef}
type="search"
value={deviceQuery}
placeholder="Найти устройство"
onChange={(event) => setDeviceQuery(event.target.value)}
/>
</label>
<div className="client-devices-filter-group" role="group" aria-label="Системные фильтры">
{systemFilters.map((item) => <button
key={item.id}
type="button"
aria-pressed={systemFilter === item.id}
onClick={() => setSystem(item.id)}
>
<span>{item.label}</span><b>{item.count}</b>
</button>)}
</div>
{snapshot?.taggingSupported ? <>
<div className="client-devices-rail-heading"><span>Теги</span><span>{tags.length}/32</span></div>
<div className="client-devices-tag-filters" role="group" aria-label="Фильтр по тегам">
{tags.map((tag) => <button
key={tag.id}
data-tag-tone={tagTone(tag.id)}
type="button"
aria-pressed={selectedTagIds.includes(tag.id)}
onClick={() => toggleTagFilter(tag.id)}
>
<span>{tag.name}</span><b>{counts.byTag[tag.id] || 0}</b>
</button>)}
{!tags.length && <p>Тегов пока нет.</p>}
</div>
<button className="client-devices-manage-tags" type="button" onClick={showTagManager}>
Управление тегами
</button>
</> : snapshot && <p className="client-devices-tags-unsupported">
Теги доступны после обновления Gateway.
</p>}
</> : <>
<button
className="client-devices-manager-back"
type="button"
onClick={() => {
clearTagError();
setTagErrorCopy('');
void changeRailMode('filters');
}}
>Назад</button>
<h3>Управление тегами</h3>
<form className="client-devices-tag-create" onSubmit={submitNewTag}>
<input
ref={managerInputRef}
value={newTagName}
maxLength={24}
placeholder="Название тега"
aria-label="Название нового тега"
disabled={tags.length >= 32 || tagSavingId === 'create'}
onChange={(event) => setNewTagName(event.target.value)}
/>
<button type="submit" disabled={tags.length >= 32 || tagSavingId === 'create' || !newTagName.trim()}>Создать</button>
</form>
{Boolean(tagError) && tagErrorCopy && <p className="client-devices-tag-error" role="alert">{tagErrorCopy}</p>}
<div className="client-devices-tag-manager-list">
{tags.map((tag) => <div key={tag.id} className="client-devices-tag-manager-row" data-tag-tone={tagTone(tag.id)}>
{editingTagId === tag.id ? <form onSubmit={(event) => {
event.preventDefault();
void submitTagRename(tag);
}}>
<input
autoFocus
value={editingTagName}
maxLength={24}
aria-label={`Новое название тега ${tag.name}`}
disabled={tagSavingId === tag.id}
onChange={(event) => setEditingTagName(event.target.value)}
/>
<button type="submit" disabled={tagSavingId === tag.id || !editingTagName.trim()}>Сохранить</button>
<button type="button" onClick={() => setEditingTagId('')}>Отмена</button>
</form> : <>
<span>{tag.name}</span><b>{counts.byTag[tag.id] || 0}</b>
<button
type="button"
aria-label={`Переименовать тег ${tag.name}`}
disabled={tagSavingId === tag.id}
onClick={() => {
clearTagError();
setTagErrorCopy('');
setEditingTagId(tag.id);
setEditingTagName(tag.name);
editingTagBaseline.current = tag.name;
}}
><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" /></svg></button>
<button
type="button"
aria-label={`Удалить тег ${tag.name}`}
disabled={tagSavingId === tag.id}
onClick={() => {
clearTagError();
setTagErrorCopy('');
setDeletingTagId(tag.id);
}}
><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5" /></svg></button>
</>}
</div>)}
{!tags.length && <p>Создайте первый тег, чтобы распределить устройства.</p>}
</div>
</>}
</div>
</aside>
<div ref={contentRef} className="client-devices-content">
<div className="client-devices-kicker">
<span>Gateway · {devices.length}</span>
<span>Gateway · {counts.all}</span>
<button
ref={filterButtonRef}
className="client-devices-filter-trigger"
type="button"
aria-expanded={mobileRailOpen}
onClick={() => setMobileRailOpen(true)}
>Фильтры{filtersActive ? ' · активны' : ''}</button>
<span className="client-devices-refresh-wrap client-tooltip-anchor">
<button
className={`client-devices-refresh${refreshing ? ' is-refreshing' : ''}`}
@@ -420,12 +930,23 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</div>
)}
{status === 'loading' && <p className="client-devices-empty" role="status">Ищем устройства</p>}
{status !== 'loading' && !devices.length && !error && (
{status !== 'loading' && !allDevices.length && !error && (
<p className="client-devices-empty">Gateway пока не видит устройств.</p>
)}
{status !== 'loading' && allDevices.length > 0 && !devices.length && !error && (
<div className="client-devices-empty client-devices-filter-empty">
<p>По этим фильтрам устройств нет.</p>
<button type="button" onClick={resetFilters}>Сбросить фильтры</button>
</div>
)}
{filtersActive && devices.length > 0 && <div className="client-devices-filter-summary">
<span>{activeSummary} · Результатов: {devices.length}</span>
<button type="button" onClick={resetFilters}>Сбросить</button>
</div>}
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyAnnouncement?.message || ''}
{tagAnnouncement || copyAnnouncement?.message || ''}
</div>
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device, index) => {
@@ -446,6 +967,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const hasName = Boolean(device.alias || device.hostname);
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
const newDevice = isNewDevice(device.firstSeenAt);
const deviceTags = tags.filter(({ id }) => device.tagIds.includes(id));
const firstTag = deviceTags[0];
const editing = editingId === device.id;
const saving = savingId === device.id;
const seen = formatLastSeen(device.lastSeenAt);
@@ -561,6 +1084,20 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
<span aria-hidden="true">NEW</span>
<span className="client-device-new-a11y">Новое устройство</span>
</span>}
{!editing && snapshot?.taggingSupported && <button
ref={(node) => {
if (node) tagTriggerRefs.current.set(device.id, node);
else tagTriggerRefs.current.delete(device.id);
}}
className={firstTag ? 'client-device-tag-trigger has-tag' : 'client-device-tag-trigger'}
data-tag-tone={firstTag ? tagTone(firstTag.id) : undefined}
type="button"
aria-label={`Изменить теги устройства ${title}`}
disabled={tagSavingId === device.id}
onClick={(event) => openTagPopover(device, title, event.currentTarget.getBoundingClientRect())}
>
{firstTag ? <><span>{firstTag.name}</span>{deviceTags.length > 1 && <b>+{deviceTags.length - 1}</b>}</> : '+ тег'}
</button>}
{!editing && <span className="client-device-identity-details" role="group" aria-label={`Технические данные устройства ${title}`}>
{device.ip && <button
className={`client-device-identity-copy${feedback?.field === 'IP' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
@@ -681,7 +1218,64 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</article>;
})}
</div>
</div>
</div>
</Drawer>
{tagPopover && createPortal(
<div
className={`client-device-tag-popover-layer${tagPopoverClosing ? ' is-closing' : ''}`}
inert={tagPopoverClosing ? true : undefined}
onPointerDown={(event) => {
if (event.target === event.currentTarget && !tagSavingId) closeTagPopover();
}}
>
<section
ref={popoverRef}
className="client-device-tag-popover"
role="dialog"
aria-modal="true"
aria-label={`Теги устройства ${tagPopover.title}`}
aria-busy={tagSavingId === tagPopover.deviceId}
style={{
top: `${Math.max(12, Math.min(tagPopover.anchor.bottom + 8, window.innerHeight - 360))}px`,
left: `${Math.max(12, Math.min(tagPopover.anchor.left, window.innerWidth - 292))}px`,
}}
>
<h3>Теги устройства {tagPopover.title}</h3>
{tags.length ? <div className="client-device-tag-popover-options">
{tags.map((tag) => {
const checked = tagPopover.draft.includes(tag.id);
return <label key={tag.id} data-tag-tone={tagTone(tag.id)}>
<input
type="checkbox"
checked={checked}
disabled={tagSavingId === tagPopover.deviceId || (!checked && tagPopover.draft.length >= 8)}
onChange={() => setTagPopover((current) => current && ({
...current,
draft: checked
? current.draft.filter((id) => id !== tag.id)
: [...current.draft, tag.id],
}))}
/>
<span>{tag.name}</span>
</label>;
})}
</div> : <p>Тегов пока нет.</p>}
{Boolean(tagError) && <p className="client-devices-tag-error" role="alert">Не удалось сохранить теги.</p>}
<div className="client-device-tag-popover-actions">
{!tags.length && <button type="button" onClick={showTagManager}>Создать тег</button>}
<button type="button" disabled={tagSavingId === tagPopover.deviceId} onClick={() => closeTagPopover()}>Отмена</button>
<button
type="button"
disabled={tagSavingId === tagPopover.deviceId || (tagPopover.baseline.length === tagPopover.draft.length
&& tagPopover.baseline.every((id, index) => id === tagPopover.draft[index]))}
onClick={saveDeviceTags}
>Сохранить</button>
</div>
</section>
</div>,
document.querySelector('.app.client-app') || document.body,
)}
<ConfirmationDialog
open={resetOpen}
id="client-devices-reset"
@@ -694,5 +1288,24 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
onCancel={cancelTrafficReset}
onConfirm={confirmResetTraffic}
/>
<ConfirmationDialog
open={Boolean(deletingTag)}
id="client-device-tag-delete"
kicker="Теги устройств"
title={deletingTag ? `Удалить тег «${deletingTag.name}»?` : 'Удалить тег?'}
description={<>{deletingTagDescription}{Boolean(tagError) && tagErrorCopy && <>
<br /><span className="client-devices-tag-error" role="alert">{tagErrorCopy}</span>
</>}</>}
cancelLabel="Оставить тег"
confirmLabel="Удалить"
busy={Boolean(deletingTag && tagSavingId === deletingTag.id)}
onCancel={() => {
if (tagSavingId) return;
setDeletingTagId('');
clearTagError();
setTagErrorCopy('');
}}
onConfirm={confirmTagDelete}
/>
</>;
}
+43 -1
View File
@@ -5,6 +5,11 @@ type DeviceStatus = 'online' | 'recent' | 'offline';
type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed';
type DeviceConfidence = 'high' | 'medium' | 'ambiguous';
export interface DeviceTag extends Record<string, unknown> {
id: string;
name: string;
}
export interface TrafficSample extends Record<string, unknown> {
observedAt: string;
gatewayBytes: ByteValue;
@@ -37,6 +42,7 @@ export interface Device extends Record<string, unknown> {
status: DeviceStatus;
pinned: boolean;
deprioritized?: boolean;
tagIds: string[];
downloadBytes: ByteValue;
uploadBytes: ByteValue;
proxyDownloadBytes: ByteValue;
@@ -74,6 +80,8 @@ interface SnapshotSource extends Record<string, unknown> {
export interface DeviceSnapshot extends Record<string, unknown> {
revision: number;
tags: DeviceTag[];
taggingSupported: boolean;
devices: Device[];
trafficHistoryCapacity: number;
traffic: {
@@ -157,6 +165,10 @@ function validDevice(value: unknown): value is Device {
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
&& typeof value.pinned === 'boolean'
&& (value.deprioritized === undefined || typeof value.deprioritized === 'boolean')
&& (value.tagIds === undefined || (Array.isArray(value.tagIds)
&& value.tagIds.length <= 8
&& value.tagIds.every((tagId) => typeof tagId === 'string' && /^tag_[a-f0-9]{16}$/.test(tagId))
&& new Set(value.tagIds).size === value.tagIds.length))
&& !(value.pinned === true && value.deprioritized === true)
&& bytes(value.downloadBytes)
&& bytes(value.uploadBytes)
@@ -173,6 +185,20 @@ function validDevice(value: unknown): value is Device {
&& (value.outboundTrafficHistory === undefined || validOutboundHistory(value.outboundTrafficHistory));
}
function validTags(value: unknown): value is DeviceTag[] {
return Array.isArray(value)
&& value.length <= 32
&& value.every((tag) => record(tag)
&& typeof tag.id === 'string'
&& /^tag_[a-f0-9]{16}$/.test(tag.id)
&& typeof tag.name === 'string'
&& tag.name.trim() === tag.name
&& tag.name.length > 0
&& tag.name.length <= 24)
&& new Set(value.map((tag) => tag.id)).size === value.length
&& new Set(value.map((tag) => tag.name.toLocaleLowerCase('ru-RU'))).size === value.length;
}
function validSource(value: unknown): value is SnapshotSource {
return record(value)
&& value.kind === 'neighbor'
@@ -203,12 +229,19 @@ function validTraffic(value: unknown): value is DeviceSnapshot['traffic'] {
}
function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot {
const taggingSupported = record(value) && Object.hasOwn(value, 'tags');
const tags = taggingSupported && record(value) && validTags(value.tags) ? value.tags : [];
const knownTagIds = new Set(tags.map(({ id }) => id));
if (!record(value)
|| !Number.isSafeInteger(value.revision)
|| typeof value.revision !== 'number'
|| value.revision < 0
|| !Array.isArray(value.devices)
|| !value.devices.every(validDevice)
|| (taggingSupported && !validTags(value.tags))
|| (taggingSupported && value.devices.some((device) => (
!Array.isArray(device.tagIds) || device.tagIds.some((tagId) => !knownTagIds.has(tagId))
)))
|| !Number.isSafeInteger(value.trafficHistoryCapacity)
|| typeof value.trafficHistoryCapacity !== 'number'
|| value.trafficHistoryCapacity <= 0
@@ -220,5 +253,14 @@ function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot {
export function parseDeviceSnapshot(value: unknown): DeviceSnapshot {
assertDeviceSnapshot(value);
return value;
const taggingSupported = Object.hasOwn(value, 'tags');
return {
...value,
taggingSupported,
tags: taggingSupported ? value.tags : [],
devices: value.devices.map((device) => ({
...device,
tagIds: taggingSupported && Array.isArray(device.tagIds) ? device.tagIds : [],
})),
};
}