From 116686a1386a7aed42ebbfe4ac95dc183fe2986c Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Mon, 31 Aug 2026 02:06:22 +0300 Subject: [PATCH] Implement Harbor gateway device ecosystem support --- README.md | 2 + .../http/routes/deviceInventoryRoute.ts | 33 + src/server/services/deviceInventoryService.ts | 196 +++++- src/shared/errors.ts | 2 + src/shared/versions.ts | 6 +- src/web/App.tsx | 3 + src/web/api/harborClient.ts | 18 + src/web/components/ClientOverviewPage.tsx | 6 + src/web/features/devices/DevicesFeature.tsx | 137 ++++ src/web/features/devices/DevicesPanel.tsx | 643 +++++++++++++++++- src/web/features/devices/deviceSnapshot.ts | 44 +- src/web/styles/features/devices.css | 459 +++++++++++++ src/web/styles/themes.css | 4 + src/web/ui/ConfirmationDialog.tsx | 4 +- src/web/utils/format.ts | 57 +- test/server/device-inventory.test.js | 138 ++++ test/server/device-routes.test.js | 63 +- test/web/api-errors.test.js | 9 + test/web/device-inventory-contract.test.js | 91 ++- test/web/devices-feature-contract.test.js | 22 +- test/web/style-boundaries.test.js | 38 +- 21 files changed, 1924 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 5d53313..33953ae 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ http://АДРЕС-GATEWAY:3456 Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, выбранный график трафика и иконку применённого маршрута. По умолчанию график показывает приблизительный выход `VPN`/`Direct`; переключатель `Вход` возвращает накопленную разбивку `Gateway`/`Прокси`. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory. +Левая панель списка ищет по имени, hostname, IP, MAC и тегам, фильтрует новые, закреплённые, фоновые или устройства без тегов и позволяет выбрать несколько тегов по правилу «хотя бы один». Каталог тегов общий для Gateway: в нём можно создать до 32 тегов и назначить устройству до 8. Назначения сохраняются вместе с `devices.json`, но маршруты не меняют. После удаления устройства по 30-дневному retention его назначения удаляются, сам каталог остаётся; вернувшееся позже устройство появляется без тегов. Если Mac-клиент подключён к старой версии Gateway, список продолжает работать, а управление тегами скрывается до обновления Gateway. + Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters. Устройство, впервые замеченное после обновления Gateway, по умолчанию идёт `Напрямую` и первые семь дней отмечается `NEW`; исчезновение метки маршрут не меняет. Уже известные при обновлении устройства сохраняют текущий VPN, даже если метка ещё видна по их `firstSeenAt`. VPN разрешается существующей последней иконкой маршрута. Если новый device пока распознан неоднозначно, Harbor сохраняет Direct-намерение, временно оставляет фактический VPN и применяет Direct после однозначного наблюдения. diff --git a/src/server/http/routes/deviceInventoryRoute.ts b/src/server/http/routes/deviceInventoryRoute.ts index 6d89d08..285919f 100644 --- a/src/server/http/routes/deviceInventoryRoute.ts +++ b/src/server/http/routes/deviceInventoryRoute.ts @@ -7,6 +7,9 @@ interface DeviceInventoryPort { snapshot(): unknown; refresh(): Promise; update(deviceId: string, patch: Record, expectedRevision: unknown): unknown; + createTag(name: unknown, expectedRevision: unknown): unknown; + renameTag(tagId: string, name: unknown, expectedRevision: unknown): unknown; + deleteTag(tagId: string, expectedRevision: unknown): unknown; resetTraffic(expectedRevision: unknown): Promise; setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise; } @@ -18,6 +21,7 @@ interface DeviceInventoryRouteDependencies { const DEVICE_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})$/; const DEVICE_POLICY_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/; +const DEVICE_TAG_PATH = /^\/api\/device-tags\/(tag_[a-f0-9]{16})$/; export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDependencies) { return { @@ -49,6 +53,35 @@ export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDep return true; } + if (pathname === '/api/device-tags') { + if (!dependencies.deviceInventory || req.method !== 'POST') { + throw new HarborError('ENDPOINT_NOT_FOUND'); + } + const body = await dependencies.readBody(req); + sendJson( + res, + 200, + dependencies.deviceInventory.createTag(body.name, body.expectedRevision), + ); + return true; + } + + const tagMatch = pathname.match(DEVICE_TAG_PATH); + if (tagMatch) { + if (!dependencies.deviceInventory || !['PUT', 'DELETE'].includes(req.method || '')) { + throw new HarborError('ENDPOINT_NOT_FOUND'); + } + const body = await dependencies.readBody(req); + sendJson( + res, + 200, + req.method === 'PUT' + ? dependencies.deviceInventory.renameTag(tagMatch[1], body.name, body.expectedRevision) + : dependencies.deviceInventory.deleteTag(tagMatch[1], body.expectedRevision), + ); + return true; + } + const deviceMatch = pathname.match(DEVICE_PATH); if (deviceMatch) { if (!dependencies.deviceInventory || req.method !== 'PUT') { diff --git a/src/server/services/deviceInventoryService.ts b/src/server/services/deviceInventoryService.ts index 1531b41..8d74fc5 100644 --- a/src/server/services/deviceInventoryService.ts +++ b/src/server/services/deviceInventoryService.ts @@ -64,6 +64,17 @@ interface DevicePolicyState { byMac: Record; } +interface DeviceTag { + id: string; + name: string; +} + +interface DeviceTagState { + schemaVersion: number; + items: DeviceTag[]; + byMac: Record; +} + interface InventoryDevice { id: string; alias: string; @@ -101,6 +112,7 @@ export interface InventoryState { lastObservedAt: string | null; lastError: string | null; policy: DevicePolicyState; + tags: DeviceTagState; traffic: InventoryTrafficState; devices: InventoryDevice[]; [key: string]: unknown; @@ -204,6 +216,10 @@ const COUNTER_PATTERN = /^\d+$/; const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/; const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/; const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/; +const TAG_ID_PATTERN = /^tag_[a-f0-9]{16}$/; +const TAG_NAME_MAX_LENGTH = 24; +const TAG_CATALOG_LIMIT = 32; +const DEVICE_TAG_LIMIT = 8; const POLICY_MODES: ReadonlySet = new Set(['vpn', 'direct']); const POLICY_STATUSES: ReadonlySet = new Set(['applied', 'applying', 'pending', 'failed']); const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных'; @@ -228,6 +244,12 @@ const DEFAULT_POLICY_STATE: DevicePolicyState = { byMac: {}, }; +const DEFAULT_TAG_STATE: DeviceTagState = { + schemaVersion: 1, + items: [], + byMac: {}, +}; + const DEFAULT_PROXY_TRAFFIC: ProxyTrafficState = { schemaVersion: 1, lastObservedAt: null, @@ -252,6 +274,7 @@ const DEFAULT_STATE: InventoryState = { lastObservedAt: null, lastError: null, policy: DEFAULT_POLICY_STATE, + tags: DEFAULT_TAG_STATE, traffic: { epoch: null, generation: null, @@ -281,6 +304,17 @@ const parseStoredCounter = (value: unknown) => { return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null; }; +const normalizeTagName = (value: unknown) => typeof value === 'string' ? value.trim() : ''; +const tagNameKey = (value: string) => value.toLocaleLowerCase('ru-RU'); + +function validTagName(value: string) { + return value.length > 0 && value.length <= TAG_NAME_MAX_LENGTH; +} + +const sameStringList = (left: string[], right: string[]) => ( + left.length === right.length && left.every((value, index) => value === right[index]) +); + const validTimestamp = (value: unknown): value is string => ( typeof value === 'string' && Number.isFinite(Date.parse(value)) ); @@ -526,6 +560,38 @@ function normalizePolicyState(value: unknown): DevicePolicyState { }; } +function normalizeTagState(value: unknown, devices: InventoryDevice[]): DeviceTagState { + const tags = record(value); + if (typeof tags.schemaVersion === 'number' && Number.isSafeInteger(tags.schemaVersion) + && tags.schemaVersion > 1) { + throw new Error(`Unsupported device tags schemaVersion: ${tags.schemaVersion}`); + } + const items: DeviceTag[] = []; + const ids = new Set(); + const names = new Set(); + for (const itemValue of Array.isArray(tags.items) ? tags.items : []) { + if (items.length >= TAG_CATALOG_LIMIT) break; + const item = record(itemValue); + const id = typeof item.id === 'string' ? item.id : ''; + const name = normalizeTagName(item.name); + const nameKey = tagNameKey(name); + if (!TAG_ID_PATTERN.test(id) || !validTagName(name) || ids.has(id) || names.has(nameKey)) continue; + items.push({ id, name }); + ids.add(id); + names.add(nameKey); + } + const knownMacs = new Set(devices.map(({ mac }) => mac)); + const byMac: Record = {}; + for (const [rawMac, rawIds] of Object.entries(record(tags.byMac))) { + const mac = normalizeMac(rawMac); + if (!knownMacs.has(mac) || !Array.isArray(rawIds)) continue; + const selected = new Set(rawIds.filter((id): id is string => typeof id === 'string' && ids.has(id))); + const ordered = items.map(({ id }) => id).filter((id) => selected.has(id)).slice(0, DEVICE_TAG_LIMIT); + if (ordered.length) byMac[mac] = ordered; + } + return { schemaVersion: 1, items, byMac }; +} + export function parseOuiVendors(text: unknown) { const vendors = new Map(); for (const line of String(text || '').split(/\r?\n/)) { @@ -564,6 +630,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState { .map(normalizeInventoryDevice) .filter((device): device is InventoryDevice => device !== null) : []; + const tags = normalizeTagState(state.tags, devices); const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices); const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : []) .map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac))); @@ -658,6 +725,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState { schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION, revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) ? state.revision : 0, policy: normalizePolicyState(state.policy), + tags, traffic: { ...DEFAULT_STATE.traffic, ...traffic, @@ -978,6 +1046,7 @@ export function createDeviceInventoryService({ const policy = policyFor(state, device.mac); return { ...device, + tagIds: state.tags.byMac[device.mac] || [], status: deviceStatus(device.lastSeenAt, current), uploadBytes: traffic?.uploadBytes || '0', downloadBytes: traffic?.downloadBytes || '0', @@ -1013,6 +1082,7 @@ export function createDeviceInventoryService({ : [gatewayTraffic.lastObservedAt, proxyTraffic.lastObservedAt].filter(Boolean); return { revision: state.revision, + tags: state.tags.items, trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT, traffic: { gatewayBytes: gatewayBytes.toString(), @@ -1578,11 +1648,20 @@ export function createDeviceInventoryService({ const aliasProvided = Object.hasOwn(value, 'alias'); const pinProvided = Object.hasOwn(value, 'pinned'); const deprioritizedProvided = Object.hasOwn(value, 'deprioritized'); + const tagIdsProvided = Object.hasOwn(value, 'tagIds'); + const requestedTagIds = Array.isArray(value.tagIds) + ? value.tagIds.filter((tagId): tagId is string => typeof tagId === 'string') + : []; if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0 - || (!aliasProvided && !pinProvided && !deprioritizedProvided) + || (!aliasProvided && !pinProvided && !deprioritizedProvided && !tagIdsProvided) || (aliasProvided && (typeof value.alias !== 'string' || value.alias.length > 64)) || (pinProvided && typeof value.pinned !== 'boolean') || (deprioritizedProvided && typeof value.deprioritized !== 'boolean') + || (tagIdsProvided && (!Array.isArray(value.tagIds) + || requestedTagIds.length !== value.tagIds.length + || requestedTagIds.length > DEVICE_TAG_LIMIT + || new Set(requestedTagIds).size !== requestedTagIds.length + || requestedTagIds.some((tagId) => !TAG_ID_PATTERN.test(tagId)))) || (value.pinned === true && value.deprioritized === true)) { throw new HarborError('REQUEST_INVALID'); } @@ -1595,16 +1674,114 @@ export function createDeviceInventoryService({ if (state.revision !== revision) throw new HarborError('STATE_CONFLICT'); const index = state.devices.findIndex((device) => device.id === id); if (index < 0) throw new HarborError('DEVICE_NOT_FOUND'); + const knownTagIds = new Set(state.tags.items.map(({ id: tagId }) => tagId)); + if (tagIdsProvided && requestedTagIds.some((tagId) => !knownTagIds.has(tagId))) { + throw new HarborError('DEVICE_TAG_NOT_FOUND'); + } + const tagIds = state.tags.items + .map(({ id: tagId }) => tagId) + .filter((tagId) => requestedTagIds.includes(tagId)); + const currentTagIds = state.tags.byMac[state.devices[index].mac] || []; + const nextAlias = aliasProvided ? alias.trim() : state.devices[index].alias; + const nextPinned = pinProvided ? pinned : state.devices[index].pinned; + const nextDeprioritized = deprioritizedProvided + ? deprioritized + : pinProvided && pinned ? false : state.devices[index].deprioritized; + if (nextAlias === state.devices[index].alias + && nextPinned === state.devices[index].pinned + && nextDeprioritized === state.devices[index].deprioritized + && (!tagIdsProvided || sameStringList(tagIds, currentTagIds))) return state; const devices = [...state.devices]; devices[index] = { ...devices[index], - ...(aliasProvided ? { alias: alias.trim() } : {}), + ...(aliasProvided ? { alias: nextAlias } : {}), ...(pinProvided ? { pinned, ...(pinned ? { deprioritized: false } : {}) } : {}), ...(deprioritizedProvided ? { deprioritized, ...(deprioritized ? { pinned: false } : {}) } : {}), }; - return { ...state, revision: state.revision + 1, devices }; + let tags = state.tags; + if (tagIdsProvided) { + const byMac = { ...state.tags.byMac }; + if (tagIds.length) byMac[devices[index].mac] = tagIds; + else delete byMac[devices[index].mac]; + tags = { ...state.tags, byMac }; + } + return { ...state, revision: state.revision + 1, devices, tags }; + }); + return snapshot(); + } + + function createTag(nameValue: unknown, expectedRevision: unknown) { + const name = normalizeTagName(nameValue); + if (!validTagName(name) || typeof expectedRevision !== 'number' + || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + throw new HarborError('REQUEST_INVALID'); + } + store.update((stored) => { + const state = migrateDeviceInventoryState(stored); + if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); + if (state.tags.items.length >= TAG_CATALOG_LIMIT) throw new HarborError('REQUEST_INVALID'); + if (state.tags.items.some((tag) => tagNameKey(tag.name) === tagNameKey(name))) { + throw new HarborError('DEVICE_TAG_NAME_CONFLICT'); + } + let id = ''; + const ids = new Set(state.tags.items.map((tag) => tag.id)); + do id = `tag_${crypto.randomBytes(8).toString('hex')}`; while (ids.has(id)); + return { + ...state, + revision: state.revision + 1, + tags: { ...state.tags, items: [...state.tags.items, { id, name }] }, + }; + }); + return snapshot(); + } + + function renameTag(id: string, nameValue: unknown, expectedRevision: unknown) { + const name = normalizeTagName(nameValue); + if (!TAG_ID_PATTERN.test(id) || !validTagName(name) || typeof expectedRevision !== 'number' + || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + throw new HarborError('REQUEST_INVALID'); + } + store.update((stored) => { + const state = migrateDeviceInventoryState(stored); + if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); + const index = state.tags.items.findIndex((tag) => tag.id === id); + if (index < 0) throw new HarborError('DEVICE_TAG_NOT_FOUND'); + if (state.tags.items[index].name === name) return state; + if (state.tags.items.some((tag) => tag.id !== id && tagNameKey(tag.name) === tagNameKey(name))) { + throw new HarborError('DEVICE_TAG_NAME_CONFLICT'); + } + const items = [...state.tags.items]; + items[index] = { ...items[index], name }; + return { ...state, revision: state.revision + 1, tags: { ...state.tags, items } }; + }); + return snapshot(); + } + + function deleteTag(id: string, expectedRevision: unknown) { + if (!TAG_ID_PATTERN.test(id) || typeof expectedRevision !== 'number' + || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + throw new HarborError('REQUEST_INVALID'); + } + store.update((stored) => { + const state = migrateDeviceInventoryState(stored); + if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); + if (!state.tags.items.some((tag) => tag.id === id)) throw new HarborError('DEVICE_TAG_NOT_FOUND'); + const byMac: Record = {}; + for (const [mac, tagIds] of Object.entries(state.tags.byMac)) { + const remaining = tagIds.filter((tagId) => tagId !== id); + if (remaining.length) byMac[mac] = remaining; + } + return { + ...state, + revision: state.revision + 1, + tags: { + ...state.tags, + items: state.tags.items.filter((tag) => tag.id !== id), + byMac, + }, + }; }); return snapshot(); } @@ -1716,5 +1893,16 @@ export function createDeviceInventoryService({ return serializePolicy(() => reconcileLocked(observed, true)); } - return { snapshot, metricsSnapshot, refresh, update, resetTraffic, setPolicy, reconcilePolicies }; + return { + snapshot, + metricsSnapshot, + refresh, + update, + createTag, + renameTag, + deleteTag, + resetTraffic, + setPolicy, + reconcilePolicies, + }; } diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 2164f19..8c666ad 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -20,6 +20,8 @@ export const ERROR_DEFINITIONS = Object.freeze({ STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true }, SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false }, DEVICE_NOT_FOUND: { status: 404, message: 'Устройство больше недоступно.', retryable: false }, + DEVICE_TAG_NOT_FOUND: { status: 404, message: 'Тег больше недоступен.', retryable: false }, + DEVICE_TAG_NAME_CONFLICT: { status: 409, message: 'Тег с таким именем уже существует.', retryable: false }, DEVICE_IDENTITY_AMBIGUOUS: { status: 409, message: 'Gateway не может безопасно применить маршрут к этому устройству.', retryable: true }, DEVICE_POLICY_APPLY_FAILED: { status: 503, message: 'Не удалось применить маршрут устройства.', retryable: true }, DIAGNOSTICS_FAILED: { status: 503, message: 'Не удалось проверить маршруты. Попробуйте ещё раз.', retryable: true }, diff --git a/src/shared/versions.ts b/src/shared/versions.ts index e799b44..68cee62 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.32.0', - gatewayClient: '0.33.0', - gatewayBackend: '0.33.0', + macClient: '0.33.0', + gatewayClient: '0.34.0', + gatewayBackend: '0.34.0', }); export interface ParsedVersion { diff --git a/src/web/App.tsx b/src/web/App.tsx index ab4bf6f..509c579 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -22,6 +22,9 @@ const componentActions = { refreshDevices: api.devices.refresh, resetDeviceTraffic: api.devices.resetTraffic, updateDevice: api.devices.update, + createDeviceTag: api.devices.createTag, + renameDeviceTag: api.devices.renameTag, + deleteDeviceTag: api.devices.deleteTag, setDevicePolicy: api.devices.setPolicy, pingServers: api.servers.ping, runConnectivityDiagnostics: api.diagnostics.connectivity, diff --git a/src/web/api/harborClient.ts b/src/web/api/harborClient.ts index be56381..ae6dc2f 100644 --- a/src/web/api/harborClient.ts +++ b/src/web/api/harborClient.ts @@ -168,6 +168,24 @@ export const api = { body: JSON.stringify({ ...patch, expectedRevision }), }, ), + createTag: (name: string, expectedRevision: unknown) => request('/api/device-tags', { + method: 'POST', + body: JSON.stringify({ name, expectedRevision }), + }), + renameTag: (id: string, name: string, expectedRevision: unknown) => request( + `/api/device-tags/${id}`, + { + method: 'PUT', + body: JSON.stringify({ name, expectedRevision }), + }, + ), + deleteTag: (id: string, expectedRevision: unknown) => request( + `/api/device-tags/${id}`, + { + method: 'DELETE', + body: JSON.stringify({ expectedRevision }), + }, + ), setPolicy: (id: string, mode: unknown, expectedRevision: unknown) => request( `/api/devices/${id}/policy`, { diff --git a/src/web/components/ClientOverviewPage.tsx b/src/web/components/ClientOverviewPage.tsx index bead913..d8304c2 100644 --- a/src/web/components/ClientOverviewPage.tsx +++ b/src/web/components/ClientOverviewPage.tsx @@ -121,6 +121,9 @@ interface ComponentActions { refreshDevices: () => Promise; resetDeviceTraffic: (expectedRevision: number) => Promise; updateDevice: (id: string, patch: Record, expectedRevision: number) => Promise; + createDeviceTag: (name: string, expectedRevision: number) => Promise; + renameDeviceTag: (id: string, name: string, expectedRevision: number) => Promise; + deleteDeviceTag: (id: string, expectedRevision: number) => Promise; setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise; pingServers: (profileId: string, ids: string[]) => Promise; runConnectivityDiagnostics: (target?: unknown) => Promise; @@ -569,6 +572,9 @@ export function ClientOverviewPage({ refreshDevices: actions.refreshDevices, resetDeviceTraffic: actions.resetDeviceTraffic, updateDevice: actions.updateDevice, + createDeviceTag: actions.createDeviceTag, + renameDeviceTag: actions.renameDeviceTag, + deleteDeviceTag: actions.deleteDeviceTag, setDevicePolicy: actions.setDevicePolicy, }); const diagnosticsFeature = useDiagnosticsFeature(); diff --git a/src/web/features/devices/DevicesFeature.tsx b/src/web/features/devices/DevicesFeature.tsx index 460859c..bc8ee17 100644 --- a/src/web/features/devices/DevicesFeature.tsx +++ b/src/web/features/devices/DevicesFeature.tsx @@ -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; resetDeviceTraffic: (expectedRevision: number) => Promise; updateDevice: (id: string, patch: Record, expectedRevision: number) => Promise; + createDeviceTag: (name: string, expectedRevision: number) => Promise; + renameDeviceTag: (id: string, name: string, expectedRevision: number) => Promise; + deleteDeviceTag: (id: string, expectedRevision: number) => Promise; setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise; } @@ -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(null); const [resetOpen, setResetOpen] = useState(false); const [resetting, setResetting] = useState(false); const panelRef = useRef(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), diff --git a/src/web/features/devices/DevicesPanel.tsx b/src/web/features/devices/DevicesPanel.tsx index 0625b07..fe6289f 100644 --- a/src/web/features/devices/DevicesPanel.tsx +++ b/src/web/features/devices/DevicesPanel.tsx @@ -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>({}); const [pinCollapses, setPinCollapses] = useState>({}); + const [systemFilter, setSystemFilter] = useState('all'); + const [selectedTagIds, setSelectedTagIds] = useState([]); + 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(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()); const previousPositions = useRef(new Map()); const previousScrollTop = useRef(0); @@ -113,29 +151,190 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { const copyTimers = useRef(new Map>()); const copyAttempts = useRef(new Map()); const trafficDeltaTimer = useRef | null>(null); + const tagPopoverCloseTimer = useRef | null>(null); + const railModeAnimation = useRef(null); + const railModeRequest = useRef(0); const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] }); + const filterButtonRef = useRef(null); + const railRef = useRef(null); + const layoutRef = useRef(null); + const contentRef = useRef(null); + const popoverRef = useRef(null); + const searchRef = useRef(null); + const managerInputRef = useRef(null); + const tagTriggerRefs = useRef(new Map()); + 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(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('input:checked'); + const first = popoverRef.current?.querySelector(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(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(); + const interruptedPositions = new Map(); 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('.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('.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 <> +
+ {compactRail && )} +
+ {snapshot?.taggingSupported ? <> +
Теги{tags.length}/32
+
+ {tags.map((tag) => )} + {!tags.length &&

Тегов пока нет.

} +
+ + : snapshot &&

+ Теги доступны после обновления Gateway. +

} + : <> + +

Управление тегами

+
+ = 32 || tagSavingId === 'create'} + onChange={(event) => setNewTagName(event.target.value)} + /> + +
+ {Boolean(tagError) && tagErrorCopy &&

{tagErrorCopy}

} +
+ {tags.map((tag) =>
+ {editingTagId === tag.id ?
{ + event.preventDefault(); + void submitTagRename(tag); + }}> + setEditingTagName(event.target.value)} + /> + + +
: <> + {tag.name}{counts.byTag[tag.id] || 0} + + + } +
)} + {!tags.length &&

Создайте первый тег, чтобы распределить устройства.

} +
+ } + + +
- Gateway · {devices.length} + Gateway · {counts.all} + +
+ )} + + {filtersActive && devices.length > 0 &&
+ {activeSummary} · Результатов: {devices.length} + +
}
- {copyAnnouncement?.message || ''} + {tagAnnouncement || copyAnnouncement?.message || ''}
{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 }) { Новое устройство } + {!editing && snapshot?.taggingSupported && } {!editing && {device.ip &&
+
+
+ {tagPopover && createPortal( +
{ + if (event.target === event.currentTarget && !tagSavingId) closeTagPopover(); + }} + > +
+

Теги устройства {tagPopover.title}

+ {tags.length ?
+ {tags.map((tag) => { + const checked = tagPopover.draft.includes(tag.id); + return ; + })} +
:

Тегов пока нет.

} + {Boolean(tagError) &&

Не удалось сохранить теги.

} +
+ {!tags.length && } + + +
+
+
, + document.querySelector('.app.client-app') || document.body, + )} + {deletingTagDescription}{Boolean(tagError) && tagErrorCopy && <> +
{tagErrorCopy} + }} + cancelLabel="Оставить тег" + confirmLabel="Удалить" + busy={Boolean(deletingTag && tagSavingId === deletingTag.id)} + onCancel={() => { + if (tagSavingId) return; + setDeletingTagId(''); + clearTagError(); + setTagErrorCopy(''); + }} + onConfirm={confirmTagDelete} + /> ; } diff --git a/src/web/features/devices/deviceSnapshot.ts b/src/web/features/devices/deviceSnapshot.ts index c2bb87b..d37a6d3 100644 --- a/src/web/features/devices/deviceSnapshot.ts +++ b/src/web/features/devices/deviceSnapshot.ts @@ -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 { + id: string; + name: string; +} + export interface TrafficSample extends Record { observedAt: string; gatewayBytes: ByteValue; @@ -37,6 +42,7 @@ export interface Device extends Record { status: DeviceStatus; pinned: boolean; deprioritized?: boolean; + tagIds: string[]; downloadBytes: ByteValue; uploadBytes: ByteValue; proxyDownloadBytes: ByteValue; @@ -74,6 +80,8 @@ interface SnapshotSource extends Record { export interface DeviceSnapshot extends Record { 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 : [], + })), + }; } diff --git a/src/web/styles/features/devices.css b/src/web/styles/features/devices.css index f7510d1..cc33d6c 100644 --- a/src/web/styles/features/devices.css +++ b/src/web/styles/features/devices.css @@ -1,3 +1,257 @@ +.client-drawer.client-devices { + width: min(780px, 100vw); +} + +.client-devices-layout { + display: grid; + grid-template-columns: 168px minmax(0, 1fr); + gap: 28px; +} + +.client-devices-content { + min-width: 0; +} + +.client-devices-rail { + position: sticky; + top: 54px; + align-self: start; + min-width: 0; +} + +.client-devices-rail-view { + display: grid; + gap: 14px; +} + +.client-devices-search { + display: grid; + gap: 6px; + color: var(--client-muted); + font: var(--type-label); + letter-spacing: var(--type-label-tracking); + text-transform: var(--type-label-transform); +} + +.client-devices-search input, +.client-devices-tag-create input, +.client-devices-tag-manager-row input { + min-width: 0; + height: 32px; + box-sizing: border-box; + padding: 0 9px; + border: 1px solid color-mix(in oklch, var(--client-border) 80%, transparent); + border-radius: 6px; + outline: 0; + background: color-mix(in oklch, var(--client-control) 56%, transparent); + color: var(--client-text); + font: var(--type-control); + letter-spacing: var(--type-control-tracking); + text-transform: var(--type-control-transform); +} + +.client-devices-search input:focus, +.client-devices-tag-create input:focus, +.client-devices-tag-manager-row input:focus { + border: 1px solid var(--client-accent); +} + +.client-devices-filter-group, +.client-devices-tag-filters { + display: grid; + gap: 2px; +} + +.client-devices-filter-group button, +.client-devices-tag-filters button { + min-width: 0; + min-height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 7px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--client-muted); + font: var(--type-control); + letter-spacing: var(--type-control-tracking); + text-align: left; + text-transform: var(--type-control-transform); + cursor: pointer; +} + +.client-devices-filter-group button[aria-pressed="true"], +.client-devices-tag-filters button[aria-pressed="true"] { + background: color-mix(in oklch, var(--client-accent) 11%, transparent); + color: var(--harbor-gateway); + box-shadow: inset 2px 0 0 var(--harbor-gateway); +} + +.client-devices-filter-group b, +.client-devices-tag-filters b, +.client-devices-tag-manager-row > b { + color: var(--client-muted); + font: var(--type-micro); + letter-spacing: var(--type-micro-tracking); + text-transform: var(--type-micro-transform); + font-variant-numeric: var(--numeric-tabular); +} + +.client-devices-rail-heading { + display: flex; + justify-content: space-between; + color: var(--client-muted); + font: var(--type-label); + letter-spacing: var(--type-label-tracking); + text-transform: var(--type-label-transform); +} + +.client-devices-tag-filters button { + color: var(--tag-tone, var(--client-muted)); +} + +.client-devices-tag-filters p, +.client-devices-tag-manager-list > p, +.client-devices-tags-unsupported { + margin: 0; + color: var(--client-muted); + font: var(--type-control); + letter-spacing: var(--type-control-tracking); + text-transform: var(--type-control-transform); +} + +.client-devices-manage-tags, +.client-devices-manager-back, +.client-devices-tag-create button, +.client-devices-tag-manager-row button, +.client-device-tag-popover button, +.client-devices-filter-summary button, +.client-devices-filter-empty button { + padding: 0; + border: 0; + background: transparent; + color: var(--client-accent); + font: var(--type-control); + letter-spacing: var(--type-control-tracking); + text-transform: var(--type-control-transform); + cursor: pointer; +} + +.client-devices-manager-back { + justify-self: start; +} + +.client-devices-rail h3 { + margin: 0; + font: var(--type-section-title); + letter-spacing: var(--type-section-title-tracking); + text-transform: var(--type-section-title-transform); +} + +.client-devices-tag-create { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; +} + +.client-devices-tag-manager-list { + display: grid; + gap: 4px; +} + +.client-devices-tag-manager-row { + min-height: 30px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto 26px 26px; + align-items: center; + gap: 2px; + color: var(--tag-tone, var(--client-text)); + font: var(--type-control); + letter-spacing: var(--type-control-tracking); + text-transform: var(--type-control-transform); +} + +.client-devices-tag-manager-row > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.client-devices-tag-manager-row > button { + width: 26px; + height: 26px; + display: grid; + place-items: center; + color: var(--client-muted); +} + +.client-devices-tag-manager-row svg { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.client-devices-tag-manager-row form { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 5px; +} + +.client-devices-tag-error { + margin: 0; + color: oklch(0.68 0.15 28); + font: var(--type-control); + letter-spacing: var(--type-control-tracking); + text-transform: var(--type-control-transform); +} + +[data-tag-tone="0"] { --tag-tone: var(--harbor-connect); } +[data-tag-tone="1"] { --tag-tone: var(--harbor-gateway); } +[data-tag-tone="2"] { --tag-tone: var(--harbor-word); } +[data-tag-tone="3"] { --tag-tone: var(--client-muted); } + +.client-devices-filter-trigger, +.client-devices-rail-backdrop { + display: none; +} + +.client-devices-filter-summary { + min-height: 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: -8px 8px 14px; + color: var(--client-muted); + font: var(--type-control); + letter-spacing: var(--type-control-tracking); + text-transform: var(--type-control-transform); +} + +.client-devices-filter-summary span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.client-devices-filter-empty { + display: grid; + gap: 8px; +} + +.client-devices-filter-empty p { + margin: 0; +} + .client-devices-header { margin-bottom: 28px; } @@ -446,6 +700,57 @@ white-space: nowrap; } +.client-device-tag-trigger { + height: 14px; + max-width: 104px; + box-sizing: border-box; + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 3px; + padding: 0 4px; + overflow: hidden; + border: 1px dashed color-mix(in oklch, var(--client-muted) 46%, transparent); + border-radius: 999px; + background: transparent; + color: var(--client-muted); + font: var(--type-micro); + letter-spacing: var(--type-micro-tracking); + text-transform: var(--type-micro-transform); + white-space: nowrap; + cursor: pointer; +} + +.client-device-tag-trigger.has-tag { + border: 1px solid color-mix(in oklch, var(--tag-tone) 48%, transparent); + background: color-mix(in oklch, var(--tag-tone) 11%, transparent); + color: var(--tag-tone); +} + +.client-device-tag-trigger span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.client-device-tag-trigger b { + flex: 0 0 auto; + font: inherit; +} + +@media (hover: hover) { + .client-device-tag-trigger:not(.has-tag) { + opacity: 0; + pointer-events: none; + } + + .client-device:hover .client-device-tag-trigger:not(.has-tag), + .client-device:focus-within .client-device-tag-trigger:not(.has-tag) { + opacity: 1; + pointer-events: auto; + } +} + .client-device-identity-details { position: absolute; top: 100%; @@ -1359,10 +1664,164 @@ opacity: 0.35; } +.client-device-tag-popover-layer { + position: fixed; + inset: 0; + z-index: 80; +} + +.client-device-tag-popover { + position: fixed; + width: min(280px, calc(100vw - 24px)); + max-height: min(348px, calc(100dvh - 24px)); + box-sizing: border-box; + display: grid; + gap: 12px; + padding: 14px; + overflow-y: auto; + border: 1px solid color-mix(in oklch, var(--client-border) 78%, transparent); + border-radius: 9px; + background: color-mix(in oklch, var(--client-bg) 96%, var(--client-panel)); + color: var(--client-text); + box-shadow: 0 14px 36px oklch(0.08 0.015 145 / 0.24); + animation: client-device-tag-popover-in 160ms ease; +} + +@keyframes client-device-tag-popover-in { + from { opacity: 0; transform: translateY(-6px); } +} + +.client-device-tag-popover-layer.is-closing { + pointer-events: none; +} + +.client-device-tag-popover-layer.is-closing .client-device-tag-popover { + animation: client-device-tag-popover-out 160ms ease forwards; +} + +@keyframes client-device-tag-popover-out { + to { opacity: 0; transform: translateY(-6px); } +} + +.client-device-tag-popover h3 { + margin: 0; + font: var(--type-section-title); + letter-spacing: var(--type-section-title-tracking); + text-transform: var(--type-section-title-transform); +} + +.client-device-tag-popover > p { + margin: 0; + color: var(--client-muted); + font: var(--type-control); + letter-spacing: var(--type-control-tracking); + text-transform: var(--type-control-transform); +} + +.client-device-tag-popover-options { + display: grid; + gap: 4px; +} + +.client-device-tag-popover-options label { + min-height: 30px; + display: flex; + align-items: center; + gap: 8px; + color: var(--tag-tone, var(--client-text)); + font: var(--type-control); + letter-spacing: var(--type-control-tracking); + text-transform: var(--type-control-transform); + cursor: pointer; +} + +.client-device-tag-popover-actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.client-device-tag-popover-actions button:last-child { + color: var(--client-text); +} + +@media (max-width: 640px) { + .client-devices-layout { + display: block; + } + + .client-devices-rail { + position: fixed; + inset: 0 auto 0 0; + z-index: 42; + width: min(300px, calc(100vw - 48px)); + box-sizing: border-box; + padding: 54px 18px 32px; + overflow-y: auto; + background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel)); + box-shadow: 18px 0 42px oklch(0.08 0.015 145 / 0.2); + opacity: 0; + pointer-events: none; + transform: translateX(-100%); + transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1), opacity 260ms cubic-bezier(0.16, 1, 0.3, 1); + } + + .client-devices-rail.is-open { + opacity: 1; + pointer-events: auto; + transform: translateX(0); + } + + .client-devices-rail-backdrop { + position: fixed; + inset: 0; + z-index: 41; + display: block; + padding: 0; + border: 0; + background: oklch(0.08 0.015 145 / 0.26); + opacity: 0; + pointer-events: none; + transition: opacity 260ms cubic-bezier(0.16, 1, 0.3, 1); + } + + .client-devices-rail-backdrop.is-open { + opacity: 1; + pointer-events: auto; + } + + .client-devices-filter-trigger { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--client-accent); + font: var(--type-control); + letter-spacing: var(--type-control-tracking); + text-transform: var(--type-control-transform); + } + + .client-devices-kicker > span:first-child { + display: none; + } + + .client-device-tag-trigger:not(.has-tag) { + opacity: 1; + pointer-events: auto; + } +} + @media (hover: none) { .client-device-edit-wrap { opacity: 0.72; } + + .client-device-tag-trigger:not(.has-tag) { + opacity: 1; + pointer-events: auto; + } } .client-gateway-summary { diff --git a/src/web/styles/themes.css b/src/web/styles/themes.css index e39da7d..c860b29 100644 --- a/src/web/styles/themes.css +++ b/src/web/styles/themes.css @@ -90,6 +90,10 @@ .client-devices-sort-icon, .client-devices-refresh-ring circle, .client-devices-refresh-icon, + .client-devices-rail, + .client-devices-rail-backdrop, + .client-devices-rail-view, + .client-device-tag-popover, .client-text-morph-value, .client-proxy-label > span { transition: none; diff --git a/src/web/ui/ConfirmationDialog.tsx b/src/web/ui/ConfirmationDialog.tsx index b84725a..d115b60 100644 --- a/src/web/ui/ConfirmationDialog.tsx +++ b/src/web/ui/ConfirmationDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; const FOCUSABLE = 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])'; @@ -8,7 +8,7 @@ interface ConfirmationDialogProps { id: string; kicker?: string; title: string; - description: string; + description: ReactNode; cancelLabel: string; confirmLabel: string; busy?: boolean; diff --git a/src/web/utils/format.ts b/src/web/utils/format.ts index 6b19a32..d8c89b2 100644 --- a/src/web/utils/format.ts +++ b/src/web/utils/format.ts @@ -1,4 +1,4 @@ -import type { Device } from '../features/devices/deviceSnapshot.js'; +import type { Device, DeviceTag } from '../features/devices/deviceSnapshot.js'; export function formatBytes(value: number) { if (!value) return "0 Б"; @@ -15,6 +15,14 @@ export function formatBytes(value: number) { const BYTE_STRING_PATTERN = /^\d+$/; const NEW_DEVICE_MS = 7 * 24 * 60 * 60 * 1000; +export type DeviceSystemFilter = 'all' | 'new' | 'pinned' | 'background' | 'untagged'; + +export interface DeviceFilters { + system: DeviceSystemFilter; + tagIds: string[]; + query: string; +} + export function byteString(value: unknown) { const normalized = String(value ?? '0'); return BYTE_STRING_PATTERN.test(normalized) ? BigInt(normalized) : 0n; @@ -107,6 +115,53 @@ export function stabilizeDevicesByTraffic( return { devices: stable, ids: stable.map(({ id }) => id) }; } +export function filterDevices( + devices: Device[] | undefined, + tags: DeviceTag[] | undefined, + filters: DeviceFilters, + now: Date | string | number = Date.now(), +) { + const tagNames = new Map((tags || []).map(({ id, name }) => [id, name])); + const selected = new Set(filters.tagIds); + const query = filters.query.trim().toLocaleLowerCase('ru-RU'); + return (devices || []).filter((device) => { + const systemMatch = filters.system === 'all' + || (filters.system === 'new' && isNewDevice(device.firstSeenAt, now)) + || (filters.system === 'pinned' && device.pinned) + || (filters.system === 'background' && device.deprioritized === true) + || (filters.system === 'untagged' && device.tagIds.length === 0); + const tagMatch = selected.size === 0 || device.tagIds.some((tagId) => selected.has(tagId)); + const searchMatch = !query || [ + device.alias, + device.hostname, + device.ip, + device.mac, + ...device.tagIds.map((tagId) => tagNames.get(tagId)), + ].some((value) => String(value || '').toLocaleLowerCase('ru-RU').includes(query)); + return systemMatch && tagMatch && searchMatch; + }); +} + +export function deviceFilterCounts( + devices: Device[] | undefined, + tags: DeviceTag[] | undefined, + now: Date | string | number = Date.now(), +) { + const rows = devices || []; + const byTag = Object.fromEntries((tags || []).map(({ id }) => [ + id, + rows.filter((device) => device.tagIds.includes(id)).length, + ])); + return { + all: rows.length, + new: rows.filter((device) => isNewDevice(device.firstSeenAt, now)).length, + pinned: rows.filter((device) => device.pinned).length, + background: rows.filter((device) => device.deprioritized === true).length, + untagged: rows.filter((device) => device.tagIds.length === 0).length, + byTag, + }; +} + export function formatRelative(iso: string | null | undefined) { if (!iso) return ""; const ts = new Date(iso).getTime(); diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js index 919ea47..ec20868 100644 --- a/test/server/device-inventory.test.js +++ b/test/server/device-inventory.test.js @@ -1443,3 +1443,141 @@ test('direct IPv4 counters stay metrics-only and keep the last good transport sn assert.deepEqual(service.metricsSnapshot().directTraffic.series, []); assert.equal(service.metricsSnapshot().directTraffic.source.error, null); }); + +test('device tag catalog validates, orders and mutates assignments under one revision', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-tags-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const store = createJsonStore({ + filePath: path.join(directory, 'devices.json'), + defaultValue: {}, + migrate: migrateDeviceInventoryState, + }); + const observedAt = '2026-08-31T12:00:00.000Z'; + const mac = '00:11:22:33:44:55'; + const service = createDeviceInventoryService({ + store, + observe: () => ({ + observedAt, + error: null, + observations: [{ ip: '192.168.50.10', mac, interface: 'br0', observedAt, active: true }], + }), + now: () => new Date(observedAt), + }); + + let snapshot = await service.refresh(); + snapshot = service.createTag(' Home ', snapshot.revision); + const home = snapshot.tags[0]; + assert.match(home.id, /^tag_[a-f0-9]{16}$/); + assert.equal(home.name, 'Home'); + assert.throws( + () => service.createTag('home', snapshot.revision), + (error) => error.code === 'DEVICE_TAG_NAME_CONFLICT', + ); + assert.throws( + () => service.createTag('x'.repeat(25), snapshot.revision), + (error) => error.code === 'REQUEST_INVALID', + ); + + snapshot = service.createTag('Work', snapshot.revision); + const work = snapshot.tags[1]; + snapshot = service.update(snapshot.devices[0].id, { tagIds: [work.id, home.id] }, snapshot.revision); + assert.deepEqual(snapshot.devices[0].tagIds, [home.id, work.id]); + assert.deepEqual(store.read().tags.byMac[mac], [home.id, work.id]); + assert.throws( + () => service.update(snapshot.devices[0].id, { tagIds: [home.id, work.id] }, snapshot.revision - 1), + (error) => error.code === 'STATE_CONFLICT', + ); + assert.equal( + service.update(snapshot.devices[0].id, { tagIds: [home.id, work.id] }, snapshot.revision).revision, + snapshot.revision, + ); + + snapshot = service.renameTag(home.id, 'HOME', snapshot.revision); + const renamedRevision = snapshot.revision; + assert.equal(snapshot.tags[0].name, 'HOME'); + assert.throws( + () => service.renameTag(home.id, 'HOME', renamedRevision - 1), + (error) => error.code === 'STATE_CONFLICT', + ); + assert.equal(service.renameTag(home.id, 'HOME', renamedRevision).revision, renamedRevision); + assert.throws( + () => service.update(snapshot.devices[0].id, { tagIds: ['tag_ffffffffffffffff'] }, renamedRevision), + (error) => error.code === 'DEVICE_TAG_NOT_FOUND', + ); + + while (snapshot.tags.length < 9) snapshot = service.createTag(`Tag ${snapshot.tags.length}`, snapshot.revision); + snapshot = service.update(snapshot.devices[0].id, { tagIds: snapshot.tags.slice(0, 8).map(({ id }) => id) }, snapshot.revision); + assert.throws( + () => service.update(snapshot.devices[0].id, { tagIds: snapshot.tags.slice(0, 9).map(({ id }) => id) }, snapshot.revision), + (error) => error.code === 'REQUEST_INVALID', + ); + + snapshot = service.deleteTag(home.id, snapshot.revision); + assert.equal(snapshot.tags.some(({ id }) => id === home.id), false); + assert.equal(snapshot.devices[0].tagIds.includes(home.id), false); + while (snapshot.tags.length < 32) snapshot = service.createTag(`More ${snapshot.tags.length}`, snapshot.revision); + assert.throws( + () => service.createTag('Overflow', snapshot.revision), + (error) => error.code === 'REQUEST_INVALID', + ); +}); + +test('tag migration recovers safe values and retention removes assignment but keeps catalog', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-tag-retention-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const store = createJsonStore({ + filePath: path.join(directory, 'devices.json'), + defaultValue: {}, + migrate: migrateDeviceInventoryState, + }); + let current = new Date('2026-07-01T12:00:00.000Z'); + const mac = '00:11:22:33:44:66'; + let present = true; + const service = createDeviceInventoryService({ + store, + observe: () => ({ + observedAt: current.toISOString(), + error: null, + observations: present + ? [{ ip: '192.168.50.12', mac, interface: 'br0', observedAt: current.toISOString(), active: true }] + : [], + }), + now: () => current, + }); + + let snapshot = await service.refresh(); + snapshot = service.createTag('Дом', snapshot.revision); + const tag = snapshot.tags[0]; + snapshot = service.update(snapshot.devices[0].id, { tagIds: [tag.id] }, snapshot.revision); + + const recovered = migrateDeviceInventoryState({ + ...store.read(), + tags: { + schemaVersion: 1, + items: [tag, { ...tag, id: 'tag_ffffffffffffffff' }, { id: 'bad', name: 'Bad' }], + byMac: { [mac.toUpperCase()]: [tag.id, tag.id, 'tag_ffffffffffffffff'] }, + }, + }); + assert.deepEqual(recovered.tags, { + schemaVersion: 1, + items: [tag], + byMac: { [mac]: [tag.id] }, + }); + assert.throws( + () => migrateDeviceInventoryState({ ...store.read(), tags: { schemaVersion: 2 } }), + /Unsupported device tags schemaVersion/, + ); + + present = false; + current = new Date('2026-08-02T12:00:00.000Z'); + snapshot = await service.refresh(); + assert.equal(snapshot.devices.length, 0); + assert.deepEqual(snapshot.tags, [tag]); + assert.deepEqual(store.read().tags.byMac, {}); + + present = true; + current = new Date('2026-08-03T12:00:00.000Z'); + snapshot = await service.refresh(); + assert.deepEqual(snapshot.devices[0].tagIds, []); + assert.deepEqual(snapshot.tags, [tag]); +}); diff --git a/test/server/device-routes.test.js b/test/server/device-routes.test.js index 755e6e5..b945f2a 100644 --- a/test/server/device-routes.test.js +++ b/test/server/device-routes.test.js @@ -3,8 +3,10 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import { createDeviceInventoryRoute } from '../../dist/server/http/routes/deviceInventoryRoute.js'; +import { errorDefinition } from '../../dist/shared/errors.js'; const deviceId = 'dev_0123456789abcdef'; +const tagId = 'tag_0123456789abcdef'; function response() { return { @@ -34,6 +36,18 @@ function createHarness({ inventory = {}, body = {} } = {}) { calls.push(['update', ...args]); return inventory.update ?? { revision: 3 }; }, + createTag: (...args) => { + calls.push(['createTag', ...args]); + return inventory.createTag ?? { revision: 6 }; + }, + renameTag: (...args) => { + calls.push(['renameTag', ...args]); + return inventory.renameTag ?? { revision: 7 }; + }, + deleteTag: (...args) => { + calls.push(['deleteTag', ...args]); + return inventory.deleteTag ?? { revision: 8 }; + }, resetTraffic: async (...args) => { calls.push(['resetTraffic', ...args]); return inventory.resetTraffic ?? { revision: 4 }; @@ -79,7 +93,7 @@ test('device route forwards list and refresh query paths as raw JSON responses', }); test('device route forwards metadata patch and policy arguments without coercion', async () => { - const patch = { expectedRevision: 7, alias: 'Desk', pinned: false, deprioritized: true, extra: 0 }; + const patch = { expectedRevision: 7, alias: 'Desk', pinned: false, deprioritized: true, tagIds: [tagId], extra: 0 }; const metadata = createHarness({ body: patch }); const metadataResponse = response(); assert.equal(await metadata.route.handle({ @@ -89,7 +103,7 @@ test('device route forwards metadata patch and policy arguments without coercion assert.deepEqual(metadata.calls, [[ 'update', deviceId, - { alias: 'Desk', pinned: false, deprioritized: true, extra: 0 }, + { alias: 'Desk', pinned: false, deprioritized: true, tagIds: [tagId], extra: 0 }, 7, ]]); assert.deepEqual(metadataResponse.payload, { revision: 3 }); @@ -113,6 +127,39 @@ test('device route forwards metadata patch and policy arguments without coercion assert.deepEqual(resetResponse.payload, { revision: 4 }); }); +test('device tag routes forward catalog mutations and return complete snapshots', async () => { + const create = createHarness({ body: { name: 'Умный дом', expectedRevision: 10 } }); + const createResponse = response(); + assert.equal(await create.route.handle({ method: 'POST', url: '/api/device-tags' }, createResponse), true); + assert.deepEqual(create.calls, [['createTag', 'Умный дом', 10]]); + assert.deepEqual(createResponse.payload, { revision: 6 }); + + const rename = createHarness({ body: { name: 'Дом', expectedRevision: 11 } }); + const renameResponse = response(); + assert.equal(await rename.route.handle({ method: 'PUT', url: `/api/device-tags/${tagId}` }, renameResponse), true); + assert.deepEqual(rename.calls, [['renameTag', tagId, 'Дом', 11]]); + assert.deepEqual(renameResponse.payload, { revision: 7 }); + + const remove = createHarness({ body: { expectedRevision: 12 } }); + const deleteResponse = response(); + assert.equal(await remove.route.handle({ method: 'DELETE', url: `/api/device-tags/${tagId}` }, deleteResponse), true); + assert.deepEqual(remove.calls, [['deleteTag', tagId, 12]]); + assert.deepEqual(deleteResponse.payload, { revision: 8 }); +}); + +test('device tag errors expose stable HTTP contracts', () => { + assert.deepEqual(errorDefinition('DEVICE_TAG_NOT_FOUND'), { + status: 404, + message: 'Тег больше недоступен.', + retryable: false, + }); + assert.deepEqual(errorDefinition('DEVICE_TAG_NAME_CONFLICT'), { + status: 409, + message: 'Тег с таким именем уже существует.', + retryable: false, + }); +}); + test('device route preserves endpoint gating and strict lowercase IDs', async () => { for (const [method, url] of [ ['POST', '/api/devices'], @@ -120,6 +167,8 @@ test('device route preserves endpoint gating and strict lowercase IDs', async () ['GET', '/api/devices/traffic'], ['GET', `/api/devices/${deviceId}`], ['POST', `/api/devices/${deviceId}/policy`], + ['GET', '/api/device-tags'], + ['PATCH', `/api/device-tags/${tagId}`], ]) { const harness = createHarness(); await assert.rejects( @@ -147,6 +196,9 @@ test('device route preserves endpoint gating and strict lowercase IDs', async () ['DELETE', '/api/devices/traffic'], ['PUT', `/api/devices/${deviceId}`], ['PUT', `/api/devices/${deviceId}/policy`], + ['POST', '/api/device-tags'], + ['PUT', `/api/device-tags/${tagId}`], + ['DELETE', `/api/device-tags/${tagId}`], ]) { const client = createHarness({ inventory: null }); await assert.rejects( @@ -164,6 +216,9 @@ test('device route propagates synchronous and asynchronous service errors unchan snapshot: () => { throw syncError; }, refresh: async () => ({}), update: () => ({}), + createTag: () => ({}), + renameTag: () => ({}), + deleteTag: () => ({}), resetTraffic: async () => ({}), setPolicy: async () => ({}), }, @@ -180,6 +235,9 @@ test('device route propagates synchronous and asynchronous service errors unchan snapshot: () => ({}), refresh: async () => { throw asyncError; }, update: () => ({}), + createTag: () => ({}), + renameTag: () => ({}), + deleteTag: () => ({}), resetTraffic: async () => ({}), setPolicy: async () => ({}), }, @@ -205,4 +263,5 @@ test('device route is the only HTTP owner while lifecycle stays in composition', assert.match(index, /deviceInventory\.refresh\(\)/); assert.match(route, /DEVICE_PATH/); assert.match(route, /DEVICE_POLICY_PATH/); + assert.match(route, /DEVICE_TAG_PATH/); }); diff --git a/test/web/api-errors.test.js b/test/web/api-errors.test.js index ffd7b22..c08bc1c 100644 --- a/test/web/api-errors.test.js +++ b/test/web/api-errors.test.js @@ -112,6 +112,15 @@ test('typed endpoint facade preserves exact request contracts and raw payload id [() => api.devices.setPolicy('dev_1', 'direct', 9), '/api/devices/dev_1/policy', { method: 'PUT', body: JSON.stringify({ mode: 'direct', expectedRevision: 9 }), }], + [() => api.devices.createTag('Дом', 10), '/api/device-tags', { + method: 'POST', body: JSON.stringify({ name: 'Дом', expectedRevision: 10 }), + }], + [() => api.devices.renameTag('tag_1', 'Работа', 11), '/api/device-tags/tag_1', { + method: 'PUT', body: JSON.stringify({ name: 'Работа', expectedRevision: 11 }), + }], + [() => api.devices.deleteTag('tag_1', 12), '/api/device-tags/tag_1', { + method: 'DELETE', body: JSON.stringify({ expectedRevision: 12 }), + }], [() => api.diagnostics.connectivity(), '/api/diagnostics/connectivity', { method: 'POST', body: JSON.stringify({ target: null }), }], diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index e060b72..572dc5b 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -5,6 +5,8 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { formatByteString, + deviceFilterCounts, + filterDevices, formatLastSeen, isNewDevice, positiveByteDelta, @@ -38,7 +40,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(feature, /requestDeviceUpdate\(device\.id, patch, snapshot\.revision\)/); assert.match(feature, /requestError\(caught\)\.code !== 'STATE_CONFLICT'[\s\S]*listDevices\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*requestDeviceUpdate\(device\.id, patch, latest\.revision\)/); assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/); - assert.match(panel, /movementAnimations\.current\.get\(id\)\?\.cancel\(\)/); + assert.match(panel, /const activeAnimation = movementAnimations\.current\.get\(id\)[\s\S]*activeAnimation\.commitStyles\(\)[\s\S]*activeAnimation\.cancel\(\)[\s\S]*interruptedPositions\.set/); assert.match(panel, /previousPositions\.current\.size > 0/); assert.match(panel, /previousScrollTop\.current - currentScrollTop/); assert.match(feature, /next\.revision > current\.revision/); @@ -86,7 +88,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(panel, /\{device\.hostname && { assert.match(panel, /const feedback = copyFeedback\[device\.id\]/); }); +test('device tags keep filtering, assignment and management accessible at every width', () => { + assert.match(panel, /Найти устройство/); + assert.match(panel, /'all', label: 'Все'[\s\S]*'new', label: 'Новые'[\s\S]*'pinned', label: 'Закреплённые'[\s\S]*'background', label: 'Фоновые'[\s\S]*'untagged', label: 'Без тегов'/); + assert.match(panel, /if \(value === 'untagged'\) setSelectedTagIds\(\[\]\)/); + assert.match(panel, /if \(systemFilter === 'untagged'\) setSystemFilter\('all'\)/); + assert.match(panel, /deviceFilterCounts\(allDevices, tags\)/); + assert.match(panel, /По этим фильтрам устройств нет\.[\s\S]*Сбросить фильтры/); + assert.match(panel, /deviceTags = tags\.filter[\s\S]*client-device-tag-trigger[\s\S]*\+\{deviceTags\.length - 1\}[\s\S]*'\+ тег'/); + assert.match(panel, /role="dialog"[\s\S]*aria-modal="true"[\s\S]*Теги устройства \{tagPopover\.title\}/); + assert.match(panel, /contentRef\.current\.inert = true[\s\S]*layoutRef\.current\.inert = true/); + assert.match(panel, /FOCUSABLE[\s\S]*event\.key === 'Escape'[\s\S]*document\.activeElement === first/); + assert.match(panel, /catalogChanged[\s\S]*Список тегов изменился\. Откройте теги устройства снова\./); + assert.match(panel, /Теги доступны после обновления Gateway\./); + assert.match(panel, /Управление тегами[\s\S]*Переименовать тег[\s\S]*Удалить тег/); + assert.match(panel, /placeholder="Найти устройство"[\s\S]*Результатов: \{devices\.length\}/); + assert.match(panel, />Назад<\/button>[\s\S]*Не удалось сохранить теги\./); + assert.match(panel, /restoreTagTriggerFocus[\s\S]*searchRef\.current,[\s\S]*filterButtonRef\.current,[\s\S]*candidate\?\.isConnected && !candidate\.closest\('\[inert\]'\)/); + assert.match(panel, /updateDeviceTags\(device, tagPopover\.draft, tagPopover\.baseline\)/); + assert.match(feature, /updateDeviceTags\(device: Device, tagIds: string\[\], baselineTagIds: string\[\]\)[\s\S]*sameStringList\(currentDevice\.tagIds, baselineTagIds\)[\s\S]*sameStringList\(latestDevice\.tagIds, baselineTagIds\)/); + assert.match(feature, /renameTag\(tag: DeviceTag, name: string, baselineName: string\)[\s\S]*snapshot\.tags\.find[\s\S]*name !== baselineName[\s\S]*latestTag\.name !== baselineName/); + assert.match(panel, /Тег больше не будет доступен для назначения\. Устройства и маршруты не изменятся\./); + assert.match(panel, /Тег исчезнет у одного устройства\. Само устройство и его маршрут не изменятся\./); + assert.match(panel, /Сами устройства и их маршруты не изменятся\./); + assert.match(feature, /deleteDeviceTag\(tag\.id, snapshot\.revision\)[\s\S]*STATE_CONFLICT[\s\S]*listDevices\(\)[\s\S]*return 'conflict'/); + assert.doesNotMatch(feature, /deleteDeviceTag\(tag\.id, latest\.revision\)/); + assert.match(styles, /@media \(max-width: 640px\)[\s\S]*width: min\(300px, calc\(100vw - 48px\)\)[\s\S]*translateX\(-100%\)[\s\S]*260ms cubic-bezier\(0\.16, 1, 0\.3, 1\)/); + assert.match(panel, /activeAnimation\.commitStyles\(\)[\s\S]*activeAnimation\.cancel\(\)[\s\S]*opacity: 0, transform: 'translateX\(-6px\)'[\s\S]*duration: 160[\s\S]*opacity: 0, transform: 'translateX\(6px\)'[\s\S]*opacity: 1, transform: 'translateX\(0\)'/); + assert.match(styles, /client-device-tag-popover-in 160ms ease[\s\S]*translateY\(-6px\)[\s\S]*client-device-tag-popover-out 160ms ease forwards/); + assert.match(styles, /client-devices-rail-backdrop \{[\s\S]*opacity: 0[\s\S]*opacity 260ms cubic-bezier\(0\.16, 1, 0\.3, 1\)[\s\S]*client-devices-rail-backdrop\.is-open[\s\S]*opacity: 1/); + assert.match(styles, /client-device-tag-trigger \{[\s\S]*height: 14px[\s\S]*flex: 0 0 auto/); + assert.match(styles, /@media \(hover: hover\)[\s\S]*client-device-tag-trigger:not\(\.has-tag\)[\s\S]*opacity: 0/); + assert.match(styles, /@media \(hover: none\)[\s\S]*client-device-tag-trigger:not\(\.has-tag\)[\s\S]*opacity: 1/); + assert.match(styles, /prefers-reduced-motion: reduce[\s\S]*client-devices-rail[\s\S]*client-devices-rail-view[\s\S]*client-device-tag-popover/); +}); + test('Gateway Home reuses the canonical device snapshot for applied route and global traffic', () => { const powerStart = connection.indexOf('
{ assert.equal(isNewDevice('not-a-date', now), false); }); +test('device filters combine system and search with tag OR while counts stay absolute', () => { + const now = Date.parse('2026-08-31T12:00:00.000Z'); + const tags = [ + { id: 'tag_0000000000000001', name: 'Умный дом' }, + { id: 'tag_0000000000000002', name: 'Работа' }, + ]; + const devices = Array.from({ length: 100 }, (_, index) => ({ + id: `device-${index}`, + alias: index === 3 ? 'Телевизор' : `Клиент ${index}`, + hostname: index === 4 ? 'office-mac.local' : null, + ip: `192.168.50.${index + 1}`, + mac: `00:11:22:33:44:${index.toString(16).padStart(2, '0')}`, + firstSeenAt: new Date(now - (index < 7 ? index : 10) * 86_400_000).toISOString(), + pinned: index === 3, + deprioritized: index === 5, + tagIds: index === 3 ? [tags[0].id] : index === 4 ? [tags[1].id] : [], + })); + + assert.deepEqual(filterDevices(devices, tags, { + system: 'all', tagIds: tags.map(({ id }) => id), query: 'local', + }, now).map(({ id }) => id), ['device-4']); + assert.deepEqual(filterDevices(devices, tags, { + system: 'pinned', tagIds: [tags[0].id, tags[1].id], query: 'умный', + }, now).map(({ id }) => id), ['device-3']); + assert.equal(filterDevices(devices, tags, { + system: 'untagged', tagIds: [], query: '192.168.50.', + }, now).length, 98); + + assert.deepEqual(deviceFilterCounts(devices, tags, now), { + all: 100, + new: 7, + pinned: 1, + background: 1, + untagged: 98, + byTag: { [tags[0].id]: 1, [tags[1].id]: 1 }, + }); + + const canonical = stabilizeDevicesByTraffic(devices, 'desc'); + const visible = filterDevices(devices, tags, { + system: 'all', tagIds: [], query: '192.168.50.1', + }, now); + const filteredOrder = stabilizeDevicesByTraffic(visible, 'desc', canonical.ids).devices; + assert.deepEqual(filteredOrder.map(({ id }) => id), canonical.devices + .filter(({ id }) => visible.some((device) => device.id === id)) + .map(({ id }) => id)); + assert.deepEqual(stabilizeDevicesByTraffic(devices, 'desc', canonical.ids).ids, canonical.ids); +}); + test('device traffic formatting and sorting preserve uint64 precision and canonical ties', () => { assert.equal(formatByteString('9007199254740993'), '8,0 ПБ'); assert.equal(formatByteString('1536'), '1,5 КБ'); diff --git a/test/web/devices-feature-contract.test.js b/test/web/devices-feature-contract.test.js index 2ca71f2..4a2b341 100644 --- a/test/web/devices-feature-contract.test.js +++ b/test/web/devices-feature-contract.test.js @@ -41,7 +41,7 @@ test('device controller preserves Gateway-only polling, monotonic publication an assert.match(page, / { +test('all unknown inventory results pass one capability-aware runtime parser', () => { const observedAt = '2026-08-08T12:34:56.000Z'; const valid = { revision: 3, @@ -106,7 +106,21 @@ test('all unknown inventory results pass one identity-preserving runtime parser' }, extra: { retained: true }, }; - assert.equal(parseDeviceSnapshot(valid), valid); + const oldBackend = parseDeviceSnapshot(valid); + assert.equal(oldBackend.taggingSupported, false); + assert.deepEqual(oldBackend.tags, []); + assert.deepEqual(oldBackend.devices[0].tagIds, []); + assert.deepEqual(oldBackend.extra, valid.extra); + + const supported = { + ...valid, + tags: [{ id: 'tag_0123456789abcdef', name: 'Умный дом' }], + devices: [{ ...valid.devices[0], tagIds: ['tag_0123456789abcdef'] }], + }; + const parsedSupported = parseDeviceSnapshot(supported); + assert.equal(parsedSupported.taggingSupported, true); + assert.deepEqual(parsedSupported.tags, supported.tags); + assert.deepEqual(parsedSupported.devices[0].tagIds, ['tag_0123456789abcdef']); for (const invalid of [ null, {}, @@ -134,6 +148,10 @@ test('all unknown inventory results pass one identity-preserving runtime parser' { ...valid, devices: [{ ...valid.devices[0], outboundTraffic: { observedAt, vpnBytes: '1' } }] }, { ...valid, devices: [{ ...valid.devices[0], outboundTraffic: { ...valid.devices[0].outboundTraffic, directIpv4ObservedAt: 'not-a-date' } }] }, { ...valid, devices: [{ ...valid.devices[0], outboundTrafficHistory: [{ observedAt, vpnBytes: '1' }] }] }, + { ...supported, tags: [{ id: 'bad', name: 'Дом' }] }, + { ...supported, tags: [{ ...supported.tags[0], name: ' '.repeat(2) }] }, + { ...supported, devices: [{ ...valid.devices[0] }] }, + { ...supported, devices: [{ ...supported.devices[0], tagIds: ['tag_ffffffffffffffff'] }] }, { ...valid, traffic: { ...valid.traffic, totalBytes: -4 } }, { ...valid, traffic: { ...valid.traffic, observedAt: 'not-a-date' } }, { ...valid, source: { ...valid.source, kind: 'arp' } }, diff --git a/test/web/style-boundaries.test.js b/test/web/style-boundaries.test.js index 5631bb3..3d6c10c 100644 --- a/test/web/style-boundaries.test.js +++ b/test/web/style-boundaries.test.js @@ -39,26 +39,26 @@ const expectedImports = [ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const acceptedLedger = { counts: { - cascadeEdges: 1072, - customProperties: 111, - declarations: 4170, + cascadeEdges: 1093, + customProperties: 115, + declarations: 4433, important: 0, - keyframes: 50, - media: 17, - rules: 1126, - variableReferences: 1042, + keyframes: 52, + media: 19, + rules: 1188, + variableReferences: 1130, }, hashes: { - cascadeEdges: 'd60343b571f5a9bcf809609b4731500587da3bdd9ee9f40e33938ecbd8aec6ce', - customProperties: '42f9fa9f2caaab6e5d90ae7d224563355822fa270083ea3496cfe2caaaea50bf', - declarations: '15fc1474c16562886582469a7d240dde36882e682b266374fdca7f47f90c2b13', + cascadeEdges: '6676dd45ca19923561ef30b2057a9952a795fd142d83b612aaba70132378e91d', + customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097', + declarations: 'fcecaa0fb211a14826577bf9629e261b0b4b63b4601febebc43e8d757f6a07f1', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848', - keyframes: 'af4b9ae18d070fd2462a9b050293f3821bf5017c6e7cc53bbe18dc826f0fe3b9', - ruleDeclarationSequences: '16ccf3b3b8b6f76e784c69a3f06a3abb6e3b57d5d0710dff4bdad23f87bad6c3', - selectors: 'b32eeeff7896fedd9057a953b61a8b149116a5c5ce110fb84bf06e24d22bf8ee', - variableReferences: 'c9f64054cfe384f90e5c47844ffbbc7fba60f5045efac5d272f45f1f451e950b', - witnesses: '2d952ddef91d3578857d7aeb1f469a9a6f3b7b4bba998ab6bc054d0028cf2785', + keyframes: '405688c9a452aa9d54e9c50dd30abb13143fdb5910f63f4474ecefcc800311e6', + ruleDeclarationSequences: '231bd9880d19c8d29e6d9538ccb934e135bd58b33155175e317b743916d93ef7', + selectors: '159b53f5cdd6c4aa6d892b9ed314d9a100345290d4c95e31a4c6be98b609c8b6', + variableReferences: 'd8f4abdb1ea6e9077283f342efef0ea920068165d0fc2ac4e7a9a22e16b31461', + witnesses: 'a84c2ee1c55cb68873f62f230aeec39f88093bcbb50493aaf47d852b13e6985c', }, }; @@ -211,7 +211,7 @@ test('client typography uses the shared semantic scale outside the token owner', test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { const witnesses = readStyleWitnesses(root); - assert.equal(witnesses.length, 1110); + assert.equal(witnesses.length, 1209); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); const ledger = createStyleLedger(readStyleSource(root), { witnesses }); assert.deepEqual(ledger.counts, acceptedLedger.counts); @@ -408,8 +408,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); - assert.deepEqual(assets, ['index-jhWgOVxh.css']); + assert.deepEqual(assets, ['index-fn-ai4xB.css']); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); - assert.equal(built.byteLength, 158716); - assert.equal(sha256(built), 'b10c9602bdf224599db0262e2e872e3db2b3c75b85410d50c2fe42b69d44e14c'); + assert.equal(built.byteLength, 168021); + assert.equal(sha256(built), 'a160cdc915a4a8e0b95e360ed721e6037cbeda28df4e0815ab6be43f1004176d'); });