Default new devices to Direct routing
This commit is contained in:
@@ -89,6 +89,8 @@ http://АДРЕС-GATEWAY:3456
|
||||
|
||||
Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на 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 сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
|
||||
|
||||
Список приблизительный: имя и пользовательские настройки привязаны к 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 state = migrateDeviceInventoryState(stored);
|
||||
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||
let policyByMac = state.policy.byMac;
|
||||
for (const observation of observations) {
|
||||
const mac = normalizeMac(observation.mac);
|
||||
if (!mac) continue;
|
||||
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 lastSeenAt = observation.active || !previous
|
||||
? observationTime
|
||||
@@ -1532,6 +1545,9 @@ export function createDeviceInventoryService({
|
||||
revision: state.revision + 1,
|
||||
lastObservedAt: observedAt,
|
||||
lastError: typeof result.error === 'string' ? result.error : null,
|
||||
policy: policyByMac === state.policy.byMac
|
||||
? state.policy
|
||||
: { ...state.policy, lastError: null, byMac: policyByMac },
|
||||
traffic,
|
||||
devices,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.31.2',
|
||||
gatewayClient: '0.32.2',
|
||||
gatewayBackend: '0.32.2',
|
||||
macClient: '0.32.0',
|
||||
gatewayClient: '0.33.0',
|
||||
gatewayBackend: '0.33.0',
|
||||
});
|
||||
|
||||
export interface ParsedVersion {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
byteString,
|
||||
formatByteString,
|
||||
formatLastSeen,
|
||||
isNewDevice,
|
||||
positiveByteDelta,
|
||||
stabilizeDevicesByTraffic,
|
||||
} from '../../utils/format.js';
|
||||
@@ -444,6 +445,7 @@ 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 newDevice = isNewDevice(device.firstSeenAt);
|
||||
const editing = editingId === device.id;
|
||||
const saving = savingId === device.id;
|
||||
const seen = formatLastSeen(device.lastSeenAt);
|
||||
@@ -493,7 +495,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
: device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct'
|
||||
? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства'
|
||||
: displayPolicy === 'direct'
|
||||
? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway'
|
||||
? newDevice
|
||||
? 'Новое устройство идёт напрямую. Нажмите, чтобы разрешить VPN через правила Gateway'
|
||||
: 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway'
|
||||
: 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую';
|
||||
return <article
|
||||
key={device.id}
|
||||
@@ -553,6 +557,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
onClick={() => startEditing(device)}
|
||||
>{title}</button>}
|
||||
{!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}`}>
|
||||
{device.ip && <button
|
||||
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;
|
||||
mac: string;
|
||||
ip: string | null;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string | null;
|
||||
status: DeviceStatus;
|
||||
pinned: boolean;
|
||||
@@ -151,6 +152,7 @@ function validDevice(value: unknown): value is Device {
|
||||
&& typeof value.mac === 'string'
|
||||
&& /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(value.mac)
|
||||
&& nullableString(value.ip)
|
||||
&& timestamp(value.firstSeenAt)
|
||||
&& nullableTimestamp(value.lastSeenAt)
|
||||
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
|
||||
&& typeof value.pinned === 'boolean'
|
||||
|
||||
@@ -421,6 +421,31 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
|
||||
@@ -13,6 +13,7 @@ export function formatBytes(value: number) {
|
||||
}
|
||||
|
||||
const BYTE_STRING_PATTERN = /^\d+$/;
|
||||
const NEW_DEVICE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function byteString(value: unknown) {
|
||||
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 });
|
||||
}
|
||||
|
||||
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()) {
|
||||
const date = new Date(iso || '');
|
||||
if (Number.isNaN(date.getTime())) return { label: "Нет данных", relative: "Нет данных", tooltip: "Нет данных" };
|
||||
|
||||
@@ -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 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 = [{
|
||||
ip: '192.168.50.7',
|
||||
mac,
|
||||
@@ -900,14 +919,9 @@ test('device policy is independent from pinning, persists, and keeps the last ap
|
||||
observedAt,
|
||||
active: true,
|
||||
});
|
||||
failApply = true;
|
||||
snapshot = await service.refresh();
|
||||
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]));
|
||||
assert.equal(byId.get(id).desiredPolicy, '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) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-migration-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
formatByteString,
|
||||
formatLastSeen,
|
||||
isNewDevice,
|
||||
positiveByteDelta,
|
||||
sortDevicesByTraffic,
|
||||
stabilizeDevicesByTraffic,
|
||||
@@ -54,6 +55,9 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(panel, /copyText\(value\)/);
|
||||
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 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, /onClick=\{\(\) => startEditing\(device\)\}/);
|
||||
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-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-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, /@keyframes client-device-alias-edit-in[\s\S]*color: var\(--client-accent\)[\s\S]*filter: blur\(2px\)/);
|
||||
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', () => {
|
||||
assert.equal(formatByteString('9007199254740993'), '8,0 ПБ');
|
||||
assert.equal(formatByteString('1536'), '1,5 КБ');
|
||||
|
||||
@@ -51,6 +51,7 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
|
||||
hostname: null,
|
||||
mac: '00:11:22:33:44:55',
|
||||
ip: null,
|
||||
firstSeenAt: observedAt,
|
||||
lastSeenAt: null,
|
||||
status: 'online',
|
||||
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], policyStatus: 'queued' }] },
|
||||
{ ...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], trafficHistory: [{ observedAt, gatewayBytes: '1' }] }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], outboundTraffic: { observedAt, vpnBytes: '1' } }] },
|
||||
|
||||
@@ -41,24 +41,24 @@ const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 1072,
|
||||
customProperties: 111,
|
||||
declarations: 4151,
|
||||
declarations: 4170,
|
||||
important: 0,
|
||||
keyframes: 50,
|
||||
media: 17,
|
||||
rules: 1124,
|
||||
variableReferences: 1036,
|
||||
rules: 1126,
|
||||
variableReferences: 1042,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: 'd60343b571f5a9bcf809609b4731500587da3bdd9ee9f40e33938ecbd8aec6ce',
|
||||
customProperties: '42f9fa9f2caaab6e5d90ae7d224563355822fa270083ea3496cfe2caaaea50bf',
|
||||
declarations: 'ee6b58dc6ef2edd16b8edf1762bb5c19aaf12d908958110c691f0a42f8e9067c',
|
||||
declarations: '15fc1474c16562886582469a7d240dde36882e682b266374fdca7f47f90c2b13',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||
keyframes: 'af4b9ae18d070fd2462a9b050293f3821bf5017c6e7cc53bbe18dc826f0fe3b9',
|
||||
ruleDeclarationSequences: 'e6a9c7019ab48d5bb15ac8ff0ffa51f8df7f1143ee9f8f562bdc78237a64b4b1',
|
||||
selectors: '4788b9fbc3aef9455ea9bf071f111dde318fbfe1fa96fe78127cc436131c4506',
|
||||
variableReferences: '04c19450f58f80dbe90e6df8988b8fa97f8c963d579d4f8acdb5bccd9d4020e9',
|
||||
witnesses: '7e42ab6ad3901fd68c9328e612eb413308ad8716031cbedd3134bf85224f6b9a',
|
||||
ruleDeclarationSequences: '16ccf3b3b8b6f76e784c69a3f06a3abb6e3b57d5d0710dff4bdad23f87bad6c3',
|
||||
selectors: 'b32eeeff7896fedd9057a953b61a8b149116a5c5ce110fb84bf06e24d22bf8ee',
|
||||
variableReferences: 'c9f64054cfe384f90e5c47844ffbbc7fba60f5045efac5d272f45f1f451e950b',
|
||||
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', () => {
|
||||
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);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
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);
|
||||
|
||||
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]));
|
||||
assert.equal(built.byteLength, 158205);
|
||||
assert.equal(sha256(built), 'b866b95d9cac46602023a35e0f55656af353e87ff230c7ad47d0aaeb768a7d3e');
|
||||
assert.equal(built.byteLength, 158716);
|
||||
assert.equal(sha256(built), 'b10c9602bdf224599db0262e2e872e3db2b3c75b85410d50c2fe42b69d44e14c');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user