Preserve device inventory across unavailable observations
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-10 10:49:02 +03:00
parent f233660dc3
commit c89e56942a
6 changed files with 26 additions and 19 deletions
+2 -2
View File
@@ -77,11 +77,11 @@ http://АДРЕС-GATEWAY:3456
### Устройства Gateway ### Устройства 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 сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут. У однозначно распознанного устройства маршрут можно переключить последней иконкой между `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 сохраняется при очистке старых устройств, а короткая история скорости после перезапуска начинает заполняться заново. Home показывает фактически применённый VPN-сервер, накопленное `Учтено Harbor` и большой нижний график средней скорости Download/Upload за фактический интервал между снимками. `Учтено Harbor` — сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткая история скорости после перезапуска начинает заполняться заново.
@@ -1015,6 +1015,7 @@ export function createDeviceInventoryService({
const lastSeenAt = observation.active || !previous const lastSeenAt = observation.active || !previous
? observationTime ? observationTime
: previous.lastSeenAt; : previous.lastSeenAt;
const replaceAddress = observation.active || !previous;
byMac.set(mac, { byMac.set(mac, {
id: previous?.id || deviceId(mac), id: previous?.id || deviceId(mac),
alias: previous?.alias || '', alias: previous?.alias || '',
@@ -1023,8 +1024,8 @@ export function createDeviceInventoryService({
hostname: previous?.hostname || null, hostname: previous?.hostname || null,
manufacturer: previous?.manufacturer || vendor(mac), manufacturer: previous?.manufacturer || vendor(mac),
mac, mac,
ip: String(observation.ip || previous?.ip || ''), ip: replaceAddress ? observation.ip : previous.ip,
interface: String(observation.interface || previous?.interface || ''), interface: replaceAddress ? observation.interface : previous.interface,
firstSeenAt: previous?.firstSeenAt || observationTime, firstSeenAt: previous?.firstSeenAt || observationTime,
lastSeenAt, lastSeenAt,
source: 'neighbor', source: 'neighbor',
@@ -1034,8 +1035,9 @@ export function createDeviceInventoryService({
}); });
} }
const cutoff = new Date(observedAt).getTime() - RETENTION_MS; const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
const sourceUnavailable = typeof result.error === 'string';
const devices = [...byMac.values()].filter((device) => ( 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; let traffic = state.traffic;
if (trafficResult) { if (trafficResult) {
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.22.0', macClient: '0.22.1',
gatewayClient: '0.23.0', gatewayClient: '0.23.1',
gatewayBackend: '0.23.0', gatewayBackend: '0.23.1',
}); });
export interface ParsedVersion { export interface ParsedVersion {
+3 -3
View File
@@ -391,7 +391,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
: ''; : '';
const groupLabel = group === 'pinned' const groupLabel = group === 'pinned'
? 'Закреплённые' ? 'Закреплённые'
: group === 'deprioritized' ? 'Убраны вниз' : 'Остальные'; : group === 'deprioritized' ? 'Фоновые' : 'Остальные';
const groupStart = group !== previousGroup; const groupStart = group !== previousGroup;
const hasName = Boolean(device.alias || device.hostname); const hasName = Boolean(device.alias || device.hostname);
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство'; const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
@@ -559,7 +559,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
className="client-device-deprioritize" className="client-device-deprioritize"
type="button" type="button"
aria-pressed={deprioritized} aria-pressed={deprioritized}
aria-label={deprioritized ? `Вернуть ${title} в основной список` : `Убрать ${title} вниз`} aria-label={deprioritized ? `Вернуть ${title} в основной список` : `Убрать ${title} в фон`}
disabled={saving} disabled={saving}
onClick={() => toggleDeprioritized(device)} onClick={() => toggleDeprioritized(device)}
> >
@@ -569,7 +569,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
: <path d="M12 4v12M7 11l5 5 5-5M5 20h14" />} : <path d="M12 4v12M7 11l5 5 5-5M5 20h14" />}
</svg> </svg>
</button> </button>
<Tooltip>{deprioritized ? 'Вернуть в список' : 'Убрать вниз'}</Tooltip> <Tooltip>{deprioritized ? 'Вернуть в основной список' : 'Убрать в фон'}</Tooltip>
</span> </span>
{!compact && <TrafficChart {!compact && <TrafficChart
samples={device.trafficHistory || []} samples={device.trafficHistory || []}
+11 -6
View File
@@ -116,7 +116,7 @@ test('malformed persisted devices and remote observations cannot enter canonical
]); ]);
}); });
test('device inventory discovers, merges, persists metadata and expires anonymous devices', async (t) => { 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-')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-devices-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true })); t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const store = createJsonStore({ filePath: path.join(directory, 'devices.json'), defaultValue: {} }); 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(); snapshot = await service.refresh();
assert.equal(snapshot.devices.length, 1); assert.equal(snapshot.devices.length, 1);
assert.equal(snapshot.devices[0].confidence, 'ambiguous'); 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'); current = new Date('2026-07-01T10:05:00.000Z');
observation = { observation = {
@@ -229,19 +230,23 @@ test('device inventory discovers, merges, persists metadata and expires anonymou
(error) => error.code === 'REQUEST_INVALID', (error) => error.code === 'REQUEST_INVALID',
); );
const persisted = createDeviceInventoryService({ store, observe: async () => observation, vendor, now: () => current }); 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' }; observation = { observedAt: current.toISOString(), observations: [], error: 'source unavailable' };
snapshot = await persisted.refresh(); snapshot = await persisted.refresh();
assert.equal(snapshot.devices.length, 2); assert.equal(snapshot.devices.length, 2);
assert.equal(snapshot.source.error, 'source unavailable'); assert.equal(snapshot.source.error, 'source unavailable');
snapshot = persisted.update(namedId, { alias: '' }, snapshot.revision);
current = new Date('2026-08-02T10:00:00.000Z'); 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(); snapshot = await persisted.refresh();
assert.deepEqual(snapshot.devices.map(({ id }) => id), [namedId]); assert.equal(snapshot.devices.length, 2);
snapshot = persisted.update(namedId, { deprioritized: false }, snapshot.revision); assert.equal(snapshot.source.error, 'source unavailable');
observation = { observedAt: current.toISOString(), observations: [], error: null };
snapshot = await persisted.refresh(); snapshot = await persisted.refresh();
assert.equal(snapshot.devices.length, 0); assert.equal(snapshot.devices.length, 0);
+2 -2
View File
@@ -117,10 +117,10 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /aria-pressed=\{device\.pinned\}/); 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 \[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, /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, /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, /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 && <TrafficChart/); assert.match(panel, /\{!compact && <TrafficChart/);
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/); assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
assert.match(panel, /maxLength=\{64\}[\s\S]*autoFocus/); assert.match(panel, /maxLength=\{64\}[\s\S]*autoFocus/);