Preserve device inventory across unavailable observations
This commit is contained in:
@@ -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 сохраняется при очистке старых устройств, а короткая история скорости после перезапуска начинает заполняться заново.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }) {
|
||||
: <path d="M12 4v12M7 11l5 5 5-5M5 20h14" />}
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>{deprioritized ? 'Вернуть в список' : 'Убрать вниз'}</Tooltip>
|
||||
<Tooltip>{deprioritized ? 'Вернуть в основной список' : 'Убрать в фон'}</Tooltip>
|
||||
</span>
|
||||
{!compact && <TrafficChart
|
||||
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-'));
|
||||
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);
|
||||
|
||||
|
||||
@@ -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 && <TrafficChart/);
|
||||
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
|
||||
assert.match(panel, /maxLength=\{64\}[\s\S]*autoFocus/);
|
||||
|
||||
Reference in New Issue
Block a user