Implement Harbor gateway device ecosystem support
This commit is contained in:
@@ -87,6 +87,8 @@ http://АДРЕС-GATEWAY:3456
|
|||||||
|
|
||||||
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, выбранный график трафика и иконку применённого маршрута. По умолчанию график показывает приблизительный выход `VPN`/`Direct`; переключатель `Вход` возвращает накопленную разбивку `Gateway`/`Прокси`. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory.
|
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, выбранный график трафика и иконку применённого маршрута. По умолчанию график показывает приблизительный выход `VPN`/`Direct`; переключатель `Вход` возвращает накопленную разбивку `Gateway`/`Прокси`. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory.
|
||||||
|
|
||||||
|
Левая панель списка ищет по имени, hostname, IP, MAC и тегам, фильтрует новые, закреплённые, фоновые или устройства без тегов и позволяет выбрать несколько тегов по правилу «хотя бы один». Каталог тегов общий для Gateway: в нём можно создать до 32 тегов и назначить устройству до 8. Назначения сохраняются вместе с `devices.json`, но маршруты не меняют. После удаления устройства по 30-дневному retention его назначения удаляются, сам каталог остаётся; вернувшееся позже устройство появляется без тегов. Если Mac-клиент подключён к старой версии Gateway, список продолжает работать, а управление тегами скрывается до обновления Gateway.
|
||||||
|
|
||||||
Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на 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 после однозначного наблюдения.
|
Устройство, впервые замеченное после обновления Gateway, по умолчанию идёт `Напрямую` и первые семь дней отмечается `NEW`; исчезновение метки маршрут не меняет. Уже известные при обновлении устройства сохраняют текущий VPN, даже если метка ещё видна по их `firstSeenAt`. VPN разрешается существующей последней иконкой маршрута. Если новый device пока распознан неоднозначно, Harbor сохраняет Direct-намерение, временно оставляет фактический VPN и применяет Direct после однозначного наблюдения.
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ interface DeviceInventoryPort {
|
|||||||
snapshot(): unknown;
|
snapshot(): unknown;
|
||||||
refresh(): Promise<unknown>;
|
refresh(): Promise<unknown>;
|
||||||
update(deviceId: string, patch: Record<string, unknown>, expectedRevision: unknown): unknown;
|
update(deviceId: string, patch: Record<string, unknown>, expectedRevision: unknown): unknown;
|
||||||
|
createTag(name: unknown, expectedRevision: unknown): unknown;
|
||||||
|
renameTag(tagId: string, name: unknown, expectedRevision: unknown): unknown;
|
||||||
|
deleteTag(tagId: string, expectedRevision: unknown): unknown;
|
||||||
resetTraffic(expectedRevision: unknown): Promise<unknown>;
|
resetTraffic(expectedRevision: unknown): Promise<unknown>;
|
||||||
setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise<unknown>;
|
setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise<unknown>;
|
||||||
}
|
}
|
||||||
@@ -18,6 +21,7 @@ interface DeviceInventoryRouteDependencies {
|
|||||||
|
|
||||||
const DEVICE_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})$/;
|
const DEVICE_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})$/;
|
||||||
const DEVICE_POLICY_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/;
|
const DEVICE_POLICY_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/;
|
||||||
|
const DEVICE_TAG_PATH = /^\/api\/device-tags\/(tag_[a-f0-9]{16})$/;
|
||||||
|
|
||||||
export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDependencies) {
|
export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDependencies) {
|
||||||
return {
|
return {
|
||||||
@@ -49,6 +53,35 @@ export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDep
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/device-tags') {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'POST') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const body = await dependencies.readBody(req);
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
dependencies.deviceInventory.createTag(body.name, body.expectedRevision),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagMatch = pathname.match(DEVICE_TAG_PATH);
|
||||||
|
if (tagMatch) {
|
||||||
|
if (!dependencies.deviceInventory || !['PUT', 'DELETE'].includes(req.method || '')) {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const body = await dependencies.readBody(req);
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
req.method === 'PUT'
|
||||||
|
? dependencies.deviceInventory.renameTag(tagMatch[1], body.name, body.expectedRevision)
|
||||||
|
: dependencies.deviceInventory.deleteTag(tagMatch[1], body.expectedRevision),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const deviceMatch = pathname.match(DEVICE_PATH);
|
const deviceMatch = pathname.match(DEVICE_PATH);
|
||||||
if (deviceMatch) {
|
if (deviceMatch) {
|
||||||
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ interface DevicePolicyState {
|
|||||||
byMac: Record<string, DevicePolicyEntry>;
|
byMac: Record<string, DevicePolicyEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeviceTag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceTagState {
|
||||||
|
schemaVersion: number;
|
||||||
|
items: DeviceTag[];
|
||||||
|
byMac: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
interface InventoryDevice {
|
interface InventoryDevice {
|
||||||
id: string;
|
id: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
@@ -101,6 +112,7 @@ export interface InventoryState {
|
|||||||
lastObservedAt: string | null;
|
lastObservedAt: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
policy: DevicePolicyState;
|
policy: DevicePolicyState;
|
||||||
|
tags: DeviceTagState;
|
||||||
traffic: InventoryTrafficState;
|
traffic: InventoryTrafficState;
|
||||||
devices: InventoryDevice[];
|
devices: InventoryDevice[];
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -204,6 +216,10 @@ 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}$/;
|
||||||
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
||||||
|
const TAG_ID_PATTERN = /^tag_[a-f0-9]{16}$/;
|
||||||
|
const TAG_NAME_MAX_LENGTH = 24;
|
||||||
|
const TAG_CATALOG_LIMIT = 32;
|
||||||
|
const DEVICE_TAG_LIMIT = 8;
|
||||||
const POLICY_MODES: ReadonlySet<unknown> = new Set(['vpn', 'direct']);
|
const POLICY_MODES: ReadonlySet<unknown> = new Set(['vpn', 'direct']);
|
||||||
const POLICY_STATUSES: ReadonlySet<unknown> = new Set(['applied', 'applying', 'pending', 'failed']);
|
const POLICY_STATUSES: ReadonlySet<unknown> = new Set(['applied', 'applying', 'pending', 'failed']);
|
||||||
const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных';
|
const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных';
|
||||||
@@ -228,6 +244,12 @@ const DEFAULT_POLICY_STATE: DevicePolicyState = {
|
|||||||
byMac: {},
|
byMac: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TAG_STATE: DeviceTagState = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
items: [],
|
||||||
|
byMac: {},
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_PROXY_TRAFFIC: ProxyTrafficState = {
|
const DEFAULT_PROXY_TRAFFIC: ProxyTrafficState = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
lastObservedAt: null,
|
lastObservedAt: null,
|
||||||
@@ -252,6 +274,7 @@ const DEFAULT_STATE: InventoryState = {
|
|||||||
lastObservedAt: null,
|
lastObservedAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
policy: DEFAULT_POLICY_STATE,
|
policy: DEFAULT_POLICY_STATE,
|
||||||
|
tags: DEFAULT_TAG_STATE,
|
||||||
traffic: {
|
traffic: {
|
||||||
epoch: null,
|
epoch: null,
|
||||||
generation: null,
|
generation: null,
|
||||||
@@ -281,6 +304,17 @@ const parseStoredCounter = (value: unknown) => {
|
|||||||
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeTagName = (value: unknown) => typeof value === 'string' ? value.trim() : '';
|
||||||
|
const tagNameKey = (value: string) => value.toLocaleLowerCase('ru-RU');
|
||||||
|
|
||||||
|
function validTagName(value: string) {
|
||||||
|
return value.length > 0 && value.length <= TAG_NAME_MAX_LENGTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameStringList = (left: string[], right: string[]) => (
|
||||||
|
left.length === right.length && left.every((value, index) => value === right[index])
|
||||||
|
);
|
||||||
|
|
||||||
const validTimestamp = (value: unknown): value is string => (
|
const validTimestamp = (value: unknown): value is string => (
|
||||||
typeof value === 'string' && Number.isFinite(Date.parse(value))
|
typeof value === 'string' && Number.isFinite(Date.parse(value))
|
||||||
);
|
);
|
||||||
@@ -526,6 +560,38 @@ function normalizePolicyState(value: unknown): DevicePolicyState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTagState(value: unknown, devices: InventoryDevice[]): DeviceTagState {
|
||||||
|
const tags = record(value);
|
||||||
|
if (typeof tags.schemaVersion === 'number' && Number.isSafeInteger(tags.schemaVersion)
|
||||||
|
&& tags.schemaVersion > 1) {
|
||||||
|
throw new Error(`Unsupported device tags schemaVersion: ${tags.schemaVersion}`);
|
||||||
|
}
|
||||||
|
const items: DeviceTag[] = [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const itemValue of Array.isArray(tags.items) ? tags.items : []) {
|
||||||
|
if (items.length >= TAG_CATALOG_LIMIT) break;
|
||||||
|
const item = record(itemValue);
|
||||||
|
const id = typeof item.id === 'string' ? item.id : '';
|
||||||
|
const name = normalizeTagName(item.name);
|
||||||
|
const nameKey = tagNameKey(name);
|
||||||
|
if (!TAG_ID_PATTERN.test(id) || !validTagName(name) || ids.has(id) || names.has(nameKey)) continue;
|
||||||
|
items.push({ id, name });
|
||||||
|
ids.add(id);
|
||||||
|
names.add(nameKey);
|
||||||
|
}
|
||||||
|
const knownMacs = new Set(devices.map(({ mac }) => mac));
|
||||||
|
const byMac: Record<string, string[]> = {};
|
||||||
|
for (const [rawMac, rawIds] of Object.entries(record(tags.byMac))) {
|
||||||
|
const mac = normalizeMac(rawMac);
|
||||||
|
if (!knownMacs.has(mac) || !Array.isArray(rawIds)) continue;
|
||||||
|
const selected = new Set(rawIds.filter((id): id is string => typeof id === 'string' && ids.has(id)));
|
||||||
|
const ordered = items.map(({ id }) => id).filter((id) => selected.has(id)).slice(0, DEVICE_TAG_LIMIT);
|
||||||
|
if (ordered.length) byMac[mac] = ordered;
|
||||||
|
}
|
||||||
|
return { schemaVersion: 1, items, byMac };
|
||||||
|
}
|
||||||
|
|
||||||
export function parseOuiVendors(text: unknown) {
|
export function parseOuiVendors(text: unknown) {
|
||||||
const vendors = new Map<string, string>();
|
const vendors = new Map<string, string>();
|
||||||
for (const line of String(text || '').split(/\r?\n/)) {
|
for (const line of String(text || '').split(/\r?\n/)) {
|
||||||
@@ -564,6 +630,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
|||||||
.map(normalizeInventoryDevice)
|
.map(normalizeInventoryDevice)
|
||||||
.filter((device): device is InventoryDevice => device !== null)
|
.filter((device): device is InventoryDevice => device !== null)
|
||||||
: [];
|
: [];
|
||||||
|
const tags = normalizeTagState(state.tags, devices);
|
||||||
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
|
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
|
||||||
const rebaselineMacs = new Set<string>((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
|
const rebaselineMacs = new Set<string>((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
|
||||||
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||||
@@ -658,6 +725,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
|||||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) ? state.revision : 0,
|
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) ? state.revision : 0,
|
||||||
policy: normalizePolicyState(state.policy),
|
policy: normalizePolicyState(state.policy),
|
||||||
|
tags,
|
||||||
traffic: {
|
traffic: {
|
||||||
...DEFAULT_STATE.traffic,
|
...DEFAULT_STATE.traffic,
|
||||||
...traffic,
|
...traffic,
|
||||||
@@ -978,6 +1046,7 @@ export function createDeviceInventoryService({
|
|||||||
const policy = policyFor(state, device.mac);
|
const policy = policyFor(state, device.mac);
|
||||||
return {
|
return {
|
||||||
...device,
|
...device,
|
||||||
|
tagIds: state.tags.byMac[device.mac] || [],
|
||||||
status: deviceStatus(device.lastSeenAt, current),
|
status: deviceStatus(device.lastSeenAt, current),
|
||||||
uploadBytes: traffic?.uploadBytes || '0',
|
uploadBytes: traffic?.uploadBytes || '0',
|
||||||
downloadBytes: traffic?.downloadBytes || '0',
|
downloadBytes: traffic?.downloadBytes || '0',
|
||||||
@@ -1013,6 +1082,7 @@ export function createDeviceInventoryService({
|
|||||||
: [gatewayTraffic.lastObservedAt, proxyTraffic.lastObservedAt].filter(Boolean);
|
: [gatewayTraffic.lastObservedAt, proxyTraffic.lastObservedAt].filter(Boolean);
|
||||||
return {
|
return {
|
||||||
revision: state.revision,
|
revision: state.revision,
|
||||||
|
tags: state.tags.items,
|
||||||
trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT,
|
trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT,
|
||||||
traffic: {
|
traffic: {
|
||||||
gatewayBytes: gatewayBytes.toString(),
|
gatewayBytes: gatewayBytes.toString(),
|
||||||
@@ -1578,11 +1648,20 @@ export function createDeviceInventoryService({
|
|||||||
const aliasProvided = Object.hasOwn(value, 'alias');
|
const aliasProvided = Object.hasOwn(value, 'alias');
|
||||||
const pinProvided = Object.hasOwn(value, 'pinned');
|
const pinProvided = Object.hasOwn(value, 'pinned');
|
||||||
const deprioritizedProvided = Object.hasOwn(value, 'deprioritized');
|
const deprioritizedProvided = Object.hasOwn(value, 'deprioritized');
|
||||||
|
const tagIdsProvided = Object.hasOwn(value, 'tagIds');
|
||||||
|
const requestedTagIds = Array.isArray(value.tagIds)
|
||||||
|
? value.tagIds.filter((tagId): tagId is string => typeof tagId === 'string')
|
||||||
|
: [];
|
||||||
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|
||||||
|| (!aliasProvided && !pinProvided && !deprioritizedProvided)
|
|| (!aliasProvided && !pinProvided && !deprioritizedProvided && !tagIdsProvided)
|
||||||
|| (aliasProvided && (typeof value.alias !== 'string' || value.alias.length > 64))
|
|| (aliasProvided && (typeof value.alias !== 'string' || value.alias.length > 64))
|
||||||
|| (pinProvided && typeof value.pinned !== 'boolean')
|
|| (pinProvided && typeof value.pinned !== 'boolean')
|
||||||
|| (deprioritizedProvided && typeof value.deprioritized !== 'boolean')
|
|| (deprioritizedProvided && typeof value.deprioritized !== 'boolean')
|
||||||
|
|| (tagIdsProvided && (!Array.isArray(value.tagIds)
|
||||||
|
|| requestedTagIds.length !== value.tagIds.length
|
||||||
|
|| requestedTagIds.length > DEVICE_TAG_LIMIT
|
||||||
|
|| new Set(requestedTagIds).size !== requestedTagIds.length
|
||||||
|
|| requestedTagIds.some((tagId) => !TAG_ID_PATTERN.test(tagId))))
|
||||||
|| (value.pinned === true && value.deprioritized === true)) {
|
|| (value.pinned === true && value.deprioritized === true)) {
|
||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
@@ -1595,16 +1674,114 @@ export function createDeviceInventoryService({
|
|||||||
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
|
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
|
||||||
const index = state.devices.findIndex((device) => device.id === id);
|
const index = state.devices.findIndex((device) => device.id === id);
|
||||||
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
|
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
|
||||||
|
const knownTagIds = new Set(state.tags.items.map(({ id: tagId }) => tagId));
|
||||||
|
if (tagIdsProvided && requestedTagIds.some((tagId) => !knownTagIds.has(tagId))) {
|
||||||
|
throw new HarborError('DEVICE_TAG_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const tagIds = state.tags.items
|
||||||
|
.map(({ id: tagId }) => tagId)
|
||||||
|
.filter((tagId) => requestedTagIds.includes(tagId));
|
||||||
|
const currentTagIds = state.tags.byMac[state.devices[index].mac] || [];
|
||||||
|
const nextAlias = aliasProvided ? alias.trim() : state.devices[index].alias;
|
||||||
|
const nextPinned = pinProvided ? pinned : state.devices[index].pinned;
|
||||||
|
const nextDeprioritized = deprioritizedProvided
|
||||||
|
? deprioritized
|
||||||
|
: pinProvided && pinned ? false : state.devices[index].deprioritized;
|
||||||
|
if (nextAlias === state.devices[index].alias
|
||||||
|
&& nextPinned === state.devices[index].pinned
|
||||||
|
&& nextDeprioritized === state.devices[index].deprioritized
|
||||||
|
&& (!tagIdsProvided || sameStringList(tagIds, currentTagIds))) return state;
|
||||||
const devices = [...state.devices];
|
const devices = [...state.devices];
|
||||||
devices[index] = {
|
devices[index] = {
|
||||||
...devices[index],
|
...devices[index],
|
||||||
...(aliasProvided ? { alias: alias.trim() } : {}),
|
...(aliasProvided ? { alias: nextAlias } : {}),
|
||||||
...(pinProvided ? { pinned, ...(pinned ? { deprioritized: false } : {}) } : {}),
|
...(pinProvided ? { pinned, ...(pinned ? { deprioritized: false } : {}) } : {}),
|
||||||
...(deprioritizedProvided
|
...(deprioritizedProvided
|
||||||
? { deprioritized, ...(deprioritized ? { pinned: false } : {}) }
|
? { deprioritized, ...(deprioritized ? { pinned: false } : {}) }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
return { ...state, revision: state.revision + 1, devices };
|
let tags = state.tags;
|
||||||
|
if (tagIdsProvided) {
|
||||||
|
const byMac = { ...state.tags.byMac };
|
||||||
|
if (tagIds.length) byMac[devices[index].mac] = tagIds;
|
||||||
|
else delete byMac[devices[index].mac];
|
||||||
|
tags = { ...state.tags, byMac };
|
||||||
|
}
|
||||||
|
return { ...state, revision: state.revision + 1, devices, tags };
|
||||||
|
});
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTag(nameValue: unknown, expectedRevision: unknown) {
|
||||||
|
const name = normalizeTagName(nameValue);
|
||||||
|
if (!validTagName(name) || typeof expectedRevision !== 'number'
|
||||||
|
|| !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
store.update((stored) => {
|
||||||
|
const state = migrateDeviceInventoryState(stored);
|
||||||
|
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
|
if (state.tags.items.length >= TAG_CATALOG_LIMIT) throw new HarborError('REQUEST_INVALID');
|
||||||
|
if (state.tags.items.some((tag) => tagNameKey(tag.name) === tagNameKey(name))) {
|
||||||
|
throw new HarborError('DEVICE_TAG_NAME_CONFLICT');
|
||||||
|
}
|
||||||
|
let id = '';
|
||||||
|
const ids = new Set(state.tags.items.map((tag) => tag.id));
|
||||||
|
do id = `tag_${crypto.randomBytes(8).toString('hex')}`; while (ids.has(id));
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
revision: state.revision + 1,
|
||||||
|
tags: { ...state.tags, items: [...state.tags.items, { id, name }] },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameTag(id: string, nameValue: unknown, expectedRevision: unknown) {
|
||||||
|
const name = normalizeTagName(nameValue);
|
||||||
|
if (!TAG_ID_PATTERN.test(id) || !validTagName(name) || typeof expectedRevision !== 'number'
|
||||||
|
|| !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
store.update((stored) => {
|
||||||
|
const state = migrateDeviceInventoryState(stored);
|
||||||
|
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
|
const index = state.tags.items.findIndex((tag) => tag.id === id);
|
||||||
|
if (index < 0) throw new HarborError('DEVICE_TAG_NOT_FOUND');
|
||||||
|
if (state.tags.items[index].name === name) return state;
|
||||||
|
if (state.tags.items.some((tag) => tag.id !== id && tagNameKey(tag.name) === tagNameKey(name))) {
|
||||||
|
throw new HarborError('DEVICE_TAG_NAME_CONFLICT');
|
||||||
|
}
|
||||||
|
const items = [...state.tags.items];
|
||||||
|
items[index] = { ...items[index], name };
|
||||||
|
return { ...state, revision: state.revision + 1, tags: { ...state.tags, items } };
|
||||||
|
});
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTag(id: string, expectedRevision: unknown) {
|
||||||
|
if (!TAG_ID_PATTERN.test(id) || typeof expectedRevision !== 'number'
|
||||||
|
|| !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
store.update((stored) => {
|
||||||
|
const state = migrateDeviceInventoryState(stored);
|
||||||
|
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
|
if (!state.tags.items.some((tag) => tag.id === id)) throw new HarborError('DEVICE_TAG_NOT_FOUND');
|
||||||
|
const byMac: Record<string, string[]> = {};
|
||||||
|
for (const [mac, tagIds] of Object.entries(state.tags.byMac)) {
|
||||||
|
const remaining = tagIds.filter((tagId) => tagId !== id);
|
||||||
|
if (remaining.length) byMac[mac] = remaining;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
revision: state.revision + 1,
|
||||||
|
tags: {
|
||||||
|
...state.tags,
|
||||||
|
items: state.tags.items.filter((tag) => tag.id !== id),
|
||||||
|
byMac,
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
@@ -1716,5 +1893,16 @@ export function createDeviceInventoryService({
|
|||||||
return serializePolicy(() => reconcileLocked(observed, true));
|
return serializePolicy(() => reconcileLocked(observed, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { snapshot, metricsSnapshot, refresh, update, resetTraffic, setPolicy, reconcilePolicies };
|
return {
|
||||||
|
snapshot,
|
||||||
|
metricsSnapshot,
|
||||||
|
refresh,
|
||||||
|
update,
|
||||||
|
createTag,
|
||||||
|
renameTag,
|
||||||
|
deleteTag,
|
||||||
|
resetTraffic,
|
||||||
|
setPolicy,
|
||||||
|
reconcilePolicies,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export const ERROR_DEFINITIONS = Object.freeze({
|
|||||||
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
|
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
|
||||||
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
|
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
|
||||||
DEVICE_NOT_FOUND: { status: 404, message: 'Устройство больше недоступно.', retryable: false },
|
DEVICE_NOT_FOUND: { status: 404, message: 'Устройство больше недоступно.', retryable: false },
|
||||||
|
DEVICE_TAG_NOT_FOUND: { status: 404, message: 'Тег больше недоступен.', retryable: false },
|
||||||
|
DEVICE_TAG_NAME_CONFLICT: { status: 409, message: 'Тег с таким именем уже существует.', retryable: false },
|
||||||
DEVICE_IDENTITY_AMBIGUOUS: { status: 409, message: 'Gateway не может безопасно применить маршрут к этому устройству.', retryable: true },
|
DEVICE_IDENTITY_AMBIGUOUS: { status: 409, message: 'Gateway не может безопасно применить маршрут к этому устройству.', retryable: true },
|
||||||
DEVICE_POLICY_APPLY_FAILED: { status: 503, message: 'Не удалось применить маршрут устройства.', retryable: true },
|
DEVICE_POLICY_APPLY_FAILED: { status: 503, message: 'Не удалось применить маршрут устройства.', retryable: true },
|
||||||
DIAGNOSTICS_FAILED: { status: 503, message: 'Не удалось проверить маршруты. Попробуйте ещё раз.', retryable: true },
|
DIAGNOSTICS_FAILED: { status: 503, message: 'Не удалось проверить маршруты. Попробуйте ещё раз.', retryable: true },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.32.0',
|
macClient: '0.33.0',
|
||||||
gatewayClient: '0.33.0',
|
gatewayClient: '0.34.0',
|
||||||
gatewayBackend: '0.33.0',
|
gatewayBackend: '0.34.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ParsedVersion {
|
export interface ParsedVersion {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const componentActions = {
|
|||||||
refreshDevices: api.devices.refresh,
|
refreshDevices: api.devices.refresh,
|
||||||
resetDeviceTraffic: api.devices.resetTraffic,
|
resetDeviceTraffic: api.devices.resetTraffic,
|
||||||
updateDevice: api.devices.update,
|
updateDevice: api.devices.update,
|
||||||
|
createDeviceTag: api.devices.createTag,
|
||||||
|
renameDeviceTag: api.devices.renameTag,
|
||||||
|
deleteDeviceTag: api.devices.deleteTag,
|
||||||
setDevicePolicy: api.devices.setPolicy,
|
setDevicePolicy: api.devices.setPolicy,
|
||||||
pingServers: api.servers.ping,
|
pingServers: api.servers.ping,
|
||||||
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
||||||
|
|||||||
@@ -168,6 +168,24 @@ export const api = {
|
|||||||
body: JSON.stringify({ ...patch, expectedRevision }),
|
body: JSON.stringify({ ...patch, expectedRevision }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
createTag: (name: string, expectedRevision: unknown) => request('/api/device-tags', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, expectedRevision }),
|
||||||
|
}),
|
||||||
|
renameTag: (id: string, name: string, expectedRevision: unknown) => request(
|
||||||
|
`/api/device-tags/${id}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name, expectedRevision }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
deleteTag: (id: string, expectedRevision: unknown) => request(
|
||||||
|
`/api/device-tags/${id}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ expectedRevision }),
|
||||||
|
},
|
||||||
|
),
|
||||||
setPolicy: (id: string, mode: unknown, expectedRevision: unknown) => request(
|
setPolicy: (id: string, mode: unknown, expectedRevision: unknown) => request(
|
||||||
`/api/devices/${id}/policy`,
|
`/api/devices/${id}/policy`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ interface ComponentActions {
|
|||||||
refreshDevices: () => Promise<unknown>;
|
refreshDevices: () => Promise<unknown>;
|
||||||
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
||||||
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||||
|
createDeviceTag: (name: string, expectedRevision: number) => Promise<unknown>;
|
||||||
|
renameDeviceTag: (id: string, name: string, expectedRevision: number) => Promise<unknown>;
|
||||||
|
deleteDeviceTag: (id: string, expectedRevision: number) => Promise<unknown>;
|
||||||
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
||||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||||
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
||||||
@@ -569,6 +572,9 @@ export function ClientOverviewPage({
|
|||||||
refreshDevices: actions.refreshDevices,
|
refreshDevices: actions.refreshDevices,
|
||||||
resetDeviceTraffic: actions.resetDeviceTraffic,
|
resetDeviceTraffic: actions.resetDeviceTraffic,
|
||||||
updateDevice: actions.updateDevice,
|
updateDevice: actions.updateDevice,
|
||||||
|
createDeviceTag: actions.createDeviceTag,
|
||||||
|
renameDeviceTag: actions.renameDeviceTag,
|
||||||
|
deleteDeviceTag: actions.deleteDeviceTag,
|
||||||
setDevicePolicy: actions.setDevicePolicy,
|
setDevicePolicy: actions.setDevicePolicy,
|
||||||
});
|
});
|
||||||
const diagnosticsFeature = useDiagnosticsFeature();
|
const diagnosticsFeature = useDiagnosticsFeature();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type Device,
|
type Device,
|
||||||
type DevicePolicy,
|
type DevicePolicy,
|
||||||
type DeviceSnapshot,
|
type DeviceSnapshot,
|
||||||
|
type DeviceTag,
|
||||||
} from './deviceSnapshot.js';
|
} from './deviceSnapshot.js';
|
||||||
|
|
||||||
const DEVICE_AUTO_REFRESH_MS = 15_000;
|
const DEVICE_AUTO_REFRESH_MS = 15_000;
|
||||||
@@ -21,6 +22,9 @@ interface DevicesFeatureOptions {
|
|||||||
refreshDevices: () => Promise<unknown>;
|
refreshDevices: () => Promise<unknown>;
|
||||||
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
||||||
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||||
|
createDeviceTag: (name: string, expectedRevision: number) => Promise<unknown>;
|
||||||
|
renameDeviceTag: (id: string, name: string, expectedRevision: number) => Promise<unknown>;
|
||||||
|
deleteDeviceTag: (id: string, expectedRevision: number) => Promise<unknown>;
|
||||||
setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise<unknown>;
|
setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +41,21 @@ function requestError(value: unknown): RequestError {
|
|||||||
return { code: typeof value.code === 'string' ? value.code : undefined };
|
return { code: typeof value.code === 'string' ? value.code : undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sameStringList = (left: string[], right: string[]) => (
|
||||||
|
left.length === right.length && left.every((value, index) => value === right[index])
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagNameKey = (value: string) => value.trim().toLocaleLowerCase('ru-RU');
|
||||||
|
|
||||||
export function useDevicesFeature({
|
export function useDevicesFeature({
|
||||||
isGateway,
|
isGateway,
|
||||||
listDevices,
|
listDevices,
|
||||||
refreshDevices,
|
refreshDevices,
|
||||||
resetDeviceTraffic,
|
resetDeviceTraffic,
|
||||||
updateDevice: requestDeviceUpdate,
|
updateDevice: requestDeviceUpdate,
|
||||||
|
createDeviceTag,
|
||||||
|
renameDeviceTag,
|
||||||
|
deleteDeviceTag,
|
||||||
setDevicePolicy,
|
setDevicePolicy,
|
||||||
}: DevicesFeatureOptions) {
|
}: DevicesFeatureOptions) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -52,6 +65,8 @@ export function useDevicesFeature({
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [refreshCycle, setRefreshCycle] = useState(0);
|
const [refreshCycle, setRefreshCycle] = useState(0);
|
||||||
const [savingId, setSavingId] = useState('');
|
const [savingId, setSavingId] = useState('');
|
||||||
|
const [tagSavingId, setTagSavingId] = useState('');
|
||||||
|
const [tagError, setTagError] = useState<unknown>(null);
|
||||||
const [resetOpen, setResetOpen] = useState(false);
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
const panelRef = useRef<HTMLElement>(null);
|
const panelRef = useRef<HTMLElement>(null);
|
||||||
@@ -140,6 +155,120 @@ export function useDevicesFeature({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateDeviceTags(device: Device, tagIds: string[], baselineTagIds: string[]) {
|
||||||
|
if (!snapshot) return false;
|
||||||
|
setTagSavingId(device.id);
|
||||||
|
setTagError(null);
|
||||||
|
const currentDevice = snapshot.devices.find(({ id }) => id === device.id);
|
||||||
|
if (!currentDevice || !sameStringList(currentDevice.tagIds, baselineTagIds)) {
|
||||||
|
setTagError(new Error('Device tags changed'));
|
||||||
|
setTagSavingId('');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let next: DeviceSnapshot;
|
||||||
|
try {
|
||||||
|
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, { tagIds }, snapshot.revision));
|
||||||
|
} catch (caught) {
|
||||||
|
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||||
|
const latest = parseDeviceSnapshot(await listDevices());
|
||||||
|
publish(latest);
|
||||||
|
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||||
|
const knownTagIds = new Set(latest.tags.map(({ id }) => id));
|
||||||
|
if (!latestDevice || !sameStringList(latestDevice.tagIds, baselineTagIds)
|
||||||
|
|| tagIds.some((tagId) => !knownTagIds.has(tagId))) throw caught;
|
||||||
|
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, { tagIds }, latest.revision));
|
||||||
|
}
|
||||||
|
publish(next);
|
||||||
|
return true;
|
||||||
|
} catch (caught) {
|
||||||
|
setTagError(caught);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setTagSavingId('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTag(name: string) {
|
||||||
|
if (!snapshot) return false;
|
||||||
|
setTagSavingId('create');
|
||||||
|
setTagError(null);
|
||||||
|
try {
|
||||||
|
let next: DeviceSnapshot;
|
||||||
|
try {
|
||||||
|
next = parseDeviceSnapshot(await createDeviceTag(name, snapshot.revision));
|
||||||
|
} catch (caught) {
|
||||||
|
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||||
|
const latest = parseDeviceSnapshot(await listDevices());
|
||||||
|
publish(latest);
|
||||||
|
const nameKey = tagNameKey(name);
|
||||||
|
if (latest.tags.length >= 32 || latest.tags.some((tag) => tagNameKey(tag.name) === nameKey)) throw caught;
|
||||||
|
next = parseDeviceSnapshot(await createDeviceTag(name, latest.revision));
|
||||||
|
}
|
||||||
|
publish(next);
|
||||||
|
return true;
|
||||||
|
} catch (caught) {
|
||||||
|
setTagError(caught);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setTagSavingId('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameTag(tag: DeviceTag, name: string, baselineName: string) {
|
||||||
|
if (!snapshot) return false;
|
||||||
|
setTagSavingId(tag.id);
|
||||||
|
setTagError(null);
|
||||||
|
if (snapshot.tags.find(({ id }) => id === tag.id)?.name !== baselineName) {
|
||||||
|
setTagError(new Error('Device tag changed'));
|
||||||
|
setTagSavingId('');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let next: DeviceSnapshot;
|
||||||
|
try {
|
||||||
|
next = parseDeviceSnapshot(await renameDeviceTag(tag.id, name, snapshot.revision));
|
||||||
|
} catch (caught) {
|
||||||
|
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||||
|
const latest = parseDeviceSnapshot(await listDevices());
|
||||||
|
publish(latest);
|
||||||
|
const latestTag = latest.tags.find(({ id }) => id === tag.id);
|
||||||
|
if (!latestTag || latestTag.name !== baselineName) throw caught;
|
||||||
|
next = parseDeviceSnapshot(await renameDeviceTag(tag.id, name, latest.revision));
|
||||||
|
}
|
||||||
|
publish(next);
|
||||||
|
return true;
|
||||||
|
} catch (caught) {
|
||||||
|
setTagError(caught);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setTagSavingId('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTag(tag: DeviceTag): Promise<'saved' | 'conflict' | 'failed'> {
|
||||||
|
if (!snapshot) return 'failed';
|
||||||
|
setTagSavingId(tag.id);
|
||||||
|
setTagError(null);
|
||||||
|
try {
|
||||||
|
publish(parseDeviceSnapshot(await deleteDeviceTag(tag.id, snapshot.revision)));
|
||||||
|
return 'saved';
|
||||||
|
} catch (caught) {
|
||||||
|
setTagError(caught);
|
||||||
|
if (requestError(caught).code === 'STATE_CONFLICT') {
|
||||||
|
try {
|
||||||
|
publish(parseDeviceSnapshot(await listDevices()));
|
||||||
|
} catch {
|
||||||
|
// Preserve the conflict as the actionable error.
|
||||||
|
}
|
||||||
|
return 'conflict';
|
||||||
|
}
|
||||||
|
return 'failed';
|
||||||
|
} finally {
|
||||||
|
setTagSavingId('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmResetTraffic() {
|
async function confirmResetTraffic() {
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
setResetting(true);
|
setResetting(true);
|
||||||
@@ -180,6 +309,7 @@ export function useDevicesFeature({
|
|||||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||||
const closeDevices = (event: PointerEvent | KeyboardEvent) => {
|
const closeDevices = (event: PointerEvent | KeyboardEvent) => {
|
||||||
if (resetOpen) return;
|
if (resetOpen) return;
|
||||||
|
if (document.querySelector('.client-devices-rail.is-open, .client-device-tag-popover, .client-confirmation-popup.is-open')) return;
|
||||||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||||||
if (event.type !== 'keydown' && (
|
if (event.type !== 'keydown' && (
|
||||||
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
||||||
@@ -206,6 +336,8 @@ export function useDevicesFeature({
|
|||||||
refreshing,
|
refreshing,
|
||||||
refreshCycle,
|
refreshCycle,
|
||||||
savingId,
|
savingId,
|
||||||
|
tagSavingId,
|
||||||
|
tagError,
|
||||||
resetOpen,
|
resetOpen,
|
||||||
resetting,
|
resetting,
|
||||||
panelRef,
|
panelRef,
|
||||||
@@ -213,6 +345,11 @@ export function useDevicesFeature({
|
|||||||
closeRef,
|
closeRef,
|
||||||
load,
|
load,
|
||||||
updateDevice,
|
updateDevice,
|
||||||
|
updateDeviceTags,
|
||||||
|
createTag,
|
||||||
|
renameTag,
|
||||||
|
deleteTag,
|
||||||
|
clearTagError: () => setTagError(null),
|
||||||
updatePolicy,
|
updatePolicy,
|
||||||
requestTrafficReset: () => setResetOpen(true),
|
requestTrafficReset: () => setResetOpen(true),
|
||||||
cancelTrafficReset: () => setResetOpen(false),
|
cancelTrafficReset: () => setResetOpen(false),
|
||||||
|
|||||||
@@ -5,26 +5,34 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
|
type FormEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { Drawer } from '../../ui/Drawer.js';
|
import { Drawer } from '../../ui/Drawer.js';
|
||||||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||||||
import { Tooltip } from '../../ui/Tooltip.js';
|
import { Tooltip } from '../../ui/Tooltip.js';
|
||||||
import { copyText } from '../../utils/clientControls.js';
|
import { copyText } from '../../utils/clientControls.js';
|
||||||
import {
|
import {
|
||||||
byteString,
|
byteString,
|
||||||
|
deviceFilterCounts,
|
||||||
|
filterDevices,
|
||||||
formatByteString,
|
formatByteString,
|
||||||
formatLastSeen,
|
formatLastSeen,
|
||||||
isNewDevice,
|
isNewDevice,
|
||||||
positiveByteDelta,
|
positiveByteDelta,
|
||||||
stabilizeDevicesByTraffic,
|
stabilizeDevicesByTraffic,
|
||||||
|
type DeviceSystemFilter,
|
||||||
} from '../../utils/format.js';
|
} from '../../utils/format.js';
|
||||||
import { TrafficChart } from './TrafficChart.js';
|
import { TrafficChart } from './TrafficChart.js';
|
||||||
import { type Device } from './deviceSnapshot.js';
|
import { type Device, type DeviceTag } from './deviceSnapshot.js';
|
||||||
import type { DevicesFeature } from './DevicesFeature.js';
|
import type { DevicesFeature } from './DevicesFeature.js';
|
||||||
|
|
||||||
const DEVICE_MOVE_MS = 520;
|
const DEVICE_MOVE_MS = 520;
|
||||||
const COPY_FEEDBACK_MS = 800;
|
const COPY_FEEDBACK_MS = 800;
|
||||||
const TRAFFIC_DELTA_MS = 2_200;
|
const TRAFFIC_DELTA_MS = 2_200;
|
||||||
|
const FOCUSABLE = 'button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
const tagTone = (id: string) => Number.parseInt(id.slice(-2), 16) % 4;
|
||||||
|
|
||||||
interface TrafficDelta {
|
interface TrafficDelta {
|
||||||
gateway?: string;
|
gateway?: string;
|
||||||
@@ -51,6 +59,15 @@ interface PinCollapse {
|
|||||||
|
|
||||||
type DeviceCopyField = 'IP' | 'MAC' | 'Hostname';
|
type DeviceCopyField = 'IP' | 'MAC' | 'Hostname';
|
||||||
|
|
||||||
|
interface TagPopoverState {
|
||||||
|
deviceId: string;
|
||||||
|
title: string;
|
||||||
|
baseline: string[];
|
||||||
|
draft: string[];
|
||||||
|
catalogKey: string;
|
||||||
|
anchor: DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
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');
|
||||||
@@ -84,10 +101,17 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
refreshing,
|
refreshing,
|
||||||
refreshCycle,
|
refreshCycle,
|
||||||
savingId,
|
savingId,
|
||||||
|
tagSavingId,
|
||||||
|
tagError,
|
||||||
resetOpen,
|
resetOpen,
|
||||||
resetting,
|
resetting,
|
||||||
load: onLoad,
|
load: onLoad,
|
||||||
updateDevice,
|
updateDevice,
|
||||||
|
updateDeviceTags,
|
||||||
|
createTag,
|
||||||
|
renameTag,
|
||||||
|
deleteTag,
|
||||||
|
clearTagError,
|
||||||
updatePolicy,
|
updatePolicy,
|
||||||
requestTrafficReset,
|
requestTrafficReset,
|
||||||
cancelTrafficReset,
|
cancelTrafficReset,
|
||||||
@@ -104,6 +128,20 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
const [pencilAnimationId, setPencilAnimationId] = useState('');
|
const [pencilAnimationId, setPencilAnimationId] = useState('');
|
||||||
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
|
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
|
||||||
const [pinCollapses, setPinCollapses] = useState<Record<string, PinCollapse>>({});
|
const [pinCollapses, setPinCollapses] = useState<Record<string, PinCollapse>>({});
|
||||||
|
const [systemFilter, setSystemFilter] = useState<DeviceSystemFilter>('all');
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
|
const [deviceQuery, setDeviceQuery] = useState('');
|
||||||
|
const [mobileRailOpen, setMobileRailOpen] = useState(false);
|
||||||
|
const [compactRail, setCompactRail] = useState(() => window.matchMedia('(max-width: 640px)').matches);
|
||||||
|
const [railMode, setRailMode] = useState<'filters' | 'manager'>('filters');
|
||||||
|
const [tagPopover, setTagPopover] = useState<TagPopoverState | null>(null);
|
||||||
|
const [tagPopoverClosing, setTagPopoverClosing] = useState(false);
|
||||||
|
const [tagAnnouncement, setTagAnnouncement] = useState('');
|
||||||
|
const [newTagName, setNewTagName] = useState('');
|
||||||
|
const [editingTagId, setEditingTagId] = useState('');
|
||||||
|
const [editingTagName, setEditingTagName] = useState('');
|
||||||
|
const [deletingTagId, setDeletingTagId] = useState('');
|
||||||
|
const [tagErrorCopy, setTagErrorCopy] = useState('');
|
||||||
const deviceNodes = useRef(new Map<string, HTMLElement>());
|
const deviceNodes = useRef(new Map<string, HTMLElement>());
|
||||||
const previousPositions = useRef(new Map<string, DOMRect>());
|
const previousPositions = useRef(new Map<string, DOMRect>());
|
||||||
const previousScrollTop = useRef(0);
|
const previousScrollTop = useRef(0);
|
||||||
@@ -113,29 +151,190 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
const copyTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
const copyTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
||||||
const copyAttempts = useRef(new Map<string, object>());
|
const copyAttempts = useRef(new Map<string, object>());
|
||||||
const trafficDeltaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const trafficDeltaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const tagPopoverCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const railModeAnimation = useRef<Animation | null>(null);
|
||||||
|
const railModeRequest = useRef(0);
|
||||||
const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] });
|
const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] });
|
||||||
|
const filterButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const railRef = useRef<HTMLElement>(null);
|
||||||
|
const layoutRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const popoverRef = useRef<HTMLElement>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const managerInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const tagTriggerRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||||
|
const restoreTagTriggerFocus = useRef(true);
|
||||||
|
const editingTagBaseline = useRef('');
|
||||||
|
const allDevices = snapshot?.devices || [];
|
||||||
|
const tags = snapshot?.tags || [];
|
||||||
|
const counts = useMemo(
|
||||||
|
() => deviceFilterCounts(allDevices, tags),
|
||||||
|
[snapshot?.devices, snapshot?.tags],
|
||||||
|
);
|
||||||
|
const filteredDevices = useMemo(
|
||||||
|
() => filterDevices(allDevices, tags, {
|
||||||
|
system: systemFilter,
|
||||||
|
tagIds: selectedTagIds,
|
||||||
|
query: deviceQuery,
|
||||||
|
}),
|
||||||
|
[snapshot?.devices, snapshot?.tags, systemFilter, selectedTagIds, deviceQuery],
|
||||||
|
);
|
||||||
const devices = useMemo(
|
const devices = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const previousIds = trafficOrder.current.direction === sortDirection
|
const previousIds = trafficOrder.current.direction === sortDirection
|
||||||
? trafficOrder.current.ids
|
? trafficOrder.current.ids
|
||||||
: [];
|
: [];
|
||||||
const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds) as {
|
const result = stabilizeDevicesByTraffic(filteredDevices, sortDirection, previousIds) as {
|
||||||
ids: string[];
|
ids: string[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
};
|
};
|
||||||
trafficOrder.current = { direction: sortDirection, ids: result.ids };
|
const canonical = stabilizeDevicesByTraffic(allDevices, sortDirection, previousIds) as {
|
||||||
|
ids: string[];
|
||||||
|
devices: Device[];
|
||||||
|
};
|
||||||
|
trafficOrder.current = { direction: sortDirection, ids: canonical.ids };
|
||||||
return result.devices;
|
return result.devices;
|
||||||
},
|
},
|
||||||
[snapshot?.devices, sortDirection],
|
[allDevices, filteredDevices, sortDirection],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filtersActive = systemFilter !== 'all' || selectedTagIds.length > 0 || deviceQuery.trim().length > 0;
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
for (const timer of copyTimers.current.values()) clearTimeout(timer);
|
for (const timer of copyTimers.current.values()) clearTimeout(timer);
|
||||||
copyTimers.current.clear();
|
copyTimers.current.clear();
|
||||||
copyAttempts.current.clear();
|
copyAttempts.current.clear();
|
||||||
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
|
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
|
||||||
|
if (tagPopoverCloseTimer.current) clearTimeout(tagPopoverCloseTimer.current);
|
||||||
|
railModeAnimation.current?.cancel();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const knownTagIds = new Set(tags.map(({ id }) => id));
|
||||||
|
setSelectedTagIds((current) => current.filter((id) => knownTagIds.has(id)));
|
||||||
|
if (!tagPopover) return;
|
||||||
|
const device = allDevices.find(({ id }) => id === tagPopover.deviceId);
|
||||||
|
const baselineChanged = !device || device.tagIds.length !== tagPopover.baseline.length
|
||||||
|
|| device.tagIds.some((id, index) => id !== tagPopover.baseline[index]);
|
||||||
|
const catalogChanged = tags.map(({ id, name }) => `${id}:${name}`).join('|') !== tagPopover.catalogKey;
|
||||||
|
if (!baselineChanged && !catalogChanged) return;
|
||||||
|
closeTagPopover();
|
||||||
|
setTagAnnouncement('Список тегов изменился. Откройте теги устройства снова.');
|
||||||
|
}, [snapshot?.tags, snapshot?.devices]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) return;
|
||||||
|
restoreTagTriggerFocus.current = false;
|
||||||
|
setMobileRailOpen(false);
|
||||||
|
setRailMode('filters');
|
||||||
|
closeTagPopover(true, false);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (railMode !== 'manager' || tagPopover || (compactRail && !mobileRailOpen)) return undefined;
|
||||||
|
const frame = requestAnimationFrame(() => managerInputRef.current?.focus());
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [compactRail, mobileRailOpen, railMode, tagPopover]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia('(max-width: 640px)');
|
||||||
|
const update = () => {
|
||||||
|
setCompactRail(media.matches);
|
||||||
|
if (!media.matches) setMobileRailOpen(false);
|
||||||
|
};
|
||||||
|
media.addEventListener('change', update);
|
||||||
|
return () => media.removeEventListener('change', update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mobileRailOpen) return undefined;
|
||||||
|
if (contentRef.current) contentRef.current.inert = true;
|
||||||
|
if (closeRef.current) closeRef.current.inert = true;
|
||||||
|
const frame = requestAnimationFrame(() => searchRef.current?.focus());
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
setMobileRailOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
const controls = Array.from(railRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) || []);
|
||||||
|
const first = controls[0];
|
||||||
|
const last = controls.at(-1);
|
||||||
|
if (!first || !last) return;
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
if (contentRef.current) contentRef.current.inert = false;
|
||||||
|
if (closeRef.current) closeRef.current.inert = false;
|
||||||
|
requestAnimationFrame(() => filterButtonRef.current?.focus());
|
||||||
|
};
|
||||||
|
}, [mobileRailOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tagPopover) return undefined;
|
||||||
|
if (layoutRef.current) layoutRef.current.inert = true;
|
||||||
|
if (closeRef.current) closeRef.current.inert = true;
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
const assigned = popoverRef.current?.querySelector<HTMLInputElement>('input:checked');
|
||||||
|
const first = popoverRef.current?.querySelector<HTMLElement>(FOCUSABLE);
|
||||||
|
(assigned || first)?.focus();
|
||||||
|
});
|
||||||
|
const close = () => closeTagPopover();
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
const controls = Array.from(popoverRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) || []);
|
||||||
|
const first = controls[0];
|
||||||
|
const last = controls.at(-1);
|
||||||
|
if (!first || !last) return;
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
window.addEventListener('resize', close);
|
||||||
|
panelRef.current?.addEventListener('scroll', close);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
window.removeEventListener('resize', close);
|
||||||
|
panelRef.current?.removeEventListener('scroll', close);
|
||||||
|
if (layoutRef.current) layoutRef.current.inert = false;
|
||||||
|
if (closeRef.current) closeRef.current.inert = false;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!restoreTagTriggerFocus.current) {
|
||||||
|
restoreTagTriggerFocus.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = [
|
||||||
|
tagTriggerRefs.current.get(tagPopover.deviceId),
|
||||||
|
searchRef.current,
|
||||||
|
filterButtonRef.current,
|
||||||
|
closeRef.current,
|
||||||
|
].find((candidate) => candidate?.isConnected && !candidate.closest('[inert]'));
|
||||||
|
target?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [tagPopover?.deviceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
previousTraffic.current.clear();
|
previousTraffic.current.clear();
|
||||||
@@ -193,23 +392,37 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const positions = new Map<string, DOMRect>();
|
const positions = new Map<string, DOMRect>();
|
||||||
|
const interruptedPositions = new Map<string, DOMRect>();
|
||||||
for (const [id, node] of deviceNodes.current) {
|
for (const [id, node] of deviceNodes.current) {
|
||||||
movementAnimations.current.get(id)?.cancel();
|
const activeAnimation = movementAnimations.current.get(id);
|
||||||
|
if (activeAnimation) {
|
||||||
|
activeAnimation.commitStyles();
|
||||||
|
activeAnimation.cancel();
|
||||||
|
movementAnimations.current.delete(id);
|
||||||
|
interruptedPositions.set(id, node.getBoundingClientRect());
|
||||||
|
node.style.removeProperty('opacity');
|
||||||
|
node.style.removeProperty('transform');
|
||||||
|
}
|
||||||
positions.set(id, node.getBoundingClientRect());
|
positions.set(id, node.getBoundingClientRect());
|
||||||
}
|
}
|
||||||
const currentScrollTop = panelRef.current?.scrollTop || 0;
|
const currentScrollTop = panelRef.current?.scrollTop || 0;
|
||||||
if (previousPositions.current.size > 0
|
if (previousPositions.current.size > 0
|
||||||
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
for (const [id, after] of positions) {
|
for (const [id, after] of positions) {
|
||||||
const before = previousPositions.current.get(id);
|
const interrupted = interruptedPositions.get(id);
|
||||||
|
const before = interrupted || previousPositions.current.get(id);
|
||||||
const deltaY = before
|
const deltaY = before
|
||||||
? before.top - after.top + previousScrollTop.current - currentScrollTop
|
? before.top - after.top + (interrupted ? 0 : previousScrollTop.current - currentScrollTop)
|
||||||
: 0;
|
: 0;
|
||||||
if (Math.abs(deltaY) < 1) continue;
|
const animation = before
|
||||||
const animation = deviceNodes.current.get(id)?.animate([
|
? Math.abs(deltaY) < 1 ? undefined : deviceNodes.current.get(id)?.animate([
|
||||||
{ transform: `translateY(${deltaY}px)` },
|
{ transform: `translateY(${deltaY}px)` },
|
||||||
{ transform: 'translateY(0)' },
|
{ transform: 'translateY(0)' },
|
||||||
], { duration: DEVICE_MOVE_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
|
], { duration: DEVICE_MOVE_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' })
|
||||||
|
: deviceNodes.current.get(id)?.animate([
|
||||||
|
{ opacity: 0, transform: 'translateY(6px)' },
|
||||||
|
{ opacity: 1, transform: 'translateY(0)' },
|
||||||
|
], { duration: 160, easing: 'ease' });
|
||||||
if (animation) {
|
if (animation) {
|
||||||
movementAnimations.current.set(id, animation);
|
movementAnimations.current.set(id, animation);
|
||||||
animation.onfinish = () => movementAnimations.current.delete(id);
|
animation.onfinish = () => movementAnimations.current.delete(id);
|
||||||
@@ -316,6 +529,164 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSystem(value: DeviceSystemFilter) {
|
||||||
|
setSystemFilter(value);
|
||||||
|
if (value === 'untagged') setSelectedTagIds([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTagFilter(tagId: string) {
|
||||||
|
if (systemFilter === 'untagged') setSystemFilter('all');
|
||||||
|
setSelectedTagIds((current) => current.includes(tagId)
|
||||||
|
? current.filter((id) => id !== tagId)
|
||||||
|
: [...current, tagId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
setSystemFilter('all');
|
||||||
|
setSelectedTagIds([]);
|
||||||
|
setDeviceQuery('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTagPopover(immediate = false, restoreFocus = true) {
|
||||||
|
if (!tagPopover) return;
|
||||||
|
restoreTagTriggerFocus.current = restoreFocus;
|
||||||
|
if (tagPopoverCloseTimer.current) clearTimeout(tagPopoverCloseTimer.current);
|
||||||
|
if (immediate || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
setTagPopoverClosing(false);
|
||||||
|
setTagPopover(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTagPopoverClosing(true);
|
||||||
|
tagPopoverCloseTimer.current = setTimeout(() => {
|
||||||
|
setTagPopover(null);
|
||||||
|
setTagPopoverClosing(false);
|
||||||
|
tagPopoverCloseTimer.current = null;
|
||||||
|
}, 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeRailMode(next: 'filters' | 'manager') {
|
||||||
|
if (next === railMode) return;
|
||||||
|
const request = ++railModeRequest.current;
|
||||||
|
const activeAnimation = railModeAnimation.current;
|
||||||
|
if (activeAnimation) {
|
||||||
|
try { activeAnimation.commitStyles(); } catch { /* The previous view may already be detached. */ }
|
||||||
|
activeAnimation.cancel();
|
||||||
|
railModeAnimation.current = null;
|
||||||
|
}
|
||||||
|
const view = railRef.current?.querySelector<HTMLElement>('.client-devices-rail-view');
|
||||||
|
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (view && !reducedMotion) {
|
||||||
|
const outgoing = view.animate(
|
||||||
|
[{ opacity: 0, transform: 'translateX(-6px)' }],
|
||||||
|
{ duration: 160, easing: 'ease', fill: 'forwards' },
|
||||||
|
);
|
||||||
|
railModeAnimation.current = outgoing;
|
||||||
|
try { await outgoing.finished; } catch { return; }
|
||||||
|
if (railModeAnimation.current === outgoing) railModeAnimation.current = null;
|
||||||
|
}
|
||||||
|
if (request !== railModeRequest.current) return;
|
||||||
|
setRailMode(next);
|
||||||
|
if (reducedMotion) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (request !== railModeRequest.current) return;
|
||||||
|
const incoming = railRef.current?.querySelector<HTMLElement>('.client-devices-rail-view')?.animate([
|
||||||
|
{ opacity: 0, transform: 'translateX(6px)' },
|
||||||
|
{ opacity: 1, transform: 'translateX(0)' },
|
||||||
|
], { duration: 160, easing: 'ease' });
|
||||||
|
if (incoming) {
|
||||||
|
railModeAnimation.current = incoming;
|
||||||
|
incoming.onfinish = () => {
|
||||||
|
if (railModeAnimation.current === incoming) railModeAnimation.current = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTagPopover(device: Device, title: string, anchor: DOMRect) {
|
||||||
|
if (tagPopoverCloseTimer.current) clearTimeout(tagPopoverCloseTimer.current);
|
||||||
|
clearTagError();
|
||||||
|
setTagAnnouncement('');
|
||||||
|
restoreTagTriggerFocus.current = true;
|
||||||
|
setTagPopoverClosing(false);
|
||||||
|
setTagPopover({
|
||||||
|
deviceId: device.id,
|
||||||
|
title,
|
||||||
|
baseline: [...device.tagIds],
|
||||||
|
draft: [...device.tagIds],
|
||||||
|
catalogKey: tags.map(({ id, name }) => `${id}:${name}`).join('|'),
|
||||||
|
anchor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDeviceTags() {
|
||||||
|
if (!tagPopover) return;
|
||||||
|
const device = allDevices.find(({ id }) => id === tagPopover.deviceId);
|
||||||
|
if (!device) return;
|
||||||
|
if (await updateDeviceTags(device, tagPopover.draft, tagPopover.baseline)) closeTagPopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTagManager() {
|
||||||
|
clearTagError();
|
||||||
|
setTagErrorCopy('');
|
||||||
|
restoreTagTriggerFocus.current = false;
|
||||||
|
void changeRailMode('manager');
|
||||||
|
if (compactRail) setMobileRailOpen(true);
|
||||||
|
closeTagPopover(false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNewTag(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setTagErrorCopy('Не удалось создать тег.');
|
||||||
|
if (!newTagName.trim() || !await createTag(newTagName)) return;
|
||||||
|
setTagErrorCopy('');
|
||||||
|
setNewTagName('');
|
||||||
|
requestAnimationFrame(() => managerInputRef.current?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTagRename(tag: DeviceTag) {
|
||||||
|
setTagErrorCopy('Не удалось переименовать тег.');
|
||||||
|
if (!editingTagName.trim() || !await renameTag(tag, editingTagName, editingTagBaseline.current)) return;
|
||||||
|
setTagErrorCopy('');
|
||||||
|
setEditingTagId('');
|
||||||
|
setEditingTagName('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletingTag = tags.find(({ id }) => id === deletingTagId) || null;
|
||||||
|
const deletingTagCount = deletingTag ? counts.byTag[deletingTag.id] || 0 : 0;
|
||||||
|
const deletingTagDescription = deletingTagCount === 0
|
||||||
|
? 'Тег больше не будет доступен для назначения. Устройства и маршруты не изменятся.'
|
||||||
|
: deletingTagCount === 1
|
||||||
|
? 'Тег исчезнет у одного устройства. Само устройство и его маршрут не изменятся.'
|
||||||
|
: `Тег исчезнет у ${deletingTagCount} устройств. Сами устройства и их маршруты не изменятся.`;
|
||||||
|
|
||||||
|
async function confirmTagDelete() {
|
||||||
|
if (!deletingTag) return;
|
||||||
|
setTagErrorCopy('Не удалось удалить тег.');
|
||||||
|
const result = await deleteTag(deletingTag);
|
||||||
|
if (result === 'saved') {
|
||||||
|
setDeletingTagId('');
|
||||||
|
setTagErrorCopy('');
|
||||||
|
} else if (result === 'conflict') {
|
||||||
|
setTagAnnouncement('Список тегов обновлён. Проверьте количество устройств и подтвердите удаление ещё раз.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemFilters: Array<{ id: DeviceSystemFilter; label: string; count: number }> = [
|
||||||
|
{ id: 'all', label: 'Все', count: counts.all },
|
||||||
|
{ id: 'new', label: 'Новые', count: counts.new },
|
||||||
|
{ id: 'pinned', label: 'Закреплённые', count: counts.pinned },
|
||||||
|
{ id: 'background', label: 'Фоновые', count: counts.background },
|
||||||
|
{ id: 'untagged', label: 'Без тегов', count: counts.untagged },
|
||||||
|
];
|
||||||
|
const selectedTagNames = tags
|
||||||
|
.filter(({ id }) => selectedTagIds.includes(id))
|
||||||
|
.map(({ name }) => name);
|
||||||
|
const activeSummary = [
|
||||||
|
systemFilter === 'all' ? '' : systemFilters.find(({ id }) => id === systemFilter)?.label || '',
|
||||||
|
selectedTagNames.join(' или '),
|
||||||
|
deviceQuery.trim() ? `«${deviceQuery.trim()}»` : '',
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Drawer
|
<Drawer
|
||||||
panelRef={panelRef}
|
panelRef={panelRef}
|
||||||
@@ -328,8 +699,147 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
closeLabel="Закрыть устройства"
|
closeLabel="Закрыть устройства"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
>
|
>
|
||||||
|
<div ref={layoutRef} className="client-devices-layout">
|
||||||
|
{compactRail && <button
|
||||||
|
className={`client-devices-rail-backdrop${mobileRailOpen ? ' is-open' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-label="Закрыть фильтры"
|
||||||
|
aria-hidden={!mobileRailOpen}
|
||||||
|
inert={!mobileRailOpen ? true : undefined}
|
||||||
|
onClick={() => setMobileRailOpen(false)}
|
||||||
|
/>}
|
||||||
|
<aside
|
||||||
|
ref={railRef}
|
||||||
|
className={mobileRailOpen ? 'client-devices-rail is-open' : 'client-devices-rail'}
|
||||||
|
role={compactRail ? 'dialog' : 'navigation'}
|
||||||
|
aria-modal={compactRail && mobileRailOpen ? true : undefined}
|
||||||
|
aria-label={railMode === 'filters' ? 'Фильтры устройств' : 'Управление тегами'}
|
||||||
|
aria-hidden={compactRail && !mobileRailOpen ? true : undefined}
|
||||||
|
inert={compactRail && !mobileRailOpen ? true : undefined}
|
||||||
|
>
|
||||||
|
<div className="client-devices-rail-view" key={railMode}>
|
||||||
|
{railMode === 'filters' ? <>
|
||||||
|
<label className="client-devices-search">
|
||||||
|
<span>Найти устройство</span>
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
type="search"
|
||||||
|
value={deviceQuery}
|
||||||
|
placeholder="Найти устройство"
|
||||||
|
onChange={(event) => setDeviceQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="client-devices-filter-group" role="group" aria-label="Системные фильтры">
|
||||||
|
{systemFilters.map((item) => <button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={systemFilter === item.id}
|
||||||
|
onClick={() => setSystem(item.id)}
|
||||||
|
>
|
||||||
|
<span>{item.label}</span><b>{item.count}</b>
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
{snapshot?.taggingSupported ? <>
|
||||||
|
<div className="client-devices-rail-heading"><span>Теги</span><span>{tags.length}/32</span></div>
|
||||||
|
<div className="client-devices-tag-filters" role="group" aria-label="Фильтр по тегам">
|
||||||
|
{tags.map((tag) => <button
|
||||||
|
key={tag.id}
|
||||||
|
data-tag-tone={tagTone(tag.id)}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selectedTagIds.includes(tag.id)}
|
||||||
|
onClick={() => toggleTagFilter(tag.id)}
|
||||||
|
>
|
||||||
|
<span>{tag.name}</span><b>{counts.byTag[tag.id] || 0}</b>
|
||||||
|
</button>)}
|
||||||
|
{!tags.length && <p>Тегов пока нет.</p>}
|
||||||
|
</div>
|
||||||
|
<button className="client-devices-manage-tags" type="button" onClick={showTagManager}>
|
||||||
|
Управление тегами
|
||||||
|
</button>
|
||||||
|
</> : snapshot && <p className="client-devices-tags-unsupported">
|
||||||
|
Теги доступны после обновления Gateway.
|
||||||
|
</p>}
|
||||||
|
</> : <>
|
||||||
|
<button
|
||||||
|
className="client-devices-manager-back"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clearTagError();
|
||||||
|
setTagErrorCopy('');
|
||||||
|
void changeRailMode('filters');
|
||||||
|
}}
|
||||||
|
>Назад</button>
|
||||||
|
<h3>Управление тегами</h3>
|
||||||
|
<form className="client-devices-tag-create" onSubmit={submitNewTag}>
|
||||||
|
<input
|
||||||
|
ref={managerInputRef}
|
||||||
|
value={newTagName}
|
||||||
|
maxLength={24}
|
||||||
|
placeholder="Название тега"
|
||||||
|
aria-label="Название нового тега"
|
||||||
|
disabled={tags.length >= 32 || tagSavingId === 'create'}
|
||||||
|
onChange={(event) => setNewTagName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={tags.length >= 32 || tagSavingId === 'create' || !newTagName.trim()}>Создать</button>
|
||||||
|
</form>
|
||||||
|
{Boolean(tagError) && tagErrorCopy && <p className="client-devices-tag-error" role="alert">{tagErrorCopy}</p>}
|
||||||
|
<div className="client-devices-tag-manager-list">
|
||||||
|
{tags.map((tag) => <div key={tag.id} className="client-devices-tag-manager-row" data-tag-tone={tagTone(tag.id)}>
|
||||||
|
{editingTagId === tag.id ? <form onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void submitTagRename(tag);
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editingTagName}
|
||||||
|
maxLength={24}
|
||||||
|
aria-label={`Новое название тега ${tag.name}`}
|
||||||
|
disabled={tagSavingId === tag.id}
|
||||||
|
onChange={(event) => setEditingTagName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={tagSavingId === tag.id || !editingTagName.trim()}>Сохранить</button>
|
||||||
|
<button type="button" onClick={() => setEditingTagId('')}>Отмена</button>
|
||||||
|
</form> : <>
|
||||||
|
<span>{tag.name}</span><b>{counts.byTag[tag.id] || 0}</b>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Переименовать тег ${tag.name}`}
|
||||||
|
disabled={tagSavingId === tag.id}
|
||||||
|
onClick={() => {
|
||||||
|
clearTagError();
|
||||||
|
setTagErrorCopy('');
|
||||||
|
setEditingTagId(tag.id);
|
||||||
|
setEditingTagName(tag.name);
|
||||||
|
editingTagBaseline.current = tag.name;
|
||||||
|
}}
|
||||||
|
><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" /></svg></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Удалить тег ${tag.name}`}
|
||||||
|
disabled={tagSavingId === tag.id}
|
||||||
|
onClick={() => {
|
||||||
|
clearTagError();
|
||||||
|
setTagErrorCopy('');
|
||||||
|
setDeletingTagId(tag.id);
|
||||||
|
}}
|
||||||
|
><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5" /></svg></button>
|
||||||
|
</>}
|
||||||
|
</div>)}
|
||||||
|
{!tags.length && <p>Создайте первый тег, чтобы распределить устройства.</p>}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div ref={contentRef} className="client-devices-content">
|
||||||
<div className="client-devices-kicker">
|
<div className="client-devices-kicker">
|
||||||
<span>Gateway · {devices.length}</span>
|
<span>Gateway · {counts.all}</span>
|
||||||
|
<button
|
||||||
|
ref={filterButtonRef}
|
||||||
|
className="client-devices-filter-trigger"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={mobileRailOpen}
|
||||||
|
onClick={() => setMobileRailOpen(true)}
|
||||||
|
>Фильтры{filtersActive ? ' · активны' : ''}</button>
|
||||||
<span className="client-devices-refresh-wrap client-tooltip-anchor">
|
<span className="client-devices-refresh-wrap client-tooltip-anchor">
|
||||||
<button
|
<button
|
||||||
className={`client-devices-refresh${refreshing ? ' is-refreshing' : ''}`}
|
className={`client-devices-refresh${refreshing ? ' is-refreshing' : ''}`}
|
||||||
@@ -420,12 +930,23 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'loading' && <p className="client-devices-empty" role="status">Ищем устройства…</p>}
|
{status === 'loading' && <p className="client-devices-empty" role="status">Ищем устройства…</p>}
|
||||||
{status !== 'loading' && !devices.length && !error && (
|
{status !== 'loading' && !allDevices.length && !error && (
|
||||||
<p className="client-devices-empty">Gateway пока не видит устройств.</p>
|
<p className="client-devices-empty">Gateway пока не видит устройств.</p>
|
||||||
)}
|
)}
|
||||||
|
{status !== 'loading' && allDevices.length > 0 && !devices.length && !error && (
|
||||||
|
<div className="client-devices-empty client-devices-filter-empty">
|
||||||
|
<p>По этим фильтрам устройств нет.</p>
|
||||||
|
<button type="button" onClick={resetFilters}>Сбросить фильтры</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtersActive && devices.length > 0 && <div className="client-devices-filter-summary">
|
||||||
|
<span>{activeSummary} · Результатов: {devices.length}</span>
|
||||||
|
<button type="button" onClick={resetFilters}>Сбросить</button>
|
||||||
|
</div>}
|
||||||
|
|
||||||
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||||
{copyAnnouncement?.message || ''}
|
{tagAnnouncement || copyAnnouncement?.message || ''}
|
||||||
</div>
|
</div>
|
||||||
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
|
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
|
||||||
{devices.map((device, index) => {
|
{devices.map((device, index) => {
|
||||||
@@ -446,6 +967,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
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 newDevice = isNewDevice(device.firstSeenAt);
|
||||||
|
const deviceTags = tags.filter(({ id }) => device.tagIds.includes(id));
|
||||||
|
const firstTag = deviceTags[0];
|
||||||
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);
|
||||||
@@ -561,6 +1084,20 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
<span aria-hidden="true">NEW</span>
|
<span aria-hidden="true">NEW</span>
|
||||||
<span className="client-device-new-a11y">Новое устройство</span>
|
<span className="client-device-new-a11y">Новое устройство</span>
|
||||||
</span>}
|
</span>}
|
||||||
|
{!editing && snapshot?.taggingSupported && <button
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) tagTriggerRefs.current.set(device.id, node);
|
||||||
|
else tagTriggerRefs.current.delete(device.id);
|
||||||
|
}}
|
||||||
|
className={firstTag ? 'client-device-tag-trigger has-tag' : 'client-device-tag-trigger'}
|
||||||
|
data-tag-tone={firstTag ? tagTone(firstTag.id) : undefined}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Изменить теги устройства ${title}`}
|
||||||
|
disabled={tagSavingId === device.id}
|
||||||
|
onClick={(event) => openTagPopover(device, title, event.currentTarget.getBoundingClientRect())}
|
||||||
|
>
|
||||||
|
{firstTag ? <><span>{firstTag.name}</span>{deviceTags.length > 1 && <b>+{deviceTags.length - 1}</b>}</> : '+ тег'}
|
||||||
|
</button>}
|
||||||
{!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' : ''}`}
|
||||||
@@ -681,7 +1218,64 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
</article>;
|
</article>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
{tagPopover && createPortal(
|
||||||
|
<div
|
||||||
|
className={`client-device-tag-popover-layer${tagPopoverClosing ? ' is-closing' : ''}`}
|
||||||
|
inert={tagPopoverClosing ? true : undefined}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget && !tagSavingId) closeTagPopover();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
ref={popoverRef}
|
||||||
|
className="client-device-tag-popover"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Теги устройства ${tagPopover.title}`}
|
||||||
|
aria-busy={tagSavingId === tagPopover.deviceId}
|
||||||
|
style={{
|
||||||
|
top: `${Math.max(12, Math.min(tagPopover.anchor.bottom + 8, window.innerHeight - 360))}px`,
|
||||||
|
left: `${Math.max(12, Math.min(tagPopover.anchor.left, window.innerWidth - 292))}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>Теги устройства {tagPopover.title}</h3>
|
||||||
|
{tags.length ? <div className="client-device-tag-popover-options">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const checked = tagPopover.draft.includes(tag.id);
|
||||||
|
return <label key={tag.id} data-tag-tone={tagTone(tag.id)}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={tagSavingId === tagPopover.deviceId || (!checked && tagPopover.draft.length >= 8)}
|
||||||
|
onChange={() => setTagPopover((current) => current && ({
|
||||||
|
...current,
|
||||||
|
draft: checked
|
||||||
|
? current.draft.filter((id) => id !== tag.id)
|
||||||
|
: [...current.draft, tag.id],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<span>{tag.name}</span>
|
||||||
|
</label>;
|
||||||
|
})}
|
||||||
|
</div> : <p>Тегов пока нет.</p>}
|
||||||
|
{Boolean(tagError) && <p className="client-devices-tag-error" role="alert">Не удалось сохранить теги.</p>}
|
||||||
|
<div className="client-device-tag-popover-actions">
|
||||||
|
{!tags.length && <button type="button" onClick={showTagManager}>Создать тег</button>}
|
||||||
|
<button type="button" disabled={tagSavingId === tagPopover.deviceId} onClick={() => closeTagPopover()}>Отмена</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={tagSavingId === tagPopover.deviceId || (tagPopover.baseline.length === tagPopover.draft.length
|
||||||
|
&& tagPopover.baseline.every((id, index) => id === tagPopover.draft[index]))}
|
||||||
|
onClick={saveDeviceTags}
|
||||||
|
>Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.querySelector('.app.client-app') || document.body,
|
||||||
|
)}
|
||||||
<ConfirmationDialog
|
<ConfirmationDialog
|
||||||
open={resetOpen}
|
open={resetOpen}
|
||||||
id="client-devices-reset"
|
id="client-devices-reset"
|
||||||
@@ -694,5 +1288,24 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
onCancel={cancelTrafficReset}
|
onCancel={cancelTrafficReset}
|
||||||
onConfirm={confirmResetTraffic}
|
onConfirm={confirmResetTraffic}
|
||||||
/>
|
/>
|
||||||
|
<ConfirmationDialog
|
||||||
|
open={Boolean(deletingTag)}
|
||||||
|
id="client-device-tag-delete"
|
||||||
|
kicker="Теги устройств"
|
||||||
|
title={deletingTag ? `Удалить тег «${deletingTag.name}»?` : 'Удалить тег?'}
|
||||||
|
description={<>{deletingTagDescription}{Boolean(tagError) && tagErrorCopy && <>
|
||||||
|
<br /><span className="client-devices-tag-error" role="alert">{tagErrorCopy}</span>
|
||||||
|
</>}</>}
|
||||||
|
cancelLabel="Оставить тег"
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
busy={Boolean(deletingTag && tagSavingId === deletingTag.id)}
|
||||||
|
onCancel={() => {
|
||||||
|
if (tagSavingId) return;
|
||||||
|
setDeletingTagId('');
|
||||||
|
clearTagError();
|
||||||
|
setTagErrorCopy('');
|
||||||
|
}}
|
||||||
|
onConfirm={confirmTagDelete}
|
||||||
|
/>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ type DeviceStatus = 'online' | 'recent' | 'offline';
|
|||||||
type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed';
|
type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed';
|
||||||
type DeviceConfidence = 'high' | 'medium' | 'ambiguous';
|
type DeviceConfidence = 'high' | 'medium' | 'ambiguous';
|
||||||
|
|
||||||
|
export interface DeviceTag extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrafficSample extends Record<string, unknown> {
|
export interface TrafficSample extends Record<string, unknown> {
|
||||||
observedAt: string;
|
observedAt: string;
|
||||||
gatewayBytes: ByteValue;
|
gatewayBytes: ByteValue;
|
||||||
@@ -37,6 +42,7 @@ export interface Device extends Record<string, unknown> {
|
|||||||
status: DeviceStatus;
|
status: DeviceStatus;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
deprioritized?: boolean;
|
deprioritized?: boolean;
|
||||||
|
tagIds: string[];
|
||||||
downloadBytes: ByteValue;
|
downloadBytes: ByteValue;
|
||||||
uploadBytes: ByteValue;
|
uploadBytes: ByteValue;
|
||||||
proxyDownloadBytes: ByteValue;
|
proxyDownloadBytes: ByteValue;
|
||||||
@@ -74,6 +80,8 @@ interface SnapshotSource extends Record<string, unknown> {
|
|||||||
|
|
||||||
export interface DeviceSnapshot extends Record<string, unknown> {
|
export interface DeviceSnapshot extends Record<string, unknown> {
|
||||||
revision: number;
|
revision: number;
|
||||||
|
tags: DeviceTag[];
|
||||||
|
taggingSupported: boolean;
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
trafficHistoryCapacity: number;
|
trafficHistoryCapacity: number;
|
||||||
traffic: {
|
traffic: {
|
||||||
@@ -157,6 +165,10 @@ function validDevice(value: unknown): value is Device {
|
|||||||
&& (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'
|
||||||
&& (value.deprioritized === undefined || typeof value.deprioritized === 'boolean')
|
&& (value.deprioritized === undefined || typeof value.deprioritized === 'boolean')
|
||||||
|
&& (value.tagIds === undefined || (Array.isArray(value.tagIds)
|
||||||
|
&& value.tagIds.length <= 8
|
||||||
|
&& value.tagIds.every((tagId) => typeof tagId === 'string' && /^tag_[a-f0-9]{16}$/.test(tagId))
|
||||||
|
&& new Set(value.tagIds).size === value.tagIds.length))
|
||||||
&& !(value.pinned === true && value.deprioritized === true)
|
&& !(value.pinned === true && value.deprioritized === true)
|
||||||
&& bytes(value.downloadBytes)
|
&& bytes(value.downloadBytes)
|
||||||
&& bytes(value.uploadBytes)
|
&& bytes(value.uploadBytes)
|
||||||
@@ -173,6 +185,20 @@ function validDevice(value: unknown): value is Device {
|
|||||||
&& (value.outboundTrafficHistory === undefined || validOutboundHistory(value.outboundTrafficHistory));
|
&& (value.outboundTrafficHistory === undefined || validOutboundHistory(value.outboundTrafficHistory));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validTags(value: unknown): value is DeviceTag[] {
|
||||||
|
return Array.isArray(value)
|
||||||
|
&& value.length <= 32
|
||||||
|
&& value.every((tag) => record(tag)
|
||||||
|
&& typeof tag.id === 'string'
|
||||||
|
&& /^tag_[a-f0-9]{16}$/.test(tag.id)
|
||||||
|
&& typeof tag.name === 'string'
|
||||||
|
&& tag.name.trim() === tag.name
|
||||||
|
&& tag.name.length > 0
|
||||||
|
&& tag.name.length <= 24)
|
||||||
|
&& new Set(value.map((tag) => tag.id)).size === value.length
|
||||||
|
&& new Set(value.map((tag) => tag.name.toLocaleLowerCase('ru-RU'))).size === value.length;
|
||||||
|
}
|
||||||
|
|
||||||
function validSource(value: unknown): value is SnapshotSource {
|
function validSource(value: unknown): value is SnapshotSource {
|
||||||
return record(value)
|
return record(value)
|
||||||
&& value.kind === 'neighbor'
|
&& value.kind === 'neighbor'
|
||||||
@@ -203,12 +229,19 @@ function validTraffic(value: unknown): value is DeviceSnapshot['traffic'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot {
|
function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot {
|
||||||
|
const taggingSupported = record(value) && Object.hasOwn(value, 'tags');
|
||||||
|
const tags = taggingSupported && record(value) && validTags(value.tags) ? value.tags : [];
|
||||||
|
const knownTagIds = new Set(tags.map(({ id }) => id));
|
||||||
if (!record(value)
|
if (!record(value)
|
||||||
|| !Number.isSafeInteger(value.revision)
|
|| !Number.isSafeInteger(value.revision)
|
||||||
|| typeof value.revision !== 'number'
|
|| typeof value.revision !== 'number'
|
||||||
|| value.revision < 0
|
|| value.revision < 0
|
||||||
|| !Array.isArray(value.devices)
|
|| !Array.isArray(value.devices)
|
||||||
|| !value.devices.every(validDevice)
|
|| !value.devices.every(validDevice)
|
||||||
|
|| (taggingSupported && !validTags(value.tags))
|
||||||
|
|| (taggingSupported && value.devices.some((device) => (
|
||||||
|
!Array.isArray(device.tagIds) || device.tagIds.some((tagId) => !knownTagIds.has(tagId))
|
||||||
|
)))
|
||||||
|| !Number.isSafeInteger(value.trafficHistoryCapacity)
|
|| !Number.isSafeInteger(value.trafficHistoryCapacity)
|
||||||
|| typeof value.trafficHistoryCapacity !== 'number'
|
|| typeof value.trafficHistoryCapacity !== 'number'
|
||||||
|| value.trafficHistoryCapacity <= 0
|
|| value.trafficHistoryCapacity <= 0
|
||||||
@@ -220,5 +253,14 @@ function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot {
|
|||||||
|
|
||||||
export function parseDeviceSnapshot(value: unknown): DeviceSnapshot {
|
export function parseDeviceSnapshot(value: unknown): DeviceSnapshot {
|
||||||
assertDeviceSnapshot(value);
|
assertDeviceSnapshot(value);
|
||||||
return value;
|
const taggingSupported = Object.hasOwn(value, 'tags');
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
taggingSupported,
|
||||||
|
tags: taggingSupported ? value.tags : [],
|
||||||
|
devices: value.devices.map((device) => ({
|
||||||
|
...device,
|
||||||
|
tagIds: taggingSupported && Array.isArray(device.tagIds) ? device.tagIds : [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,257 @@
|
|||||||
|
.client-drawer.client-devices {
|
||||||
|
width: min(780px, 100vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 168px minmax(0, 1fr);
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-rail {
|
||||||
|
position: sticky;
|
||||||
|
top: 54px;
|
||||||
|
align-self: start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-rail-view {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-search {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-label);
|
||||||
|
letter-spacing: var(--type-label-tracking);
|
||||||
|
text-transform: var(--type-label-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-search input,
|
||||||
|
.client-devices-tag-create input,
|
||||||
|
.client-devices-tag-manager-row input {
|
||||||
|
min-width: 0;
|
||||||
|
height: 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: 1px solid color-mix(in oklch, var(--client-border) 80%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: 0;
|
||||||
|
background: color-mix(in oklch, var(--client-control) 56%, transparent);
|
||||||
|
color: var(--client-text);
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-search input:focus,
|
||||||
|
.client-devices-tag-create input:focus,
|
||||||
|
.client-devices-tag-manager-row input:focus {
|
||||||
|
border: 1px solid var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-filter-group,
|
||||||
|
.client-devices-tag-filters {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-filter-group button,
|
||||||
|
.client-devices-tag-filters button {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-align: left;
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-filter-group button[aria-pressed="true"],
|
||||||
|
.client-devices-tag-filters button[aria-pressed="true"] {
|
||||||
|
background: color-mix(in oklch, var(--client-accent) 11%, transparent);
|
||||||
|
color: var(--harbor-gateway);
|
||||||
|
box-shadow: inset 2px 0 0 var(--harbor-gateway);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-filter-group b,
|
||||||
|
.client-devices-tag-filters b,
|
||||||
|
.client-devices-tag-manager-row > b {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-micro);
|
||||||
|
letter-spacing: var(--type-micro-tracking);
|
||||||
|
text-transform: var(--type-micro-transform);
|
||||||
|
font-variant-numeric: var(--numeric-tabular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-rail-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-label);
|
||||||
|
letter-spacing: var(--type-label-tracking);
|
||||||
|
text-transform: var(--type-label-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-tag-filters button {
|
||||||
|
color: var(--tag-tone, var(--client-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-tag-filters p,
|
||||||
|
.client-devices-tag-manager-list > p,
|
||||||
|
.client-devices-tags-unsupported {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-manage-tags,
|
||||||
|
.client-devices-manager-back,
|
||||||
|
.client-devices-tag-create button,
|
||||||
|
.client-devices-tag-manager-row button,
|
||||||
|
.client-device-tag-popover button,
|
||||||
|
.client-devices-filter-summary button,
|
||||||
|
.client-devices-filter-empty button {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-accent);
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-manager-back {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-rail h3 {
|
||||||
|
margin: 0;
|
||||||
|
font: var(--type-section-title);
|
||||||
|
letter-spacing: var(--type-section-title-tracking);
|
||||||
|
text-transform: var(--type-section-title-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-tag-create {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-tag-manager-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-tag-manager-row {
|
||||||
|
min-height: 30px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto 26px 26px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
color: var(--tag-tone, var(--client-text));
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-tag-manager-row > span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-tag-manager-row > button {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-tag-manager-row svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-tag-manager-row form {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-tag-error {
|
||||||
|
margin: 0;
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tag-tone="0"] { --tag-tone: var(--harbor-connect); }
|
||||||
|
[data-tag-tone="1"] { --tag-tone: var(--harbor-gateway); }
|
||||||
|
[data-tag-tone="2"] { --tag-tone: var(--harbor-word); }
|
||||||
|
[data-tag-tone="3"] { --tag-tone: var(--client-muted); }
|
||||||
|
|
||||||
|
.client-devices-filter-trigger,
|
||||||
|
.client-devices-rail-backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-filter-summary {
|
||||||
|
min-height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin: -8px 8px 14px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-filter-summary span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-filter-empty {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-filter-empty p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.client-devices-header {
|
.client-devices-header {
|
||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
@@ -446,6 +700,57 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-device-tag-trigger {
|
||||||
|
height: 14px;
|
||||||
|
max-width: 104px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 0 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px dashed color-mix(in oklch, var(--client-muted) 46%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-micro);
|
||||||
|
letter-spacing: var(--type-micro-tracking);
|
||||||
|
text-transform: var(--type-micro-transform);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-trigger.has-tag {
|
||||||
|
border: 1px solid color-mix(in oklch, var(--tag-tone) 48%, transparent);
|
||||||
|
background: color-mix(in oklch, var(--tag-tone) 11%, transparent);
|
||||||
|
color: var(--tag-tone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-trigger span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-trigger b {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.client-device-tag-trigger:not(.has-tag) {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device:hover .client-device-tag-trigger:not(.has-tag),
|
||||||
|
.client-device:focus-within .client-device-tag-trigger:not(.has-tag) {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.client-device-identity-details {
|
.client-device-identity-details {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
@@ -1359,10 +1664,164 @@
|
|||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-device-tag-popover-layer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-popover {
|
||||||
|
position: fixed;
|
||||||
|
width: min(280px, calc(100vw - 24px));
|
||||||
|
max-height: min(348px, calc(100dvh - 24px));
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid color-mix(in oklch, var(--client-border) 78%, transparent);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: color-mix(in oklch, var(--client-bg) 96%, var(--client-panel));
|
||||||
|
color: var(--client-text);
|
||||||
|
box-shadow: 0 14px 36px oklch(0.08 0.015 145 / 0.24);
|
||||||
|
animation: client-device-tag-popover-in 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-device-tag-popover-in {
|
||||||
|
from { opacity: 0; transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-popover-layer.is-closing {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-popover-layer.is-closing .client-device-tag-popover {
|
||||||
|
animation: client-device-tag-popover-out 160ms ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-device-tag-popover-out {
|
||||||
|
to { opacity: 0; transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-popover h3 {
|
||||||
|
margin: 0;
|
||||||
|
font: var(--type-section-title);
|
||||||
|
letter-spacing: var(--type-section-title-tracking);
|
||||||
|
text-transform: var(--type-section-title-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-popover > p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-popover-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-popover-options label {
|
||||||
|
min-height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--tag-tone, var(--client-text));
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-popover-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-popover-actions button:last-child {
|
||||||
|
color: var(--client-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.client-devices-layout {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-rail {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
z-index: 42;
|
||||||
|
width: min(300px, calc(100vw - 48px));
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 54px 18px 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
|
||||||
|
box-shadow: 18px 0 42px oklch(0.08 0.015 145 / 0.2);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1), opacity 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-rail.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-rail-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 41;
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: oklch(0.08 0.015 145 / 0.26);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-rail-backdrop.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-filter-trigger {
|
||||||
|
min-height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-accent);
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-kicker > span:first-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-tag-trigger:not(.has-tag) {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.client-device-edit-wrap {
|
.client-device-edit-wrap {
|
||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-device-tag-trigger:not(.has-tag) {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-gateway-summary {
|
.client-gateway-summary {
|
||||||
|
|||||||
@@ -90,6 +90,10 @@
|
|||||||
.client-devices-sort-icon,
|
.client-devices-sort-icon,
|
||||||
.client-devices-refresh-ring circle,
|
.client-devices-refresh-ring circle,
|
||||||
.client-devices-refresh-icon,
|
.client-devices-refresh-icon,
|
||||||
|
.client-devices-rail,
|
||||||
|
.client-devices-rail-backdrop,
|
||||||
|
.client-devices-rail-view,
|
||||||
|
.client-device-tag-popover,
|
||||||
.client-text-morph-value,
|
.client-text-morph-value,
|
||||||
.client-proxy-label > span {
|
.client-proxy-label > span {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, type ReactNode } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
const FOCUSABLE = 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])';
|
const FOCUSABLE = 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])';
|
||||||
@@ -8,7 +8,7 @@ interface ConfirmationDialogProps {
|
|||||||
id: string;
|
id: string;
|
||||||
kicker?: string;
|
kicker?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: ReactNode;
|
||||||
cancelLabel: string;
|
cancelLabel: string;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
|
|||||||
+56
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Device } from '../features/devices/deviceSnapshot.js';
|
import type { Device, DeviceTag } from '../features/devices/deviceSnapshot.js';
|
||||||
|
|
||||||
export function formatBytes(value: number) {
|
export function formatBytes(value: number) {
|
||||||
if (!value) return "0 Б";
|
if (!value) return "0 Б";
|
||||||
@@ -15,6 +15,14 @@ export function formatBytes(value: number) {
|
|||||||
const BYTE_STRING_PATTERN = /^\d+$/;
|
const BYTE_STRING_PATTERN = /^\d+$/;
|
||||||
const NEW_DEVICE_MS = 7 * 24 * 60 * 60 * 1000;
|
const NEW_DEVICE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type DeviceSystemFilter = 'all' | 'new' | 'pinned' | 'background' | 'untagged';
|
||||||
|
|
||||||
|
export interface DeviceFilters {
|
||||||
|
system: DeviceSystemFilter;
|
||||||
|
tagIds: string[];
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function byteString(value: unknown) {
|
export function byteString(value: unknown) {
|
||||||
const normalized = String(value ?? '0');
|
const normalized = String(value ?? '0');
|
||||||
return BYTE_STRING_PATTERN.test(normalized) ? BigInt(normalized) : 0n;
|
return BYTE_STRING_PATTERN.test(normalized) ? BigInt(normalized) : 0n;
|
||||||
@@ -107,6 +115,53 @@ export function stabilizeDevicesByTraffic(
|
|||||||
return { devices: stable, ids: stable.map(({ id }) => id) };
|
return { devices: stable, ids: stable.map(({ id }) => id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filterDevices(
|
||||||
|
devices: Device[] | undefined,
|
||||||
|
tags: DeviceTag[] | undefined,
|
||||||
|
filters: DeviceFilters,
|
||||||
|
now: Date | string | number = Date.now(),
|
||||||
|
) {
|
||||||
|
const tagNames = new Map((tags || []).map(({ id, name }) => [id, name]));
|
||||||
|
const selected = new Set(filters.tagIds);
|
||||||
|
const query = filters.query.trim().toLocaleLowerCase('ru-RU');
|
||||||
|
return (devices || []).filter((device) => {
|
||||||
|
const systemMatch = filters.system === 'all'
|
||||||
|
|| (filters.system === 'new' && isNewDevice(device.firstSeenAt, now))
|
||||||
|
|| (filters.system === 'pinned' && device.pinned)
|
||||||
|
|| (filters.system === 'background' && device.deprioritized === true)
|
||||||
|
|| (filters.system === 'untagged' && device.tagIds.length === 0);
|
||||||
|
const tagMatch = selected.size === 0 || device.tagIds.some((tagId) => selected.has(tagId));
|
||||||
|
const searchMatch = !query || [
|
||||||
|
device.alias,
|
||||||
|
device.hostname,
|
||||||
|
device.ip,
|
||||||
|
device.mac,
|
||||||
|
...device.tagIds.map((tagId) => tagNames.get(tagId)),
|
||||||
|
].some((value) => String(value || '').toLocaleLowerCase('ru-RU').includes(query));
|
||||||
|
return systemMatch && tagMatch && searchMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deviceFilterCounts(
|
||||||
|
devices: Device[] | undefined,
|
||||||
|
tags: DeviceTag[] | undefined,
|
||||||
|
now: Date | string | number = Date.now(),
|
||||||
|
) {
|
||||||
|
const rows = devices || [];
|
||||||
|
const byTag = Object.fromEntries((tags || []).map(({ id }) => [
|
||||||
|
id,
|
||||||
|
rows.filter((device) => device.tagIds.includes(id)).length,
|
||||||
|
]));
|
||||||
|
return {
|
||||||
|
all: rows.length,
|
||||||
|
new: rows.filter((device) => isNewDevice(device.firstSeenAt, now)).length,
|
||||||
|
pinned: rows.filter((device) => device.pinned).length,
|
||||||
|
background: rows.filter((device) => device.deprioritized === true).length,
|
||||||
|
untagged: rows.filter((device) => device.tagIds.length === 0).length,
|
||||||
|
byTag,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function formatRelative(iso: string | null | undefined) {
|
export function formatRelative(iso: string | null | undefined) {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const ts = new Date(iso).getTime();
|
const ts = new Date(iso).getTime();
|
||||||
|
|||||||
@@ -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.deepEqual(service.metricsSnapshot().directTraffic.series, []);
|
||||||
assert.equal(service.metricsSnapshot().directTraffic.source.error, null);
|
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 test from 'node:test';
|
||||||
|
|
||||||
import { createDeviceInventoryRoute } from '../../dist/server/http/routes/deviceInventoryRoute.js';
|
import { createDeviceInventoryRoute } from '../../dist/server/http/routes/deviceInventoryRoute.js';
|
||||||
|
import { errorDefinition } from '../../dist/shared/errors.js';
|
||||||
|
|
||||||
const deviceId = 'dev_0123456789abcdef';
|
const deviceId = 'dev_0123456789abcdef';
|
||||||
|
const tagId = 'tag_0123456789abcdef';
|
||||||
|
|
||||||
function response() {
|
function response() {
|
||||||
return {
|
return {
|
||||||
@@ -34,6 +36,18 @@ function createHarness({ inventory = {}, body = {} } = {}) {
|
|||||||
calls.push(['update', ...args]);
|
calls.push(['update', ...args]);
|
||||||
return inventory.update ?? { revision: 3 };
|
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) => {
|
resetTraffic: async (...args) => {
|
||||||
calls.push(['resetTraffic', ...args]);
|
calls.push(['resetTraffic', ...args]);
|
||||||
return inventory.resetTraffic ?? { revision: 4 };
|
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 () => {
|
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 metadata = createHarness({ body: patch });
|
||||||
const metadataResponse = response();
|
const metadataResponse = response();
|
||||||
assert.equal(await metadata.route.handle({
|
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, [[
|
assert.deepEqual(metadata.calls, [[
|
||||||
'update',
|
'update',
|
||||||
deviceId,
|
deviceId,
|
||||||
{ alias: 'Desk', pinned: false, deprioritized: true, extra: 0 },
|
{ alias: 'Desk', pinned: false, deprioritized: true, tagIds: [tagId], extra: 0 },
|
||||||
7,
|
7,
|
||||||
]]);
|
]]);
|
||||||
assert.deepEqual(metadataResponse.payload, { revision: 3 });
|
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 });
|
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 () => {
|
test('device route preserves endpoint gating and strict lowercase IDs', async () => {
|
||||||
for (const [method, url] of [
|
for (const [method, url] of [
|
||||||
['POST', '/api/devices'],
|
['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/traffic'],
|
||||||
['GET', `/api/devices/${deviceId}`],
|
['GET', `/api/devices/${deviceId}`],
|
||||||
['POST', `/api/devices/${deviceId}/policy`],
|
['POST', `/api/devices/${deviceId}/policy`],
|
||||||
|
['GET', '/api/device-tags'],
|
||||||
|
['PATCH', `/api/device-tags/${tagId}`],
|
||||||
]) {
|
]) {
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
@@ -147,6 +196,9 @@ test('device route preserves endpoint gating and strict lowercase IDs', async ()
|
|||||||
['DELETE', '/api/devices/traffic'],
|
['DELETE', '/api/devices/traffic'],
|
||||||
['PUT', `/api/devices/${deviceId}`],
|
['PUT', `/api/devices/${deviceId}`],
|
||||||
['PUT', `/api/devices/${deviceId}/policy`],
|
['PUT', `/api/devices/${deviceId}/policy`],
|
||||||
|
['POST', '/api/device-tags'],
|
||||||
|
['PUT', `/api/device-tags/${tagId}`],
|
||||||
|
['DELETE', `/api/device-tags/${tagId}`],
|
||||||
]) {
|
]) {
|
||||||
const client = createHarness({ inventory: null });
|
const client = createHarness({ inventory: null });
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
@@ -164,6 +216,9 @@ test('device route propagates synchronous and asynchronous service errors unchan
|
|||||||
snapshot: () => { throw syncError; },
|
snapshot: () => { throw syncError; },
|
||||||
refresh: async () => ({}),
|
refresh: async () => ({}),
|
||||||
update: () => ({}),
|
update: () => ({}),
|
||||||
|
createTag: () => ({}),
|
||||||
|
renameTag: () => ({}),
|
||||||
|
deleteTag: () => ({}),
|
||||||
resetTraffic: async () => ({}),
|
resetTraffic: async () => ({}),
|
||||||
setPolicy: async () => ({}),
|
setPolicy: async () => ({}),
|
||||||
},
|
},
|
||||||
@@ -180,6 +235,9 @@ test('device route propagates synchronous and asynchronous service errors unchan
|
|||||||
snapshot: () => ({}),
|
snapshot: () => ({}),
|
||||||
refresh: async () => { throw asyncError; },
|
refresh: async () => { throw asyncError; },
|
||||||
update: () => ({}),
|
update: () => ({}),
|
||||||
|
createTag: () => ({}),
|
||||||
|
renameTag: () => ({}),
|
||||||
|
deleteTag: () => ({}),
|
||||||
resetTraffic: async () => ({}),
|
resetTraffic: async () => ({}),
|
||||||
setPolicy: 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(index, /deviceInventory\.refresh\(\)/);
|
||||||
assert.match(route, /DEVICE_PATH/);
|
assert.match(route, /DEVICE_PATH/);
|
||||||
assert.match(route, /DEVICE_POLICY_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', {
|
[() => api.devices.setPolicy('dev_1', 'direct', 9), '/api/devices/dev_1/policy', {
|
||||||
method: 'PUT', body: JSON.stringify({ mode: 'direct', expectedRevision: 9 }),
|
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', {
|
[() => api.diagnostics.connectivity(), '/api/diagnostics/connectivity', {
|
||||||
method: 'POST', body: JSON.stringify({ target: null }),
|
method: 'POST', body: JSON.stringify({ target: null }),
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import test from 'node:test';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
formatByteString,
|
formatByteString,
|
||||||
|
deviceFilterCounts,
|
||||||
|
filterDevices,
|
||||||
formatLastSeen,
|
formatLastSeen,
|
||||||
isNewDevice,
|
isNewDevice,
|
||||||
positiveByteDelta,
|
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, /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(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, /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, /previousPositions\.current\.size > 0/);
|
||||||
assert.match(panel, /previousScrollTop\.current - currentScrollTop/);
|
assert.match(panel, /previousScrollTop\.current - currentScrollTop/);
|
||||||
assert.match(feature, /next\.revision > current\.revision/);
|
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.match(panel, /\{device\.hostname && <button[\s\S]*copyDeviceValue\(device, 'Hostname', device\.hostname!\)/);
|
||||||
assert.doesNotMatch(panel, /identityTooltipId|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, /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, /Трафик временно не обновляется/);
|
||||||
assert.match(panel, /Вход — накопленные Gateway\/Proxy\. Выход — приблизительно через VPN или Direct с запуска текущего учёта\./);
|
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' \? 'Выход' : 'Вход'/);
|
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.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
|
||||||
assert.match(panel, /maxLength=\{64\}[\s\S]*autoFocus/);
|
assert.match(panel, /maxLength=\{64\}[\s\S]*autoFocus/);
|
||||||
assert.match(styles, /\.client-drawer \{[\s\S]*width: min\(580px, 100vw\)/);
|
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 \{[\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-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/);
|
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\]/);
|
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', () => {
|
test('Gateway Home reuses the canonical device snapshot for applied route and global traffic', () => {
|
||||||
const powerStart = connection.indexOf('<section className={`client-power-section');
|
const powerStart = connection.indexOf('<section className={`client-power-section');
|
||||||
const trafficStart = overview.indexOf('<GatewayTrafficSummary');
|
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);
|
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', () => {
|
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 КБ');
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ test('device controller preserves Gateway-only polling, monotonic publication an
|
|||||||
assert.match(page, /<GatewayTrafficSummary feature=\{devicesFeature\} now=\{now\}/);
|
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 observedAt = '2026-08-08T12:34:56.000Z';
|
||||||
const valid = {
|
const valid = {
|
||||||
revision: 3,
|
revision: 3,
|
||||||
@@ -106,7 +106,21 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
|
|||||||
},
|
},
|
||||||
extra: { retained: true },
|
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 [
|
for (const invalid of [
|
||||||
null,
|
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: { observedAt, vpnBytes: '1' } }] },
|
||||||
{ ...valid, devices: [{ ...valid.devices[0], outboundTraffic: { ...valid.devices[0].outboundTraffic, directIpv4ObservedAt: 'not-a-date' } }] },
|
{ ...valid, devices: [{ ...valid.devices[0], outboundTraffic: { ...valid.devices[0].outboundTraffic, directIpv4ObservedAt: 'not-a-date' } }] },
|
||||||
{ ...valid, devices: [{ ...valid.devices[0], outboundTrafficHistory: [{ observedAt, vpnBytes: '1' }] }] },
|
{ ...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, totalBytes: -4 } },
|
||||||
{ ...valid, traffic: { ...valid.traffic, observedAt: 'not-a-date' } },
|
{ ...valid, traffic: { ...valid.traffic, observedAt: 'not-a-date' } },
|
||||||
{ ...valid, source: { ...valid.source, kind: 'arp' } },
|
{ ...valid, source: { ...valid.source, kind: 'arp' } },
|
||||||
|
|||||||
@@ -39,26 +39,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: 1072,
|
cascadeEdges: 1093,
|
||||||
customProperties: 111,
|
customProperties: 115,
|
||||||
declarations: 4170,
|
declarations: 4433,
|
||||||
important: 0,
|
important: 0,
|
||||||
keyframes: 50,
|
keyframes: 52,
|
||||||
media: 17,
|
media: 19,
|
||||||
rules: 1126,
|
rules: 1188,
|
||||||
variableReferences: 1042,
|
variableReferences: 1130,
|
||||||
},
|
},
|
||||||
hashes: {
|
hashes: {
|
||||||
cascadeEdges: 'd60343b571f5a9bcf809609b4731500587da3bdd9ee9f40e33938ecbd8aec6ce',
|
cascadeEdges: '6676dd45ca19923561ef30b2057a9952a795fd142d83b612aaba70132378e91d',
|
||||||
customProperties: '42f9fa9f2caaab6e5d90ae7d224563355822fa270083ea3496cfe2caaaea50bf',
|
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
||||||
declarations: '15fc1474c16562886582469a7d240dde36882e682b266374fdca7f47f90c2b13',
|
declarations: 'fcecaa0fb211a14826577bf9629e261b0b4b63b4601febebc43e8d757f6a07f1',
|
||||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||||
keyframes: 'af4b9ae18d070fd2462a9b050293f3821bf5017c6e7cc53bbe18dc826f0fe3b9',
|
keyframes: '405688c9a452aa9d54e9c50dd30abb13143fdb5910f63f4474ecefcc800311e6',
|
||||||
ruleDeclarationSequences: '16ccf3b3b8b6f76e784c69a3f06a3abb6e3b57d5d0710dff4bdad23f87bad6c3',
|
ruleDeclarationSequences: '231bd9880d19c8d29e6d9538ccb934e135bd58b33155175e317b743916d93ef7',
|
||||||
selectors: 'b32eeeff7896fedd9057a953b61a8b149116a5c5ce110fb84bf06e24d22bf8ee',
|
selectors: '159b53f5cdd6c4aa6d892b9ed314d9a100345290d4c95e31a4c6be98b609c8b6',
|
||||||
variableReferences: 'c9f64054cfe384f90e5c47844ffbbc7fba60f5045efac5d272f45f1f451e950b',
|
variableReferences: 'd8f4abdb1ea6e9077283f342efef0ea920068165d0fc2ac4e7a9a22e16b31461',
|
||||||
witnesses: '2d952ddef91d3578857d7aeb1f469a9a6f3b7b4bba998ab6bc054d0028cf2785',
|
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', () => {
|
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||||
const witnesses = readStyleWitnesses(root);
|
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);
|
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-jhWgOVxh.css']);
|
assert.deepEqual(assets, ['index-fn-ai4xB.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, 158716);
|
assert.equal(built.byteLength, 168021);
|
||||||
assert.equal(sha256(built), 'b10c9602bdf224599db0262e2e872e3db2b3c75b85410d50c2fe42b69d44e14c');
|
assert.equal(sha256(built), 'a160cdc915a4a8e0b95e360ed721e6037cbeda28df4e0815ab6be43f1004176d');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user