Default new devices to Direct routing
Build and Deploy Gateway / build-and-push (push) Successful in 24s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-31 01:07:59 +03:00
parent f977874da6
commit 7e15cc199f
11 changed files with 276 additions and 22 deletions
+2
View File
@@ -89,6 +89,8 @@ http://АДРЕС-GATEWAY:3456
Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters. Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters.
Устройство, впервые замеченное после обновления Gateway, по умолчанию идёт `Напрямую` и первые семь дней отмечается `NEW`; исчезновение метки маршрут не меняет. Уже известные при обновлении устройства сохраняют текущий VPN, даже если метка ещё видна по их `firstSeenAt`. VPN разрешается существующей последней иконкой маршрута. Если новый device пока распознан неоднозначно, Harbor сохраняет Direct-намерение, временно оставляет фактический VPN и применяет Direct после однозначного наблюдения.
У однозначно распознанного устройства маршрут можно переключить последней иконкой между `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 сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
Список приблизительный: имя и пользовательские настройки привязаны к MAC и сохраняются при обычной смене IP, но новый private/randomized MAC считается новым устройством — переносить имя по одному только DHCP-адресу небезопасно. Запись автоматически удаляется после 30 дней без подтверждённого контакта независимо от имени, закрепления или фонового положения; временная ошибка чтения сети этот срок не продвигает. Один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится. Список приблизительный: имя и пользовательские настройки привязаны к MAC и сохраняются при обычной смене IP, но новый private/randomized MAC считается новым устройством — переносить имя по одному только DHCP-адресу небезопасно. Запись автоматически удаляется после 30 дней без подтверждённого контакта независимо от имени, закрепления или фонового положения; временная ошибка чтения сети этот срок не продвигает. Один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
@@ -1290,10 +1290,23 @@ export function createDeviceInventoryService({
const nextState = store.update((stored) => { const nextState = store.update((stored) => {
const state = migrateDeviceInventoryState(stored); const state = migrateDeviceInventoryState(stored);
const byMac = new Map(state.devices.map((device) => [device.mac, device])); const byMac = new Map(state.devices.map((device) => [device.mac, device]));
let policyByMac = state.policy.byMac;
for (const observation of observations) { for (const observation of observations) {
const mac = normalizeMac(observation.mac); const mac = normalizeMac(observation.mac);
if (!mac) continue; if (!mac) continue;
const previous = byMac.get(mac); const previous = byMac.get(mac);
if (!previous) {
const retained = policyByMac[mac];
if (policyByMac === state.policy.byMac) policyByMac = { ...policyByMac };
policyByMac[mac] = {
desired: 'direct',
applied: retained?.applied || 'vpn',
status: 'applying',
appliedAt: retained?.appliedAt || null,
error: null,
operationId: crypto.randomUUID(),
};
}
const observationTime = typeof observation.observedAt === 'string' ? observation.observedAt : observedAt; const observationTime = typeof observation.observedAt === 'string' ? observation.observedAt : observedAt;
const lastSeenAt = observation.active || !previous const lastSeenAt = observation.active || !previous
? observationTime ? observationTime
@@ -1532,6 +1545,9 @@ export function createDeviceInventoryService({
revision: state.revision + 1, revision: state.revision + 1,
lastObservedAt: observedAt, lastObservedAt: observedAt,
lastError: typeof result.error === 'string' ? result.error : null, lastError: typeof result.error === 'string' ? result.error : null,
policy: policyByMac === state.policy.byMac
? state.policy
: { ...state.policy, lastError: null, byMac: policyByMac },
traffic, traffic,
devices, devices,
}; };
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.31.2', macClient: '0.32.0',
gatewayClient: '0.32.2', gatewayClient: '0.33.0',
gatewayBackend: '0.32.2', gatewayBackend: '0.33.0',
}); });
export interface ParsedVersion { export interface ParsedVersion {
+9 -1
View File
@@ -14,6 +14,7 @@ import {
byteString, byteString,
formatByteString, formatByteString,
formatLastSeen, formatLastSeen,
isNewDevice,
positiveByteDelta, positiveByteDelta,
stabilizeDevicesByTraffic, stabilizeDevicesByTraffic,
} from '../../utils/format.js'; } from '../../utils/format.js';
@@ -444,6 +445,7 @@ 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 newDevice = isNewDevice(device.firstSeenAt);
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);
@@ -493,7 +495,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
: device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct' : device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct'
? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства' ? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства'
: displayPolicy === 'direct' : displayPolicy === 'direct'
? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway' ? newDevice
? 'Новое устройство идёт напрямую. Нажмите, чтобы разрешить VPN через правила Gateway'
: 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway'
: 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую'; : 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую';
return <article return <article
key={device.id} key={device.id}
@@ -553,6 +557,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
onClick={() => startEditing(device)} onClick={() => startEditing(device)}
>{title}</button>} >{title}</button>}
{!hasName && !editing && <span className="client-device-fallback-name">{title}</span>} {!hasName && !editing && <span className="client-device-fallback-name">{title}</span>}
{!editing && newDevice && <span className="client-device-new-badge">
<span aria-hidden="true">NEW</span>
<span className="client-device-new-a11y">Новое устройство</span>
</span>}
{!editing && <span className="client-device-identity-details" role="group" aria-label={`Технические данные устройства ${title}`}> {!editing && <span className="client-device-identity-details" role="group" aria-label={`Технические данные устройства ${title}`}>
{device.ip && <button {device.ip && <button
className={`client-device-identity-copy${feedback?.field === 'IP' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`} className={`client-device-identity-copy${feedback?.field === 'IP' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
@@ -32,6 +32,7 @@ export interface Device extends Record<string, unknown> {
hostname: string | null; hostname: string | null;
mac: string; mac: string;
ip: string | null; ip: string | null;
firstSeenAt: string;
lastSeenAt: string | null; lastSeenAt: string | null;
status: DeviceStatus; status: DeviceStatus;
pinned: boolean; pinned: boolean;
@@ -151,6 +152,7 @@ function validDevice(value: unknown): value is Device {
&& typeof value.mac === 'string' && typeof value.mac === 'string'
&& /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(value.mac) && /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(value.mac)
&& nullableString(value.ip) && nullableString(value.ip)
&& timestamp(value.firstSeenAt)
&& nullableTimestamp(value.lastSeenAt) && nullableTimestamp(value.lastSeenAt)
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline') && (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
&& typeof value.pinned === 'boolean' && typeof value.pinned === 'boolean'
+25
View File
@@ -421,6 +421,31 @@
flex: 0 1 auto; flex: 0 1 auto;
} }
.client-device-new-badge {
height: 14px;
box-sizing: border-box;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
padding: 0 4px;
border: 1px solid color-mix(in oklch, var(--client-accent) 48%, transparent);
border-radius: 999px;
background: var(--client-accent-soft);
color: var(--client-accent);
font: var(--type-micro);
letter-spacing: var(--type-micro-tracking);
text-transform: var(--type-micro-transform);
}
.client-device-new-a11y {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.client-device-identity-details { .client-device-identity-details {
position: absolute; position: absolute;
top: 100%; top: 100%;
+8
View File
@@ -13,6 +13,7 @@ export function formatBytes(value: number) {
} }
const BYTE_STRING_PATTERN = /^\d+$/; const BYTE_STRING_PATTERN = /^\d+$/;
const NEW_DEVICE_MS = 7 * 24 * 60 * 60 * 1000;
export function byteString(value: unknown) { export function byteString(value: unknown) {
const normalized = String(value ?? '0'); const normalized = String(value ?? '0');
@@ -126,6 +127,13 @@ export function formatTime(iso: string | null | undefined) {
return new Date(iso).toLocaleTimeString("ru-RU", { hour12: false }); return new Date(iso).toLocaleTimeString("ru-RU", { hour12: false });
} }
export function isNewDevice(iso: string | null | undefined, now: Date | string | number = Date.now()) {
const firstSeen = Date.parse(iso || '');
const current = new Date(now).getTime();
const age = current - firstSeen;
return Number.isFinite(firstSeen) && Number.isFinite(current) && age >= 0 && age < NEW_DEVICE_MS;
}
export function formatLastSeen(iso: string | null | undefined, now: Date | string | number = new Date()) { export function formatLastSeen(iso: string | null | undefined, now: Date | string | number = new Date()) {
const date = new Date(iso || ''); const date = new Date(iso || '');
if (Number.isNaN(date.getTime())) return { label: "Нет данных", relative: "Нет данных", tooltip: "Нет данных" }; if (Number.isNaN(date.getTime())) return { label: "Нет данных", relative: "Нет данных", tooltip: "Нет данных" };
+181 -6
View File
@@ -809,6 +809,25 @@ test('device policy is independent from pinning, persists, and keeps the last ap
}); });
const observedAt = '2026-08-07T12:00:00.000Z'; const observedAt = '2026-08-07T12:00:00.000Z';
const mac = '00:11:22:33:44:55'; const mac = '00:11:22:33:44:55';
store.update((state) => ({
...state,
revision: state.revision + 1,
devices: [{
id: deviceId(mac),
alias: '',
pinned: false,
deprioritized: false,
hostname: null,
manufacturer: null,
mac,
ip: '192.168.50.7',
interface: 'eth0',
firstSeenAt: observedAt,
lastSeenAt: observedAt,
source: 'neighbor',
confidence: 'high',
}],
}));
let observations = [{ let observations = [{
ip: '192.168.50.7', ip: '192.168.50.7',
mac, mac,
@@ -900,14 +919,9 @@ test('device policy is independent from pinning, persists, and keeps the last ap
observedAt, observedAt,
active: true, active: true,
}); });
failApply = true;
snapshot = await service.refresh(); snapshot = await service.refresh();
const secondId = snapshot.devices.find((device) => device.mac === secondMac).id; const secondId = snapshot.devices.find((device) => device.mac === secondMac).id;
failApply = true;
await assert.rejects(
service.setPolicy(secondId, 'direct', snapshot.revision),
(error) => error.code === 'DEVICE_POLICY_APPLY_FAILED',
);
snapshot = service.snapshot();
const byId = new Map(snapshot.devices.map((device) => [device.id, device])); const byId = new Map(snapshot.devices.map((device) => [device.id, device]));
assert.equal(byId.get(id).desiredPolicy, 'direct'); assert.equal(byId.get(id).desiredPolicy, 'direct');
assert.equal(byId.get(id).appliedPolicy, 'direct'); assert.equal(byId.get(id).appliedPolicy, 'direct');
@@ -931,6 +945,167 @@ test('device policy is independent from pinning, persists, and keeps the last ap
); );
}); });
test('new devices default to Direct while known and ambiguous devices keep truthful applied routes', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-new-device-policy-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const observedAt = '2026-08-31T12:00:00.000Z';
const knownMac = '00:11:22:33:44:10';
const newMac = '00:11:22:33:44:20';
const ambiguousMac = '00:11:22:33:44:30';
const retainedMac = '00:11:22:33:44:40';
const store = createJsonStore({
filePath: path.join(directory, 'devices.json'),
defaultValue: {},
migrate: migrateDeviceInventoryState,
initializeMissing: true,
});
store.update((state) => ({
...state,
revision: state.revision + 1,
devices: [{
id: deviceId(knownMac),
alias: '',
pinned: false,
deprioritized: false,
hostname: null,
manufacturer: null,
mac: knownMac,
ip: '192.168.50.10',
interface: 'eth0',
firstSeenAt: '2026-08-30T12:00:00.000Z',
lastSeenAt: '2026-08-30T12:00:00.000Z',
source: 'neighbor',
confidence: 'high',
}],
policy: {
...state.policy,
byMac: {
[retainedMac]: {
desired: 'direct',
applied: 'direct',
status: 'failed',
appliedAt: '2026-08-29T12:00:00.000Z',
error: 'cleanup failed',
operationId: null,
},
},
},
}));
let observations = [
{ ip: '192.168.50.10', mac: knownMac, interface: 'eth0', observedAt, active: true },
{ ip: '192.168.50.20', mac: newMac, interface: 'eth0', observedAt, active: true },
{ ip: '192.168.50.30', mac: ambiguousMac, interface: 'eth0', observedAt, active: true },
{ ip: '192.168.50.31', mac: ambiguousMac, interface: 'eth1', observedAt, active: true },
{ ip: '192.168.50.40', mac: retainedMac, interface: 'eth0', observedAt, active: true },
];
let activeDevices = [];
let generation = 0;
let failApply = false;
const appliedSets = [];
const policySnapshot = () => ({
epoch: 'policy-epoch',
generation: `policy-rules-${generation}`,
fingerprint: fingerprintDirectDevices(activeDevices),
observedAt,
appliedIds: activeDevices.map(({ id }) => id),
});
const createService = () => createDeviceInventoryService({
store,
observe: () => ({ observedAt, observations, error: null }),
observePolicy: policySnapshot,
applyPolicies: async (devices) => {
appliedSets.push(structuredClone(devices));
if (failApply) throw new Error('iptables unavailable');
activeDevices = structuredClone(devices);
generation += 1;
return policySnapshot();
},
});
let service = createService();
let snapshot = await service.refresh();
const byMac = new Map(snapshot.devices.map((device) => [device.mac, device]));
assert.equal(byMac.get(knownMac).desiredPolicy, 'vpn');
assert.equal(byMac.get(knownMac).appliedPolicy, 'vpn');
assert.equal(byMac.get(newMac).desiredPolicy, 'direct');
assert.equal(byMac.get(newMac).appliedPolicy, 'direct');
assert.equal(byMac.get(ambiguousMac).desiredPolicy, 'direct');
assert.equal(byMac.get(ambiguousMac).appliedPolicy, 'vpn');
assert.equal(byMac.get(ambiguousMac).policyStatus, 'pending');
assert.equal(byMac.get(retainedMac).appliedPolicy, 'direct');
assert.deepEqual(appliedSets.at(-1).map(({ mac }) => mac).sort(), [newMac, retainedMac].sort());
observations = observations.filter(({ mac, interface: deviceInterface }) => (
mac !== ambiguousMac || deviceInterface === 'eth0'
));
snapshot = await service.refresh();
assert.equal(snapshot.devices.find(({ mac }) => mac === ambiguousMac).appliedPolicy, 'direct');
const newId = snapshot.devices.find(({ mac }) => mac === newMac).id;
snapshot = await service.setPolicy(newId, 'vpn', snapshot.revision);
assert.equal(snapshot.devices.find(({ mac }) => mac === newMac).appliedPolicy, 'vpn');
service = createService();
snapshot = await service.refresh();
assert.equal(snapshot.devices.find(({ mac }) => mac === newMac).appliedPolicy, 'vpn');
const reappearedMac = '00:11:22:33:44:50';
observations.push({ ip: '192.168.50.50', mac: reappearedMac, interface: 'eth0', observedAt, active: true });
failApply = true;
snapshot = await service.refresh();
const failed = snapshot.devices.find(({ mac }) => mac === reappearedMac);
assert.equal(failed.desiredPolicy, 'direct');
assert.equal(failed.appliedPolicy, 'vpn');
assert.equal(failed.policyStatus, 'failed');
failApply = false;
service = createService();
snapshot = await service.refresh();
assert.equal(snapshot.devices.find(({ mac }) => mac === reappearedMac).appliedPolicy, 'direct');
});
test('reappearing device preserves retained applied Direct when cleanup acknowledgement failed', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-retained-device-policy-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const observedAt = '2026-08-31T12:00:00.000Z';
const mac = '00:11:22:33:44:60';
const store = createJsonStore({
filePath: path.join(directory, 'devices.json'),
defaultValue: {},
migrate: migrateDeviceInventoryState,
initializeMissing: true,
});
store.update((state) => ({
...state,
policy: {
...state.policy,
byMac: {
[mac]: {
desired: 'direct',
applied: 'direct',
status: 'failed',
appliedAt: '2026-08-30T12:00:00.000Z',
error: 'cleanup failed',
operationId: null,
},
},
},
}));
const service = createDeviceInventoryService({
store,
observe: () => ({
observedAt,
error: null,
observations: [{ ip: '192.168.50.60', mac, interface: 'eth0', observedAt, active: true }],
}),
applyPolicies: async () => { throw new Error('iptables unavailable'); },
});
const snapshot = await service.refresh();
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
assert.equal(snapshot.devices[0].policyStatus, 'failed');
assert.equal(store.read().policy.byMac[mac].appliedAt, '2026-08-30T12:00:00.000Z');
});
test('device inventory v1 migration creates a versioned backup', (t) => { test('device inventory v1 migration creates a versioned backup', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-migration-')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-migration-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true })); t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
import { import {
formatByteString, formatByteString,
formatLastSeen, formatLastSeen,
isNewDevice,
positiveByteDelta, positiveByteDelta,
sortDevicesByTraffic, sortDevicesByTraffic,
stabilizeDevicesByTraffic, stabilizeDevicesByTraffic,
@@ -54,6 +55,9 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /copyText\(value\)/); 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, /const title = device\.alias \|\| device\.hostname \|\| device\.ip \|\| 'Неизвестное устройство'/); assert.match(panel, /const title = device\.alias \|\| device\.hostname \|\| device\.ip \|\| 'Неизвестное устройство'/);
assert.match(panel, /const newDevice = isNewDevice\(device\.firstSeenAt\)/);
assert.match(panel, /client-device-new-badge[\s\S]*aria-hidden="true"[\s\S]*>NEW<[\s\S]*client-device-new-a11y[\s\S]*Новое устройство/);
assert.match(panel, /displayPolicy === 'direct'[\s\S]*newDevice[\s\S]*Новое устройство идёт напрямую\. Нажмите, чтобы разрешить VPN через правила Gateway/);
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, /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">/);
@@ -175,6 +179,9 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/); assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: var\(--type-item-title\)/); assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: var\(--type-item-title\)/);
assert.match(styles, /\.client-device-alias-trigger\.is-custom-name \{[\s\S]*font: var\(--type-section-title\)/); assert.match(styles, /\.client-device-alias-trigger\.is-custom-name \{[\s\S]*font: var\(--type-section-title\)/);
assert.match(styles, /\.client-device-new-badge \{[\s\S]*height: 14px;[\s\S]*flex: 0 0 auto;[\s\S]*padding: 0 4px;[\s\S]*border: 1px solid color-mix\(in oklch, var\(--client-accent\)[\s\S]*border-radius: 999px;[\s\S]*background: var\(--client-accent-soft\);[\s\S]*font: var\(--type-micro\)/);
assert.match(styles, /\.client-device-new-a11y \{[\s\S]*position: absolute;[\s\S]*width: 1px;[\s\S]*clip-path: inset\(50%\)/);
assert.doesNotMatch(styles, /client-device-new-badge[^{]*\{[^}]*(?:transition|animation):/);
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 \{/);
@@ -270,6 +277,14 @@ test('device last-seen copy is compact with precise accessible and relative form
); );
}); });
test('new-device marker uses an exact seven-day window', () => {
const now = Date.parse('2026-08-31T12:00:00.000Z');
assert.equal(isNewDevice(new Date(now - (7 * 24 * 60 * 60 * 1000 - 1_000)).toISOString(), now), true);
assert.equal(isNewDevice(new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(), now), false);
assert.equal(isNewDevice(new Date(now + 1_000).toISOString(), now), false);
assert.equal(isNewDevice('not-a-date', now), false);
});
test('device traffic formatting and sorting preserve uint64 precision and canonical ties', () => { test('device traffic formatting and sorting preserve uint64 precision and canonical ties', () => {
assert.equal(formatByteString('9007199254740993'), '8,0 ПБ'); assert.equal(formatByteString('9007199254740993'), '8,0 ПБ');
assert.equal(formatByteString('1536'), '1,5 КБ'); assert.equal(formatByteString('1536'), '1,5 КБ');
@@ -51,6 +51,7 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
hostname: null, hostname: null,
mac: '00:11:22:33:44:55', mac: '00:11:22:33:44:55',
ip: null, ip: null,
firstSeenAt: observedAt,
lastSeenAt: null, lastSeenAt: null,
status: 'online', status: 'online',
pinned: true, pinned: true,
@@ -126,6 +127,8 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
{ ...valid, devices: [{ ...valid.devices[0], desiredPolicy: 'automatic' }] }, { ...valid, devices: [{ ...valid.devices[0], desiredPolicy: 'automatic' }] },
{ ...valid, devices: [{ ...valid.devices[0], policyStatus: 'queued' }] }, { ...valid, devices: [{ ...valid.devices[0], policyStatus: 'queued' }] },
{ ...valid, devices: [{ ...valid.devices[0], confidence: 'unknown' }] }, { ...valid, devices: [{ ...valid.devices[0], confidence: 'unknown' }] },
{ ...valid, devices: [{ ...valid.devices[0], firstSeenAt: undefined }] },
{ ...valid, devices: [{ ...valid.devices[0], firstSeenAt: 'not-a-date' }] },
{ ...valid, devices: [{ ...valid.devices[0], lastSeenAt: 'not-a-date' }] }, { ...valid, devices: [{ ...valid.devices[0], lastSeenAt: 'not-a-date' }] },
{ ...valid, devices: [{ ...valid.devices[0], trafficHistory: [{ observedAt, gatewayBytes: '1' }] }] }, { ...valid, devices: [{ ...valid.devices[0], trafficHistory: [{ observedAt, gatewayBytes: '1' }] }] },
{ ...valid, devices: [{ ...valid.devices[0], outboundTraffic: { observedAt, vpnBytes: '1' } }] }, { ...valid, devices: [{ ...valid.devices[0], outboundTraffic: { observedAt, vpnBytes: '1' } }] },
+12 -12
View File
@@ -41,24 +41,24 @@ const acceptedLedger = {
counts: { counts: {
cascadeEdges: 1072, cascadeEdges: 1072,
customProperties: 111, customProperties: 111,
declarations: 4151, declarations: 4170,
important: 0, important: 0,
keyframes: 50, keyframes: 50,
media: 17, media: 17,
rules: 1124, rules: 1126,
variableReferences: 1036, variableReferences: 1042,
}, },
hashes: { hashes: {
cascadeEdges: 'd60343b571f5a9bcf809609b4731500587da3bdd9ee9f40e33938ecbd8aec6ce', cascadeEdges: 'd60343b571f5a9bcf809609b4731500587da3bdd9ee9f40e33938ecbd8aec6ce',
customProperties: '42f9fa9f2caaab6e5d90ae7d224563355822fa270083ea3496cfe2caaaea50bf', customProperties: '42f9fa9f2caaab6e5d90ae7d224563355822fa270083ea3496cfe2caaaea50bf',
declarations: 'ee6b58dc6ef2edd16b8edf1762bb5c19aaf12d908958110c691f0a42f8e9067c', declarations: '15fc1474c16562886582469a7d240dde36882e682b266374fdca7f47f90c2b13',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848', duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
keyframes: 'af4b9ae18d070fd2462a9b050293f3821bf5017c6e7cc53bbe18dc826f0fe3b9', keyframes: 'af4b9ae18d070fd2462a9b050293f3821bf5017c6e7cc53bbe18dc826f0fe3b9',
ruleDeclarationSequences: 'e6a9c7019ab48d5bb15ac8ff0ffa51f8df7f1143ee9f8f562bdc78237a64b4b1', ruleDeclarationSequences: '16ccf3b3b8b6f76e784c69a3f06a3abb6e3b57d5d0710dff4bdad23f87bad6c3',
selectors: '4788b9fbc3aef9455ea9bf071f111dde318fbfe1fa96fe78127cc436131c4506', selectors: 'b32eeeff7896fedd9057a953b61a8b149116a5c5ce110fb84bf06e24d22bf8ee',
variableReferences: '04c19450f58f80dbe90e6df8988b8fa97f8c963d579d4f8acdb5bccd9d4020e9', variableReferences: 'c9f64054cfe384f90e5c47844ffbbc7fba60f5045efac5d272f45f1f451e950b',
witnesses: '7e42ab6ad3901fd68c9328e612eb413308ad8716031cbedd3134bf85224f6b9a', witnesses: '2d952ddef91d3578857d7aeb1f469a9a6f3b7b4bba998ab6bc054d0028cf2785',
}, },
}; };
@@ -211,7 +211,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, 1107); assert.equal(witnesses.length, 1110);
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);
@@ -408,8 +408,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-oz5Ps7e5.css']); assert.deepEqual(assets, ['index-jhWgOVxh.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, 158205); assert.equal(built.byteLength, 158716);
assert.equal(sha256(built), 'b866b95d9cac46602023a35e0f55656af353e87ff230c7ad47d0aaeb768a7d3e'); assert.equal(sha256(built), 'b10c9602bdf224599db0262e2e872e3db2b3c75b85410d50c2fe42b69d44e14c');
}); });