Show device identity details and resolve hostnames
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { lookupService } from 'node:dns/promises';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import { HarborError } from '../../shared/errors.js';
|
||||
@@ -163,6 +164,9 @@ const ONLINE_MS = 2 * 60 * 1000;
|
||||
const RECENT_MS = 24 * 60 * 60 * 1000;
|
||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
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 DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
||||
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))
|
||||
);
|
||||
|
||||
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 {
|
||||
const device = record(value);
|
||||
const mac = normalizeMac(device.mac);
|
||||
@@ -267,7 +296,7 @@ function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
|
||||
alias: typeof device.alias === 'string' ? device.alias : '',
|
||||
pinned: 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,
|
||||
mac,
|
||||
ip,
|
||||
@@ -643,6 +672,7 @@ export function createDeviceInventoryService({
|
||||
observePolicy = null,
|
||||
applyPolicies = null,
|
||||
vendor = () => null,
|
||||
resolveHostname = resolveDeviceHostname,
|
||||
now = () => new Date(),
|
||||
}: {
|
||||
store: InventoryStore;
|
||||
@@ -652,6 +682,7 @@ export function createDeviceInventoryService({
|
||||
observePolicy?: (() => unknown | Promise<unknown>) | null;
|
||||
applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise<unknown>) | null;
|
||||
vendor?: (mac: string) => string | null;
|
||||
resolveHostname?: (ip: string) => unknown | Promise<unknown>;
|
||||
now?: () => Date;
|
||||
}) {
|
||||
let refreshPromise: Promise<unknown> | null = null;
|
||||
@@ -660,6 +691,7 @@ export function createDeviceInventoryService({
|
||||
const trafficCursorByMac = new Map<string, TrafficCursor>();
|
||||
let globalTrafficHistory: TrafficSample[] = [];
|
||||
let globalTrafficCursor: TrafficCursor | null = null;
|
||||
const hostnameAttempts = new Map<string, number>();
|
||||
let domainTrafficSnapshot: Record<string, unknown> = {
|
||||
epoch: null,
|
||||
observedAt: null,
|
||||
@@ -995,6 +1027,34 @@ export function createDeviceInventoryService({
|
||||
identities.add(`${String(observation.ip)}|${observation.interface || ''}`);
|
||||
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 () => {
|
||||
if (typeof domainTrafficResult?.transportError === 'string') {
|
||||
domainTrafficSnapshot = {
|
||||
@@ -1021,7 +1081,7 @@ export function createDeviceInventoryService({
|
||||
alias: previous?.alias || '',
|
||||
pinned: 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),
|
||||
mac,
|
||||
ip: replaceAddress ? observation.ip : previous.ip,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.24.1',
|
||||
gatewayClient: '0.25.1',
|
||||
gatewayBackend: '0.25.0',
|
||||
macClient: '0.24.2',
|
||||
gatewayClient: '0.25.2',
|
||||
gatewayBackend: '0.25.1',
|
||||
});
|
||||
|
||||
export interface ParsedVersion {
|
||||
|
||||
@@ -35,6 +35,8 @@ interface PinCollapse {
|
||||
result: 'pending' | 'saved' | 'failed';
|
||||
}
|
||||
|
||||
type DeviceCopyField = 'IP' | 'MAC' | 'Hostname';
|
||||
|
||||
function requestMessage(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const message: unknown = Reflect.get(value, 'message');
|
||||
@@ -77,7 +79,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
const [alias, setAlias] = useState('');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
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 [pencilAnimationId, setPencilAnimationId] = useState('');
|
||||
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
|
||||
@@ -189,15 +191,14 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
setEditingId((current) => current === device.id ? '' : current);
|
||||
}
|
||||
|
||||
async function copyDeviceIp(device: Device) {
|
||||
if (!device.ip) return;
|
||||
async function copyDeviceValue(device: Device, field: DeviceCopyField, value: string) {
|
||||
const activeTimer = copyTimers.current.get(device.id);
|
||||
if (activeTimer) clearTimeout(activeTimer);
|
||||
const attempt = {};
|
||||
copyAttempts.current.set(device.id, attempt);
|
||||
let feedback: { failed: boolean };
|
||||
try {
|
||||
await copyText(device.ip);
|
||||
await copyText(value);
|
||||
feedback = { failed: false };
|
||||
} catch {
|
||||
feedback = { failed: true };
|
||||
@@ -205,11 +206,11 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
if (copyAttempts.current.get(device.id) !== attempt) return;
|
||||
const announcement = {
|
||||
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);
|
||||
if (pendingTimer) clearTimeout(pendingTimer);
|
||||
setCopyFeedback((current) => ({ ...current, [device.id]: feedback }));
|
||||
setCopyFeedback((current) => ({ ...current, [device.id]: { ...feedback, field } }));
|
||||
setCopyAnnouncement(announcement);
|
||||
copyTimers.current.set(device.id, setTimeout(() => {
|
||||
setCopyFeedback((current) => {
|
||||
@@ -387,7 +388,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
const groupStart = group !== previousGroup;
|
||||
const hasName = Boolean(device.alias || device.hostname);
|
||||
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
|
||||
const identityTooltipId = `device-identity-${device.id}`;
|
||||
const editing = editingId === device.id;
|
||||
const saving = savingId === device.id;
|
||||
const seen = formatLastSeen(device.lastSeenAt);
|
||||
@@ -400,7 +400,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
const hasProxyTraffic = proxyTotal > 0n;
|
||||
const trafficDelta = trafficDeltas[device.id] || {};
|
||||
const feedback = copyFeedback[device.id];
|
||||
const copied = Boolean(feedback);
|
||||
const policyBusy = device.policyStatus === 'applying';
|
||||
const policyFailed = device.policyStatus === 'failed';
|
||||
const policyPending = device.policyStatus === 'pending';
|
||||
@@ -453,7 +452,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
</span>}
|
||||
|
||||
<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 ? (
|
||||
<input
|
||||
className="client-device-alias-input"
|
||||
@@ -462,7 +464,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
maxLength={64}
|
||||
autoFocus
|
||||
aria-label="Название устройства"
|
||||
aria-describedby={identityTooltipId}
|
||||
aria-busy={saving}
|
||||
disabled={saving}
|
||||
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' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Изменить название ${title}`}
|
||||
aria-describedby={identityTooltipId}
|
||||
onClick={() => startEditing(device)}
|
||||
>{title}</button>}
|
||||
{(hasName || (editing && Boolean(alias))) && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
|
||||
{device.ip ? <button
|
||||
className={`client-device-ip${copied ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
|
||||
aria-describedby={identityTooltipId}
|
||||
onClick={() => copyDeviceIp(device)}
|
||||
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>}
|
||||
<Tooltip id={identityTooltipId}>Hostname: {device.hostname || '—'} · MAC: {device.mac}</Tooltip>
|
||||
{!hasName && !editing && <span className="client-device-fallback-name">{title}</span>}
|
||||
{!editing && <span className="client-device-identity-details" role="group" aria-label={`Технические данные устройства ${title}`}>
|
||||
{device.ip && <button
|
||||
className={`client-device-identity-copy${feedback?.field === 'IP' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Скопировать IP ${device.ip}`}
|
||||
onClick={() => copyDeviceValue(device, 'IP', device.ip!)}
|
||||
><b>IP</b><span>{device.ip}</span></button>}
|
||||
<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>
|
||||
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
|
||||
<button
|
||||
|
||||
@@ -273,7 +273,8 @@
|
||||
|
||||
.client-device-main > h4 {
|
||||
height: 23px;
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -287,13 +288,9 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-device-main > h4.is-address-only {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.client-device-alias-trigger,
|
||||
.client-device-alias-input,
|
||||
.client-device-ip {
|
||||
.client-device-fallback-name {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@@ -349,46 +346,109 @@
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-device-name-separator {
|
||||
flex: 0 0 auto;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-body);
|
||||
letter-spacing: var(--type-body-tracking);
|
||||
text-transform: var(--type-body-transform);
|
||||
.client-device-fallback-name {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.client-device-ip {
|
||||
flex: 0 0 auto;
|
||||
.client-device-identity-details {
|
||||
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);
|
||||
letter-spacing: var(--type-data-tracking);
|
||||
text-transform: var(--type-data-transform);
|
||||
font-variant-numeric: var(--numeric-tabular);
|
||||
cursor: copy;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-device-ip:hover,
|
||||
.client-device-ip:focus-visible {
|
||||
.client-device-identity-copy:hover,
|
||||
.client-device-identity-copy:focus-visible {
|
||||
background: color-mix(in oklch, var(--client-accent) 9%, transparent);
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-device-ip.is-copied {
|
||||
.client-device-identity-copy.is-copied {
|
||||
--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);
|
||||
animation: client-device-ip-copy 800ms ease-out;
|
||||
animation: client-device-copy 800ms ease-out;
|
||||
}
|
||||
|
||||
.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;
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@keyframes client-device-ip-copy {
|
||||
@keyframes client-device-copy {
|
||||
35% {
|
||||
color: var(--client-device-copy-color);
|
||||
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-device-copy-color) 48%, transparent));
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
.client-device-policy svg,
|
||||
.client-device-alias-trigger,
|
||||
.client-device-alias-input,
|
||||
.client-device-ip,
|
||||
.client-device-identity-details,
|
||||
.client-device-identity-copy,
|
||||
.client-device-traffic-value > span,
|
||||
.client-device-traffic-breakdown,
|
||||
.client-device-traffic-breakdown > span,
|
||||
|
||||
Reference in New Issue
Block a user