Show device identity details and resolve hostnames
This commit is contained in:
@@ -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 сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
className={`client-device-identity-copy${feedback?.field === 'IP' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
|
aria-label={`Скопировать IP ${device.ip}`}
|
||||||
aria-describedby={identityTooltipId}
|
onClick={() => copyDeviceValue(device, 'IP', device.ip!)}
|
||||||
onClick={() => copyDeviceIp(device)}
|
><b>IP</b><span>{device.ip}</span></button>}
|
||||||
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>}
|
<button
|
||||||
<Tooltip id={identityTooltipId}>Hostname: {device.hostname || '—'} · MAC: {device.mac}</Tooltip>
|
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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user