Track per-device traffic totals and recover inventory state
This commit is contained in:
@@ -76,9 +76,9 @@ http://АДРЕС-GATEWAY:3456
|
|||||||
|
|
||||||
### Устройства Gateway
|
### Устройства Gateway
|
||||||
|
|
||||||
После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в минуту читает локальную таблицу соседей, показывает IP, MAC, интерфейс, последний контакт и производителя из локальной OUI-базы. Устройство можно переименовать и закрепить; эти настройки сохраняются в volume Gateway.
|
После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей, показывает IP, MAC, последний контакт, производителя из локальной OUI-базы и сохранённые значения полученного/отданного интернет-трафика. Устройство можно переименовать и закрепить; название, закрепление и накопленные traffic totals сохраняются в volume Gateway. Кнопка «Трафик ↓/↑» сортирует список от большего объёма к меньшему или наоборот.
|
||||||
|
|
||||||
Список приблизительный: private/randomized MAC определяется как менее надёжная identity, а устройство появляется только после сетевого контакта с Gateway. Внешние сервисы распознавания производителя не используются. Учёт трафика по устройствам в этот экран пока не входит.
|
Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. Локальные, приватные и multicast-пакеты в traffic totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
|
||||||
|
|
||||||
## Установка Harbor Connect на macOS
|
## Установка Harbor Connect на macOS
|
||||||
|
|
||||||
|
|||||||
+20
-2
@@ -39,13 +39,18 @@ import {
|
|||||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||||
import { normalizeRouteRules } from '../shared/routingRules.js';
|
import { normalizeRouteRules } from '../shared/routingRules.js';
|
||||||
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
||||||
import { createDeviceInventoryService, createVendorLookup } from './services/deviceInventoryService.js';
|
import {
|
||||||
|
createDeviceInventoryService,
|
||||||
|
createVendorLookup,
|
||||||
|
DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
|
migrateDeviceInventoryState,
|
||||||
|
} from './services/deviceInventoryService.js';
|
||||||
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
|
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 1_000_000;
|
const MAX_BODY_BYTES = 1_000_000;
|
||||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
|
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
|
||||||
const DEVICE_DISCOVERY_INTERVAL_MS = 60_000;
|
const DEVICE_DISCOVERY_INTERVAL_MS = 15_000;
|
||||||
const TERMINAL_SUBSCRIPTION_CODES = new Set([
|
const TERMINAL_SUBSCRIPTION_CODES = new Set([
|
||||||
'SUBSCRIPTION_EXPIRED',
|
'SUBSCRIPTION_EXPIRED',
|
||||||
'SUBSCRIPTION_DISABLED',
|
'SUBSCRIPTION_DISABLED',
|
||||||
@@ -62,7 +67,17 @@ const subscriptionCacheStore = createJsonStore({
|
|||||||
const deviceStore = createJsonStore({
|
const deviceStore = createJsonStore({
|
||||||
filePath: settings.deviceStatePath,
|
filePath: settings.deviceStatePath,
|
||||||
defaultValue: {},
|
defaultValue: {},
|
||||||
|
migrate: migrateDeviceInventoryState,
|
||||||
|
initializeMissing: true,
|
||||||
|
backupWhen: () => true,
|
||||||
});
|
});
|
||||||
|
deviceStore.read();
|
||||||
|
if (deviceStore.migration) {
|
||||||
|
console.log(`[storage] devices migrated to v${DEVICE_INVENTORY_SCHEMA_VERSION}; backup: ${deviceStore.migration.backupPath}`);
|
||||||
|
}
|
||||||
|
if (deviceStore.recovery) {
|
||||||
|
console.warn(`[storage] corrupt devices recovered; backup: ${deviceStore.recovery.backupPath}`);
|
||||||
|
}
|
||||||
let cacheRecoveryLogged = false;
|
let cacheRecoveryLogged = false;
|
||||||
|
|
||||||
function readSubscriptionCache() {
|
function readSubscriptionCache() {
|
||||||
@@ -99,6 +114,9 @@ const deviceInventory = settings.appMode === 'gateway'
|
|||||||
observe: remoteDataplane
|
observe: remoteDataplane
|
||||||
? () => singboxRuntime.observeDevices()
|
? () => singboxRuntime.observeDevices()
|
||||||
: () => readNeighborSnapshot(),
|
: () => readNeighborSnapshot(),
|
||||||
|
observeTraffic: remoteDataplane
|
||||||
|
? () => singboxRuntime.observeTraffic()
|
||||||
|
: null,
|
||||||
vendor: createVendorLookup(),
|
vendor: createVendorLookup(),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -3,22 +3,40 @@ import fs from 'node:fs';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { HarborError } from '../../shared/errors.js';
|
import { HarborError } from '../../shared/errors.js';
|
||||||
|
|
||||||
const SCHEMA_VERSION = 1;
|
export const DEVICE_INVENTORY_SCHEMA_VERSION = 2;
|
||||||
const ONLINE_MS = 2 * 60 * 1000;
|
const ONLINE_MS = 2 * 60 * 1000;
|
||||||
const RECENT_MS = 24 * 60 * 60 * 1000;
|
const RECENT_MS = 24 * 60 * 60 * 1000;
|
||||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const COUNTER_PATTERN = /^\d+$/;
|
||||||
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
||||||
|
|
||||||
const DEFAULT_STATE = {
|
const DEFAULT_STATE = {
|
||||||
schemaVersion: SCHEMA_VERSION,
|
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
lastObservedAt: null,
|
lastObservedAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
traffic: {
|
||||||
|
epoch: null,
|
||||||
|
generation: null,
|
||||||
|
lastObservedAt: null,
|
||||||
|
lastError: null,
|
||||||
|
baselinesByMac: {},
|
||||||
|
totalsByMac: {},
|
||||||
|
rebaselineMacs: [],
|
||||||
|
},
|
||||||
devices: [],
|
devices: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeMac = (value) => String(value || '').trim().toLowerCase();
|
const normalizeMac = (value) => String(value || '').trim().toLowerCase();
|
||||||
const deviceId = (mac) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`;
|
const deviceId = (mac) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`;
|
||||||
const isPrivateMac = (mac) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0;
|
const isPrivateMac = (mac) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0;
|
||||||
|
const recordEntries = (value) => value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? Object.entries(value)
|
||||||
|
: [];
|
||||||
|
const parseStoredCounter = (value) => {
|
||||||
|
const counter = String(value ?? '');
|
||||||
|
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
||||||
|
};
|
||||||
|
|
||||||
export function parseOuiVendors(text) {
|
export function parseOuiVendors(text) {
|
||||||
const vendors = new Map();
|
const vendors = new Map();
|
||||||
@@ -44,18 +62,77 @@ export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrate(value) {
|
export function migrateDeviceInventoryState(value) {
|
||||||
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||||
const version = Number.isSafeInteger(state.schemaVersion) ? state.schemaVersion : 0;
|
const version = Number.isSafeInteger(state.schemaVersion) ? state.schemaVersion : 0;
|
||||||
if (version < 0 || version > SCHEMA_VERSION) {
|
if (version < 0 || version > DEVICE_INVENTORY_SCHEMA_VERSION) {
|
||||||
throw new Error(`Unsupported device inventory schemaVersion: ${version}`);
|
throw new Error(`Unsupported device inventory schemaVersion: ${version}`);
|
||||||
}
|
}
|
||||||
|
const traffic = state.traffic && typeof state.traffic === 'object' && !Array.isArray(state.traffic)
|
||||||
|
? state.traffic
|
||||||
|
: {};
|
||||||
|
const devices = Array.isArray(state.devices) ? state.devices : [];
|
||||||
|
const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
|
||||||
|
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||||
|
let recoveredTraffic = version >= 2 && (
|
||||||
|
traffic !== state.traffic
|
||||||
|
|| !traffic.baselinesByMac || typeof traffic.baselinesByMac !== 'object' || Array.isArray(traffic.baselinesByMac)
|
||||||
|
|| !traffic.totalsByMac || typeof traffic.totalsByMac !== 'object' || Array.isArray(traffic.totalsByMac)
|
||||||
|
);
|
||||||
|
if (recoveredTraffic) {
|
||||||
|
for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac));
|
||||||
|
}
|
||||||
|
const baselinesByMac = {};
|
||||||
|
for (const [rawMac, baseline] of recordEntries(traffic.baselinesByMac)) {
|
||||||
|
const mac = normalizeMac(rawMac);
|
||||||
|
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
|
||||||
|
const downloadBytes = parseStoredCounter(baseline?.downloadBytes);
|
||||||
|
if (!MAC_PATTERN.test(mac) || typeof baseline?.epoch !== 'string' || !baseline.epoch
|
||||||
|
|| uploadBytes == null || downloadBytes == null) {
|
||||||
|
if (MAC_PATTERN.test(mac)) rebaselineMacs.add(mac);
|
||||||
|
recoveredTraffic = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
|
||||||
|
}
|
||||||
|
const totalsByMac = {};
|
||||||
|
for (const [rawMac, total] of recordEntries(traffic.totalsByMac)) {
|
||||||
|
const mac = normalizeMac(rawMac);
|
||||||
|
const uploadBytes = parseStoredCounter(total?.uploadBytes);
|
||||||
|
const downloadBytes = parseStoredCounter(total?.downloadBytes);
|
||||||
|
if (!MAC_PATTERN.test(mac) || uploadBytes == null || downloadBytes == null) {
|
||||||
|
if (MAC_PATTERN.test(mac)) rebaselineMacs.add(mac);
|
||||||
|
recoveredTraffic = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totalsByMac[mac] = {
|
||||||
|
uploadBytes,
|
||||||
|
downloadBytes,
|
||||||
|
observedAt: typeof total?.observedAt === 'string' ? total.observedAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (const mac of new Set([...Object.keys(baselinesByMac), ...Object.keys(totalsByMac)])) {
|
||||||
|
if (!Object.hasOwn(baselinesByMac, mac) || !Object.hasOwn(totalsByMac, mac)) {
|
||||||
|
rebaselineMacs.add(mac);
|
||||||
|
recoveredTraffic = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
...state,
|
...state,
|
||||||
schemaVersion: SCHEMA_VERSION,
|
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
revision: Number.isSafeInteger(state.revision) ? state.revision : 0,
|
revision: Number.isSafeInteger(state.revision) ? state.revision : 0,
|
||||||
devices: Array.isArray(state.devices) ? state.devices : [],
|
traffic: {
|
||||||
|
...DEFAULT_STATE.traffic,
|
||||||
|
...traffic,
|
||||||
|
lastError: recoveredTraffic
|
||||||
|
? 'Повреждённый traffic checkpoint восстановлен из корректных данных'
|
||||||
|
: traffic.lastError || null,
|
||||||
|
baselinesByMac,
|
||||||
|
totalsByMac,
|
||||||
|
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
|
||||||
|
},
|
||||||
|
devices,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,17 +143,29 @@ function deviceStatus(lastSeenAt, now) {
|
|||||||
return 'offline';
|
return 'offline';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDeviceInventoryService({ store, observe, vendor = () => null, now = () => new Date() }) {
|
export function createDeviceInventoryService({
|
||||||
|
store,
|
||||||
|
observe,
|
||||||
|
observeTraffic = null,
|
||||||
|
vendor = () => null,
|
||||||
|
now = () => new Date(),
|
||||||
|
}) {
|
||||||
let refreshPromise = null;
|
let refreshPromise = null;
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const state = migrate(store.read());
|
const state = migrateDeviceInventoryState(store.read());
|
||||||
const current = now();
|
const current = now();
|
||||||
const rank = { online: 0, recent: 1, offline: 2 };
|
const rank = { online: 0, recent: 1, offline: 2 };
|
||||||
const devices = state.devices.map((device) => ({
|
const devices = state.devices.map((device) => {
|
||||||
...device,
|
const traffic = state.traffic.totalsByMac[device.mac];
|
||||||
status: deviceStatus(device.lastSeenAt, current),
|
return {
|
||||||
})).sort((left, right) => (
|
...device,
|
||||||
|
status: deviceStatus(device.lastSeenAt, current),
|
||||||
|
uploadBytes: traffic?.uploadBytes || '0',
|
||||||
|
downloadBytes: traffic?.downloadBytes || '0',
|
||||||
|
trafficObservedAt: traffic?.observedAt || null,
|
||||||
|
};
|
||||||
|
}).sort((left, right) => (
|
||||||
Number(right.pinned) - Number(left.pinned)
|
Number(right.pinned) - Number(left.pinned)
|
||||||
|| rank[left.status] - rank[right.status]
|
|| rank[left.status] - rank[right.status]
|
||||||
|| String(right.lastSeenAt).localeCompare(String(left.lastSeenAt))
|
|| String(right.lastSeenAt).localeCompare(String(left.lastSeenAt))
|
||||||
@@ -87,18 +176,27 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu
|
|||||||
kind: 'neighbor',
|
kind: 'neighbor',
|
||||||
lastObservedAt: state.lastObservedAt,
|
lastObservedAt: state.lastObservedAt,
|
||||||
error: state.lastError,
|
error: state.lastError,
|
||||||
|
traffic: {
|
||||||
|
lastObservedAt: state.traffic.lastObservedAt,
|
||||||
|
error: state.traffic.lastError,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
devices,
|
devices,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performRefresh() {
|
async function performRefresh() {
|
||||||
let result;
|
const [result, trafficResult] = await Promise.all([
|
||||||
try {
|
Promise.resolve().then(() => observe()).catch((error) => ({
|
||||||
result = await observe();
|
observedAt: now().toISOString(),
|
||||||
} catch (error) {
|
observations: [],
|
||||||
result = { observedAt: now().toISOString(), observations: [], error: error.message || String(error) };
|
error: error.message || String(error),
|
||||||
}
|
})),
|
||||||
|
observeTraffic
|
||||||
|
? Promise.resolve().then(() => observeTraffic())
|
||||||
|
.catch((error) => ({ transportError: error.message || String(error) }))
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
const observedAt = result?.observedAt || now().toISOString();
|
const observedAt = result?.observedAt || now().toISOString();
|
||||||
const observations = Array.isArray(result?.observations) ? result.observations : [];
|
const observations = Array.isArray(result?.observations) ? result.observations : [];
|
||||||
const ipsByMac = new Map();
|
const ipsByMac = new Map();
|
||||||
@@ -109,7 +207,7 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu
|
|||||||
ipsByMac.get(mac).add(String(observation.ip));
|
ipsByMac.get(mac).add(String(observation.ip));
|
||||||
}
|
}
|
||||||
store.update((stored) => {
|
store.update((stored) => {
|
||||||
const state = migrate(stored);
|
const state = migrateDeviceInventoryState(stored);
|
||||||
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||||
for (const observation of observations) {
|
for (const observation of observations) {
|
||||||
const mac = normalizeMac(observation.mac);
|
const mac = normalizeMac(observation.mac);
|
||||||
@@ -139,11 +237,94 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu
|
|||||||
const devices = [...byMac.values()].filter((device) => (
|
const devices = [...byMac.values()].filter((device) => (
|
||||||
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
|
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
|
||||||
));
|
));
|
||||||
|
let traffic = state.traffic;
|
||||||
|
if (trafficResult) {
|
||||||
|
if (trafficResult.transportError) {
|
||||||
|
traffic = { ...traffic, lastError: trafficResult.transportError };
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch) {
|
||||||
|
throw new Error('Dataplane не вернул traffic epoch');
|
||||||
|
}
|
||||||
|
const processByMac = new Map();
|
||||||
|
for (const row of Array.isArray(trafficResult.devices) ? trafficResult.devices : []) {
|
||||||
|
const mac = normalizeMac(row?.mac);
|
||||||
|
const upload = String(row?.uploadBytes ?? '');
|
||||||
|
const download = String(row?.downloadBytes ?? '');
|
||||||
|
if (!MAC_PATTERN.test(mac) || !COUNTER_PATTERN.test(upload) || !COUNTER_PATTERN.test(download)) {
|
||||||
|
throw new Error('Dataplane вернул невалидный traffic counter');
|
||||||
|
}
|
||||||
|
const previous = processByMac.get(mac) || { upload: 0n, download: 0n };
|
||||||
|
processByMac.set(mac, {
|
||||||
|
upload: previous.upload + BigInt(upload),
|
||||||
|
download: previous.download + BigInt(download),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const knownMacs = new Set(devices.map((device) => device.mac));
|
||||||
|
const baselinesByMac = { ...traffic.baselinesByMac };
|
||||||
|
const totalsByMac = { ...traffic.totalsByMac };
|
||||||
|
const rebaselineMacs = new Set(traffic.rebaselineMacs);
|
||||||
|
for (const [mac, processTotal] of processByMac) {
|
||||||
|
if (!knownMacs.has(mac)) continue;
|
||||||
|
const baseline = baselinesByMac[mac];
|
||||||
|
const recovering = rebaselineMacs.has(mac);
|
||||||
|
const sameEpoch = !recovering && baseline?.epoch === trafficResult.epoch;
|
||||||
|
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
|
||||||
|
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
|
||||||
|
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
|
||||||
|
throw new Error('Dataplane traffic counter уменьшился внутри одного epoch');
|
||||||
|
}
|
||||||
|
const total = totalsByMac[mac] || { uploadBytes: '0', downloadBytes: '0' };
|
||||||
|
totalsByMac[mac] = {
|
||||||
|
uploadBytes: (BigInt(total.uploadBytes)
|
||||||
|
+ (recovering ? 0n : processTotal.upload - baselineUpload)).toString(),
|
||||||
|
downloadBytes: (BigInt(total.downloadBytes)
|
||||||
|
+ (recovering ? 0n : processTotal.download - baselineDownload)).toString(),
|
||||||
|
observedAt: trafficResult.observedAt || traffic.lastObservedAt,
|
||||||
|
};
|
||||||
|
baselinesByMac[mac] = {
|
||||||
|
epoch: trafficResult.epoch,
|
||||||
|
uploadBytes: processTotal.upload.toString(),
|
||||||
|
downloadBytes: processTotal.download.toString(),
|
||||||
|
};
|
||||||
|
rebaselineMacs.delete(mac);
|
||||||
|
}
|
||||||
|
for (const mac of Object.keys(totalsByMac)) {
|
||||||
|
if (!knownMacs.has(mac)) {
|
||||||
|
delete totalsByMac[mac];
|
||||||
|
delete baselinesByMac[mac];
|
||||||
|
rebaselineMacs.delete(mac);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const mac of rebaselineMacs) {
|
||||||
|
if (!knownMacs.has(mac)) {
|
||||||
|
delete totalsByMac[mac];
|
||||||
|
delete baselinesByMac[mac];
|
||||||
|
rebaselineMacs.delete(mac);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traffic = {
|
||||||
|
...traffic,
|
||||||
|
epoch: trafficResult.epoch,
|
||||||
|
generation: trafficResult.generation || traffic.generation,
|
||||||
|
lastObservedAt: trafficResult.observedAt || traffic.lastObservedAt,
|
||||||
|
lastError: trafficResult.source?.error
|
||||||
|
|| (rebaselineMacs.size ? traffic.lastError : null),
|
||||||
|
baselinesByMac,
|
||||||
|
totalsByMac,
|
||||||
|
rebaselineMacs: [...rebaselineMacs],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
traffic = { ...traffic, lastError: error.message || String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
revision: state.revision + 1,
|
revision: state.revision + 1,
|
||||||
lastObservedAt: observedAt,
|
lastObservedAt: observedAt,
|
||||||
lastError: result?.error || null,
|
lastError: result?.error || null,
|
||||||
|
traffic,
|
||||||
devices,
|
devices,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -172,7 +353,7 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu
|
|||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
store.update((stored) => {
|
store.update((stored) => {
|
||||||
const state = migrate(stored);
|
const state = migrateDeviceInventoryState(stored);
|
||||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
const index = state.devices.findIndex((device) => device.id === id);
|
const index = state.devices.findIndex((device) => device.id === id);
|
||||||
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
|
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
|
||||||
|
|||||||
@@ -112,12 +112,18 @@ export function createDeviceTrafficService({
|
|||||||
run = spawnSync,
|
run = spawnSync,
|
||||||
nextGeneration = () => crypto.randomUUID(),
|
nextGeneration = () => crypto.randomUUID(),
|
||||||
}) {
|
}) {
|
||||||
|
const epoch = nextGeneration();
|
||||||
let activeSlot = null;
|
let activeSlot = null;
|
||||||
let activeDevices = [];
|
let activeDevices = [];
|
||||||
let activeSignature = '';
|
let activeSignature = '';
|
||||||
|
let activeCounters = new Map();
|
||||||
|
let pendingRetired = null;
|
||||||
let refreshPromise = null;
|
let refreshPromise = null;
|
||||||
|
const finalized = new Map();
|
||||||
|
const devicesByKey = new Map();
|
||||||
let current = {
|
let current = {
|
||||||
generation: nextGeneration(),
|
epoch,
|
||||||
|
generation: epoch,
|
||||||
observedAt: null,
|
observedAt: null,
|
||||||
source: { error: null },
|
source: { error: null },
|
||||||
devices: [],
|
devices: [],
|
||||||
@@ -162,21 +168,72 @@ export function createDeviceTrafficService({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCounters(devices) {
|
function readCounters(devices, slot) {
|
||||||
if (!activeSlot) return [];
|
if (!slot) return new Map();
|
||||||
const upload = parseTrafficCounters(
|
const upload = parseTrafficCounters(
|
||||||
execute('iptables-save', ['-c', '-t', 'raw']),
|
execute('iptables-save', ['-c', '-t', 'raw']),
|
||||||
childChain(uploadChain, activeSlot),
|
childChain(uploadChain, slot),
|
||||||
);
|
);
|
||||||
const download = parseTrafficCounters(
|
const download = parseTrafficCounters(
|
||||||
execute('iptables-save', ['-c', '-t', 'mangle']),
|
execute('iptables-save', ['-c', '-t', 'mangle']),
|
||||||
childChain(downloadChain, activeSlot),
|
childChain(downloadChain, slot),
|
||||||
);
|
);
|
||||||
return devices.map(({ key, ...device }) => ({
|
const counters = new Map();
|
||||||
...device,
|
for (const { key } of devices) {
|
||||||
uploadBytes: upload.get(`${key}:upload`) || '0',
|
counters.set(`${key}:upload`, upload.get(`${key}:upload`) || '0');
|
||||||
downloadBytes: download.get(`${key}:download`) || '0',
|
counters.set(`${key}:download`, download.get(`${key}:download`) || '0');
|
||||||
}));
|
}
|
||||||
|
return counters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function counter(counters, key, direction) {
|
||||||
|
return BigInt(counters.get(`${key}:${direction}`) || '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(devices) {
|
||||||
|
for (const device of devices) devicesByKey.set(device.key, device);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeRetired() {
|
||||||
|
if (!pendingRetired) return false;
|
||||||
|
const counters = readCounters(pendingRetired.devices, pendingRetired.slot);
|
||||||
|
for (const { key } of pendingRetired.devices) {
|
||||||
|
const previous = finalized.get(key) || { upload: 0n, download: 0n };
|
||||||
|
finalized.set(key, {
|
||||||
|
upload: previous.upload + counter(counters, key, 'upload'),
|
||||||
|
download: previous.download + counter(counters, key, 'download'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pendingRetired = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processTotals() {
|
||||||
|
const activeByMac = new Map(activeDevices.map((device) => [device.mac, device]));
|
||||||
|
const totalsByMac = new Map();
|
||||||
|
for (const [key, remembered] of devicesByKey) {
|
||||||
|
const base = finalized.get(key) || { upload: 0n, download: 0n };
|
||||||
|
const pending = pendingRetired?.counters || new Map();
|
||||||
|
const upload = base.upload
|
||||||
|
+ counter(pending, key, 'upload')
|
||||||
|
+ counter(activeCounters, key, 'upload');
|
||||||
|
const download = base.download
|
||||||
|
+ counter(pending, key, 'download')
|
||||||
|
+ counter(activeCounters, key, 'download');
|
||||||
|
const previous = totalsByMac.get(remembered.mac) || { upload: 0n, download: 0n };
|
||||||
|
totalsByMac.set(remembered.mac, {
|
||||||
|
...(activeByMac.get(remembered.mac) || remembered),
|
||||||
|
upload: previous.upload + upload,
|
||||||
|
download: previous.download + download,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...totalsByMac.values()]
|
||||||
|
.map(({ key: _key, upload, download, ...device }) => ({
|
||||||
|
...device,
|
||||||
|
uploadBytes: upload.toString(),
|
||||||
|
downloadBytes: download.toString(),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.mac.localeCompare(right.mac));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performRefresh() {
|
async function performRefresh() {
|
||||||
@@ -192,34 +249,51 @@ export function createDeviceTrafficService({
|
|||||||
? activeDevices
|
? activeDevices
|
||||||
: selectTrafficDevices(observed?.observations);
|
: selectTrafficDevices(observed?.observations);
|
||||||
const nextSignature = JSON.stringify(nextDevices);
|
const nextSignature = JSON.stringify(nextDevices);
|
||||||
|
let countersRead = false;
|
||||||
|
|
||||||
if (!sourceError && nextSignature !== activeSignature) {
|
if (pendingRetired) {
|
||||||
|
try {
|
||||||
|
countersRead = finalizeRetired() || countersRead;
|
||||||
|
} catch (error) {
|
||||||
|
sourceError = sourceError || error.message || String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingRetired && !sourceError && nextSignature !== activeSignature) {
|
||||||
const nextSlot = activeSlot === 'A' ? 'B' : 'A';
|
const nextSlot = activeSlot === 'A' ? 'B' : 'A';
|
||||||
try {
|
try {
|
||||||
prepare(nextSlot, nextDevices);
|
prepare(nextSlot, nextDevices);
|
||||||
switchTo(nextSlot);
|
switchTo(nextSlot);
|
||||||
|
const retired = activeSlot ? {
|
||||||
|
slot: activeSlot,
|
||||||
|
devices: activeDevices,
|
||||||
|
counters: activeCounters,
|
||||||
|
} : null;
|
||||||
activeSlot = nextSlot;
|
activeSlot = nextSlot;
|
||||||
activeDevices = nextDevices;
|
activeDevices = nextDevices;
|
||||||
activeSignature = nextSignature;
|
activeSignature = nextSignature;
|
||||||
|
activeCounters = new Map();
|
||||||
|
pendingRetired = retired;
|
||||||
|
remember(nextDevices);
|
||||||
current.generation = nextGeneration();
|
current.generation = nextGeneration();
|
||||||
|
if (pendingRetired) countersRead = finalizeRetired() || countersRead;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sourceError = error.message || String(error);
|
sourceError = error.message || String(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let devices = current.devices;
|
|
||||||
let countersRead = false;
|
|
||||||
try {
|
try {
|
||||||
devices = readCounters(activeDevices);
|
activeCounters = readCounters(activeDevices, activeSlot);
|
||||||
countersRead = true;
|
countersRead = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sourceError = sourceError || error.message || String(error);
|
sourceError = sourceError || error.message || String(error);
|
||||||
}
|
}
|
||||||
current = {
|
current = {
|
||||||
|
epoch,
|
||||||
generation: current.generation,
|
generation: current.generation,
|
||||||
observedAt: countersRead ? observed?.observedAt || current.observedAt : current.observedAt,
|
observedAt: countersRead ? observed?.observedAt || current.observedAt : current.observedAt,
|
||||||
source: { error: sourceError },
|
source: { error: sourceError },
|
||||||
devices,
|
devices: countersRead ? processTotals() : current.devices,
|
||||||
};
|
};
|
||||||
return structuredClone(current);
|
return structuredClone(current);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.10.0',
|
macClient: '0.11.0',
|
||||||
gatewayClient: '0.10.0',
|
gatewayClient: '0.11.0',
|
||||||
gatewayBackend: '0.10.0',
|
gatewayBackend: '0.11.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -361,12 +361,12 @@ function LocalRulesPanel({
|
|||||||
<aside
|
<aside
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
id="client-local-rules"
|
id="client-local-rules"
|
||||||
className={`client-local-rules${open ? ' is-open' : ''}`}
|
className={`client-drawer client-local-rules${open ? ' is-open' : ''}`}
|
||||||
aria-labelledby="local-rules-title"
|
aria-labelledby="local-rules-title"
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
inert={!open ? true : undefined}
|
inert={!open ? true : undefined}
|
||||||
>
|
>
|
||||||
<div className="client-local-rules-sheet">
|
<div className="client-drawer-sheet client-local-rules-sheet">
|
||||||
<button
|
<button
|
||||||
ref={closeRef}
|
ref={closeRef}
|
||||||
className="client-drawer-close"
|
className="client-drawer-close"
|
||||||
@@ -1444,12 +1444,12 @@ export function ClientOverviewPage({
|
|||||||
{hasSubscription && subscriptionContentReady && <aside
|
{hasSubscription && subscriptionContentReady && <aside
|
||||||
ref={instructionsPanelRef}
|
ref={instructionsPanelRef}
|
||||||
id="client-instructions"
|
id="client-instructions"
|
||||||
className={`client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
className={`client-drawer client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
||||||
aria-labelledby="instructions-title"
|
aria-labelledby="instructions-title"
|
||||||
aria-hidden={!instructionsOpen}
|
aria-hidden={!instructionsOpen}
|
||||||
inert={!instructionsOpen ? true : undefined}
|
inert={!instructionsOpen ? true : undefined}
|
||||||
>
|
>
|
||||||
<div className="client-instructions-sheet">
|
<div className="client-drawer-sheet client-instructions-sheet">
|
||||||
<button
|
<button
|
||||||
ref={instructionsCloseRef}
|
ref={instructionsCloseRef}
|
||||||
className="client-drawer-close"
|
className="client-drawer-close"
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { formatLastSeen } from '../utils/format.js';
|
import {
|
||||||
|
formatByteString,
|
||||||
|
formatLastSeen,
|
||||||
|
sortDevicesByTraffic,
|
||||||
|
} from '../utils/format.js';
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
online: 'В сети',
|
online: 'В сети',
|
||||||
@@ -32,9 +36,13 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
const [savingId, setSavingId] = useState('');
|
const [savingId, setSavingId] = useState('');
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [refreshCycle, setRefreshCycle] = useState(0);
|
const [refreshCycle, setRefreshCycle] = useState(0);
|
||||||
|
const [sortDirection, setSortDirection] = useState('desc');
|
||||||
const deviceNodes = useRef(new Map());
|
const deviceNodes = useRef(new Map());
|
||||||
const previousPositions = useRef(new Map());
|
const previousPositions = useRef(new Map());
|
||||||
const devices = snapshot?.devices || [];
|
const devices = useMemo(
|
||||||
|
() => sortDevicesByTraffic(snapshot?.devices, sortDirection),
|
||||||
|
[snapshot?.devices, sortDirection],
|
||||||
|
);
|
||||||
|
|
||||||
async function load(quiet = false, discover = false) {
|
async function load(quiet = false, discover = false) {
|
||||||
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
|
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
|
||||||
@@ -92,12 +100,23 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
async function updateDevice(device, patch) {
|
async function updateDevice(device, patch) {
|
||||||
setSavingId(device.id);
|
setSavingId(device.id);
|
||||||
try {
|
try {
|
||||||
const next = await api.devices.update(device.id, patch, snapshot.revision);
|
let next;
|
||||||
|
try {
|
||||||
|
next = await api.devices.update(device.id, patch, snapshot.revision);
|
||||||
|
} catch (requestError) {
|
||||||
|
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
|
||||||
|
const latest = await api.devices.list();
|
||||||
|
setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current);
|
||||||
|
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||||
|
if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) {
|
||||||
|
throw requestError;
|
||||||
|
}
|
||||||
|
next = await api.devices.update(device.id, patch, latest.revision);
|
||||||
|
}
|
||||||
setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
|
setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
|
||||||
setError(null);
|
setError(null);
|
||||||
return true;
|
return true;
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
if (requestError.code === 'STATE_CONFLICT') await load(true);
|
|
||||||
setError(requestError);
|
setError(requestError);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -115,12 +134,12 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
<aside
|
<aside
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
id="client-devices"
|
id="client-devices"
|
||||||
className={`client-instructions client-devices${open ? ' is-open' : ''}`}
|
className={`client-drawer client-instructions client-devices${open ? ' is-open' : ''}`}
|
||||||
aria-labelledby="client-devices-title"
|
aria-labelledby="client-devices-title"
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
inert={!open ? true : undefined}
|
inert={!open ? true : undefined}
|
||||||
>
|
>
|
||||||
<div className="client-instructions-sheet client-devices-sheet">
|
<div className="client-drawer-sheet client-instructions-sheet client-devices-sheet">
|
||||||
<button
|
<button
|
||||||
ref={closeRef}
|
ref={closeRef}
|
||||||
className="client-drawer-close"
|
className="client-drawer-close"
|
||||||
@@ -149,6 +168,18 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
</button>
|
</button>
|
||||||
<Tooltip>{refreshing ? 'Обновляем устройства…' : 'Обновить сейчас · автоматически каждые 15 с'}</Tooltip>
|
<Tooltip>{refreshing ? 'Обновляем устройства…' : 'Обновить сейчас · автоматически каждые 15 с'}</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="client-devices-sort-wrap client-tooltip-anchor">
|
||||||
|
<button
|
||||||
|
className="client-devices-sort"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Сортировка по трафику: сначала ${sortDirection === 'desc' ? 'больше' : 'меньше'}. Изменить направление`}
|
||||||
|
onClick={() => setSortDirection((direction) => direction === 'desc' ? 'asc' : 'desc')}
|
||||||
|
>
|
||||||
|
<span>Трафик</span>
|
||||||
|
<span aria-hidden="true">{sortDirection === 'desc' ? '↓' : '↑'}</span>
|
||||||
|
</button>
|
||||||
|
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 id="client-devices-title">Устройства</h2>
|
<h2 id="client-devices-title">Устройства</h2>
|
||||||
<div className="client-instructions-intro">
|
<div className="client-instructions-intro">
|
||||||
@@ -161,6 +192,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
Список временно не обновляется. Показаны последние сохранённые данные.
|
Список временно не обновляется. Показаны последние сохранённые данные.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{snapshot?.source?.traffic?.error && (
|
||||||
|
<p className="client-devices-source" role="status">
|
||||||
|
Трафик временно не обновляется. Показаны последние сохранённые значения.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="client-devices-error" role="alert">
|
<div className="client-devices-error" role="alert">
|
||||||
<span>{error.message}</span>
|
<span>{error.message}</span>
|
||||||
@@ -179,6 +215,8 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
const saving = savingId === device.id;
|
const saving = savingId === device.id;
|
||||||
const seen = formatLastSeen(device.lastSeenAt);
|
const seen = formatLastSeen(device.lastSeenAt);
|
||||||
const uncertainIdentity = device.confidence !== 'high';
|
const uncertainIdentity = device.confidence !== 'high';
|
||||||
|
const download = formatByteString(device.downloadBytes);
|
||||||
|
const upload = formatByteString(device.uploadBytes);
|
||||||
return <article
|
return <article
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
if (node) deviceNodes.current.set(device.id, node);
|
if (node) deviceNodes.current.set(device.id, node);
|
||||||
@@ -260,7 +298,15 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
</time>
|
</time>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{device.manufacturer && <p className="client-device-manufacturer">{device.manufacturer}</p>}
|
{(device.manufacturer || device.trafficObservedAt) && <div className="client-device-details">
|
||||||
|
{device.manufacturer && <span className="client-device-manufacturer">{device.manufacturer}</span>}
|
||||||
|
{device.trafficObservedAt && <span
|
||||||
|
className="client-device-traffic"
|
||||||
|
aria-label={`Получено ${download}, отдано ${upload}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">↓ {download} · ↑ {upload}</span>
|
||||||
|
</span>}
|
||||||
|
</div>}
|
||||||
</article>;
|
</article>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+59
-37
@@ -666,7 +666,7 @@ p {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
right: max(14px, env(safe-area-inset-right));
|
right: max(14px, env(safe-area-inset-right));
|
||||||
z-index: 30;
|
z-index: 60;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
@@ -715,7 +715,7 @@ p {
|
|||||||
transition: opacity 350ms ease, filter 350ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
transition: opacity 350ms ease, filter 350ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-devices {
|
.client-instructions.client-devices {
|
||||||
width: min(560px, 100vw);
|
width: min(560px, 100vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,6 +737,30 @@ p {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-devices-sort-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-sort {
|
||||||
|
min-height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: 700 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 220ms ease, filter 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-sort:hover,
|
||||||
|
.client-devices-sort:focus-visible {
|
||||||
|
color: var(--client-accent);
|
||||||
|
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 44%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
.client-devices-refresh {
|
.client-devices-refresh {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
@@ -807,7 +831,8 @@ p {
|
|||||||
animation: client-spin 900ms linear infinite;
|
animation: client-spin 900ms linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-instructions-header .client-devices-refresh-wrap > .client-tooltip {
|
.client-instructions-header .client-devices-refresh-wrap > .client-tooltip,
|
||||||
|
.client-instructions-header .client-devices-sort-wrap > .client-tooltip {
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,15 +1100,32 @@ p {
|
|||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-manufacturer {
|
.client-device-details {
|
||||||
overflow: hidden;
|
min-width: 0;
|
||||||
margin: 0 0 0 76px;
|
display: flex;
|
||||||
color: var(--client-muted);
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px 12px;
|
||||||
|
margin-left: 76px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-manufacturer {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--client-muted);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-device-traffic {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--client-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.client-device-alias {
|
.client-device-alias {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 28px 28px;
|
grid-template-columns: minmax(0, 1fr) 28px 28px;
|
||||||
@@ -1202,34 +1244,38 @@ p {
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-local-rules {
|
.client-drawer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 0 0 auto;
|
inset: 0 0 0 auto;
|
||||||
z-index: 20;
|
z-index: 50;
|
||||||
width: min(480px, 100vw);
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
|
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
|
box-shadow: -26px 0 72px oklch(0.09 0.015 145 / 0.12);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translateX(104%);
|
transform: translateX(104%);
|
||||||
transition: transform 760ms cubic-bezier(0.16, 1, 0.3, 1), opacity 500ms ease, visibility 0s 760ms;
|
transition: transform 760ms cubic-bezier(0.16, 1, 0.3, 1), opacity 500ms ease, visibility 0s 760ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-local-rules.is-open {
|
.client-drawer.is-open {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
transition-delay: 0s;
|
transition-delay: 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-local-rules-sheet {
|
.client-drawer-sheet {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 54px 72px 72px 34px;
|
padding: 54px 72px 72px 34px;
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-local-rules {
|
||||||
|
width: min(480px, 100vw);
|
||||||
|
}
|
||||||
|
|
||||||
.client-local-rules-header {
|
.client-local-rules-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -1731,32 +1777,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-instructions {
|
.client-instructions {
|
||||||
position: fixed;
|
|
||||||
inset: 0 0 0 auto;
|
|
||||||
z-index: 20;
|
|
||||||
width: min(470px, 100vw);
|
width: min(470px, 100vw);
|
||||||
overflow-y: auto;
|
|
||||||
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
|
|
||||||
color: var(--client-text);
|
|
||||||
box-shadow: -26px 0 72px oklch(0.09 0.015 145 / 0.12);
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transform: translateX(104%);
|
|
||||||
transition: transform 760ms cubic-bezier(0.16, 1, 0.3, 1), opacity 500ms ease, visibility 0s 760ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-instructions.is-open {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateX(0);
|
|
||||||
transition-delay: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-instructions-sheet {
|
|
||||||
position: relative;
|
|
||||||
min-height: 100%;
|
|
||||||
padding: 54px 72px 72px 34px;
|
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-instructions-header {
|
.client-instructions-header {
|
||||||
@@ -4080,6 +4101,7 @@ p {
|
|||||||
.client-device-edit svg,
|
.client-device-edit svg,
|
||||||
.client-device-edit-wrap,
|
.client-device-edit-wrap,
|
||||||
.client-devices-refresh,
|
.client-devices-refresh,
|
||||||
|
.client-devices-sort,
|
||||||
.client-devices-refresh-ring circle,
|
.client-devices-refresh-ring circle,
|
||||||
.client-devices-refresh-icon,
|
.client-devices-refresh-icon,
|
||||||
.client-text-morph-value,
|
.client-text-morph-value,
|
||||||
|
|||||||
@@ -10,6 +10,40 @@ export function formatBytes(value) {
|
|||||||
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BYTE_STRING_PATTERN = /^\d+$/;
|
||||||
|
|
||||||
|
export function byteString(value) {
|
||||||
|
const normalized = String(value ?? '0');
|
||||||
|
return BYTE_STRING_PATTERN.test(normalized) ? BigInt(normalized) : 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatByteString(value) {
|
||||||
|
const bytes = byteString(value);
|
||||||
|
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ', 'ПБ', 'ЭБ'];
|
||||||
|
let unit = 0;
|
||||||
|
let divisor = 1n;
|
||||||
|
while (bytes >= divisor * 1024n && unit < units.length - 1) {
|
||||||
|
divisor *= 1024n;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
if (unit === 0) return `${bytes} ${units[unit]}`;
|
||||||
|
const tenths = (bytes * 10n + divisor / 2n) / divisor;
|
||||||
|
return `${tenths / 10n},${tenths % 10n} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortDevicesByTraffic(devices, direction = 'desc') {
|
||||||
|
const factor = direction === 'asc' ? 1 : -1;
|
||||||
|
return (Array.isArray(devices) ? devices : [])
|
||||||
|
.map((device, index) => ({ device, index }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftTotal = byteString(left.device.uploadBytes) + byteString(left.device.downloadBytes);
|
||||||
|
const rightTotal = byteString(right.device.uploadBytes) + byteString(right.device.downloadBytes);
|
||||||
|
if (leftTotal === rightTotal) return left.index - right.index;
|
||||||
|
return (leftTotal < rightTotal ? -1 : 1) * factor;
|
||||||
|
})
|
||||||
|
.map(({ device }) => device);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatRelative(iso) {
|
export function formatRelative(iso) {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const ts = new Date(iso).getTime();
|
const ts = new Date(iso).getTime();
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { parseNeighborSnapshot, readNeighborSnapshot } from '../../src/server/ad
|
|||||||
import {
|
import {
|
||||||
createDeviceInventoryService,
|
createDeviceInventoryService,
|
||||||
createVendorLookup,
|
createVendorLookup,
|
||||||
|
DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
|
migrateDeviceInventoryState,
|
||||||
} from '../../src/server/services/deviceInventoryService.js';
|
} from '../../src/server/services/deviceInventoryService.js';
|
||||||
import { createJsonStore } from '../../src/server/services/stateStore.js';
|
import { createJsonStore } from '../../src/server/services/stateStore.js';
|
||||||
|
|
||||||
@@ -100,3 +102,244 @@ test('device inventory discovers, merges, persists metadata and expires anonymou
|
|||||||
assert.deepEqual(failed.observations, []);
|
assert.deepEqual(failed.observations, []);
|
||||||
assert.match(failed.error, /not available/);
|
assert.match(failed.error, /not available/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('device traffic totals persist exact deltas across polls and process epochs', async (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-traffic-'));
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const store = createJsonStore({
|
||||||
|
filePath: path.join(directory, 'devices.json'),
|
||||||
|
defaultValue: {},
|
||||||
|
migrate: migrateDeviceInventoryState,
|
||||||
|
});
|
||||||
|
const observedAt = '2026-08-07T12:00:00.000Z';
|
||||||
|
const mac = '00:11:22:33:44:55';
|
||||||
|
const neighbor = {
|
||||||
|
observedAt,
|
||||||
|
observations: [{
|
||||||
|
ip: '192.168.50.7',
|
||||||
|
mac,
|
||||||
|
interface: 'eth0',
|
||||||
|
observedAt,
|
||||||
|
active: true,
|
||||||
|
}],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
let traffic = {
|
||||||
|
epoch: 'epoch-a',
|
||||||
|
generation: 'rules-a',
|
||||||
|
observedAt,
|
||||||
|
source: { error: null },
|
||||||
|
devices: [{
|
||||||
|
ip: '192.168.50.7',
|
||||||
|
mac,
|
||||||
|
interface: 'eth0',
|
||||||
|
uploadBytes: '9007199254740993',
|
||||||
|
downloadBytes: '100',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let trafficError = null;
|
||||||
|
const createService = () => createDeviceInventoryService({
|
||||||
|
store,
|
||||||
|
observe: () => neighbor,
|
||||||
|
observeTraffic: () => {
|
||||||
|
if (trafficError) throw trafficError;
|
||||||
|
return traffic;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let service = createService();
|
||||||
|
|
||||||
|
let snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
|
||||||
|
assert.equal(snapshot.devices[0].downloadBytes, '100');
|
||||||
|
assert.equal(snapshot.devices[0].trafficObservedAt, observedAt);
|
||||||
|
assert.deepEqual(snapshot.source.traffic, { lastObservedAt: observedAt, error: null });
|
||||||
|
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
|
||||||
|
assert.equal(snapshot.devices[0].downloadBytes, '100');
|
||||||
|
|
||||||
|
traffic = {
|
||||||
|
...traffic,
|
||||||
|
devices: [{ ...traffic.devices[0], uploadBytes: '9007199254740995', downloadBytes: '150' }],
|
||||||
|
};
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995');
|
||||||
|
assert.equal(snapshot.devices[0].downloadBytes, '150');
|
||||||
|
|
||||||
|
service = createService();
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995');
|
||||||
|
assert.equal(snapshot.devices[0].downloadBytes, '150');
|
||||||
|
|
||||||
|
traffic = {
|
||||||
|
...traffic,
|
||||||
|
epoch: 'epoch-b',
|
||||||
|
generation: 'rules-b',
|
||||||
|
devices: [{ ...traffic.devices[0], uploadBytes: '10', downloadBytes: '20' }],
|
||||||
|
};
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
|
||||||
|
assert.equal(snapshot.devices[0].downloadBytes, '170');
|
||||||
|
|
||||||
|
traffic = {
|
||||||
|
...traffic,
|
||||||
|
devices: [{ ...traffic.devices[0], uploadBytes: '9', downloadBytes: '20' }],
|
||||||
|
};
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
|
||||||
|
assert.match(snapshot.source.traffic.error, /уменьшился/);
|
||||||
|
|
||||||
|
trafficError = new Error('traffic unavailable');
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
|
||||||
|
assert.equal(snapshot.source.traffic.lastObservedAt, observedAt);
|
||||||
|
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('device inventory v1 migration creates a versioned backup', (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-migration-'));
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const filePath = path.join(directory, 'devices.json');
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, revision: 4, devices: [] }));
|
||||||
|
const store = createJsonStore({
|
||||||
|
filePath,
|
||||||
|
defaultValue: {},
|
||||||
|
migrate: migrateDeviceInventoryState,
|
||||||
|
backupWhen: (before, after) => before?.schemaVersion !== after.schemaVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
const migrated = store.read();
|
||||||
|
assert.equal(migrated.schemaVersion, DEVICE_INVENTORY_SCHEMA_VERSION);
|
||||||
|
assert.deepEqual(migrated.traffic.baselinesByMac, {});
|
||||||
|
assert.match(store.migration?.backupPath || '', /\.backup-v1-/);
|
||||||
|
assert.ok(fs.existsSync(store.migration.backupPath));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('device inventory backs up malformed v2 traffic and re-baselines without double counting', async (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-corrupt-traffic-'));
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const filePath = path.join(directory, 'devices.json');
|
||||||
|
const observedAt = '2026-08-07T12:00:00.000Z';
|
||||||
|
const macs = [
|
||||||
|
'00:11:22:33:44:55',
|
||||||
|
'00:11:22:33:44:66',
|
||||||
|
'00:11:22:33:44:77',
|
||||||
|
'00:11:22:33:44:88',
|
||||||
|
'00:11:22:33:44:99',
|
||||||
|
];
|
||||||
|
const [firstMac, secondMac, missingBaselineMac, missingTotalMac, expiredMac] = macs;
|
||||||
|
const devices = macs.map((mac, index) => ({
|
||||||
|
id: `device-${index}`,
|
||||||
|
alias: '',
|
||||||
|
pinned: false,
|
||||||
|
hostname: null,
|
||||||
|
manufacturer: null,
|
||||||
|
mac,
|
||||||
|
ip: `192.168.50.${index + 7}`,
|
||||||
|
interface: 'eth0',
|
||||||
|
firstSeenAt: mac === expiredMac ? '2026-06-01T12:00:00.000Z' : observedAt,
|
||||||
|
lastSeenAt: mac === expiredMac ? '2026-06-01T12:00:00.000Z' : observedAt,
|
||||||
|
source: 'neighbor',
|
||||||
|
confidence: 'high',
|
||||||
|
}));
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
revision: 4,
|
||||||
|
lastObservedAt: observedAt,
|
||||||
|
lastError: null,
|
||||||
|
devices,
|
||||||
|
traffic: {
|
||||||
|
epoch: 'epoch-a',
|
||||||
|
generation: 'rules-a',
|
||||||
|
lastObservedAt: observedAt,
|
||||||
|
lastError: null,
|
||||||
|
baselinesByMac: {
|
||||||
|
[firstMac]: { epoch: 'epoch-a', uploadBytes: 'broken', downloadBytes: '100' },
|
||||||
|
[secondMac]: { epoch: 'epoch-a', uploadBytes: '50', downloadBytes: '60' },
|
||||||
|
[missingTotalMac]: { epoch: 'epoch-a', uploadBytes: '90', downloadBytes: '100' },
|
||||||
|
[expiredMac]: { epoch: 'epoch-a', uploadBytes: '110', downloadBytes: '120' },
|
||||||
|
},
|
||||||
|
totalsByMac: {
|
||||||
|
[firstMac]: { uploadBytes: '500', downloadBytes: '600', observedAt },
|
||||||
|
[secondMac]: { uploadBytes: 'broken', downloadBytes: '700', observedAt },
|
||||||
|
[missingBaselineMac]: { uploadBytes: '800', downloadBytes: '900', observedAt },
|
||||||
|
[expiredMac]: { uploadBytes: 'broken', downloadBytes: '1000', observedAt },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const store = createJsonStore({
|
||||||
|
filePath,
|
||||||
|
defaultValue: {},
|
||||||
|
migrate: migrateDeviceInventoryState,
|
||||||
|
backupWhen: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const migrated = store.read();
|
||||||
|
assert.match(store.migration?.backupPath || '', /\.backup-v2-/);
|
||||||
|
assert.ok(fs.existsSync(store.migration.backupPath));
|
||||||
|
assert.deepEqual(migrated.traffic.totalsByMac[firstMac], {
|
||||||
|
uploadBytes: '500',
|
||||||
|
downloadBytes: '600',
|
||||||
|
observedAt,
|
||||||
|
});
|
||||||
|
assert.equal(migrated.traffic.totalsByMac[secondMac], undefined);
|
||||||
|
assert.deepEqual(
|
||||||
|
new Set(migrated.traffic.rebaselineMacs),
|
||||||
|
new Set([firstMac, secondMac, missingBaselineMac, missingTotalMac, expiredMac]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let counters = [
|
||||||
|
{ mac: firstMac, uploadBytes: '200', downloadBytes: '300' },
|
||||||
|
{ mac: secondMac, uploadBytes: '70', downloadBytes: '80' },
|
||||||
|
{ mac: missingBaselineMac, uploadBytes: '110', downloadBytes: '120' },
|
||||||
|
{ mac: missingTotalMac, uploadBytes: '130', downloadBytes: '140' },
|
||||||
|
];
|
||||||
|
const service = createDeviceInventoryService({
|
||||||
|
store,
|
||||||
|
observe: () => ({
|
||||||
|
observedAt,
|
||||||
|
error: null,
|
||||||
|
observations: devices.filter(({ mac }) => mac !== expiredMac)
|
||||||
|
.map(({ ip, mac, interface: deviceInterface }) => ({
|
||||||
|
ip,
|
||||||
|
mac,
|
||||||
|
interface: deviceInterface,
|
||||||
|
observedAt,
|
||||||
|
active: true,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
observeTraffic: () => ({
|
||||||
|
epoch: 'epoch-a',
|
||||||
|
generation: 'rules-a',
|
||||||
|
observedAt,
|
||||||
|
source: { error: null },
|
||||||
|
devices: counters,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let snapshot = await service.refresh();
|
||||||
|
let byMac = new Map(snapshot.devices.map((device) => [device.mac, device]));
|
||||||
|
assert.equal(byMac.get(firstMac).uploadBytes, '500');
|
||||||
|
assert.equal(byMac.get(secondMac).uploadBytes, '0');
|
||||||
|
assert.equal(byMac.get(missingBaselineMac).uploadBytes, '800');
|
||||||
|
assert.equal(byMac.get(missingTotalMac).uploadBytes, '0');
|
||||||
|
assert.equal(byMac.has(expiredMac), false);
|
||||||
|
assert.deepEqual(store.read().traffic.rebaselineMacs, []);
|
||||||
|
assert.equal(snapshot.source.traffic.error, null);
|
||||||
|
counters = [
|
||||||
|
{ mac: firstMac, uploadBytes: '250', downloadBytes: '330' },
|
||||||
|
{ mac: secondMac, uploadBytes: '75', downloadBytes: '90' },
|
||||||
|
{ mac: missingBaselineMac, uploadBytes: '115', downloadBytes: '125' },
|
||||||
|
{ mac: missingTotalMac, uploadBytes: '150', downloadBytes: '160' },
|
||||||
|
];
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
byMac = new Map(snapshot.devices.map((device) => [device.mac, device]));
|
||||||
|
assert.equal(byMac.get(firstMac).uploadBytes, '550');
|
||||||
|
assert.equal(byMac.get(firstMac).downloadBytes, '630');
|
||||||
|
assert.equal(byMac.get(secondMac).uploadBytes, '5');
|
||||||
|
assert.equal(byMac.get(secondMac).downloadBytes, '10');
|
||||||
|
assert.equal(byMac.get(missingBaselineMac).uploadBytes, '805');
|
||||||
|
assert.equal(byMac.get(missingBaselineMac).downloadBytes, '905');
|
||||||
|
assert.equal(byMac.get(missingTotalMac).uploadBytes, '20');
|
||||||
|
assert.equal(byMac.get(missingTotalMac).downloadBytes, '20');
|
||||||
|
});
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ test('traffic service preserves active rules and snapshot when replacement fails
|
|||||||
});
|
});
|
||||||
|
|
||||||
const first = await service.refresh();
|
const first = await service.refresh();
|
||||||
|
assert.equal(first.epoch, 'boot');
|
||||||
assert.equal(first.generation, 'rules-a');
|
assert.equal(first.generation, 'rules-a');
|
||||||
assert.deepEqual(first.devices, [{
|
assert.deepEqual(first.devices, [{
|
||||||
ip: '192.168.50.7',
|
ip: '192.168.50.7',
|
||||||
@@ -175,3 +176,94 @@ test('traffic service preserves active rules and snapshot when replacement fails
|
|||||||
assert.deepEqual(timedOut.devices, first.devices);
|
assert.deepEqual(timedOut.devices, first.devices);
|
||||||
assert.ok(calls.every(([, , options]) => options.timeout === 2_000));
|
assert.ok(calls.every(([, , options]) => options.timeout === 2_000));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('traffic service finalizes a detached slot once and keeps epoch totals monotonic', async () => {
|
||||||
|
const firstObservation = observation('192.168.50.7', '00:11:22:33:44:55');
|
||||||
|
const secondObservation = observation('192.168.50.8', '00:11:22:33:44:66');
|
||||||
|
const firstDevice = selectTrafficDevices([firstObservation])[0];
|
||||||
|
const secondDevice = selectTrafficDevices([secondObservation])[0];
|
||||||
|
let observed = {
|
||||||
|
observedAt: '2026-08-07T12:00:00.000Z',
|
||||||
|
observations: [firstObservation],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
const values = {
|
||||||
|
A: { upload: '100', download: '200' },
|
||||||
|
B: { upload: '5', download: '7' },
|
||||||
|
};
|
||||||
|
const keys = { A: firstDevice.key, B: secondDevice.key };
|
||||||
|
let failNextCounterRead = false;
|
||||||
|
const run = (command, args) => {
|
||||||
|
if (command !== 'iptables-save') return { status: 0, stdout: '', stderr: '' };
|
||||||
|
if (failNextCounterRead) {
|
||||||
|
failNextCounterRead = false;
|
||||||
|
return { status: null, stdout: '', stderr: '', error: new Error('retired slot read failed') };
|
||||||
|
}
|
||||||
|
const direction = args.includes('raw') ? 'upload' : 'download';
|
||||||
|
const tableChain = args.includes('raw') ? uploadChain : downloadChain;
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout: ['A', 'B'].map((slot) => (
|
||||||
|
`[1:${values[slot][direction]}] -A ${tableChain}_${slot} -m comment --comment "harbor-traffic:${keys[slot]}:${direction}" -j RETURN`
|
||||||
|
)).join('\n'),
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const generations = ['epoch-1', 'rules-a', 'rules-b'];
|
||||||
|
const service = createDeviceTrafficService({
|
||||||
|
observe: async () => observed,
|
||||||
|
uploadChain,
|
||||||
|
downloadChain,
|
||||||
|
bypassCidrs: [],
|
||||||
|
run,
|
||||||
|
nextGeneration: () => generations.shift(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await service.refresh();
|
||||||
|
assert.equal(first.epoch, 'epoch-1');
|
||||||
|
assert.equal(first.generation, 'rules-a');
|
||||||
|
assert.deepEqual(first.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||||
|
mac, uploadBytes, downloadBytes,
|
||||||
|
})), [{
|
||||||
|
mac: firstObservation.mac,
|
||||||
|
uploadBytes: '100',
|
||||||
|
downloadBytes: '200',
|
||||||
|
}]);
|
||||||
|
|
||||||
|
values.A = { upload: '130', download: '240' };
|
||||||
|
observed = {
|
||||||
|
observedAt: '2026-08-07T12:01:00.000Z',
|
||||||
|
observations: [secondObservation],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
failNextCounterRead = true;
|
||||||
|
const pending = await service.refresh();
|
||||||
|
assert.equal(pending.epoch, 'epoch-1');
|
||||||
|
assert.equal(pending.generation, 'rules-b');
|
||||||
|
assert.match(pending.source.error, /retired slot read failed/);
|
||||||
|
assert.deepEqual(pending.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||||
|
mac, uploadBytes, downloadBytes,
|
||||||
|
})), [
|
||||||
|
{ mac: firstObservation.mac, uploadBytes: '100', downloadBytes: '200' },
|
||||||
|
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const finalized = await service.refresh();
|
||||||
|
assert.equal(finalized.generation, 'rules-b');
|
||||||
|
assert.equal(finalized.source.error, null);
|
||||||
|
assert.deepEqual(finalized.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||||
|
mac, uploadBytes, downloadBytes,
|
||||||
|
})), [
|
||||||
|
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240' },
|
||||||
|
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
values.B = { upload: '15', download: '17' };
|
||||||
|
const polled = await service.refresh();
|
||||||
|
assert.deepEqual(polled.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
|
||||||
|
mac, uploadBytes, downloadBytes,
|
||||||
|
})), [
|
||||||
|
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240' },
|
||||||
|
{ mac: secondObservation.mac, uploadBytes: '15', downloadBytes: '17' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { formatLastSeen } from '../../src/web/utils/format.js';
|
import {
|
||||||
|
formatByteString,
|
||||||
|
formatLastSeen,
|
||||||
|
sortDevicesByTraffic,
|
||||||
|
} from '../../src/web/utils/format.js';
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||||
@@ -17,6 +21,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(panel, /api\.devices\.list\(\)/);
|
assert.match(panel, /api\.devices\.list\(\)/);
|
||||||
assert.match(panel, /api\.devices\.refresh\(\)/);
|
assert.match(panel, /api\.devices\.refresh\(\)/);
|
||||||
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
||||||
|
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*api\.devices\.list\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*api\.devices\.update\(device\.id, patch, latest\.revision\)/);
|
||||||
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
||||||
assert.match(panel, /prefers-reduced-motion: reduce/);
|
assert.match(panel, /prefers-reduced-motion: reduce/);
|
||||||
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
||||||
@@ -27,6 +32,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(panel, /client-device-addresses/);
|
assert.match(panel, /client-device-addresses/);
|
||||||
assert.doesNotMatch(panel, /device\.interface/);
|
assert.doesNotMatch(panel, /device\.interface/);
|
||||||
assert.match(panel, /device\.confidence === 'ambiguous'/);
|
assert.match(panel, /device\.confidence === 'ambiguous'/);
|
||||||
|
assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/);
|
||||||
|
assert.match(panel, /Трафик временно не обновляется/);
|
||||||
|
assert.match(panel, /Получено \$\{download\}, отдано \$\{upload\}/);
|
||||||
|
assert.match(panel, /client-device-traffic/);
|
||||||
|
assert.match(panel, /client-drawer client-instructions client-devices/);
|
||||||
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
||||||
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
||||||
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
||||||
@@ -36,6 +46,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(styles, /\.client-device-pin-wrap\.client-tooltip-anchor:hover > \.client-tooltip[\s\S]*translate\(0, 0\)/);
|
assert.match(styles, /\.client-device-pin-wrap\.client-tooltip-anchor:hover > \.client-tooltip[\s\S]*translate\(0, 0\)/);
|
||||||
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
||||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-text-morph-value/);
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-text-morph-value/);
|
||||||
|
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
|
||||||
|
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('device last-seen copy is compact with precise accessible and relative forms', () => {
|
test('device last-seen copy is compact with precise accessible and relative forms', () => {
|
||||||
@@ -50,3 +62,18 @@ test('device last-seen copy is compact with precise accessible and relative form
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('device traffic formatting and sorting preserve uint64 precision and canonical ties', () => {
|
||||||
|
assert.equal(formatByteString('9007199254740993'), '8,0 ПБ');
|
||||||
|
assert.equal(formatByteString('1536'), '1,5 КБ');
|
||||||
|
assert.equal(formatByteString('invalid'), '0 Б');
|
||||||
|
|
||||||
|
const devices = [
|
||||||
|
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0' },
|
||||||
|
{ id: 'b', uploadBytes: '9007199254740992', downloadBytes: '2' },
|
||||||
|
{ id: 'c', uploadBytes: '10', downloadBytes: '10' },
|
||||||
|
{ id: 'd', uploadBytes: '15', downloadBytes: '5' },
|
||||||
|
];
|
||||||
|
assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['b', 'a', 'c', 'd']);
|
||||||
|
assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['c', 'd', 'a', 'b']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
|
|||||||
|
|
||||||
test('secondary menus share one right rail and both drawers open from the right', () => {
|
test('secondary menus share one right rail and both drawers open from the right', () => {
|
||||||
const disabledRulesLabel = rule('.client-local-rules-toggle:disabled span');
|
const disabledRulesLabel = rule('.client-local-rules-toggle:disabled span');
|
||||||
|
const zIndex = (selector) => Number(/z-index:\s*(\d+)/.exec(rule(selector))?.[1]);
|
||||||
|
|
||||||
assert.match(component, /<nav className="client-secondary-menu" aria-label="Дополнительные меню">/);
|
assert.match(component, /<nav className="client-secondary-menu" aria-label="Дополнительные меню">/);
|
||||||
assert.match(component, /client-instructions-toggle[\s\S]*client-local-rules-toggle/);
|
assert.match(component, /client-instructions-toggle[\s\S]*client-local-rules-toggle/);
|
||||||
@@ -92,10 +93,18 @@ test('secondary menus share one right rail and both drawers open from the right'
|
|||||||
assert.match(disabledRulesLabel, /opacity:\s*0/);
|
assert.match(disabledRulesLabel, /opacity:\s*0/);
|
||||||
assert.match(disabledRulesLabel, /filter:\s*blur\(5px\)/);
|
assert.match(disabledRulesLabel, /filter:\s*blur\(5px\)/);
|
||||||
assert.match(styles, /\.client-local-rules-toggle:disabled:hover span\s*\{[\s\S]*opacity:\s*1/);
|
assert.match(styles, /\.client-local-rules-toggle:disabled:hover span\s*\{[\s\S]*opacity:\s*1/);
|
||||||
assert.match(rule('.client-instructions'), /inset:\s*0 0 0 auto/);
|
assert.match(rule('.client-drawer'), /inset:\s*0 0 0 auto/);
|
||||||
assert.match(rule('.client-instructions'), /transform:\s*translateX\(104%\)/);
|
assert.match(rule('.client-drawer'), /transform:\s*translateX\(104%\)/);
|
||||||
assert.match(rule('.client-local-rules'), /inset:\s*0 0 0 auto/);
|
assert.match(rule('.client-drawer'), /z-index:\s*50/);
|
||||||
assert.match(rule('.client-local-rules'), /transform:\s*translateX\(104%\)/);
|
assert.match(rule('.client-drawer'), /box-shadow:/);
|
||||||
|
assert.deepEqual(
|
||||||
|
['.client-confirmation-popup', '.client-secondary-menu', '.client-drawer', '.harbor-versions'].map(zIndex),
|
||||||
|
[100, 60, 50, 40],
|
||||||
|
);
|
||||||
|
assert.match(rule('.client-instructions'), /width:\s*min\(470px, 100vw\)/);
|
||||||
|
assert.match(rule('.client-local-rules'), /width:\s*min\(480px, 100vw\)/);
|
||||||
|
assert.match(component, /className={`client-drawer client-instructions/);
|
||||||
|
assert.match(component, /className={`client-drawer client-local-rules/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('duration and Gateway access keep stable geometry without tabs', () => {
|
test('duration and Gateway access keep stable geometry without tabs', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user