Add Gateway device inventory panel
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-06 10:17:33 +03:00
parent fdc6f687f3
commit 158aaadd23
18 changed files with 811 additions and 5 deletions
@@ -0,0 +1,170 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import { HarborError } from '../../shared/errors.js';
const SCHEMA_VERSION = 1;
const ONLINE_MS = 2 * 60 * 1000;
const RECENT_MS = 24 * 60 * 60 * 1000;
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const DEFAULT_STATE = {
schemaVersion: SCHEMA_VERSION,
revision: 0,
lastObservedAt: null,
lastError: null,
devices: [],
};
const normalizeMac = (value) => String(value || '').trim().toLowerCase();
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;
export function parseOuiVendors(text) {
const vendors = new Map();
for (const line of String(text || '').split(/\r?\n/)) {
const match = line.match(/^([0-9a-f]{2}(?:-[0-9a-f]{2}){2})\s+\(hex\)\s+(.+)$/i);
if (match) vendors.set(match[1].replaceAll('-', '').toLowerCase(), match[2].trim());
}
return vendors;
}
export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') {
let vendors;
return (mac) => {
if (!mac || isPrivateMac(mac)) return null;
if (!vendors) {
try {
vendors = parseOuiVendors(fs.readFileSync(filePath, 'utf8'));
} catch {
vendors = new Map();
}
}
return vendors.get(mac.replaceAll(':', '').slice(0, 6)) || null;
};
}
function migrate(value) {
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const version = Number.isSafeInteger(state.schemaVersion) ? state.schemaVersion : 0;
if (version < 0 || version > SCHEMA_VERSION) {
throw new Error(`Unsupported device inventory schemaVersion: ${version}`);
}
return {
...DEFAULT_STATE,
...state,
schemaVersion: SCHEMA_VERSION,
revision: Number.isSafeInteger(state.revision) ? state.revision : 0,
devices: Array.isArray(state.devices) ? state.devices : [],
};
}
function deviceStatus(lastSeenAt, now) {
const age = now.getTime() - new Date(lastSeenAt).getTime();
if (age <= ONLINE_MS) return 'online';
if (age <= RECENT_MS) return 'recent';
return 'offline';
}
export function createDeviceInventoryService({ store, observe, vendor = () => null, now = () => new Date() }) {
function snapshot() {
const state = migrate(store.read());
const current = now();
const rank = { online: 0, recent: 1, offline: 2 };
const devices = state.devices.map((device) => ({
...device,
status: deviceStatus(device.lastSeenAt, current),
})).sort((left, right) => (
Number(right.pinned) - Number(left.pinned)
|| rank[left.status] - rank[right.status]
|| String(right.lastSeenAt).localeCompare(String(left.lastSeenAt))
));
return {
revision: state.revision,
source: {
kind: 'neighbor',
lastObservedAt: state.lastObservedAt,
error: state.lastError,
},
devices,
};
}
async function refresh() {
let result;
try {
result = await observe();
} catch (error) {
result = { observedAt: now().toISOString(), observations: [], error: error.message || String(error) };
}
const observedAt = result?.observedAt || now().toISOString();
const observations = Array.isArray(result?.observations) ? result.observations : [];
store.update((stored) => {
const state = migrate(stored);
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
for (const observation of observations) {
const mac = normalizeMac(observation.mac);
if (!mac) continue;
const previous = byMac.get(mac);
const lastSeenAt = observation.active || !previous
? observation.observedAt || observedAt
: previous.lastSeenAt;
byMac.set(mac, {
id: previous?.id || deviceId(mac),
alias: previous?.alias || '',
pinned: previous?.pinned === true,
hostname: previous?.hostname || null,
manufacturer: previous?.manufacturer || vendor(mac),
mac,
ip: String(observation.ip || previous?.ip || ''),
interface: String(observation.interface || previous?.interface || ''),
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
lastSeenAt,
source: 'neighbor',
confidence: isPrivateMac(mac) ? 'medium' : 'high',
});
}
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
const devices = [...byMac.values()].filter((device) => (
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
));
return {
...state,
revision: state.revision + 1,
lastObservedAt: observedAt,
lastError: result?.error || null,
devices,
};
});
return snapshot();
}
function update(id, patch, expectedRevision) {
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
throw new HarborError('REQUEST_INVALID');
}
const aliasProvided = Object.hasOwn(patch, 'alias');
const pinProvided = Object.hasOwn(patch, 'pinned');
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|| (!aliasProvided && !pinProvided)
|| (aliasProvided && (typeof patch.alias !== 'string' || patch.alias.length > 64))
|| (pinProvided && typeof patch.pinned !== 'boolean')) {
throw new HarborError('REQUEST_INVALID');
}
store.update((stored) => {
const state = migrate(stored);
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
const index = state.devices.findIndex((device) => device.id === id);
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
const devices = [...state.devices];
devices[index] = {
...devices[index],
...(aliasProvided ? { alias: patch.alias.trim() } : {}),
...(pinProvided ? { pinned: patch.pinned } : {}),
};
return { ...state, revision: state.revision + 1, devices };
});
return snapshot();
}
return { snapshot, refresh, update };
}