From dfa9f09695abc99d2aea9637cafce6b489c9cba7 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 7 Aug 2026 17:27:13 +0300 Subject: [PATCH] Allow device routing without pinning --- .codex/skills/design-vpn-client-ui/SKILL.md | 2 + .../references/motion-and-interaction.md | 9 +++ README.md | 4 +- src/server/services/deviceInventoryService.js | 8 +-- src/shared/errors.js | 1 - src/shared/versions.js | 6 +- src/web/components/DevicesPanel.jsx | 43 ++++++------ src/web/components/ServerPicker.jsx | 2 +- src/web/styles.css | 65 +++++++++++++++---- src/web/utils/format.js | 2 + test/server/device-inventory.test.js | 11 ++-- test/web/device-inventory-contract.test.js | 16 +++-- test/web/server-picker.test.js | 6 +- 13 files changed, 114 insertions(+), 61 deletions(-) diff --git a/.codex/skills/design-vpn-client-ui/SKILL.md b/.codex/skills/design-vpn-client-ui/SKILL.md index d2bf230..9c52afa 100644 --- a/.codex/skills/design-vpn-client-ui/SKILL.md +++ b/.codex/skills/design-vpn-client-ui/SKILL.md @@ -26,6 +26,7 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a - Never let labels, timers, feedback, icons, progress, or server rows shift neighboring content. - Animate state, opacity, blur, glow, color, filter, and transform. Do not animate layout properties. - Make live behavior visibly alive: running processes, changing values, mode changes, and interactive affordances should communicate through restrained motion instead of abrupt static replacement. +- Give every actionable icon a semantic hover/focus response; rotate cyclic actions, move the physical part of object-like controls, and keep their hit targets fixed. - Let every visible cycle finish and return to its resting coordinates before stopping. Never cancel a hover animation, spinner, or list exit at an arbitrary frame. - Animate dynamic rows through complete enter and exit phases; keep a departing row mounted until its exit finishes, with immediate removal under reduced motion. - Animate only what changed. Keep unchanged digits, labels, icons, and surrounding geometry stable. @@ -51,5 +52,6 @@ Before handing off, verify: - Server separators are compact and only slightly wider than their content. - Repeated polling does not replay decorative list animations. - Manual refresh has an obvious but non-jarring response. +- Icon-only controls respond on hover and focus, active cyclic work spins, and durable states such as pinned remain legible at rest. - Keyboard focus remains visible even when the text caret is intentionally hidden. - Narrow screens return to a simple single-column layout. diff --git a/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md b/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md index b61c231..ec942dd 100644 --- a/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md +++ b/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md @@ -61,6 +61,15 @@ Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arri - On updated traffic, tween the number, advance the bar, and emit a visible but brief mode-accent flare. - Keep refresh tooltip outside the rotating button so it remains upright and unfiltered. +## Icon controls + +- Give every actionable icon a small semantic response on hover and keyboard focus; leave decorative icons still. +- Rotate cyclic actions such as refresh, ping, and traffic sorting on hover, then use the shared continuous spin while work is running. +- Move the physical part of object-like controls: lift and tilt a pin, pencil, or trash lid instead of moving its fixed hit target. +- Keep durable state on the icon wrapper and transient motion on the SVG child. A pinned icon stays lifted and tilted while its row moves to the pinned group. +- Keep the hit target, tooltip, and surrounding layout fixed. Tooltips remain outside the transformed SVG. +- Under reduced motion, preserve color, focus, and final state without animated travel or rotation. + ## Server cascade - On initial display, reveal rows from top to bottom with a small negative Y offset, opacity, and blur. diff --git a/README.md b/README.md index 11b5bc4..f95dfb0 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,9 @@ http://АДРЕС-GATEWAY:3456 ### Устройства Gateway -После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и компактно показывает IP, последний контакт, производителя из локальной OUI-базы и сохранённый интернет-трафик. В строке отдельно отмечаются ненулевые источники `Gateway` и `Прокси`; одно устройство может использовать оба, а подробное получено/отдано доступно при наведении или фокусе. Технический MAC хранится для идентификации и правил, но в обычной строке скрыт. Устройство можно переименовать и закрепить; название, закрепление и накопленные totals сохраняются в volume Gateway. Кнопка «Трафик ↓/↑» сортирует список по сумме обоих источников от большего объёма к меньшему или наоборот. +После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и компактно показывает IP, последний контакт, производителя из локальной OUI-базы и сохранённый интернет-трафик. В строке отдельно отмечаются ненулевые источники `Gateway` и `Прокси`; одно устройство может использовать оба, а подробное получено/отдано доступно при наведении или фокусе. Технический MAC хранится для идентификации и правил, но в обычной строке скрыт. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway. -У закреплённого и однозначно распознанного устройства маршрут можно переключить между `VPN` и `Напрямую`. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут; перед откреплением устройство нужно вернуть в `VPN`. +У однозначно распознанного устройства маршрут можно переключить между `VPN` и `Напрямую` независимо от закрепления. `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 секунд; история по часам пока не хранится. diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js index 2013ed7..8e78539 100644 --- a/src/server/services/deviceInventoryService.js +++ b/src/server/services/deviceInventoryService.js @@ -314,7 +314,7 @@ export function createDeviceInventoryService({ } function policyIdentity(device) { - return device?.pinned && device.confidence !== 'ambiguous' + return Boolean(device) && device.confidence !== 'ambiguous' && net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac) && isDeviceInterface(device.interface); } @@ -783,11 +783,6 @@ export function createDeviceInventoryService({ if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); const index = state.devices.findIndex((device) => device.id === id); if (index < 0) throw new HarborError('DEVICE_NOT_FOUND'); - const currentPolicy = policyFor(state, state.devices[index].mac); - if (pinProvided && patch.pinned === false - && (currentPolicy.desired === 'direct' || currentPolicy.applied === 'direct')) { - throw new HarborError('REQUEST_INVALID'); - } const devices = [...state.devices]; devices[index] = { ...devices[index], @@ -809,7 +804,6 @@ export function createDeviceInventoryService({ if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); const device = state.devices.find((candidate) => candidate.id === id); if (!device) throw new HarborError('DEVICE_NOT_FOUND'); - if (mode === 'direct' && !device.pinned) throw new HarborError('DEVICE_POLICY_REQUIRES_PIN'); if (mode === 'direct' && !policyIdentity(device)) throw new HarborError('DEVICE_IDENTITY_AMBIGUOUS'); const current = policyFor(state, device.mac); if (current.desired === mode && current.status === 'applied') return state; diff --git a/src/shared/errors.js b/src/shared/errors.js index c5873fe..b6d36a8 100644 --- a/src/shared/errors.js +++ b/src/shared/errors.js @@ -11,7 +11,6 @@ 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_POLICY_REQUIRES_PIN: { status: 409, message: 'Сначала закрепите устройство.', retryable: false }, DEVICE_IDENTITY_AMBIGUOUS: { status: 409, message: 'Gateway не может безопасно применить маршрут к этому устройству.', retryable: true }, DEVICE_POLICY_APPLY_FAILED: { status: 503, message: 'Не удалось применить маршрут устройства.', retryable: true }, CONFIG_INVALID: { status: 422, message: 'Конфигурация VPN недействительна.', retryable: false }, diff --git a/src/shared/versions.js b/src/shared/versions.js index 11bf082..5dde818 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.12.3', - gatewayClient: '0.13.0', - gatewayBackend: '0.13.1', + macClient: '0.13.0', + gatewayClient: '0.14.0', + gatewayBackend: '0.14.0', }); export function parseVersion(value) { diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index 367d255..53edae6 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -203,7 +203,9 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { onClick={() => setSortDirection((direction) => direction === 'desc' ? 'asc' : 'desc')} > Трафик - + Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика @@ -274,16 +276,14 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { : device.appliedPolicy; const cannotEnableDirect = device.policyStatus === 'applied' && device.appliedPolicy !== 'direct' - && (!device.pinned || device.confidence === 'ambiguous'); + && device.confidence === 'ambiguous'; const policyTooltip = policyBusy ? `Применяем: ${device.desiredPolicy === 'direct' ? 'полностью напрямую' : 'через правила Gateway'}` : policyFailed ? `${device.policyError || 'Маршрут не применён'}. Сейчас: ${device.appliedPolicy === 'direct' ? 'напрямую' : 'через Gateway'}. Нажмите, чтобы оставить текущий маршрут` : policyPending ? 'Gateway должен однозначно распознать устройство. Нажмите, чтобы отменить ожидание' - : !device.pinned - ? 'Закрепите устройство, чтобы изменить маршрут' - : device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct' + : device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct' ? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства' : displayPolicy === 'direct' ? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway' @@ -296,6 +296,22 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { className={`client-device is-${device.status}`} key={device.id} > + + + {device.pinned ? 'Открепить' : 'Закрепить'} + +
{editing ? (
saveAlias(event, device)}> @@ -343,23 +359,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { {trafficLabel} } - - - {device.desiredPolicy === 'direct' || device.appliedPolicy === 'direct' - ? 'Сначала верните маршрут через Gateway' - : device.pinned ? 'Открепить' : 'Закрепить'} -
diff --git a/src/web/components/ServerPicker.jsx b/src/web/components/ServerPicker.jsx index 61d9736..7ad3697 100644 --- a/src/web/components/ServerPicker.jsx +++ b/src/web/components/ServerPicker.jsx @@ -185,7 +185,7 @@ export function ServerPicker({ ...Object.fromEntries(ids.map((id) => [id, { error: true, checking: true, checkedAt: new Date().toISOString() }])), })); } finally { - await new Promise((resolve) => setTimeout(resolve, Math.max(0, 700 - (performance.now() - startedAt)))); + await new Promise((resolve) => setTimeout(resolve, Math.max(0, 900 - (performance.now() - startedAt)))); setPings((current) => ({ ...current, ...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: false }])), diff --git a/src/web/styles.css b/src/web/styles.css index 315a7f6..93895ef 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -761,6 +761,17 @@ p { filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 44%, transparent)); } +.client-devices-sort-icon { + display: inline-block; + transform-origin: center; + transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-devices-sort:hover .client-devices-sort-icon, +.client-devices-sort:focus-visible .client-devices-sort-icon { + transform: rotate(360deg); +} + .client-devices-refresh { position: relative; width: 24px; @@ -878,14 +889,18 @@ p { .client-device { display: grid; - gap: 3px; + grid-template-columns: 32px minmax(0, 1fr); + grid-template-rows: auto auto; + gap: 3px 4px; padding: 10px 8px; border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent); } .client-device-heading { + grid-column: 2; + grid-row: 1; display: grid; - grid-template-columns: minmax(0, 1fr) auto 32px; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px; min-height: 28px; @@ -927,6 +942,9 @@ p { } .client-device-pin-wrap { + grid-column: 1; + grid-row: 1 / 3; + align-self: center; width: 32px; height: 32px; } @@ -949,7 +967,7 @@ p { stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; - transition: color 180ms ease, filter 220ms ease; + transition: color 180ms ease, filter 220ms ease, transform 320ms cubic-bezier(0.16, 1, 0.3, 1); } .client-device-edit:hover, @@ -959,11 +977,33 @@ p { color: var(--client-accent); } +.client-device-edit:hover svg, +.client-device-edit:focus-visible svg { + transform: translate(1px, -1px) rotate(-4deg); +} + .client-device-pin[aria-pressed="true"] { color: var(--client-accent); + filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 42%, transparent)); +} + +.client-device-pin svg { + transform-origin: 50% 70%; +} + +.client-device-pin:hover:not(:disabled) svg, +.client-device-pin:focus-visible svg, +.client-device-pin[aria-pressed="true"] svg { + transform: translateY(-2px) rotate(-12deg); +} + +.client-device-pin:active:not(:disabled) svg { + transform: translateY(-1px) rotate(-8deg) scale(0.94); } .client-device-meta { + grid-column: 2; + grid-row: 2; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) 72px auto; @@ -1003,8 +1043,8 @@ p { } .client-device-pin-wrap.client-tooltip-anchor > .client-tooltip { - right: 0; - left: auto; + right: auto; + left: 0; transform: translate(0, 2px); } @@ -3215,7 +3255,7 @@ p { .client-server-health.is-checking .client-server-health-checking { opacity: 1; filter: blur(0); - animation: client-server-check-spin 700ms linear infinite; + animation: client-spin 900ms linear infinite; } .client-servers.is-scalable { @@ -3295,6 +3335,7 @@ p { stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; + transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1); } .client-server-check.is-checking { @@ -3304,11 +3345,7 @@ p { } .client-server-check.is-checking svg { - animation: client-server-check-spin 700ms linear infinite; -} - -@keyframes client-server-check-spin { - to { transform: rotate(360deg); } + animation: client-spin 900ms linear infinite; } .client-server-check:hover:not(:disabled), @@ -3316,6 +3353,11 @@ p { color: var(--client-accent); } +.client-server-check:hover:not(:disabled) svg, +.client-server-check:focus-visible:not(.is-checking) svg { + transform: rotate(90deg); +} + .client-server-check:focus-visible { outline: 2px solid var(--client-accent); outline-offset: 1px; @@ -4150,6 +4192,7 @@ p { .client-device-edit-wrap, .client-devices-refresh, .client-devices-sort, + .client-devices-sort-icon, .client-devices-refresh-ring circle, .client-devices-refresh-icon, .client-text-morph-value, diff --git a/src/web/utils/format.js b/src/web/utils/format.js index 064da80..6411738 100644 --- a/src/web/utils/format.js +++ b/src/web/utils/format.js @@ -36,6 +36,8 @@ export function sortDevicesByTraffic(devices, direction = 'desc') { return (Array.isArray(devices) ? devices : []) .map((device, index) => ({ device, index })) .sort((left, right) => { + const pinned = Number(right.device.pinned === true) - Number(left.device.pinned === true); + if (pinned) return pinned; 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) diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js index b29c743..a00d3a5 100644 --- a/test/server/device-inventory.test.js +++ b/test/server/device-inventory.test.js @@ -360,7 +360,7 @@ test('a proxy regression rejects every device in that proxy sample atomically', assert.equal(snapshot.devices.find(({ mac }) => mac === macs[0]).uploadBytes, '11'); }); -test('pinned device policy persists, reconciles the full set, and keeps the last applied mode on failure', async (t) => { +test('device policy is independent from pinning, persists, and keeps the last applied mode on failure', async (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-policy-')); t.after(() => fs.rmSync(directory, { recursive: true, force: true })); const store = createJsonStore({ @@ -412,7 +412,6 @@ test('pinned device policy persists, reconciles the full set, and keeps the last assert.equal(snapshot.devices[0].desiredPolicy, 'vpn'); assert.equal(snapshot.devices[0].appliedPolicy, 'vpn'); - snapshot = service.update(id, { pinned: true }, snapshot.revision); snapshot = await service.setPolicy(id, 'direct', snapshot.revision); assert.deepEqual(appliedSets.at(-1), [{ id, ip: '192.168.50.7', mac, interface: 'eth0' }]); assert.equal(snapshot.devices[0].desiredPolicy, 'direct'); @@ -424,10 +423,9 @@ test('pinned device policy persists, reconciles the full set, and keeps the last service = createService(); snapshot = await service.reconcilePolicies(); assert.equal(snapshot.devices[0].appliedPolicy, 'direct'); - assert.throws( - () => service.update(id, { pinned: false }, snapshot.revision), - (error) => error.code === 'REQUEST_INVALID', - ); + snapshot = service.update(id, { pinned: true }, snapshot.revision); + snapshot = service.update(id, { pinned: false }, snapshot.revision); + assert.equal(snapshot.devices[0].appliedPolicy, 'direct'); policyEpoch = 'policy-epoch-b'; activeDevices = []; @@ -467,7 +465,6 @@ test('pinned device policy persists, reconciles the full set, and keeps the last }); snapshot = await service.refresh(); const secondId = snapshot.devices.find((device) => device.mac === secondMac).id; - snapshot = service.update(secondId, { pinned: true }, snapshot.revision); failApply = true; await assert.rejects( service.setPolicy(secondId, 'direct', snapshot.revision), diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index 848b28d..7c2fbce 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -49,7 +49,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(panel, /gatewayTotal > 0n && Gateway/); assert.match(panel, /proxyTotal > 0n && Прокси/); assert.match(panel, /source\?\.traffic\?\.proxy\?\.error/); - assert.match(panel, /client-device-traffic-slot[\s\S]*client-device-pin-wrap/); + assert.match(panel, /client-device-pin-wrap[\s\S]*client-device-heading/); assert.doesNotMatch(panel, /client-device-details/); assert.match(panel, /api\.devices\.setPolicy\(device\.id, mode, snapshot\.revision\)/); assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*latestDevice\.desiredPolicy !== device\.desiredPolicy[\s\S]*api\.devices\.setPolicy\(device\.id, mode, latest\.revision\)/); @@ -58,16 +58,21 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(panel, /client-drawer client-instructions client-devices/); assert.doesNotMatch(panel, /точная MAC|частная MAC|
Источник<\/dt>/); assert.match(panel, /aria-pressed=\{device\.pinned\}/); + assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/); assert.match(panel, /maxLength="64"[\s\S]*autoFocus/); assert.match(styles, /\.client-devices \{\s*width: min\(560px, 100vw\)/); - assert.match(styles, /\.client-device \{[\s\S]*gap: 3px;[\s\S]*padding: 10px 8px/); - assert.match(styles, /\.client-device-heading \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) auto 32px/); + assert.match(styles, /\.client-device \{[\s\S]*grid-template-columns: 32px minmax\(0, 1fr\);[\s\S]*padding: 10px 8px/); + assert.match(styles, /\.client-device-heading \{[\s\S]*grid-column: 2;[\s\S]*grid-template-columns: minmax\(0, 1fr\) auto/); assert.match(styles, /\.client-device-meta \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 72px auto/); assert.doesNotMatch(styles, /\.client-device-meta \{[^}]*margin-left/); assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/); assert.match(styles, /\.client-text-morph-value \{[\s\S]*transition: opacity 360ms[\s\S]*filter 480ms/); assert.match(styles, /\.client-device-last-seen:hover \.client-text-morph-value\.is-relative[\s\S]*opacity: 1/); assert.match(styles, /\.client-device-pin-wrap\.client-tooltip-anchor:hover > \.client-tooltip[\s\S]*translate\(0, 0\)/); + assert.match(styles, /\.client-device-pin:hover:not\(:disabled\) svg[\s\S]*translateY\(-2px\) rotate\(-12deg\)/); + assert.match(styles, /\.client-device-pin\[aria-pressed="true"\] svg/); + assert.match(styles, /\.client-device-edit:hover svg[\s\S]*translate\(1px, -1px\) rotate\(-4deg\)/); + assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/); assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/); assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy[\s\S]*\.client-text-morph-value/); assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/); @@ -97,7 +102,8 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni { id: 'b', uploadBytes: '9007199254740992', downloadBytes: '2' }, { id: 'c', uploadBytes: '10', downloadBytes: '10', proxyDownloadBytes: '100' }, { id: 'd', uploadBytes: '15', downloadBytes: '5', proxyUploadBytes: '100' }, + { id: 'e', pinned: true, uploadBytes: '0', downloadBytes: '0' }, ]; - assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['b', 'a', 'c', 'd']); - assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['c', 'd', 'a', 'b']); + 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']); }); diff --git a/test/web/server-picker.test.js b/test/web/server-picker.test.js index 3601808..edc4dfd 100644 --- a/test/web/server-picker.test.js +++ b/test/web/server-picker.test.js @@ -42,7 +42,7 @@ test('server picker checks health only on manual refresh and bounds the result w assert.doesNotMatch(picker, /checkVisible\(\);/); assert.match(picker, /onClick={checkVisible}/); assert.match(picker, /\{ \.\.\.current\[id\], checking: true \}/); - assert.match(picker, /700 - \(performance\.now\(\) - startedAt\)/); + assert.match(picker, /900 - \(performance\.now\(\) - startedAt\)/); assert.match(picker, /\{ \.\.\.current\[id\], checking: false \}/); assert.match(picker, /\.slice\(page \* SERVER_RESULT_WINDOW, \(page \+ 1\) \* SERVER_RESULT_WINDOW\)/); assert.match(picker, /\.slice\(0, 30\)/); @@ -66,7 +66,9 @@ test('manual ping uses plain language and keeps results beside server names', () assert.match(styles, /\.client-server-row \.client-server \{[\s\S]*?width: 120px;/); assert.match(styles, /\.client-server-health \{[\s\S]*?font-size: 9px;[\s\S]*?font-variant-numeric: tabular-nums;/); assert.match(styles, /\.client-server-meta \.client-server-health \{[\s\S]*?place-items: end start;[\s\S]*?padding-bottom: 7px;/); - assert.match(styles, /\.client-server-health\.is-checking \.client-server-health-checking \{[\s\S]*?animation: client-server-check-spin 700ms linear infinite;/); + assert.match(styles, /\.client-server-health\.is-checking \.client-server-health-checking \{[\s\S]*?animation: client-spin 900ms linear infinite;/); + assert.match(styles, /\.client-server-check\.is-checking svg \{[\s\S]*?animation: client-spin 900ms linear infinite;/); + assert.match(styles, /\.client-server-check:hover:not\(:disabled\) svg[\s\S]*?transform: rotate\(90deg\);/); assert.match(styles, /\.client-server-meta \{[\s\S]*?width: 42px;[\s\S]*?margin-left: 4px;/); assert.match(styles, /\.client-server-favorite \{[\s\S]*?position: absolute;[\s\S]*?width: 44px;/); assert.match(picker, /server={servers\[0\]}[\s\S]*?ping={pings\[servers\[0\]\.id\]}/);