From c89e56942ae3a78586fdd56192a4bf502d5c70fe Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Mon, 10 Aug 2026 10:49:02 +0300 Subject: [PATCH] Preserve device inventory across unavailable observations --- README.md | 4 ++-- src/server/services/deviceInventoryService.ts | 8 +++++--- src/shared/versions.ts | 6 +++--- src/web/features/devices/DevicesPanel.tsx | 6 +++--- test/server/device-inventory.test.js | 17 +++++++++++------ test/web/device-inventory-contract.test.js | 4 ++-- 6 files changed, 26 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 9b931db..d6dc6ec 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 продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Убраны вниз»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, положение в нижней группе и накопленные totals сохраняются в volume Gateway. +Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название, IP и последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Нажмите IP, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory. У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут. -Список приблизительный: имя и пользовательские настройки привязаны к MAC и сохраняются при обычной смене IP, но новый private/randomized MAC считается новым устройством — переносить имя по одному только DHCP-адресу небезопасно. Один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится. +Список приблизительный: имя и пользовательские настройки привязаны к MAC и сохраняются при обычной смене IP, но новый private/randomized MAC считается новым устройством — переносить имя по одному только DHCP-адресу небезопасно. Запись автоматически удаляется после 30 дней без подтверждённого контакта независимо от имени, закрепления или фонового положения; временная ошибка чтения сети этот срок не продвигает. Один 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 cffc3cd..fb31621 100644 --- a/src/server/services/deviceInventoryService.ts +++ b/src/server/services/deviceInventoryService.ts @@ -1015,6 +1015,7 @@ export function createDeviceInventoryService({ const lastSeenAt = observation.active || !previous ? observationTime : previous.lastSeenAt; + const replaceAddress = observation.active || !previous; byMac.set(mac, { id: previous?.id || deviceId(mac), alias: previous?.alias || '', @@ -1023,8 +1024,8 @@ export function createDeviceInventoryService({ hostname: previous?.hostname || null, manufacturer: previous?.manufacturer || vendor(mac), mac, - ip: String(observation.ip || previous?.ip || ''), - interface: String(observation.interface || previous?.interface || ''), + ip: replaceAddress ? observation.ip : previous.ip, + interface: replaceAddress ? observation.interface : previous.interface, firstSeenAt: previous?.firstSeenAt || observationTime, lastSeenAt, source: 'neighbor', @@ -1034,8 +1035,9 @@ export function createDeviceInventoryService({ }); } const cutoff = new Date(observedAt).getTime() - RETENTION_MS; + const sourceUnavailable = typeof result.error === 'string'; const devices = [...byMac.values()].filter((device) => ( - device.pinned || device.deprioritized || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff + sourceUnavailable || new Date(device.lastSeenAt).getTime() >= cutoff )); let traffic = state.traffic; if (trafficResult) { diff --git a/src/shared/versions.ts b/src/shared/versions.ts index c197ddb..7d232fc 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.22.0', - gatewayClient: '0.23.0', - gatewayBackend: '0.23.0', + macClient: '0.22.1', + gatewayClient: '0.23.1', + gatewayBackend: '0.23.1', }); export interface ParsedVersion { diff --git a/src/web/features/devices/DevicesPanel.tsx b/src/web/features/devices/DevicesPanel.tsx index 1e0babe..b3911b2 100644 --- a/src/web/features/devices/DevicesPanel.tsx +++ b/src/web/features/devices/DevicesPanel.tsx @@ -391,7 +391,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { : ''; const groupLabel = group === 'pinned' ? 'Закреплённые' - : group === 'deprioritized' ? 'Убраны вниз' : 'Остальные'; + : group === 'deprioritized' ? 'Фоновые' : 'Остальные'; const groupStart = group !== previousGroup; const hasName = Boolean(device.alias || device.hostname); const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство'; @@ -559,7 +559,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { className="client-device-deprioritize" type="button" aria-pressed={deprioritized} - aria-label={deprioritized ? `Вернуть ${title} в основной список` : `Убрать ${title} вниз`} + aria-label={deprioritized ? `Вернуть ${title} в основной список` : `Убрать ${title} в фон`} disabled={saving} onClick={() => toggleDeprioritized(device)} > @@ -569,7 +569,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { : } - {deprioritized ? 'Вернуть в список' : 'Убрать вниз'} + {deprioritized ? 'Вернуть в основной список' : 'Убрать в фон'} {!compact && { +test('device inventory discovers, merges, persists metadata and expires devices after 30 days', async (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-devices-')); t.after(() => fs.rmSync(directory, { recursive: true, force: true })); const store = createJsonStore({ filePath: path.join(directory, 'devices.json'), defaultValue: {} }); @@ -169,6 +169,7 @@ test('device inventory discovers, merges, persists metadata and expires anonymou snapshot = await service.refresh(); assert.equal(snapshot.devices.length, 1); assert.equal(snapshot.devices[0].confidence, 'ambiguous'); + assert.equal(snapshot.devices[0].ip, '192.168.50.10'); current = new Date('2026-07-01T10:05:00.000Z'); observation = { @@ -229,19 +230,23 @@ test('device inventory discovers, merges, persists metadata and expires anonymou (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); + snapshot = persisted.snapshot(); + assert.equal(snapshot.devices.find(({ id }) => id === namedId).deprioritized, true); + snapshot = persisted.update(replacement.id, { pinned: true }, snapshot.revision); + assert.equal(snapshot.devices.find(({ id }) => id === replacement.id).pinned, 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 = persisted.update(namedId, { alias: '' }, snapshot.revision); current = new Date('2026-08-02T10:00:00.000Z'); - observation = { observedAt: current.toISOString(), observations: [], error: null }; + observation = { observedAt: current.toISOString(), observations: [], error: 'source unavailable' }; snapshot = await persisted.refresh(); - assert.deepEqual(snapshot.devices.map(({ id }) => id), [namedId]); - snapshot = persisted.update(namedId, { deprioritized: false }, snapshot.revision); + assert.equal(snapshot.devices.length, 2); + assert.equal(snapshot.source.error, 'source unavailable'); + + observation = { observedAt: current.toISOString(), observations: [], error: null }; snapshot = await persisted.refresh(); assert.equal(snapshot.devices.length, 0); diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index 3a42a4f..f94a132 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -117,10 +117,10 @@ test('Gateway device inventory uses the existing accessible responsive drawer', 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 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, /Закреплённые[\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, /client-device-deprioritize[\s\S]*aria-pressed=\{deprioritized\}[\s\S]*Вернуть \$\{title\} в основной список[\s\S]*Убрать \$\{title\} в фон/); assert.match(panel, /\{!compact &&