Implement Harbor gateway device ecosystem support
This commit is contained in:
@@ -1443,3 +1443,141 @@ test('direct IPv4 counters stay metrics-only and keep the last good transport sn
|
||||
assert.deepEqual(service.metricsSnapshot().directTraffic.series, []);
|
||||
assert.equal(service.metricsSnapshot().directTraffic.source.error, null);
|
||||
});
|
||||
|
||||
test('device tag catalog validates, orders and mutates assignments under one revision', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-tags-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const store = createJsonStore({
|
||||
filePath: path.join(directory, 'devices.json'),
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
});
|
||||
const observedAt = '2026-08-31T12:00:00.000Z';
|
||||
const mac = '00:11:22:33:44:55';
|
||||
const service = createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => ({
|
||||
observedAt,
|
||||
error: null,
|
||||
observations: [{ ip: '192.168.50.10', mac, interface: 'br0', observedAt, active: true }],
|
||||
}),
|
||||
now: () => new Date(observedAt),
|
||||
});
|
||||
|
||||
let snapshot = await service.refresh();
|
||||
snapshot = service.createTag(' Home ', snapshot.revision);
|
||||
const home = snapshot.tags[0];
|
||||
assert.match(home.id, /^tag_[a-f0-9]{16}$/);
|
||||
assert.equal(home.name, 'Home');
|
||||
assert.throws(
|
||||
() => service.createTag('home', snapshot.revision),
|
||||
(error) => error.code === 'DEVICE_TAG_NAME_CONFLICT',
|
||||
);
|
||||
assert.throws(
|
||||
() => service.createTag('x'.repeat(25), snapshot.revision),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
|
||||
snapshot = service.createTag('Work', snapshot.revision);
|
||||
const work = snapshot.tags[1];
|
||||
snapshot = service.update(snapshot.devices[0].id, { tagIds: [work.id, home.id] }, snapshot.revision);
|
||||
assert.deepEqual(snapshot.devices[0].tagIds, [home.id, work.id]);
|
||||
assert.deepEqual(store.read().tags.byMac[mac], [home.id, work.id]);
|
||||
assert.throws(
|
||||
() => service.update(snapshot.devices[0].id, { tagIds: [home.id, work.id] }, snapshot.revision - 1),
|
||||
(error) => error.code === 'STATE_CONFLICT',
|
||||
);
|
||||
assert.equal(
|
||||
service.update(snapshot.devices[0].id, { tagIds: [home.id, work.id] }, snapshot.revision).revision,
|
||||
snapshot.revision,
|
||||
);
|
||||
|
||||
snapshot = service.renameTag(home.id, 'HOME', snapshot.revision);
|
||||
const renamedRevision = snapshot.revision;
|
||||
assert.equal(snapshot.tags[0].name, 'HOME');
|
||||
assert.throws(
|
||||
() => service.renameTag(home.id, 'HOME', renamedRevision - 1),
|
||||
(error) => error.code === 'STATE_CONFLICT',
|
||||
);
|
||||
assert.equal(service.renameTag(home.id, 'HOME', renamedRevision).revision, renamedRevision);
|
||||
assert.throws(
|
||||
() => service.update(snapshot.devices[0].id, { tagIds: ['tag_ffffffffffffffff'] }, renamedRevision),
|
||||
(error) => error.code === 'DEVICE_TAG_NOT_FOUND',
|
||||
);
|
||||
|
||||
while (snapshot.tags.length < 9) snapshot = service.createTag(`Tag ${snapshot.tags.length}`, snapshot.revision);
|
||||
snapshot = service.update(snapshot.devices[0].id, { tagIds: snapshot.tags.slice(0, 8).map(({ id }) => id) }, snapshot.revision);
|
||||
assert.throws(
|
||||
() => service.update(snapshot.devices[0].id, { tagIds: snapshot.tags.slice(0, 9).map(({ id }) => id) }, snapshot.revision),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
|
||||
snapshot = service.deleteTag(home.id, snapshot.revision);
|
||||
assert.equal(snapshot.tags.some(({ id }) => id === home.id), false);
|
||||
assert.equal(snapshot.devices[0].tagIds.includes(home.id), false);
|
||||
while (snapshot.tags.length < 32) snapshot = service.createTag(`More ${snapshot.tags.length}`, snapshot.revision);
|
||||
assert.throws(
|
||||
() => service.createTag('Overflow', snapshot.revision),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
});
|
||||
|
||||
test('tag migration recovers safe values and retention removes assignment but keeps catalog', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-tag-retention-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const store = createJsonStore({
|
||||
filePath: path.join(directory, 'devices.json'),
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
});
|
||||
let current = new Date('2026-07-01T12:00:00.000Z');
|
||||
const mac = '00:11:22:33:44:66';
|
||||
let present = true;
|
||||
const service = createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => ({
|
||||
observedAt: current.toISOString(),
|
||||
error: null,
|
||||
observations: present
|
||||
? [{ ip: '192.168.50.12', mac, interface: 'br0', observedAt: current.toISOString(), active: true }]
|
||||
: [],
|
||||
}),
|
||||
now: () => current,
|
||||
});
|
||||
|
||||
let snapshot = await service.refresh();
|
||||
snapshot = service.createTag('Дом', snapshot.revision);
|
||||
const tag = snapshot.tags[0];
|
||||
snapshot = service.update(snapshot.devices[0].id, { tagIds: [tag.id] }, snapshot.revision);
|
||||
|
||||
const recovered = migrateDeviceInventoryState({
|
||||
...store.read(),
|
||||
tags: {
|
||||
schemaVersion: 1,
|
||||
items: [tag, { ...tag, id: 'tag_ffffffffffffffff' }, { id: 'bad', name: 'Bad' }],
|
||||
byMac: { [mac.toUpperCase()]: [tag.id, tag.id, 'tag_ffffffffffffffff'] },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(recovered.tags, {
|
||||
schemaVersion: 1,
|
||||
items: [tag],
|
||||
byMac: { [mac]: [tag.id] },
|
||||
});
|
||||
assert.throws(
|
||||
() => migrateDeviceInventoryState({ ...store.read(), tags: { schemaVersion: 2 } }),
|
||||
/Unsupported device tags schemaVersion/,
|
||||
);
|
||||
|
||||
present = false;
|
||||
current = new Date('2026-08-02T12:00:00.000Z');
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices.length, 0);
|
||||
assert.deepEqual(snapshot.tags, [tag]);
|
||||
assert.deepEqual(store.read().tags.byMac, {});
|
||||
|
||||
present = true;
|
||||
current = new Date('2026-08-03T12:00:00.000Z');
|
||||
snapshot = await service.refresh();
|
||||
assert.deepEqual(snapshot.devices[0].tagIds, []);
|
||||
assert.deepEqual(snapshot.tags, [tag]);
|
||||
});
|
||||
|
||||
@@ -3,8 +3,10 @@ import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createDeviceInventoryRoute } from '../../dist/server/http/routes/deviceInventoryRoute.js';
|
||||
import { errorDefinition } from '../../dist/shared/errors.js';
|
||||
|
||||
const deviceId = 'dev_0123456789abcdef';
|
||||
const tagId = 'tag_0123456789abcdef';
|
||||
|
||||
function response() {
|
||||
return {
|
||||
@@ -34,6 +36,18 @@ function createHarness({ inventory = {}, body = {} } = {}) {
|
||||
calls.push(['update', ...args]);
|
||||
return inventory.update ?? { revision: 3 };
|
||||
},
|
||||
createTag: (...args) => {
|
||||
calls.push(['createTag', ...args]);
|
||||
return inventory.createTag ?? { revision: 6 };
|
||||
},
|
||||
renameTag: (...args) => {
|
||||
calls.push(['renameTag', ...args]);
|
||||
return inventory.renameTag ?? { revision: 7 };
|
||||
},
|
||||
deleteTag: (...args) => {
|
||||
calls.push(['deleteTag', ...args]);
|
||||
return inventory.deleteTag ?? { revision: 8 };
|
||||
},
|
||||
resetTraffic: async (...args) => {
|
||||
calls.push(['resetTraffic', ...args]);
|
||||
return inventory.resetTraffic ?? { revision: 4 };
|
||||
@@ -79,7 +93,7 @@ test('device route forwards list and refresh query paths as raw JSON responses',
|
||||
});
|
||||
|
||||
test('device route forwards metadata patch and policy arguments without coercion', async () => {
|
||||
const patch = { expectedRevision: 7, alias: 'Desk', pinned: false, deprioritized: true, extra: 0 };
|
||||
const patch = { expectedRevision: 7, alias: 'Desk', pinned: false, deprioritized: true, tagIds: [tagId], extra: 0 };
|
||||
const metadata = createHarness({ body: patch });
|
||||
const metadataResponse = response();
|
||||
assert.equal(await metadata.route.handle({
|
||||
@@ -89,7 +103,7 @@ test('device route forwards metadata patch and policy arguments without coercion
|
||||
assert.deepEqual(metadata.calls, [[
|
||||
'update',
|
||||
deviceId,
|
||||
{ alias: 'Desk', pinned: false, deprioritized: true, extra: 0 },
|
||||
{ alias: 'Desk', pinned: false, deprioritized: true, tagIds: [tagId], extra: 0 },
|
||||
7,
|
||||
]]);
|
||||
assert.deepEqual(metadataResponse.payload, { revision: 3 });
|
||||
@@ -113,6 +127,39 @@ test('device route forwards metadata patch and policy arguments without coercion
|
||||
assert.deepEqual(resetResponse.payload, { revision: 4 });
|
||||
});
|
||||
|
||||
test('device tag routes forward catalog mutations and return complete snapshots', async () => {
|
||||
const create = createHarness({ body: { name: 'Умный дом', expectedRevision: 10 } });
|
||||
const createResponse = response();
|
||||
assert.equal(await create.route.handle({ method: 'POST', url: '/api/device-tags' }, createResponse), true);
|
||||
assert.deepEqual(create.calls, [['createTag', 'Умный дом', 10]]);
|
||||
assert.deepEqual(createResponse.payload, { revision: 6 });
|
||||
|
||||
const rename = createHarness({ body: { name: 'Дом', expectedRevision: 11 } });
|
||||
const renameResponse = response();
|
||||
assert.equal(await rename.route.handle({ method: 'PUT', url: `/api/device-tags/${tagId}` }, renameResponse), true);
|
||||
assert.deepEqual(rename.calls, [['renameTag', tagId, 'Дом', 11]]);
|
||||
assert.deepEqual(renameResponse.payload, { revision: 7 });
|
||||
|
||||
const remove = createHarness({ body: { expectedRevision: 12 } });
|
||||
const deleteResponse = response();
|
||||
assert.equal(await remove.route.handle({ method: 'DELETE', url: `/api/device-tags/${tagId}` }, deleteResponse), true);
|
||||
assert.deepEqual(remove.calls, [['deleteTag', tagId, 12]]);
|
||||
assert.deepEqual(deleteResponse.payload, { revision: 8 });
|
||||
});
|
||||
|
||||
test('device tag errors expose stable HTTP contracts', () => {
|
||||
assert.deepEqual(errorDefinition('DEVICE_TAG_NOT_FOUND'), {
|
||||
status: 404,
|
||||
message: 'Тег больше недоступен.',
|
||||
retryable: false,
|
||||
});
|
||||
assert.deepEqual(errorDefinition('DEVICE_TAG_NAME_CONFLICT'), {
|
||||
status: 409,
|
||||
message: 'Тег с таким именем уже существует.',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('device route preserves endpoint gating and strict lowercase IDs', async () => {
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/devices'],
|
||||
@@ -120,6 +167,8 @@ test('device route preserves endpoint gating and strict lowercase IDs', async ()
|
||||
['GET', '/api/devices/traffic'],
|
||||
['GET', `/api/devices/${deviceId}`],
|
||||
['POST', `/api/devices/${deviceId}/policy`],
|
||||
['GET', '/api/device-tags'],
|
||||
['PATCH', `/api/device-tags/${tagId}`],
|
||||
]) {
|
||||
const harness = createHarness();
|
||||
await assert.rejects(
|
||||
@@ -147,6 +196,9 @@ test('device route preserves endpoint gating and strict lowercase IDs', async ()
|
||||
['DELETE', '/api/devices/traffic'],
|
||||
['PUT', `/api/devices/${deviceId}`],
|
||||
['PUT', `/api/devices/${deviceId}/policy`],
|
||||
['POST', '/api/device-tags'],
|
||||
['PUT', `/api/device-tags/${tagId}`],
|
||||
['DELETE', `/api/device-tags/${tagId}`],
|
||||
]) {
|
||||
const client = createHarness({ inventory: null });
|
||||
await assert.rejects(
|
||||
@@ -164,6 +216,9 @@ test('device route propagates synchronous and asynchronous service errors unchan
|
||||
snapshot: () => { throw syncError; },
|
||||
refresh: async () => ({}),
|
||||
update: () => ({}),
|
||||
createTag: () => ({}),
|
||||
renameTag: () => ({}),
|
||||
deleteTag: () => ({}),
|
||||
resetTraffic: async () => ({}),
|
||||
setPolicy: async () => ({}),
|
||||
},
|
||||
@@ -180,6 +235,9 @@ test('device route propagates synchronous and asynchronous service errors unchan
|
||||
snapshot: () => ({}),
|
||||
refresh: async () => { throw asyncError; },
|
||||
update: () => ({}),
|
||||
createTag: () => ({}),
|
||||
renameTag: () => ({}),
|
||||
deleteTag: () => ({}),
|
||||
resetTraffic: async () => ({}),
|
||||
setPolicy: async () => ({}),
|
||||
},
|
||||
@@ -205,4 +263,5 @@ test('device route is the only HTTP owner while lifecycle stays in composition',
|
||||
assert.match(index, /deviceInventory\.refresh\(\)/);
|
||||
assert.match(route, /DEVICE_PATH/);
|
||||
assert.match(route, /DEVICE_POLICY_PATH/);
|
||||
assert.match(route, /DEVICE_TAG_PATH/);
|
||||
});
|
||||
|
||||
@@ -112,6 +112,15 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
|
||||
[() => api.devices.setPolicy('dev_1', 'direct', 9), '/api/devices/dev_1/policy', {
|
||||
method: 'PUT', body: JSON.stringify({ mode: 'direct', expectedRevision: 9 }),
|
||||
}],
|
||||
[() => api.devices.createTag('Дом', 10), '/api/device-tags', {
|
||||
method: 'POST', body: JSON.stringify({ name: 'Дом', expectedRevision: 10 }),
|
||||
}],
|
||||
[() => api.devices.renameTag('tag_1', 'Работа', 11), '/api/device-tags/tag_1', {
|
||||
method: 'PUT', body: JSON.stringify({ name: 'Работа', expectedRevision: 11 }),
|
||||
}],
|
||||
[() => api.devices.deleteTag('tag_1', 12), '/api/device-tags/tag_1', {
|
||||
method: 'DELETE', body: JSON.stringify({ expectedRevision: 12 }),
|
||||
}],
|
||||
[() => api.diagnostics.connectivity(), '/api/diagnostics/connectivity', {
|
||||
method: 'POST', body: JSON.stringify({ target: null }),
|
||||
}],
|
||||
|
||||
@@ -5,6 +5,8 @@ import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
formatByteString,
|
||||
deviceFilterCounts,
|
||||
filterDevices,
|
||||
formatLastSeen,
|
||||
isNewDevice,
|
||||
positiveByteDelta,
|
||||
@@ -38,7 +40,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(feature, /requestDeviceUpdate\(device\.id, patch, snapshot\.revision\)/);
|
||||
assert.match(feature, /requestError\(caught\)\.code !== 'STATE_CONFLICT'[\s\S]*listDevices\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*requestDeviceUpdate\(device\.id, patch, latest\.revision\)/);
|
||||
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
||||
assert.match(panel, /movementAnimations\.current\.get\(id\)\?\.cancel\(\)/);
|
||||
assert.match(panel, /const activeAnimation = movementAnimations\.current\.get\(id\)[\s\S]*activeAnimation\.commitStyles\(\)[\s\S]*activeAnimation\.cancel\(\)[\s\S]*interruptedPositions\.set/);
|
||||
assert.match(panel, /previousPositions\.current\.size > 0/);
|
||||
assert.match(panel, /previousScrollTop\.current - currentScrollTop/);
|
||||
assert.match(feature, /next\.revision > current\.revision/);
|
||||
@@ -86,7 +88,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
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, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/);
|
||||
assert.match(panel, /filterDevices\(allDevices, tags,[\s\S]*stabilizeDevicesByTraffic\(filteredDevices, sortDirection, previousIds\)[\s\S]*stabilizeDevicesByTraffic\(allDevices, sortDirection, previousIds\)[\s\S]*trafficOrder\.current = \{ direction: sortDirection, ids: canonical\.ids \}/);
|
||||
assert.match(panel, /Трафик временно не обновляется/);
|
||||
assert.match(panel, /Вход — накопленные Gateway\/Proxy\. Выход — приблизительно через VPN или Direct с запуска текущего учёта\./);
|
||||
assert.match(panel, /const displayedTotal = trafficView === 'outbound'[\s\S]*`≈ \$\{formatByteString\(outboundTotal\.toString\(\)\)\}`[\s\S]*const trafficLabel = trafficView === 'outbound' \? 'Выход' : 'Вход'/);
|
||||
@@ -144,6 +146,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
|
||||
assert.match(panel, /maxLength=\{64\}[\s\S]*autoFocus/);
|
||||
assert.match(styles, /\.client-drawer \{[\s\S]*width: min\(580px, 100vw\)/);
|
||||
assert.match(styles, /\.client-drawer\.client-devices \{[\s\S]*width: min\(780px, 100vw\)/);
|
||||
assert.match(styles, /\.client-devices-layout \{[\s\S]*grid-template-columns: 168px minmax\(0, 1fr\);[\s\S]*gap: 28px/);
|
||||
assert.match(styles, /\.client-device \{[\s\S]*--client-device-chart-height: 34px;[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*grid-template-rows: 34px var\(--client-device-chart-height\);[\s\S]*padding: 10px 8px/);
|
||||
assert.match(styles, /\.client-device\.is-pinned \{[\s\S]*--client-device-chart-height: 72px/);
|
||||
assert.match(styles, /\.client-device\.is-deprioritized \{[\s\S]*grid-template-rows: 34px;[\s\S]*padding-block: 6px/);
|
||||
@@ -231,6 +235,41 @@ test('device copy feedback stays keyed per device', () => {
|
||||
assert.match(panel, /const feedback = copyFeedback\[device\.id\]/);
|
||||
});
|
||||
|
||||
test('device tags keep filtering, assignment and management accessible at every width', () => {
|
||||
assert.match(panel, /Найти устройство/);
|
||||
assert.match(panel, /'all', label: 'Все'[\s\S]*'new', label: 'Новые'[\s\S]*'pinned', label: 'Закреплённые'[\s\S]*'background', label: 'Фоновые'[\s\S]*'untagged', label: 'Без тегов'/);
|
||||
assert.match(panel, /if \(value === 'untagged'\) setSelectedTagIds\(\[\]\)/);
|
||||
assert.match(panel, /if \(systemFilter === 'untagged'\) setSystemFilter\('all'\)/);
|
||||
assert.match(panel, /deviceFilterCounts\(allDevices, tags\)/);
|
||||
assert.match(panel, /По этим фильтрам устройств нет\.[\s\S]*Сбросить фильтры/);
|
||||
assert.match(panel, /deviceTags = tags\.filter[\s\S]*client-device-tag-trigger[\s\S]*\+\{deviceTags\.length - 1\}[\s\S]*'\+ тег'/);
|
||||
assert.match(panel, /role="dialog"[\s\S]*aria-modal="true"[\s\S]*Теги устройства \{tagPopover\.title\}/);
|
||||
assert.match(panel, /contentRef\.current\.inert = true[\s\S]*layoutRef\.current\.inert = true/);
|
||||
assert.match(panel, /FOCUSABLE[\s\S]*event\.key === 'Escape'[\s\S]*document\.activeElement === first/);
|
||||
assert.match(panel, /catalogChanged[\s\S]*Список тегов изменился\. Откройте теги устройства снова\./);
|
||||
assert.match(panel, /Теги доступны после обновления Gateway\./);
|
||||
assert.match(panel, /Управление тегами[\s\S]*Переименовать тег[\s\S]*Удалить тег/);
|
||||
assert.match(panel, /placeholder="Найти устройство"[\s\S]*Результатов: \{devices\.length\}/);
|
||||
assert.match(panel, />Назад<\/button>[\s\S]*Не удалось сохранить теги\./);
|
||||
assert.match(panel, /restoreTagTriggerFocus[\s\S]*searchRef\.current,[\s\S]*filterButtonRef\.current,[\s\S]*candidate\?\.isConnected && !candidate\.closest\('\[inert\]'\)/);
|
||||
assert.match(panel, /updateDeviceTags\(device, tagPopover\.draft, tagPopover\.baseline\)/);
|
||||
assert.match(feature, /updateDeviceTags\(device: Device, tagIds: string\[\], baselineTagIds: string\[\]\)[\s\S]*sameStringList\(currentDevice\.tagIds, baselineTagIds\)[\s\S]*sameStringList\(latestDevice\.tagIds, baselineTagIds\)/);
|
||||
assert.match(feature, /renameTag\(tag: DeviceTag, name: string, baselineName: string\)[\s\S]*snapshot\.tags\.find[\s\S]*name !== baselineName[\s\S]*latestTag\.name !== baselineName/);
|
||||
assert.match(panel, /Тег больше не будет доступен для назначения\. Устройства и маршруты не изменятся\./);
|
||||
assert.match(panel, /Тег исчезнет у одного устройства\. Само устройство и его маршрут не изменятся\./);
|
||||
assert.match(panel, /Сами устройства и их маршруты не изменятся\./);
|
||||
assert.match(feature, /deleteDeviceTag\(tag\.id, snapshot\.revision\)[\s\S]*STATE_CONFLICT[\s\S]*listDevices\(\)[\s\S]*return 'conflict'/);
|
||||
assert.doesNotMatch(feature, /deleteDeviceTag\(tag\.id, latest\.revision\)/);
|
||||
assert.match(styles, /@media \(max-width: 640px\)[\s\S]*width: min\(300px, calc\(100vw - 48px\)\)[\s\S]*translateX\(-100%\)[\s\S]*260ms cubic-bezier\(0\.16, 1, 0\.3, 1\)/);
|
||||
assert.match(panel, /activeAnimation\.commitStyles\(\)[\s\S]*activeAnimation\.cancel\(\)[\s\S]*opacity: 0, transform: 'translateX\(-6px\)'[\s\S]*duration: 160[\s\S]*opacity: 0, transform: 'translateX\(6px\)'[\s\S]*opacity: 1, transform: 'translateX\(0\)'/);
|
||||
assert.match(styles, /client-device-tag-popover-in 160ms ease[\s\S]*translateY\(-6px\)[\s\S]*client-device-tag-popover-out 160ms ease forwards/);
|
||||
assert.match(styles, /client-devices-rail-backdrop \{[\s\S]*opacity: 0[\s\S]*opacity 260ms cubic-bezier\(0\.16, 1, 0\.3, 1\)[\s\S]*client-devices-rail-backdrop\.is-open[\s\S]*opacity: 1/);
|
||||
assert.match(styles, /client-device-tag-trigger \{[\s\S]*height: 14px[\s\S]*flex: 0 0 auto/);
|
||||
assert.match(styles, /@media \(hover: hover\)[\s\S]*client-device-tag-trigger:not\(\.has-tag\)[\s\S]*opacity: 0/);
|
||||
assert.match(styles, /@media \(hover: none\)[\s\S]*client-device-tag-trigger:not\(\.has-tag\)[\s\S]*opacity: 1/);
|
||||
assert.match(styles, /prefers-reduced-motion: reduce[\s\S]*client-devices-rail[\s\S]*client-devices-rail-view[\s\S]*client-device-tag-popover/);
|
||||
});
|
||||
|
||||
test('Gateway Home reuses the canonical device snapshot for applied route and global traffic', () => {
|
||||
const powerStart = connection.indexOf('<section className={`client-power-section');
|
||||
const trafficStart = overview.indexOf('<GatewayTrafficSummary');
|
||||
@@ -285,6 +324,54 @@ test('new-device marker uses an exact seven-day window', () => {
|
||||
assert.equal(isNewDevice('not-a-date', now), false);
|
||||
});
|
||||
|
||||
test('device filters combine system and search with tag OR while counts stay absolute', () => {
|
||||
const now = Date.parse('2026-08-31T12:00:00.000Z');
|
||||
const tags = [
|
||||
{ id: 'tag_0000000000000001', name: 'Умный дом' },
|
||||
{ id: 'tag_0000000000000002', name: 'Работа' },
|
||||
];
|
||||
const devices = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `device-${index}`,
|
||||
alias: index === 3 ? 'Телевизор' : `Клиент ${index}`,
|
||||
hostname: index === 4 ? 'office-mac.local' : null,
|
||||
ip: `192.168.50.${index + 1}`,
|
||||
mac: `00:11:22:33:44:${index.toString(16).padStart(2, '0')}`,
|
||||
firstSeenAt: new Date(now - (index < 7 ? index : 10) * 86_400_000).toISOString(),
|
||||
pinned: index === 3,
|
||||
deprioritized: index === 5,
|
||||
tagIds: index === 3 ? [tags[0].id] : index === 4 ? [tags[1].id] : [],
|
||||
}));
|
||||
|
||||
assert.deepEqual(filterDevices(devices, tags, {
|
||||
system: 'all', tagIds: tags.map(({ id }) => id), query: 'local',
|
||||
}, now).map(({ id }) => id), ['device-4']);
|
||||
assert.deepEqual(filterDevices(devices, tags, {
|
||||
system: 'pinned', tagIds: [tags[0].id, tags[1].id], query: 'умный',
|
||||
}, now).map(({ id }) => id), ['device-3']);
|
||||
assert.equal(filterDevices(devices, tags, {
|
||||
system: 'untagged', tagIds: [], query: '192.168.50.',
|
||||
}, now).length, 98);
|
||||
|
||||
assert.deepEqual(deviceFilterCounts(devices, tags, now), {
|
||||
all: 100,
|
||||
new: 7,
|
||||
pinned: 1,
|
||||
background: 1,
|
||||
untagged: 98,
|
||||
byTag: { [tags[0].id]: 1, [tags[1].id]: 1 },
|
||||
});
|
||||
|
||||
const canonical = stabilizeDevicesByTraffic(devices, 'desc');
|
||||
const visible = filterDevices(devices, tags, {
|
||||
system: 'all', tagIds: [], query: '192.168.50.1',
|
||||
}, now);
|
||||
const filteredOrder = stabilizeDevicesByTraffic(visible, 'desc', canonical.ids).devices;
|
||||
assert.deepEqual(filteredOrder.map(({ id }) => id), canonical.devices
|
||||
.filter(({ id }) => visible.some((device) => device.id === id))
|
||||
.map(({ id }) => id));
|
||||
assert.deepEqual(stabilizeDevicesByTraffic(devices, 'desc', canonical.ids).ids, canonical.ids);
|
||||
});
|
||||
|
||||
test('device traffic formatting and sorting preserve uint64 precision and canonical ties', () => {
|
||||
assert.equal(formatByteString('9007199254740993'), '8,0 ПБ');
|
||||
assert.equal(formatByteString('1536'), '1,5 КБ');
|
||||
|
||||
@@ -41,7 +41,7 @@ test('device controller preserves Gateway-only polling, monotonic publication an
|
||||
assert.match(page, /<GatewayTrafficSummary feature=\{devicesFeature\} now=\{now\}/);
|
||||
});
|
||||
|
||||
test('all unknown inventory results pass one identity-preserving runtime parser', () => {
|
||||
test('all unknown inventory results pass one capability-aware runtime parser', () => {
|
||||
const observedAt = '2026-08-08T12:34:56.000Z';
|
||||
const valid = {
|
||||
revision: 3,
|
||||
@@ -106,7 +106,21 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
|
||||
},
|
||||
extra: { retained: true },
|
||||
};
|
||||
assert.equal(parseDeviceSnapshot(valid), valid);
|
||||
const oldBackend = parseDeviceSnapshot(valid);
|
||||
assert.equal(oldBackend.taggingSupported, false);
|
||||
assert.deepEqual(oldBackend.tags, []);
|
||||
assert.deepEqual(oldBackend.devices[0].tagIds, []);
|
||||
assert.deepEqual(oldBackend.extra, valid.extra);
|
||||
|
||||
const supported = {
|
||||
...valid,
|
||||
tags: [{ id: 'tag_0123456789abcdef', name: 'Умный дом' }],
|
||||
devices: [{ ...valid.devices[0], tagIds: ['tag_0123456789abcdef'] }],
|
||||
};
|
||||
const parsedSupported = parseDeviceSnapshot(supported);
|
||||
assert.equal(parsedSupported.taggingSupported, true);
|
||||
assert.deepEqual(parsedSupported.tags, supported.tags);
|
||||
assert.deepEqual(parsedSupported.devices[0].tagIds, ['tag_0123456789abcdef']);
|
||||
for (const invalid of [
|
||||
null,
|
||||
{},
|
||||
@@ -134,6 +148,10 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
|
||||
{ ...valid, devices: [{ ...valid.devices[0], outboundTraffic: { observedAt, vpnBytes: '1' } }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], outboundTraffic: { ...valid.devices[0].outboundTraffic, directIpv4ObservedAt: 'not-a-date' } }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], outboundTrafficHistory: [{ observedAt, vpnBytes: '1' }] }] },
|
||||
{ ...supported, tags: [{ id: 'bad', name: 'Дом' }] },
|
||||
{ ...supported, tags: [{ ...supported.tags[0], name: ' '.repeat(2) }] },
|
||||
{ ...supported, devices: [{ ...valid.devices[0] }] },
|
||||
{ ...supported, devices: [{ ...supported.devices[0], tagIds: ['tag_ffffffffffffffff'] }] },
|
||||
{ ...valid, traffic: { ...valid.traffic, totalBytes: -4 } },
|
||||
{ ...valid, traffic: { ...valid.traffic, observedAt: 'not-a-date' } },
|
||||
{ ...valid, source: { ...valid.source, kind: 'arp' } },
|
||||
|
||||
@@ -39,26 +39,26 @@ const expectedImports = [
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 1072,
|
||||
customProperties: 111,
|
||||
declarations: 4170,
|
||||
cascadeEdges: 1093,
|
||||
customProperties: 115,
|
||||
declarations: 4433,
|
||||
important: 0,
|
||||
keyframes: 50,
|
||||
media: 17,
|
||||
rules: 1126,
|
||||
variableReferences: 1042,
|
||||
keyframes: 52,
|
||||
media: 19,
|
||||
rules: 1188,
|
||||
variableReferences: 1130,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: 'd60343b571f5a9bcf809609b4731500587da3bdd9ee9f40e33938ecbd8aec6ce',
|
||||
customProperties: '42f9fa9f2caaab6e5d90ae7d224563355822fa270083ea3496cfe2caaaea50bf',
|
||||
declarations: '15fc1474c16562886582469a7d240dde36882e682b266374fdca7f47f90c2b13',
|
||||
cascadeEdges: '6676dd45ca19923561ef30b2057a9952a795fd142d83b612aaba70132378e91d',
|
||||
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
||||
declarations: 'fcecaa0fb211a14826577bf9629e261b0b4b63b4601febebc43e8d757f6a07f1',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||
keyframes: 'af4b9ae18d070fd2462a9b050293f3821bf5017c6e7cc53bbe18dc826f0fe3b9',
|
||||
ruleDeclarationSequences: '16ccf3b3b8b6f76e784c69a3f06a3abb6e3b57d5d0710dff4bdad23f87bad6c3',
|
||||
selectors: 'b32eeeff7896fedd9057a953b61a8b149116a5c5ce110fb84bf06e24d22bf8ee',
|
||||
variableReferences: 'c9f64054cfe384f90e5c47844ffbbc7fba60f5045efac5d272f45f1f451e950b',
|
||||
witnesses: '2d952ddef91d3578857d7aeb1f469a9a6f3b7b4bba998ab6bc054d0028cf2785',
|
||||
keyframes: '405688c9a452aa9d54e9c50dd30abb13143fdb5910f63f4474ecefcc800311e6',
|
||||
ruleDeclarationSequences: '231bd9880d19c8d29e6d9538ccb934e135bd58b33155175e317b743916d93ef7',
|
||||
selectors: '159b53f5cdd6c4aa6d892b9ed314d9a100345290d4c95e31a4c6be98b609c8b6',
|
||||
variableReferences: 'd8f4abdb1ea6e9077283f342efef0ea920068165d0fc2ac4e7a9a22e16b31461',
|
||||
witnesses: 'a84c2ee1c55cb68873f62f230aeec39f88093bcbb50493aaf47d852b13e6985c',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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, 1110);
|
||||
assert.equal(witnesses.length, 1209);
|
||||
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-jhWgOVxh.css']);
|
||||
assert.deepEqual(assets, ['index-fn-ai4xB.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 158716);
|
||||
assert.equal(sha256(built), 'b10c9602bdf224599db0262e2e872e3db2b3c75b85410d50c2fe42b69d44e14c');
|
||||
assert.equal(built.byteLength, 168021);
|
||||
assert.equal(sha256(built), 'a160cdc915a4a8e0b95e360ed721e6037cbeda28df4e0815ab6be43f1004176d');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user