From f233660dc311e2f3908704d953443650e97c85de Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Mon, 10 Aug 2026 09:25:29 +0300 Subject: [PATCH] Add deprioritized device group --- README.md | 4 +- src/server/services/deviceInventoryService.ts | 19 +++- src/shared/versions.ts | 6 +- src/web/features/devices/DevicesPanel.tsx | 87 ++++++++++++++----- src/web/features/devices/deviceSnapshot.ts | 3 + src/web/styles/features/devices.css | 81 +++++++++++++++-- src/web/styles/themes.css | 2 + src/web/utils/format.ts | 8 +- test/server/device-inventory.test.js | 50 ++++++++++- test/server/device-routes.test.js | 4 +- test/web/device-inventory-contract.test.js | 27 +++++- test/web/devices-feature-contract.test.js | 3 + test/web/style-boundaries.test.js | 28 +++--- 13 files changed, 262 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 70d533c..9b931db 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,11 @@ http://АДРЕС-GATEWAY:3456 ### Устройства Gateway -Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название, IP и последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Нажмите IP, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway. +Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название, IP и последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Нажмите IP, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Убраны вниз»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, положение в нижней группе и накопленные totals сохраняются в volume Gateway. У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут. -Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится. +Список приблизительный: имя и пользовательские настройки привязаны к MAC и сохраняются при обычной смене IP, но новый private/randomized MAC считается новым устройством — переносить имя по одному только DHCP-адресу небезопасно. Один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится. Home показывает фактически применённый VPN-сервер, накопленное `Учтено Harbor` и большой нижний график средней скорости Download/Upload за фактический интервал между снимками. `Учтено Harbor` — сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткая история скорости после перезапуска начинает заполняться заново. diff --git a/src/server/services/deviceInventoryService.ts b/src/server/services/deviceInventoryService.ts index 69076ed..cffc3cd 100644 --- a/src/server/services/deviceInventoryService.ts +++ b/src/server/services/deviceInventoryService.ts @@ -67,6 +67,7 @@ interface InventoryDevice { id: string; alias: string; pinned: boolean; + deprioritized: boolean; hostname: string | null; manufacturer: string | null; mac: string; @@ -265,6 +266,7 @@ function normalizeInventoryDevice(value: unknown): InventoryDevice | null { : deviceId(mac), alias: typeof device.alias === 'string' ? device.alias : '', pinned: device.pinned === true, + deprioritized: device.deprioritized === true && device.pinned !== true, hostname: typeof device.hostname === 'string' ? device.hostname : null, manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null, mac, @@ -792,6 +794,7 @@ export function createDeviceInventoryService({ }; }).sort((left, right) => ( Number(right.pinned) - Number(left.pinned) + || Number(left.deprioritized) - Number(right.deprioritized) || rank[left.status] - rank[right.status] || String(right.lastSeenAt).localeCompare(String(left.lastSeenAt)) )); @@ -1016,6 +1019,7 @@ export function createDeviceInventoryService({ id: previous?.id || deviceId(mac), alias: previous?.alias || '', pinned: previous?.pinned === true, + deprioritized: previous?.deprioritized === true && previous?.pinned !== true, hostname: previous?.hostname || null, manufacturer: previous?.manufacturer || vendor(mac), mac, @@ -1031,7 +1035,7 @@ export function createDeviceInventoryService({ } const cutoff = new Date(observedAt).getTime() - RETENTION_MS; const devices = [...byMac.values()].filter((device) => ( - device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff + device.pinned || device.deprioritized || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff )); let traffic = state.traffic; if (trafficResult) { @@ -1271,15 +1275,19 @@ export function createDeviceInventoryService({ const value = record(patch); const aliasProvided = Object.hasOwn(value, 'alias'); const pinProvided = Object.hasOwn(value, 'pinned'); + const deprioritizedProvided = Object.hasOwn(value, 'deprioritized'); if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0 - || (!aliasProvided && !pinProvided) + || (!aliasProvided && !pinProvided && !deprioritizedProvided) || (aliasProvided && (typeof value.alias !== 'string' || value.alias.length > 64)) - || (pinProvided && typeof value.pinned !== 'boolean')) { + || (pinProvided && typeof value.pinned !== 'boolean') + || (deprioritizedProvided && typeof value.deprioritized !== 'boolean') + || (value.pinned === true && value.deprioritized === true)) { throw new HarborError('REQUEST_INVALID'); } const revision = expectedRevision; const alias = typeof value.alias === 'string' ? value.alias : ''; const pinned = value.pinned === true; + const deprioritized = value.deprioritized === true; store.update((stored) => { const state = migrateDeviceInventoryState(stored); if (state.revision !== revision) throw new HarborError('STATE_CONFLICT'); @@ -1289,7 +1297,10 @@ export function createDeviceInventoryService({ devices[index] = { ...devices[index], ...(aliasProvided ? { alias: alias.trim() } : {}), - ...(pinProvided ? { pinned } : {}), + ...(pinProvided ? { pinned, ...(pinned ? { deprioritized: false } : {}) } : {}), + ...(deprioritizedProvided + ? { deprioritized, ...(deprioritized ? { pinned: false } : {}) } + : {}), }; return { ...state, revision: state.revision + 1, devices }; }); diff --git a/src/shared/versions.ts b/src/shared/versions.ts index b966e0f..c197ddb 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.21.8', - gatewayClient: '0.22.3', - gatewayBackend: '0.22.5', + macClient: '0.22.0', + gatewayClient: '0.23.0', + gatewayBackend: '0.23.0', }); export interface ParsedVersion { diff --git a/src/web/features/devices/DevicesPanel.tsx b/src/web/features/devices/DevicesPanel.tsx index 16884ff..1e0babe 100644 --- a/src/web/features/devices/DevicesPanel.tsx +++ b/src/web/features/devices/DevicesPanel.tsx @@ -1,4 +1,4 @@ -import React, { +import { useEffect, useLayoutEffect, useMemo, @@ -233,16 +233,12 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { setAlias(value); } - async function togglePin(device: Device) { - if (!device.pinned || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - await updateDevice(device, { pinned: !device.pinned }); - return; - } + async function collapsePinned(device: Device, patch: Record) { setPinCollapses((current) => ({ ...current, [device.id]: { animationDone: false, result: 'pending' }, })); - const saved = await updateDevice(device, { pinned: false }); + const saved = await updateDevice(device, patch); setPinCollapses((current) => { const phase = current[device.id]; if (!phase) return current; @@ -255,6 +251,23 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { }); } + async function togglePin(device: Device) { + if (!device.pinned || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + await updateDevice(device, { pinned: !device.pinned }); + return; + } + await collapsePinned(device, { pinned: false }); + } + + async function toggleDeprioritized(device: Device) { + const deprioritized = device.deprioritized === true; + if (!device.pinned || deprioritized || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + await updateDevice(device, { deprioritized: !deprioritized }); + return; + } + await collapsePinned(device, { deprioritized: true }); + } + function finishPinCollapse(deviceId: string) { setPinCollapses((current) => { const phase = current[deviceId]; @@ -365,10 +378,21 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { {copyAnnouncement?.message || ''}
- {devices.map((device) => { + {devices.map((device, index) => { const collapsing = Boolean(pinCollapses[device.id]); + const deprioritized = device.deprioritized === true; + const compact = deprioritized && !collapsing; const expanded = device.pinned && !collapsing; - const showDetails = device.pinned || collapsing; + const showDetails = !compact && (device.pinned || collapsing); + const group = device.pinned ? 'pinned' : deprioritized ? 'deprioritized' : 'default'; + const previous = devices[index - 1]; + const previousGroup = previous + ? previous.pinned ? 'pinned' : previous.deprioritized === true ? 'deprioritized' : 'default' + : ''; + const groupLabel = group === 'pinned' + ? 'Закреплённые' + : group === 'deprioritized' ? 'Убраны вниз' : 'Остальные'; + const groupStart = group !== previousGroup; const hasName = Boolean(device.alias || device.hostname); const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство'; const editing = editingId === device.id; @@ -406,14 +430,20 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { ? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway' : 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую'; return
{ if (node) deviceNodes.current.set(device.id, node); else deviceNodes.current.delete(device.id); }} - className={`client-device is-${device.status}${expanded ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}`} - key={device.id} + className={`client-device is-${device.status}${expanded ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}${compact ? ' is-deprioritized' : ''}${groupStart ? ' is-group-start' : ''}`} > - + {groupStart ? groupLabel : ''} + {!compact && {device.pinned ? 'Открепить' : 'Закрепить'} - + }
-

+

{editing ? ( copyDeviceIp(device)} >{device.ip} : !hasName && !editing && Неизвестное устройство} -

+ {!hasName && !editing &&
- Gateway {hasProxyTraffic && Прокси} - + } - + {!compact && {policyTooltip} + } + + + {deprioritized ? 'Вернуть в список' : 'Убрать вниз'} - finishPinCollapse(device.id)} - /> + />}
; })}
diff --git a/src/web/features/devices/deviceSnapshot.ts b/src/web/features/devices/deviceSnapshot.ts index f31ef03..0a70d9e 100644 --- a/src/web/features/devices/deviceSnapshot.ts +++ b/src/web/features/devices/deviceSnapshot.ts @@ -21,6 +21,7 @@ export interface Device extends Record { lastSeenAt: string | null; status: DeviceStatus; pinned: boolean; + deprioritized?: boolean; downloadBytes: ByteValue; uploadBytes: ByteValue; proxyDownloadBytes: ByteValue; @@ -116,6 +117,8 @@ function validDevice(value: unknown): value is Device { && nullableTimestamp(value.lastSeenAt) && (value.status === 'online' || value.status === 'recent' || value.status === 'offline') && typeof value.pinned === 'boolean' + && (value.deprioritized === undefined || typeof value.deprioritized === 'boolean') + && !(value.pinned === true && value.deprioritized === true) && bytes(value.downloadBytes) && bytes(value.uploadBytes) && bytes(value.proxyDownloadBytes) diff --git a/src/web/styles/features/devices.css b/src/web/styles/features/devices.css index e5d8c12..6371664 100644 --- a/src/web/styles/features/devices.css +++ b/src/web/styles/features/devices.css @@ -191,6 +191,7 @@ .client-devices-error button, .client-device-pin, +.client-device-deprioritize, .client-device-edit { padding: 0; border: 0; @@ -213,12 +214,42 @@ row-gap: 6px; padding: 10px 8px; border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent); + position: relative; +} + +.client-device.is-group-start { + margin-top: 22px; +} + +.client-device-group-heading { + position: absolute; + top: -17px; + left: 8px; + color: var(--client-muted); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.client-device:not(.is-group-start) > .client-device-group-heading { + display: none; } .client-device.is-pinned { --client-device-chart-height: 72px; } +.client-device.is-deprioritized { + grid-template-rows: 34px; + row-gap: 0; + padding-block: 6px; +} + +.client-device.is-deprioritized .client-device-main { + grid-column: 1 / 4; +} + .client-device-main { grid-column: 2; grid-row: 1; @@ -231,7 +262,7 @@ padding: 11px 0 0; } -.client-device-main > h3 { +.client-device-main > h4 { height: 23px; flex: 1 1 auto; min-width: 0; @@ -246,7 +277,7 @@ white-space: nowrap; } -.client-device-main > h3.is-address-only { +.client-device-main > h4.is-address-only { flex: 0 1 auto; } @@ -370,7 +401,8 @@ } .client-device-edit, -.client-device-pin { +.client-device-pin, +.client-device-deprioritize { width: 32px; height: 32px; display: grid; @@ -384,7 +416,8 @@ } .client-device-edit svg, -.client-device-pin svg { +.client-device-pin svg, +.client-device-deprioritize svg { width: 17px; height: 17px; fill: none; @@ -398,7 +431,9 @@ .client-device-edit:hover, .client-device-edit:focus-visible, .client-device-pin:hover, -.client-device-pin:focus-visible { +.client-device-pin:focus-visible, +.client-device-deprioritize:hover, +.client-device-deprioritize:focus-visible { color: var(--client-accent); } @@ -988,7 +1023,8 @@ cursor: wait; } -.client-device-policy-wrap.client-tooltip-anchor > .client-tooltip { +.client-device-policy-wrap.client-tooltip-anchor > .client-tooltip, +.client-device-deprioritize-wrap.client-tooltip-anchor > .client-tooltip { right: 0; left: auto; text-transform: none; @@ -996,10 +1032,41 @@ } .client-device-policy-wrap.client-tooltip-anchor:hover > .client-tooltip, -.client-device-policy-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip { +.client-device-policy-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip, +.client-device-deprioritize-wrap.client-tooltip-anchor:hover > .client-tooltip, +.client-device-deprioritize-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip { transform: translate(0, 0); } +.client-device-deprioritize-wrap { + grid-column: 4; + grid-row: 2; + width: 34px; + height: 34px; + position: relative; + z-index: 5; + display: grid; + place-items: center; +} + +.client-device.is-deprioritized .client-device-deprioritize-wrap { + grid-row: 1; +} + +.client-device-deprioritize[aria-pressed="true"] { + color: var(--client-accent); +} + +.client-device-deprioritize:hover:not(:disabled) svg, +.client-device-deprioritize:focus-visible svg { + transform: translateY(2px); +} + +.client-device-deprioritize[aria-pressed="true"]:hover:not(:disabled) svg, +.client-device-deprioritize[aria-pressed="true"]:focus-visible svg { + transform: translateY(-2px); +} + .client-devices-error button { justify-self: start; font-size: 10px; diff --git a/src/web/styles/themes.css b/src/web/styles/themes.css index 9a6f026..5289e29 100644 --- a/src/web/styles/themes.css +++ b/src/web/styles/themes.css @@ -67,6 +67,8 @@ .client-device, .client-device-pin, .client-device-pin svg, + .client-device-deprioritize, + .client-device-deprioritize svg, .client-device-policy, .client-device-policy svg, .client-device-alias-trigger, diff --git a/src/web/utils/format.ts b/src/web/utils/format.ts index ec2ad1f..350f244 100644 --- a/src/web/utils/format.ts +++ b/src/web/utils/format.ts @@ -69,6 +69,8 @@ export function sortDevicesByTraffic(devices: Device[] | undefined, direction: ' .sort((left, right) => { const pinned = Number(right.device.pinned === true) - Number(left.device.pinned === true); if (pinned) return pinned; + const deprioritized = Number(left.device.deprioritized === true) - Number(right.device.deprioritized === true); + if (deprioritized) return deprioritized; const leftTotal = byteString(left.device.uploadBytes) + byteString(left.device.downloadBytes) + byteString(left.device.proxyUploadBytes) + byteString(left.device.proxyDownloadBytes); const rightTotal = byteString(right.device.uploadBytes) + byteString(right.device.downloadBytes) @@ -96,7 +98,11 @@ export function stabilizeDevicesByTraffic( seen.add(id); } const ordered = ids.map((id) => byId.get(id)).filter((device): device is Device => Boolean(device)); - const stable = [...ordered.filter(({ pinned }) => pinned), ...ordered.filter(({ pinned }) => !pinned)]; + const stable = [ + ...ordered.filter(({ pinned }) => pinned), + ...ordered.filter(({ pinned, deprioritized }) => !pinned && deprioritized !== true), + ...ordered.filter(({ pinned, deprioritized }) => !pinned && deprioritized === true), + ]; return { devices: stable, ids: stable.map(({ id }) => id) }; } diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js index cf41601..60f069b 100644 --- a/test/server/device-inventory.test.js +++ b/test/server/device-inventory.test.js @@ -79,6 +79,7 @@ test('malformed persisted devices and remote observations cannot enter canonical id: deviceId(mac), alias: '', pinned: false, + deprioritized: false, hostname: null, manufacturer: null, source: 'neighbor', @@ -183,6 +184,7 @@ test('device inventory discovers, merges, persists metadata and expires anonymou assert.equal(snapshot.devices[0].status, 'recent'); snapshot = service.update(snapshot.devices[0].id, { alias: 'Телевизор', pinned: true }, snapshot.revision); + const namedId = snapshot.devices[0].id; const restarted = createDeviceInventoryService({ store, observe: async () => observation, vendor, now: () => current }); assert.deepEqual(restarted.snapshot().devices[0], snapshot.devices[0]); assert.throws( @@ -190,15 +192,57 @@ test('device inventory discovers, merges, persists metadata and expires anonymou (error) => error.code === 'STATE_CONFLICT', ); - observation = { observedAt: current.toISOString(), observations: [], error: 'source unavailable' }; + current = new Date('2026-07-01T10:06:00.000Z'); + observation = { + observedAt: current.toISOString(), + observations: parseNeighborSnapshot([ + { dst: '192.168.50.18', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['REACHABLE'] }, + ], current.toISOString()), + error: null, + }; snapshot = await restarted.refresh(); assert.equal(snapshot.devices.length, 1); + assert.equal(snapshot.devices[0].id, namedId); + assert.equal(snapshot.devices[0].ip, '192.168.50.18'); + assert.equal(snapshot.devices[0].alias, 'Телевизор'); + assert.equal(snapshot.devices[0].pinned, true); + + observation = { + observedAt: current.toISOString(), + observations: parseNeighborSnapshot([ + { dst: '192.168.50.18', dev: 'br0', lladdr: '00:11:22:33:44:66', state: ['REACHABLE'] }, + ], current.toISOString()), + error: null, + }; + snapshot = await restarted.refresh(); + const replacement = snapshot.devices.find(({ mac: deviceMac }) => deviceMac === '00:11:22:33:44:66'); + assert.notEqual(replacement.id, namedId); + assert.equal(replacement.alias, ''); + assert.equal(replacement.pinned, false); + + snapshot = restarted.update(namedId, { deprioritized: true }, snapshot.revision); + const deprioritized = snapshot.devices.find(({ id }) => id === namedId); + assert.equal(deprioritized.pinned, false); + assert.equal(deprioritized.deprioritized, true); + assert.throws( + () => restarted.update(namedId, { pinned: true, deprioritized: true }, snapshot.revision), + (error) => error.code === 'REQUEST_INVALID', + ); + const persisted = createDeviceInventoryService({ store, observe: async () => observation, vendor, now: () => current }); + assert.equal(persisted.snapshot().devices.find(({ id }) => id === namedId).deprioritized, true); + + observation = { observedAt: current.toISOString(), observations: [], error: 'source unavailable' }; + snapshot = await persisted.refresh(); + assert.equal(snapshot.devices.length, 2); assert.equal(snapshot.source.error, 'source unavailable'); - snapshot = restarted.update(snapshot.devices[0].id, { alias: '', pinned: false }, snapshot.revision); + snapshot = persisted.update(namedId, { alias: '' }, snapshot.revision); current = new Date('2026-08-02T10:00:00.000Z'); observation = { observedAt: current.toISOString(), observations: [], error: null }; - snapshot = await restarted.refresh(); + snapshot = await persisted.refresh(); + assert.deepEqual(snapshot.devices.map(({ id }) => id), [namedId]); + snapshot = persisted.update(namedId, { deprioritized: false }, snapshot.revision); + snapshot = await persisted.refresh(); assert.equal(snapshot.devices.length, 0); const failed = readNeighborSnapshot(() => ({ status: 1, stderr: 'not available' }), () => current); diff --git a/test/server/device-routes.test.js b/test/server/device-routes.test.js index ed5ab89..18235f4 100644 --- a/test/server/device-routes.test.js +++ b/test/server/device-routes.test.js @@ -75,7 +75,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, extra: 0 }; + const patch = { expectedRevision: 7, alias: 'Desk', pinned: false, deprioritized: true, extra: 0 }; const metadata = createHarness({ body: patch }); const metadataResponse = response(); assert.equal(await metadata.route.handle({ @@ -85,7 +85,7 @@ test('device route forwards metadata patch and policy arguments without coercion assert.deepEqual(metadata.calls, [[ 'update', deviceId, - { alias: 'Desk', pinned: false, extra: 0 }, + { alias: 'Desk', pinned: false, deprioritized: true, extra: 0 }, 7, ]]); assert.deepEqual(metadataResponse.payload, { revision: 3 }); diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index 7100b3a..3a42a4f 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -116,12 +116,22 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.doesNotMatch(panel, /точная MAC|частная MAC|
Источник<\/dt>/); assert.match(panel, /aria-pressed=\{device\.pinned\}/); assert.match(panel, /const \[pinCollapses, setPinCollapses\][\s\S]*animationDone[\s\S]*result: saved \? 'saved' : 'failed'/); - assert.match(panel, /const expanded = device\.pinned && !collapsing[\s\S]*const showDetails = device\.pinned \|\| collapsing[\s\S]*pinned=\{showDetails\}[\s\S]*onCollapseEnd=/); + assert.match(panel, /const compact = deprioritized && !collapsing[\s\S]*const expanded = device\.pinned && !collapsing[\s\S]*const showDetails = !compact && \(device\.pinned \|\| collapsing\)[\s\S]*pinned=\{showDetails\}[\s\S]*onCollapseEnd=/); + assert.match(panel, /Закреплённые[\s\S]*Убраны вниз[\s\S]*Остальные/); + assert.match(panel, /client-device-group-heading[\s\S]*role=\{groupStart \? 'heading' : undefined\}[\s\S]*aria-level=\{groupStart \? 3 : undefined\}/); + assert.match(panel, /toggleDeprioritized[\s\S]*deprioritized: !deprioritized[\s\S]*deprioritized: true/); + assert.match(panel, /client-device-deprioritize[\s\S]*aria-pressed=\{deprioritized\}[\s\S]*Вернуть \$\{title\} в основной список[\s\S]*Убрать \$\{title\} вниз/); + assert.match(panel, /\{!compact && h3\.is-address-only \{[\s\S]*flex: 0 1 auto/); + assert.match(styles, /\.client-device-main > h4\.is-address-only \{[\s\S]*flex: 0 1 auto/); assert.match(styles, /\.client-device-last-seen \{[^}]*height: 10px[\s\S]*align-items: center/); assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/); assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/); @@ -249,9 +259,10 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni { id: 'd', uploadBytes: '15', downloadBytes: '5', proxyUploadBytes: '100' }, { id: 'c', uploadBytes: '10', downloadBytes: '10', proxyDownloadBytes: '100' }, { id: 'e', pinned: true, uploadBytes: '0', downloadBytes: '0' }, + { id: 'f', deprioritized: true, uploadBytes: '999999', downloadBytes: '0' }, ]; - assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['e', 'b', 'a', 'c', 'd']); - assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['e', 'c', 'd', 'a', 'b']); + assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['e', 'b', 'a', 'c', 'd', 'f']); + assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['e', 'c', 'd', 'a', 'b', 'f']); assert.deepEqual( stabilizeDevicesByTraffic([ { id: 'a', uploadBytes: '999' }, @@ -266,4 +277,12 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni ], 'desc', ['a', 'b']).ids, ['b', 'a'], ); + assert.deepEqual( + stabilizeDevicesByTraffic([ + { id: 'a', deprioritized: true, uploadBytes: '999' }, + { id: 'b', uploadBytes: '1' }, + { id: 'c', pinned: true, uploadBytes: '0' }, + ], 'desc', ['a', 'b', 'c']).ids, + ['c', 'b', 'a'], + ); }); diff --git a/test/web/devices-feature-contract.test.js b/test/web/devices-feature-contract.test.js index 6fec7ac..5bcf2b8 100644 --- a/test/web/devices-feature-contract.test.js +++ b/test/web/devices-feature-contract.test.js @@ -50,6 +50,7 @@ test('all unknown inventory results pass one identity-preserving runtime parser' lastSeenAt: null, status: 'online', pinned: true, + deprioritized: false, downloadBytes: '12', uploadBytes: '30', proxyDownloadBytes: '0', @@ -99,6 +100,8 @@ test('all unknown inventory results pass one identity-preserving runtime parser' { ...valid, devices: [{ ...valid.devices[0], uploadBytes: '-1' }] }, { ...valid, devices: [{ ...valid.devices[0], proxyUploadBytes: 1 }] }, { ...valid, devices: [{ ...valid.devices[0], status: 'connected' }] }, + { ...valid, devices: [{ ...valid.devices[0], deprioritized: 'yes' }] }, + { ...valid, devices: [{ ...valid.devices[0], pinned: true, deprioritized: true }] }, { ...valid, devices: [{ ...valid.devices[0], desiredPolicy: 'automatic' }] }, { ...valid, devices: [{ ...valid.devices[0], policyStatus: 'queued' }] }, { ...valid, devices: [{ ...valid.devices[0], confidence: 'unknown' }] }, diff --git a/test/web/style-boundaries.test.js b/test/web/style-boundaries.test.js index 07b89b4..11a45ea 100644 --- a/test/web/style-boundaries.test.js +++ b/test/web/style-boundaries.test.js @@ -36,26 +36,26 @@ const expectedImports = [ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const acceptedLedger = { counts: { - cascadeEdges: 804, + cascadeEdges: 821, customProperties: 31, - declarations: 2914, + declarations: 2941, important: 0, keyframes: 51, media: 10, - rules: 878, - variableReferences: 336, + rules: 888, + variableReferences: 338, }, hashes: { - cascadeEdges: '18e5bdda435293145dbb52319f1e842cc8b7cc3e8beb60ec381c26d0410cfe44', + cascadeEdges: '1ffc2a2f986970988643129ff3e32f19c31bb8b1e018a3a474a976e23adc9776', customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545', - declarations: '01152af9f22aa7a7654356f81b8a6110488f9d70754b2517d1f6bafeff4b14fd', + declarations: '77314876411a21c88472dc277a59ca24192ca7d38de1ce8f367e7bd1007c5323', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47', keyframes: '0bed7ec3cd3a86ee091cf9b07c081ab2364ac5a17e55e95bc9c4a6430419b019', - ruleDeclarationSequences: '7ffd551563a8d801bcd5e5f9886b9551940dd9561240f0f8a771a85d44fc19db', - selectors: '09962ebb3bc769f74ae785a44f8e032c0a83985ce14ea652135732b5af69042a', - variableReferences: '5988cb6c9b658d380c5b51c04621c0579e1cc5e8abcd764d78a9d42b802252c9', - witnesses: '68d7c48f20f7a8ae2c0a6ddabe743dfb84fd02fba303e3cc9d3b902efd0f8758', + ruleDeclarationSequences: 'ce3831db5966264c17fa3af3e10fc6a41398cc619d061fa155ac43b47f0947ea', + selectors: '828cf6e95b03c890e3a969be20eb05be1f70872d6076e8f9a6a38cf6b415d012', + variableReferences: '9b6214da6e8e02b01deb6d5a98fe4c695a1fdaff4f65c936b805d7d5841db04d', + witnesses: 'c34721ec2cb99104eae77841ae88ddf77cde278d15c21675a020f3cfdd13a28a', }, }; @@ -110,7 +110,7 @@ test('tokens, shared primitives, and feature styles have one explicit owner', () test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { const witnesses = readStyleWitnesses(root); - assert.equal(witnesses.length, 709); + assert.equal(witnesses.length, 716); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); const ledger = createStyleLedger(readStyleSource(root), { witnesses }); assert.deepEqual(ledger.counts, acceptedLedger.counts); @@ -293,8 +293,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-BJnEzGgg.css']); + assert.deepEqual(assets, ['index-DQr1ElW4.css']); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); - assert.equal(built.byteLength, 109140); - assert.equal(sha256(built), '9e1f7811080177f366b693a77e92c615aff1a74537b34d597100c9707a8aea95'); + assert.equal(built.byteLength, 110647); + assert.equal(sha256(built), '91bf07a74af3312f9b048fdebd642e811fafc3bb2ec49b55592a35f085c25f41'); });