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 &&