Allow device routing without pinning
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-07 17:27:13 +03:00
parent 608f8cfcf2
commit dfa9f09695
13 changed files with 114 additions and 61 deletions
@@ -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.
@@ -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.
+2 -2
View File
@@ -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 секунд; история по часам пока не хранится.
@@ -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;
-1
View File
@@ -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 },
+3 -3
View File
@@ -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) {
+20 -21
View File
@@ -203,7 +203,9 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
onClick={() => setSortDirection((direction) => direction === 'desc' ? 'asc' : 'desc')}
>
<span>Трафик</span>
<span aria-hidden="true">{sortDirection === 'desc' ? '↓' : '↑'}</span>
<span className="client-devices-sort-icon" aria-hidden="true">
{sortDirection === 'desc' ? '↓' : '↑'}
</span>
</button>
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
</span>
@@ -274,15 +276,13 @@ 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'
? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства'
: displayPolicy === 'direct'
@@ -296,6 +296,22 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
className={`client-device is-${device.status}`}
key={device.id}
>
<span className="client-device-pin-wrap client-tooltip-anchor">
<button
className="client-device-pin"
type="button"
aria-pressed={device.pinned}
aria-label={device.pinned ? `Открепить ${title}` : `Закрепить ${title}`}
disabled={saving}
onClick={() => updateDevice(device, { pinned: !device.pinned })}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 13v8" />
</svg>
</button>
<Tooltip>{device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
</span>
<div className="client-device-heading">
{editing ? (
<form className="client-device-alias" onSubmit={(event) => saveAlias(event, device)}>
@@ -343,23 +359,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
<Tooltip>{trafficLabel}</Tooltip>
</span>}
</span>
<span className="client-device-pin-wrap client-tooltip-anchor">
<button
className="client-device-pin"
type="button"
aria-pressed={device.pinned}
aria-label={device.pinned ? `Открепить ${title}` : `Закрепить ${title}`}
disabled={saving || device.desiredPolicy === 'direct' || device.appliedPolicy === 'direct'}
onClick={() => updateDevice(device, { pinned: !device.pinned })}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 13v8" />
</svg>
</button>
<Tooltip>{device.desiredPolicy === 'direct' || device.appliedPolicy === 'direct'
? 'Сначала верните маршрут через Gateway'
: device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
</span>
</div>
<div className="client-device-meta">
+1 -1
View File
@@ -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 }])),
+54 -11
View File
@@ -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,
+2
View File
@@ -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)
+4 -7
View File
@@ -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),
+11 -5
View File
@@ -49,7 +49,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /gatewayTotal > 0n && <span>Gateway/);
assert.match(panel, /proxyTotal > 0n && <span className="is-proxy">Прокси/);
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>Источник<\/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']);
});
+4 -2
View File
@@ -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\]}/);