Add deprioritized device group
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-10 09:25:29 +03:00
parent ec68ba6a7b
commit f233660dc3
13 changed files with 262 additions and 60 deletions
+2 -2
View File
@@ -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 сохраняется при очистке старых устройств, а короткая история скорости после перезапуска начинает заполняться заново.
+15 -4
View File
@@ -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 };
});
+3 -3
View File
@@ -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 {
+67 -20
View File
@@ -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<string, unknown>) {
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 || ''}
</div>
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{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 <article
key={device.id}
ref={(node) => {
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' : ''}`}
>
<span className="client-device-pin-wrap client-tooltip-anchor">
<span
className="client-device-group-heading"
role={groupStart ? 'heading' : undefined}
aria-level={groupStart ? 3 : undefined}
aria-hidden={!groupStart}
>{groupStart ? groupLabel : ''}</span>
{!compact && <span className="client-device-pin-wrap client-tooltip-anchor">
<button
className="client-device-pin"
type="button"
@@ -427,10 +457,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</svg>
</button>
<Tooltip>{device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
</span>
</span>}
<div className="client-device-main">
<h3 className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}>
<h4 className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}>
{editing ? (
<input
className="client-device-alias-input"
@@ -460,7 +490,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
onClick={() => copyDeviceIp(device)}
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>}
</h3>
</h4>
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
<button
className={`client-device-edit${pencilAnimationId === device.id ? ' is-writing' : ''}`}
@@ -490,7 +520,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</span>
</div>
<span
{!compact && <span
className="client-device-traffic"
role="group"
tabIndex={0}
@@ -503,9 +533,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
{hasProxyTraffic && <span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>}
</span>
</span>
</span>}
<span className="client-device-policy-wrap client-tooltip-anchor">
{!compact && <span className="client-device-policy-wrap client-tooltip-anchor">
<button
className={`client-device-policy is-${displayPolicy}${policyFailed ? ' is-failed' : ''}${policyPending ? ' is-pending' : ''}`}
type="button"
@@ -523,8 +553,25 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</svg>}
</button>
<Tooltip>{policyTooltip}</Tooltip>
</span>}
<span className="client-device-deprioritize-wrap client-tooltip-anchor">
<button
className="client-device-deprioritize"
type="button"
aria-pressed={deprioritized}
aria-label={deprioritized ? `Вернуть ${title} в основной список` : `Убрать ${title} вниз`}
disabled={saving}
onClick={() => toggleDeprioritized(device)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
{deprioritized
? <path d="M12 20V8M7 13l5-5 5 5M5 4h14" />
: <path d="M12 4v12M7 11l5 5 5-5M5 20h14" />}
</svg>
</button>
<Tooltip>{deprioritized ? 'Вернуть в список' : 'Убрать вниз'}</Tooltip>
</span>
<TrafficChart
{!compact && <TrafficChart
samples={device.trafficHistory || []}
scale={trafficScale}
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
@@ -532,7 +579,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
pinned={showDetails}
collapsing={collapsing}
onCollapseEnd={() => finishPinCollapse(device.id)}
/>
/>}
</article>;
})}
</div>
@@ -21,6 +21,7 @@ export interface Device extends Record<string, unknown> {
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)
+74 -7
View File
@@ -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;
+2
View File
@@ -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,
+7 -1
View File
@@ -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) };
}
+47 -3
View File
@@ -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);
+2 -2
View File
@@ -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 });
+23 -4
View File
@@ -116,12 +116,22 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/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 && <TrafficChart/);
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
assert.match(panel, /maxLength=\{64\}[\s\S]*autoFocus/);
assert.match(styles, /\.client-devices \{\s*width: min\(580px, 100vw\)/);
assert.match(styles, /\.client-device \{[\s\S]*--client-device-chart-height: 34px;[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*grid-template-rows: 34px var\(--client-device-chart-height\);[\s\S]*padding: 10px 8px/);
assert.match(styles, /\.client-device\.is-pinned \{[\s\S]*--client-device-chart-height: 72px/);
assert.match(styles, /\.client-device\.is-deprioritized \{[\s\S]*grid-template-rows: 34px;[\s\S]*padding-block: 6px/);
assert.match(styles, /\.client-device\.is-deprioritized \.client-device-main \{[\s\S]*grid-column: 1 \/ 4/);
assert.match(styles, /\.client-device-deprioritize-wrap \{[\s\S]*grid-column: 4;[\s\S]*grid-row: 2/);
assert.match(styles, /\.client-device\.is-deprioritized \.client-device-deprioritize-wrap \{[\s\S]*grid-row: 1/);
assert.match(styles, /\.client-device-group-heading \{[\s\S]*text-transform: uppercase/);
assert.match(styles, /\.client-device\.is-pinned \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-expand 520ms/);
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-collapse 520ms/);
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-axis \{[\s\S]*top: 0;[\s\S]*height: 58px/);
@@ -156,7 +166,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-name-separator \{[\s\S]*color: var\(--client-muted\)/);
assert.match(styles, /\.client-device-ip\.is-copied \{[\s\S]*client-device-ip-copy 800ms/);
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding: 11px 0 0/);
assert.match(styles, /\.client-device-main > 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'],
);
});
@@ -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' }] },
+14 -14
View File
@@ -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');
});