Preserve device inventory across unavailable observations
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 || []}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
Reference in New Issue
Block a user