Show device identity details and resolve hostnames
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-11 12:39:27 +03:00
parent 501c498edf
commit 3566f4bc0b
10 changed files with 256 additions and 82 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ http://АДРЕС-GATEWAY:3456
### Устройства Gateway ### Устройства Gateway
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название, IP и последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Нажмите IP, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory. Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory.
У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут. У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
+62 -2
View File
@@ -1,4 +1,5 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { lookupService } from 'node:dns/promises';
import fs from 'node:fs'; 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';
@@ -163,6 +164,9 @@ 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 TRAFFIC_HISTORY_LIMIT = 120; const TRAFFIC_HISTORY_LIMIT = 120;
const HOSTNAME_LOOKUP_LIMIT = 8;
const HOSTNAME_LOOKUP_TIMEOUT_MS = 800;
const HOSTNAME_RETRY_MS = 5 * 60 * 1000;
const COUNTER_PATTERN = /^\d+$/; const COUNTER_PATTERN = /^\d+$/;
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/; const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/; const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
@@ -247,6 +251,31 @@ const validTimestamp = (value: unknown): value is string => (
typeof value === 'string' && Number.isFinite(Date.parse(value)) typeof value === 'string' && Number.isFinite(Date.parse(value))
); );
function normalizeHostname(value: unknown, ip: string) {
const hostname = typeof value === 'string' ? value.trim().replace(/\.$/, '') : '';
return hostname && hostname !== ip && /^[a-z0-9_](?:[a-z0-9_.-]{0,251}[a-z0-9_])?$/i.test(hostname)
? hostname
: null;
}
async function resolveDeviceHostname(ip: string) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
lookupService(ip, 0),
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), HOSTNAME_LOOKUP_TIMEOUT_MS);
timer.unref();
}),
]);
return normalizeHostname(result?.hostname, ip);
} catch {
return null;
} finally {
if (timer) clearTimeout(timer);
}
}
function normalizeInventoryDevice(value: unknown): InventoryDevice | null { function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
const device = record(value); const device = record(value);
const mac = normalizeMac(device.mac); const mac = normalizeMac(device.mac);
@@ -267,7 +296,7 @@ function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
alias: typeof device.alias === 'string' ? device.alias : '', alias: typeof device.alias === 'string' ? device.alias : '',
pinned: device.pinned === true, pinned: device.pinned === true,
deprioritized: device.deprioritized === true && device.pinned !== true, deprioritized: device.deprioritized === true && device.pinned !== true,
hostname: typeof device.hostname === 'string' ? device.hostname : null, hostname: normalizeHostname(device.hostname, ip),
manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null, manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null,
mac, mac,
ip, ip,
@@ -643,6 +672,7 @@ export function createDeviceInventoryService({
observePolicy = null, observePolicy = null,
applyPolicies = null, applyPolicies = null,
vendor = () => null, vendor = () => null,
resolveHostname = resolveDeviceHostname,
now = () => new Date(), now = () => new Date(),
}: { }: {
store: InventoryStore; store: InventoryStore;
@@ -652,6 +682,7 @@ export function createDeviceInventoryService({
observePolicy?: (() => unknown | Promise<unknown>) | null; observePolicy?: (() => unknown | Promise<unknown>) | null;
applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise<unknown>) | null; applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise<unknown>) | null;
vendor?: (mac: string) => string | null; vendor?: (mac: string) => string | null;
resolveHostname?: (ip: string) => unknown | Promise<unknown>;
now?: () => Date; now?: () => Date;
}) { }) {
let refreshPromise: Promise<unknown> | null = null; let refreshPromise: Promise<unknown> | null = null;
@@ -660,6 +691,7 @@ export function createDeviceInventoryService({
const trafficCursorByMac = new Map<string, TrafficCursor>(); const trafficCursorByMac = new Map<string, TrafficCursor>();
let globalTrafficHistory: TrafficSample[] = []; let globalTrafficHistory: TrafficSample[] = [];
let globalTrafficCursor: TrafficCursor | null = null; let globalTrafficCursor: TrafficCursor | null = null;
const hostnameAttempts = new Map<string, number>();
let domainTrafficSnapshot: Record<string, unknown> = { let domainTrafficSnapshot: Record<string, unknown> = {
epoch: null, epoch: null,
observedAt: null, observedAt: null,
@@ -995,6 +1027,34 @@ export function createDeviceInventoryService({
identities.add(`${String(observation.ip)}|${observation.interface || ''}`); identities.add(`${String(observation.ip)}|${observation.interface || ''}`);
identitiesByMac.set(mac, identities); identitiesByMac.set(mac, identities);
} }
const previousByMac = new Map(migrateDeviceInventoryState(store.read()).devices
.map((device) => [device.mac, device]));
const hostnameCandidates = new Map<string, DeviceObservation>();
const hostnameKeys = new Set<string>();
const refreshTime = new Date(observedAt).getTime();
for (const observation of observations) {
const key = `${observation.mac}|${observation.ip}`;
hostnameKeys.add(key);
const previous = previousByMac.get(observation.mac);
if (!observation.active || (identitiesByMac.get(observation.mac)?.size || 0) !== 1
|| (previous?.hostname && previous.ip === observation.ip)
|| refreshTime - (hostnameAttempts.get(key) || 0) < HOSTNAME_RETRY_MS) continue;
hostnameCandidates.set(observation.mac, observation);
}
for (const key of hostnameAttempts.keys()) {
if (!hostnameKeys.has(key)) hostnameAttempts.delete(key);
}
// ponytail: resolve eight names per poll; add a queue only if large LANs need faster first-pass naming.
const hostnameByMac = new Map<string, string>();
await Promise.all([...hostnameCandidates].slice(0, HOSTNAME_LOOKUP_LIMIT).map(async ([mac, observation]) => {
hostnameAttempts.set(`${mac}|${observation.ip}`, refreshTime);
try {
const hostname = normalizeHostname(await resolveHostname(observation.ip), observation.ip);
if (hostname) hostnameByMac.set(mac, hostname);
} catch {
// Reverse lookup is best-effort and must not make inventory refresh stale.
}
}));
return serializePolicy(async () => { return serializePolicy(async () => {
if (typeof domainTrafficResult?.transportError === 'string') { if (typeof domainTrafficResult?.transportError === 'string') {
domainTrafficSnapshot = { domainTrafficSnapshot = {
@@ -1021,7 +1081,7 @@ export function createDeviceInventoryService({
alias: previous?.alias || '', alias: previous?.alias || '',
pinned: previous?.pinned === true, pinned: previous?.pinned === true,
deprioritized: previous?.deprioritized === true && previous?.pinned !== true, deprioritized: previous?.deprioritized === true && previous?.pinned !== true,
hostname: previous?.hostname || null, hostname: hostnameByMac.get(mac) || previous?.hostname || null,
manufacturer: previous?.manufacturer || vendor(mac), manufacturer: previous?.manufacturer || vendor(mac),
mac, mac,
ip: replaceAddress ? observation.ip : previous.ip, ip: replaceAddress ? observation.ip : previous.ip,
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.24.1', macClient: '0.24.2',
gatewayClient: '0.25.1', gatewayClient: '0.25.2',
gatewayBackend: '0.25.0', gatewayBackend: '0.25.1',
}); });
export interface ParsedVersion { export interface ParsedVersion {
+32 -20
View File
@@ -35,6 +35,8 @@ interface PinCollapse {
result: 'pending' | 'saved' | 'failed'; result: 'pending' | 'saved' | 'failed';
} }
type DeviceCopyField = 'IP' | 'MAC' | 'Hostname';
function requestMessage(value: unknown) { function requestMessage(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const message: unknown = Reflect.get(value, 'message'); const message: unknown = Reflect.get(value, 'message');
@@ -77,7 +79,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const [alias, setAlias] = useState(''); const [alias, setAlias] = useState('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear'); const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear');
const [copyFeedback, setCopyFeedback] = useState<Record<string, { failed: boolean }>>({}); const [copyFeedback, setCopyFeedback] = useState<Record<string, { field: DeviceCopyField; failed: boolean }>>({});
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null); const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
const [pencilAnimationId, setPencilAnimationId] = useState(''); const [pencilAnimationId, setPencilAnimationId] = useState('');
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({}); const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
@@ -189,15 +191,14 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
setEditingId((current) => current === device.id ? '' : current); setEditingId((current) => current === device.id ? '' : current);
} }
async function copyDeviceIp(device: Device) { async function copyDeviceValue(device: Device, field: DeviceCopyField, value: string) {
if (!device.ip) return;
const activeTimer = copyTimers.current.get(device.id); const activeTimer = copyTimers.current.get(device.id);
if (activeTimer) clearTimeout(activeTimer); if (activeTimer) clearTimeout(activeTimer);
const attempt = {}; const attempt = {};
copyAttempts.current.set(device.id, attempt); copyAttempts.current.set(device.id, attempt);
let feedback: { failed: boolean }; let feedback: { failed: boolean };
try { try {
await copyText(device.ip); await copyText(value);
feedback = { failed: false }; feedback = { failed: false };
} catch { } catch {
feedback = { failed: true }; feedback = { failed: true };
@@ -205,11 +206,11 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
if (copyAttempts.current.get(device.id) !== attempt) return; if (copyAttempts.current.get(device.id) !== attempt) return;
const announcement = { const announcement = {
id: device.id, id: device.id,
message: feedback.failed ? `Не удалось скопировать IP ${device.ip}` : `IP ${device.ip} скопирован`, message: feedback.failed ? `Не удалось скопировать ${field} ${value}` : `${field} ${value} скопирован`,
}; };
const pendingTimer = copyTimers.current.get(device.id); const pendingTimer = copyTimers.current.get(device.id);
if (pendingTimer) clearTimeout(pendingTimer); if (pendingTimer) clearTimeout(pendingTimer);
setCopyFeedback((current) => ({ ...current, [device.id]: feedback })); setCopyFeedback((current) => ({ ...current, [device.id]: { ...feedback, field } }));
setCopyAnnouncement(announcement); setCopyAnnouncement(announcement);
copyTimers.current.set(device.id, setTimeout(() => { copyTimers.current.set(device.id, setTimeout(() => {
setCopyFeedback((current) => { setCopyFeedback((current) => {
@@ -387,7 +388,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const groupStart = group !== previousGroup; const groupStart = group !== previousGroup;
const hasName = Boolean(device.alias || device.hostname); const hasName = Boolean(device.alias || device.hostname);
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство'; const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
const identityTooltipId = `device-identity-${device.id}`;
const editing = editingId === device.id; const editing = editingId === device.id;
const saving = savingId === device.id; const saving = savingId === device.id;
const seen = formatLastSeen(device.lastSeenAt); const seen = formatLastSeen(device.lastSeenAt);
@@ -400,7 +400,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const hasProxyTraffic = proxyTotal > 0n; const hasProxyTraffic = proxyTotal > 0n;
const trafficDelta = trafficDeltas[device.id] || {}; const trafficDelta = trafficDeltas[device.id] || {};
const feedback = copyFeedback[device.id]; const feedback = copyFeedback[device.id];
const copied = Boolean(feedback);
const policyBusy = device.policyStatus === 'applying'; const policyBusy = device.policyStatus === 'applying';
const policyFailed = device.policyStatus === 'failed'; const policyFailed = device.policyStatus === 'failed';
const policyPending = device.policyStatus === 'pending'; const policyPending = device.policyStatus === 'pending';
@@ -453,7 +452,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</span>} </span>}
<div className="client-device-main"> <div className="client-device-main">
<h4 className={`client-device-name-heading client-tooltip-anchor${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}> <h4
className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}
tabIndex={!hasName && !editing ? 0 : undefined}
>
{editing ? ( {editing ? (
<input <input
className="client-device-alias-input" className="client-device-alias-input"
@@ -462,7 +464,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
maxLength={64} maxLength={64}
autoFocus autoFocus
aria-label="Название устройства" aria-label="Название устройства"
aria-describedby={identityTooltipId}
aria-busy={saving} aria-busy={saving}
disabled={saving} disabled={saving}
onChange={(event) => setAlias(event.target.value)} onChange={(event) => setAlias(event.target.value)}
@@ -475,18 +476,29 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
className={`client-device-alias-trigger${device.alias ? ' is-custom-name' : ''}`} className={`client-device-alias-trigger${device.alias ? ' is-custom-name' : ''}`}
type="button" type="button"
aria-label={`Изменить название ${title}`} aria-label={`Изменить название ${title}`}
aria-describedby={identityTooltipId}
onClick={() => startEditing(device)} onClick={() => startEditing(device)}
>{title}</button>} >{title}</button>}
{(hasName || (editing && Boolean(alias))) && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>} {!hasName && !editing && <span className="client-device-fallback-name">{title}</span>}
{device.ip ? <button {!editing && <span className="client-device-identity-details" role="group" aria-label={`Технические данные устройства ${title}`}>
className={`client-device-ip${copied ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`} {device.ip && <button
type="button" className={`client-device-identity-copy${feedback?.field === 'IP' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
aria-label={`Скопировать IP ${device.ip} устройства ${title}`} type="button"
aria-describedby={identityTooltipId} aria-label={`Скопировать IP ${device.ip}`}
onClick={() => copyDeviceIp(device)} onClick={() => copyDeviceValue(device, 'IP', device.ip!)}
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>} ><b>IP</b><span>{device.ip}</span></button>}
<Tooltip id={identityTooltipId}>Hostname: {device.hostname || '—'} · MAC: {device.mac}</Tooltip> <button
className={`client-device-identity-copy${feedback?.field === 'MAC' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать MAC ${device.mac}`}
onClick={() => copyDeviceValue(device, 'MAC', device.mac)}
><b>MAC</b><span>{device.mac}</span></button>
{device.hostname && <button
className={`client-device-identity-copy${feedback?.field === 'Hostname' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать Hostname ${device.hostname}`}
onClick={() => copyDeviceValue(device, 'Hostname', device.hostname!)}
><b>Host</b><span>{device.hostname}</span></button>}
</span>}
</h4> </h4>
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor"> {!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
<button <button
+83 -23
View File
@@ -273,7 +273,8 @@
.client-device-main > h4 { .client-device-main > h4 {
height: 23px; height: 23px;
flex: 1 1 auto; position: relative;
flex: 0 1 auto;
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -287,13 +288,9 @@
white-space: nowrap; white-space: nowrap;
} }
.client-device-main > h4.is-address-only {
flex: 0 1 auto;
}
.client-device-alias-trigger, .client-device-alias-trigger,
.client-device-alias-input, .client-device-alias-input,
.client-device-ip { .client-device-fallback-name {
min-width: 0; min-width: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
@@ -349,46 +346,109 @@
color: var(--client-accent); color: var(--client-accent);
} }
.client-device-name-separator { .client-device-fallback-name {
flex: 0 0 auto; flex: 0 1 auto;
color: var(--client-muted);
font: var(--type-body);
letter-spacing: var(--type-body-tracking);
text-transform: var(--type-body-transform);
} }
.client-device-ip { .client-device-identity-details {
flex: 0 0 auto; position: absolute;
top: 100%;
left: 0;
z-index: 20;
width: max-content;
min-width: 230px;
max-width: min(290px, calc(100vw - 88px));
display: grid;
gap: 2px;
padding: 7px;
border-radius: 8px;
background: color-mix(in oklch, var(--client-bg) 92%, transparent);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
box-shadow: 0 10px 28px oklch(0.08 0.015 145 / 0.18);
opacity: 0;
visibility: hidden;
filter: blur(2px);
pointer-events: none;
transform: translateY(-2px);
transition: opacity 90ms ease, filter 120ms ease, transform 180ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 180ms;
}
.client-device-name-heading:hover > .client-device-identity-details,
.client-device-name-heading:focus-visible > .client-device-identity-details,
.client-device-name-heading:focus-within > .client-device-identity-details {
opacity: 1;
visibility: visible;
filter: blur(0);
pointer-events: auto;
transform: translateY(0);
transition-delay: 20ms, 20ms, 20ms, 0s;
}
.client-device:hover,
.client-device:focus-within {
z-index: 8;
}
.client-device-identity-copy {
min-width: 0;
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 5px 7px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--client-text);
text-align: left;
cursor: copy;
transition: color 180ms ease, background 180ms ease, filter 300ms ease;
}
.client-device-identity-copy b {
color: var(--client-muted);
font: var(--type-label);
letter-spacing: var(--type-label-tracking);
text-transform: var(--type-label-transform);
}
.client-device-identity-copy span {
min-width: 0;
overflow: hidden;
font: var(--type-data); font: var(--type-data);
letter-spacing: var(--type-data-tracking); letter-spacing: var(--type-data-tracking);
text-transform: var(--type-data-transform); text-transform: var(--type-data-transform);
font-variant-numeric: var(--numeric-tabular); font-variant-numeric: var(--numeric-tabular);
cursor: copy; text-overflow: ellipsis;
white-space: nowrap;
} }
.client-device-ip:hover, .client-device-identity-copy:hover,
.client-device-ip:focus-visible { .client-device-identity-copy:focus-visible {
background: color-mix(in oklch, var(--client-accent) 9%, transparent);
color: var(--client-accent); color: var(--client-accent);
} }
.client-device-ip.is-copied { .client-device-identity-copy.is-copied {
--client-device-copy-color: var(--client-accent); --client-device-copy-color: var(--client-accent);
animation: client-device-ip-copy 800ms ease-out; animation: client-device-copy 800ms ease-out;
} }
.client-device-ip.is-copy-error { .client-device-identity-copy.is-copy-error {
--client-device-copy-color: oklch(0.68 0.15 28); --client-device-copy-color: oklch(0.68 0.15 28);
animation: client-device-ip-copy 800ms ease-out; animation: client-device-copy 800ms ease-out;
} }
.client-device-alias-trigger:focus-visible, .client-device-alias-trigger:focus-visible,
.client-device-ip:focus-visible { .client-device-name-heading:focus-visible,
.client-device-identity-copy:focus-visible {
border-radius: 3px; border-radius: 3px;
outline: 2px solid var(--client-accent); outline: 2px solid var(--client-accent);
outline-offset: 2px; outline-offset: 2px;
} }
@keyframes client-device-ip-copy { @keyframes client-device-copy {
35% { 35% {
color: var(--client-device-copy-color); color: var(--client-device-copy-color);
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-device-copy-color) 48%, transparent)); filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-device-copy-color) 48%, transparent));
+2 -1
View File
@@ -67,7 +67,8 @@
.client-device-policy svg, .client-device-policy svg,
.client-device-alias-trigger, .client-device-alias-trigger,
.client-device-alias-input, .client-device-alias-input,
.client-device-ip, .client-device-identity-details,
.client-device-identity-copy,
.client-device-traffic-value > span, .client-device-traffic-value > span,
.client-device-traffic-breakdown, .client-device-traffic-breakdown,
.client-device-traffic-breakdown > span, .client-device-traffic-breakdown > span,
+37
View File
@@ -116,6 +116,43 @@ test('malformed persisted devices and remote observations cannot enter canonical
]); ]);
}); });
test('device inventory resolves hostnames without making reverse lookup a refresh dependency', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-hostname-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const store = createJsonStore({ filePath: path.join(directory, 'devices.json'), defaultValue: {} });
const observedAt = '2026-08-11T12:00:00.000Z';
const mac = '00:11:22:33:44:55';
let ip = '192.168.50.10';
let lookupFails = false;
const lookups = [];
const service = createDeviceInventoryService({
store,
observe: () => ({
observedAt,
error: null,
observations: [{ ip, mac, interface: 'br0', observedAt, active: true }],
}),
resolveHostname: async (address) => {
lookups.push(address);
if (lookupFails) throw new Error('reverse DNS unavailable');
return 'living-room.local.';
},
now: () => new Date(observedAt),
});
let snapshot = await service.refresh();
assert.equal(snapshot.devices[0].hostname, 'living-room.local');
snapshot = await service.refresh();
assert.deepEqual(lookups, ['192.168.50.10']);
ip = '192.168.50.11';
lookupFails = true;
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].ip, ip);
assert.equal(snapshot.devices[0].hostname, 'living-room.local');
assert.deepEqual(lookups, ['192.168.50.10', '192.168.50.11']);
});
test('device inventory discovers, merges, persists metadata and expires devices after 30 days', async (t) => { test('device inventory discovers, merges, persists metadata and expires devices after 30 days', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-devices-')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-devices-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true })); t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
+18 -14
View File
@@ -49,12 +49,13 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(deviceRoute, /pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/); assert.match(deviceRoute, /pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
assert.match(deviceRoute, /DEVICE_POLICY_PATH[\s\S]*deviceInventory\.setPolicy/); assert.match(deviceRoute, /DEVICE_POLICY_PATH[\s\S]*deviceInventory\.setPolicy/);
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/); assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
assert.match(panel, /copyText\(device\.ip\)/); assert.match(panel, /copyText\(value\)/);
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/); assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
assert.match(panel, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/); assert.match(panel, /const title = device\.alias \|\| device\.hostname \|\| device\.ip \|\| 'Неизвестное устройство'/);
assert.match(panel, /client-device-fallback-name[\s\S]*client-device-identity-details[\s\S]*<b>IP<\/b>[\s\S]*<b>MAC<\/b>[\s\S]*<b>Host<\/b>/);
assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/); assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/);
assert.match(panel, /\{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">/); assert.match(panel, /\{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">/);
assert.match(panel, /client-device-name-heading client-tooltip-anchor\$\{hasName \? '' : ' is-address-only'\}\$\{editing \? ' is-editing' : ''\}/); assert.match(panel, /className=\{`client-device-name-heading\$\{hasName \? '' : ' is-address-only'\}\$\{editing \? ' is-editing' : ''\}`\}/);
assert.match(panel, /className="client-device-alias-input"[\s\S]*onBlur=\{\(\) => saveAlias\(device\)\}[\s\S]*event\.key === 'Enter'[\s\S]*event\.currentTarget\.blur\(\)/); assert.match(panel, /className="client-device-alias-input"[\s\S]*onBlur=\{\(\) => saveAlias\(device\)\}[\s\S]*event\.key === 'Enter'[\s\S]*event\.currentTarget\.blur\(\)/);
assert.match(panel, /aliasBaseline\.current = \{ id: device\.id, value \}/); assert.match(panel, /aliasBaseline\.current = \{ id: device\.id, value \}/);
assert.match(panel, /style=\{\{ '--alias-width': `\$\{Math\.max\(1, alias\.length\)\}ch` \} as CSSProperties\}/); assert.match(panel, /style=\{\{ '--alias-width': `\$\{Math\.max\(1, alias\.length\)\}ch` \} as CSSProperties\}/);
@@ -64,7 +65,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/); assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/);
assert.match(panel, /onAnimationEnd=\{\(\) => setPencilAnimationId/); assert.match(panel, /onAnimationEnd=\{\(\) => setPencilAnimationId/);
assert.match(panel, /COPY_FEEDBACK_MS = 800/); assert.match(panel, /COPY_FEEDBACK_MS = 800/);
assert.match(panel, /IP \$\{device\.ip\} скопирован/); assert.match(panel, /`\$\{field\} \$\{value\} скопирован`/);
assert.doesNotMatch(panel, /client-device-name-feedback|client-device-name-primary/); assert.doesNotMatch(panel, /client-device-name-feedback|client-device-name-primary/);
assert.doesNotMatch(panel, /client-device-title/); assert.doesNotMatch(panel, /client-device-title/);
assert.match(panel, /online \? 'В сети' : <TextMorph from="Не в сети" to=\{seen\.relative\} \/>/); assert.match(panel, /online \? 'В сети' : <TextMorph from="Не в сети" to=\{seen\.relative\} \/>/);
@@ -73,9 +74,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.doesNotMatch(panel, /client-text-morph-goo/); assert.doesNotMatch(panel, /client-text-morph-goo/);
assert.doesNotMatch(panel, /client-device-addresses|client-device-manufacturer|client-device-meta/); assert.doesNotMatch(panel, /client-device-addresses|client-device-manufacturer|client-device-meta/);
assert.doesNotMatch(panel, /device\.interface/); assert.doesNotMatch(panel, /device\.interface/);
assert.match(panel, /identityTooltipId = `device-identity-\$\{device\.id\}`/); assert.match(panel, /role="group" aria-label=\{`Технические данные устройства \$\{title\}`\}/);
assert.match(panel, /<Tooltip id=\{identityTooltipId\}>Hostname: \{device\.hostname \|\| '—'\} · MAC: \{device\.mac\}<\/Tooltip>/); assert.match(panel, /onClick=\{\(\) => copyDeviceValue\(device, 'IP', device\.ip!\)\}/);
assert.match(panel, /aria-describedby=\{identityTooltipId\}/); assert.match(panel, /onClick=\{\(\) => copyDeviceValue\(device, 'MAC', device\.mac\)\}/);
assert.match(panel, /\{device\.hostname && <button[\s\S]*copyDeviceValue\(device, 'Hostname', device\.hostname!\)/);
assert.doesNotMatch(panel, /identityTooltipId|Hostname: \{device\.hostname/);
assert.match(panel, /device\.confidence === 'ambiguous'/); assert.match(panel, /device\.confidence === 'ambiguous'/);
assert.match(panel, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/); assert.match(panel, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/);
assert.match(panel, /Трафик временно не обновляется/); assert.match(panel, /Трафик временно не обновляется/);
@@ -162,13 +165,14 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-alias-input \{[\s\S]*width: var\(--alias-width\)[\s\S]*caret-color: var\(--client-accent\)[\s\S]*font: var\(--type-section-title\)[\s\S]*client-device-alias-edit-in 360ms/); assert.match(styles, /\.client-device-alias-input \{[\s\S]*width: var\(--alias-width\)[\s\S]*caret-color: var\(--client-accent\)[\s\S]*font: var\(--type-section-title\)[\s\S]*client-device-alias-edit-in 360ms/);
assert.match(styles, /@keyframes client-device-alias-edit-in[\s\S]*color: var\(--client-accent\)[\s\S]*filter: blur\(2px\)/); assert.match(styles, /@keyframes client-device-alias-edit-in[\s\S]*color: var\(--client-accent\)[\s\S]*filter: blur\(2px\)/);
assert.doesNotMatch(styles, /\.client-device-alias \{/); assert.doesNotMatch(styles, /\.client-device-alias \{/);
assert.match(styles, /\.client-device-ip \{[\s\S]*font: var\(--type-data\)/); assert.match(styles, /\.client-device-identity-copy span \{[\s\S]*font: var\(--type-data\)/);
assert.match(styles, /\.client-device-last-seen \{[\s\S]*position: absolute;[\s\S]*top: 0;[\s\S]*left: 0;[\s\S]*font: var\(--type-micro\)/); assert.match(styles, /\.client-device-last-seen \{[\s\S]*position: absolute;[\s\S]*top: 0;[\s\S]*left: 0;[\s\S]*font: var\(--type-micro\)/);
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font: var\(--type-control\)/); assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font: var\(--type-control\)/);
assert.match(styles, /\.client-device-name-separator \{[\s\S]*color: var\(--client-muted\)/); assert.match(styles, /\.client-device-identity-details \{[\s\S]*top: 100%;[\s\S]*pointer-events: none/);
assert.match(styles, /\.client-device-ip\.is-copied \{[\s\S]*client-device-ip-copy 800ms/); assert.match(styles, /\.client-device-name-heading:hover > \.client-device-identity-details,[\s\S]*pointer-events: auto/);
assert.match(styles, /\.client-device-identity-copy\.is-copied \{[\s\S]*client-device-copy 800ms/);
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding: 11px 0 0/); assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding: 11px 0 0/);
assert.match(styles, /\.client-device-main > h4\.is-address-only \{[\s\S]*flex: 0 1 auto/); assert.match(styles, /\.client-device-main > h4 \{[\s\S]*flex: 0 1 auto/);
assert.match(styles, /\.client-device-last-seen \{[^}]*height: 10px[\s\S]*align-items: center/); assert.match(styles, /\.client-device-last-seen \{[^}]*height: 10px[\s\S]*align-items: center/);
assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/); assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/);
assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/); assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/);
@@ -185,15 +189,15 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-traffic-plot svg \{[\s\S]*height: 100%;[\s\S]*overflow: hidden/); assert.match(styles, /\.client-device-traffic-plot svg \{[\s\S]*height: 100%;[\s\S]*overflow: hidden/);
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/); assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
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-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-ip[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value[\s\S]*transition: none;[\s\S]*animation: none/); assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-identity-details[\s\S]*\.client-device-identity-copy[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value[\s\S]*transition: none;[\s\S]*animation: none/);
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/); assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/); assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
}); });
test('device copy feedback stays keyed per device', () => { test('device copy feedback stays keyed per device', () => {
assert.match(panel, /copyTimers = useRef\(new Map[\s\S]*copyTimers\.current\.get\(device\.id\)[\s\S]*copyTimers\.current\.set\(device\.id/); assert.match(panel, /copyTimers = useRef\(new Map[\s\S]*copyTimers\.current\.get\(device\.id\)[\s\S]*copyTimers\.current\.set\(device\.id/);
assert.match(panel, /copyAttempts = useRef\(new Map[\s\S]*copyAttempts\.current\.set\(device\.id, attempt\)[\s\S]*await copyText\(device\.ip\)[\s\S]*copyAttempts\.current\.get\(device\.id\) !== attempt/); assert.match(panel, /copyAttempts = useRef\(new Map[\s\S]*copyAttempts\.current\.set\(device\.id, attempt\)[\s\S]*await copyText\(value\)[\s\S]*copyAttempts\.current\.get\(device\.id\) !== attempt/);
assert.match(panel, /setCopyFeedback\(\(current\) => \(\{ \.\.\.current, \[device\.id\]: feedback \}\)\)/); assert.match(panel, /setCopyFeedback\(\(current\) => \(\{ \.\.\.current, \[device\.id\]: \{ \.\.\.feedback, field \} \}\)\)/);
assert.match(panel, /const feedback = copyFeedback\[device\.id\]/); assert.match(panel, /const feedback = copyFeedback\[device\.id\]/);
}); });
+17 -17
View File
@@ -37,26 +37,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = { const acceptedLedger = {
counts: { counts: {
cascadeEdges: 749, cascadeEdges: 771,
customProperties: 103, customProperties: 103,
declarations: 3203, declarations: 3247,
important: 0, important: 0,
keyframes: 56, keyframes: 56,
media: 13, media: 13,
rules: 918, rules: 922,
variableReferences: 794, variableReferences: 797,
}, },
hashes: { hashes: {
cascadeEdges: 'a47dbf5212045c865de7a41d420e78df8b992a6ec1b06fb18a8d459cd480f0ae', cascadeEdges: 'e1b9f75210ef7258d9cb5b9e332de529b2ccceaf92b9b86eabcd0b62fefb5c98',
customProperties: 'fc8401b40b8d2cc8a1fc1716360ba8e5baccb694cdabde68427d3c92c04a84d4', customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
declarations: '36e7006c508d95d5567a1aefde2a0786789c268329c12f00bd047e2878dde6db', declarations: '6e30102fba87a4c28ea429159a13239a313ea8d18928423763bb166e0456f8cc',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036', duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
keyframes: 'fb859c4d0d1bfd2f5a18904e30a5f79c6ce6931d74fe88cbd506fb28c334b7ac', keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce',
ruleDeclarationSequences: 'afbf47d7d026eaf47d402e4803364c083a6c1a031165aec7926d9b7afab6d030', ruleDeclarationSequences: '9dacb5d7407717ae041cabf6e8e75af7cfccfb7b5e4c387880468db409e93fbd',
selectors: 'beef8401967d55300d72ea04a2f6d8b95f653bbafc5269d28ed2480c9a34631d', selectors: '8456f64c608e0411bbc56c0c955d25383391f019fa5af0648a72d40564cfee1a',
variableReferences: 'c7dce56ef422f68a5e54ea8b6514ffd3dec3ae948e4bfc77098efb7342e27e2c', variableReferences: '42661286e6150115a367068908f79c79951e24fb3fcf8775c2b083bb2d520e38',
witnesses: '8126287c546fee33b029d793db116533bcb5f542ea03e686174a07299d0ee270', witnesses: 'd992876195d2c68c00d687c5a174e5657e3480ad8496251afe52d20c9571e82f',
}, },
}; };
@@ -187,7 +187,7 @@ test('client typography uses the shared semantic scale outside the token owner',
for (const selector of [ for (const selector of [
'.client-duration', '.client-power-section.is-gateway .client-duration', '.client-proxy-address', '.client-duration', '.client-power-section.is-gateway .client-duration', '.client-proxy-address',
'.client-device-ip', '.client-device-traffic strong', '.client-device-identity-copy span', '.client-device-traffic strong',
'.client-device-traffic-point-tooltip', '.client-device-traffic-point-tooltip time', '.client-device-traffic-point-tooltip', '.client-device-traffic-point-tooltip time',
'.client-device-traffic-point-tooltip strong', '.client-gateway-traffic-total > strong', '.client-device-traffic-point-tooltip strong', '.client-gateway-traffic-total > strong',
'.client-gateway-traffic-speed', '.client-diagnostics-table code', '.client-gateway-traffic-speed', '.client-diagnostics-table code',
@@ -209,7 +209,7 @@ test('client typography uses the shared semantic scale outside the token owner',
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root); const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 730); assert.equal(witnesses.length, 737);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses }); const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts); assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -405,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1); assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-BkhdgoL9.css']); assert.deepEqual(assets, ['index-CGpprjFm.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 123588); assert.equal(built.byteLength, 125113);
assert.equal(sha256(built), '53f8778322796cb24321b5a876862a14fc48d757e68f9ce63bee4f95f5b56fa9'); assert.equal(sha256(built), 'f9ddcf2a43d81f42abe53303087966b5ec4ecfd3c032bd98cf1dc2a805b47364');
}); });
+1 -1
View File
@@ -32,7 +32,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
.map(({ source }) => source) .map(({ source }) => source)
.join('\n'); .join('\n');
assert.equal((production.match(/<Tooltip\b/g) || []).length, 10); assert.equal((production.match(/<Tooltip\b/g) || []).length, 9);
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2); assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
assert.equal((production.match(/<RailAction\b/g) || []).length, 5); assert.equal((production.match(/<RailAction\b/g) || []).length, 5);
assert.equal((production.match(/<Drawer\b/g) || []).length, 5); assert.equal((production.match(/<Drawer\b/g) || []).length, 5);