Add deprioritized device group
This commit is contained in:
@@ -77,11 +77,11 @@ http://АДРЕС-GATEWAY:3456
|
|||||||
|
|
||||||
### Устройства Gateway
|
### Устройства Gateway
|
||||||
|
|
||||||
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название, IP и последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Нажмите IP, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway.
|
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название, IP и последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Нажмите IP, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Убраны вниз»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, положение в нижней группе и накопленные totals сохраняются в volume Gateway.
|
||||||
|
|
||||||
У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
|
У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
|
||||||
|
|
||||||
Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
|
Список приблизительный: имя и пользовательские настройки привязаны к MAC и сохраняются при обычной смене IP, но новый private/randomized MAC считается новым устройством — переносить имя по одному только DHCP-адресу небезопасно. Один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
|
||||||
|
|
||||||
Home показывает фактически применённый VPN-сервер, накопленное `Учтено Harbor` и большой нижний график средней скорости Download/Upload за фактический интервал между снимками. `Учтено Harbor` — сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткая история скорости после перезапуска начинает заполняться заново.
|
Home показывает фактически применённый VPN-сервер, накопленное `Учтено Harbor` и большой нижний график средней скорости Download/Upload за фактический интервал между снимками. `Учтено Harbor` — сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткая история скорости после перезапуска начинает заполняться заново.
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ interface InventoryDevice {
|
|||||||
id: string;
|
id: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
|
deprioritized: boolean;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
manufacturer: string | null;
|
manufacturer: string | null;
|
||||||
mac: string;
|
mac: string;
|
||||||
@@ -265,6 +266,7 @@ function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
|
|||||||
: deviceId(mac),
|
: deviceId(mac),
|
||||||
alias: typeof device.alias === 'string' ? device.alias : '',
|
alias: typeof device.alias === 'string' ? device.alias : '',
|
||||||
pinned: device.pinned === true,
|
pinned: device.pinned === true,
|
||||||
|
deprioritized: device.deprioritized === true && device.pinned !== true,
|
||||||
hostname: typeof device.hostname === 'string' ? device.hostname : null,
|
hostname: typeof device.hostname === 'string' ? device.hostname : null,
|
||||||
manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null,
|
manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null,
|
||||||
mac,
|
mac,
|
||||||
@@ -792,6 +794,7 @@ export function createDeviceInventoryService({
|
|||||||
};
|
};
|
||||||
}).sort((left, right) => (
|
}).sort((left, right) => (
|
||||||
Number(right.pinned) - Number(left.pinned)
|
Number(right.pinned) - Number(left.pinned)
|
||||||
|
|| Number(left.deprioritized) - Number(right.deprioritized)
|
||||||
|| rank[left.status] - rank[right.status]
|
|| rank[left.status] - rank[right.status]
|
||||||
|| String(right.lastSeenAt).localeCompare(String(left.lastSeenAt))
|
|| String(right.lastSeenAt).localeCompare(String(left.lastSeenAt))
|
||||||
));
|
));
|
||||||
@@ -1016,6 +1019,7 @@ export function createDeviceInventoryService({
|
|||||||
id: previous?.id || deviceId(mac),
|
id: previous?.id || deviceId(mac),
|
||||||
alias: previous?.alias || '',
|
alias: previous?.alias || '',
|
||||||
pinned: previous?.pinned === true,
|
pinned: previous?.pinned === true,
|
||||||
|
deprioritized: previous?.deprioritized === true && previous?.pinned !== true,
|
||||||
hostname: previous?.hostname || null,
|
hostname: previous?.hostname || null,
|
||||||
manufacturer: previous?.manufacturer || vendor(mac),
|
manufacturer: previous?.manufacturer || vendor(mac),
|
||||||
mac,
|
mac,
|
||||||
@@ -1031,7 +1035,7 @@ export function createDeviceInventoryService({
|
|||||||
}
|
}
|
||||||
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
|
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
|
||||||
const devices = [...byMac.values()].filter((device) => (
|
const devices = [...byMac.values()].filter((device) => (
|
||||||
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
|
device.pinned || device.deprioritized || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
|
||||||
));
|
));
|
||||||
let traffic = state.traffic;
|
let traffic = state.traffic;
|
||||||
if (trafficResult) {
|
if (trafficResult) {
|
||||||
@@ -1271,15 +1275,19 @@ export function createDeviceInventoryService({
|
|||||||
const value = record(patch);
|
const value = record(patch);
|
||||||
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');
|
||||||
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|
||||||
|| (!aliasProvided && !pinProvided)
|
|| (!aliasProvided && !pinProvided && !deprioritizedProvided)
|
||||||
|| (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')
|
||||||
|
|| (value.pinned === true && value.deprioritized === true)) {
|
||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
const revision = expectedRevision;
|
const revision = expectedRevision;
|
||||||
const alias = typeof value.alias === 'string' ? value.alias : '';
|
const alias = typeof value.alias === 'string' ? value.alias : '';
|
||||||
const pinned = value.pinned === true;
|
const pinned = value.pinned === true;
|
||||||
|
const deprioritized = value.deprioritized === true;
|
||||||
store.update((stored) => {
|
store.update((stored) => {
|
||||||
const state = migrateDeviceInventoryState(stored);
|
const state = migrateDeviceInventoryState(stored);
|
||||||
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
|
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
|
||||||
@@ -1289,7 +1297,10 @@ export function createDeviceInventoryService({
|
|||||||
devices[index] = {
|
devices[index] = {
|
||||||
...devices[index],
|
...devices[index],
|
||||||
...(aliasProvided ? { alias: alias.trim() } : {}),
|
...(aliasProvided ? { alias: alias.trim() } : {}),
|
||||||
...(pinProvided ? { pinned } : {}),
|
...(pinProvided ? { pinned, ...(pinned ? { deprioritized: false } : {}) } : {}),
|
||||||
|
...(deprioritizedProvided
|
||||||
|
? { deprioritized, ...(deprioritized ? { pinned: false } : {}) }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
return { ...state, revision: state.revision + 1, devices };
|
return { ...state, revision: state.revision + 1, devices };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.21.8',
|
macClient: '0.22.0',
|
||||||
gatewayClient: '0.22.3',
|
gatewayClient: '0.23.0',
|
||||||
gatewayBackend: '0.22.5',
|
gatewayBackend: '0.23.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ParsedVersion {
|
export interface ParsedVersion {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -233,16 +233,12 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
setAlias(value);
|
setAlias(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function togglePin(device: Device) {
|
async function collapsePinned(device: Device, patch: Record<string, unknown>) {
|
||||||
if (!device.pinned || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
||||||
await updateDevice(device, { pinned: !device.pinned });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPinCollapses((current) => ({
|
setPinCollapses((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[device.id]: { animationDone: false, result: 'pending' },
|
[device.id]: { animationDone: false, result: 'pending' },
|
||||||
}));
|
}));
|
||||||
const saved = await updateDevice(device, { pinned: false });
|
const saved = await updateDevice(device, patch);
|
||||||
setPinCollapses((current) => {
|
setPinCollapses((current) => {
|
||||||
const phase = current[device.id];
|
const phase = current[device.id];
|
||||||
if (!phase) return current;
|
if (!phase) return current;
|
||||||
@@ -255,6 +251,23 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function togglePin(device: Device) {
|
||||||
|
if (!device.pinned || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
await updateDevice(device, { pinned: !device.pinned });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await collapsePinned(device, { pinned: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDeprioritized(device: Device) {
|
||||||
|
const deprioritized = device.deprioritized === true;
|
||||||
|
if (!device.pinned || deprioritized || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
await updateDevice(device, { deprioritized: !deprioritized });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await collapsePinned(device, { deprioritized: true });
|
||||||
|
}
|
||||||
|
|
||||||
function finishPinCollapse(deviceId: string) {
|
function finishPinCollapse(deviceId: string) {
|
||||||
setPinCollapses((current) => {
|
setPinCollapses((current) => {
|
||||||
const phase = current[deviceId];
|
const phase = current[deviceId];
|
||||||
@@ -365,10 +378,21 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
{copyAnnouncement?.message || ''}
|
{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) => {
|
{devices.map((device, index) => {
|
||||||
const collapsing = Boolean(pinCollapses[device.id]);
|
const collapsing = Boolean(pinCollapses[device.id]);
|
||||||
|
const deprioritized = device.deprioritized === true;
|
||||||
|
const compact = deprioritized && !collapsing;
|
||||||
const expanded = device.pinned && !collapsing;
|
const expanded = device.pinned && !collapsing;
|
||||||
const showDetails = device.pinned || collapsing;
|
const showDetails = !compact && (device.pinned || collapsing);
|
||||||
|
const group = device.pinned ? 'pinned' : deprioritized ? 'deprioritized' : 'default';
|
||||||
|
const previous = devices[index - 1];
|
||||||
|
const previousGroup = previous
|
||||||
|
? previous.pinned ? 'pinned' : previous.deprioritized === true ? 'deprioritized' : 'default'
|
||||||
|
: '';
|
||||||
|
const groupLabel = group === 'pinned'
|
||||||
|
? 'Закреплённые'
|
||||||
|
: group === 'deprioritized' ? 'Убраны вниз' : 'Остальные';
|
||||||
|
const groupStart = group !== previousGroup;
|
||||||
const hasName = Boolean(device.alias || device.hostname);
|
const hasName = Boolean(device.alias || device.hostname);
|
||||||
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
|
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
|
||||||
const editing = editingId === device.id;
|
const editing = editingId === device.id;
|
||||||
@@ -406,14 +430,20 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway'
|
? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway'
|
||||||
: 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую';
|
: 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую';
|
||||||
return <article
|
return <article
|
||||||
|
key={device.id}
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
if (node) deviceNodes.current.set(device.id, node);
|
if (node) deviceNodes.current.set(device.id, node);
|
||||||
else deviceNodes.current.delete(device.id);
|
else deviceNodes.current.delete(device.id);
|
||||||
}}
|
}}
|
||||||
className={`client-device is-${device.status}${expanded ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}`}
|
className={`client-device is-${device.status}${expanded ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}${compact ? ' is-deprioritized' : ''}${groupStart ? ' is-group-start' : ''}`}
|
||||||
key={device.id}
|
|
||||||
>
|
>
|
||||||
<span className="client-device-pin-wrap client-tooltip-anchor">
|
<span
|
||||||
|
className="client-device-group-heading"
|
||||||
|
role={groupStart ? 'heading' : undefined}
|
||||||
|
aria-level={groupStart ? 3 : undefined}
|
||||||
|
aria-hidden={!groupStart}
|
||||||
|
>{groupStart ? groupLabel : ''}</span>
|
||||||
|
{!compact && <span className="client-device-pin-wrap client-tooltip-anchor">
|
||||||
<button
|
<button
|
||||||
className="client-device-pin"
|
className="client-device-pin"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -427,10 +457,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<Tooltip>{device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
|
<Tooltip>{device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
|
||||||
</span>
|
</span>}
|
||||||
|
|
||||||
<div className="client-device-main">
|
<div className="client-device-main">
|
||||||
<h3 className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}>
|
<h4 className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<input
|
<input
|
||||||
className="client-device-alias-input"
|
className="client-device-alias-input"
|
||||||
@@ -460,7 +490,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
|
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
|
||||||
onClick={() => copyDeviceIp(device)}
|
onClick={() => copyDeviceIp(device)}
|
||||||
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>}
|
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>}
|
||||||
</h3>
|
</h4>
|
||||||
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
|
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
|
||||||
<button
|
<button
|
||||||
className={`client-device-edit${pencilAnimationId === device.id ? ' is-writing' : ''}`}
|
className={`client-device-edit${pencilAnimationId === device.id ? ' is-writing' : ''}`}
|
||||||
@@ -490,7 +520,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
{!compact && <span
|
||||||
className="client-device-traffic"
|
className="client-device-traffic"
|
||||||
role="group"
|
role="group"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -503,9 +533,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
|
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
|
||||||
{hasProxyTraffic && <span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>}
|
{hasProxyTraffic && <span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>}
|
||||||
|
|
||||||
<span className="client-device-policy-wrap client-tooltip-anchor">
|
{!compact && <span className="client-device-policy-wrap client-tooltip-anchor">
|
||||||
<button
|
<button
|
||||||
className={`client-device-policy is-${displayPolicy}${policyFailed ? ' is-failed' : ''}${policyPending ? ' is-pending' : ''}`}
|
className={`client-device-policy is-${displayPolicy}${policyFailed ? ' is-failed' : ''}${policyPending ? ' is-pending' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -523,8 +553,25 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
</svg>}
|
</svg>}
|
||||||
</button>
|
</button>
|
||||||
<Tooltip>{policyTooltip}</Tooltip>
|
<Tooltip>{policyTooltip}</Tooltip>
|
||||||
|
</span>}
|
||||||
|
<span className="client-device-deprioritize-wrap client-tooltip-anchor">
|
||||||
|
<button
|
||||||
|
className="client-device-deprioritize"
|
||||||
|
type="button"
|
||||||
|
aria-pressed={deprioritized}
|
||||||
|
aria-label={deprioritized ? `Вернуть ${title} в основной список` : `Убрать ${title} вниз`}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => toggleDeprioritized(device)}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
{deprioritized
|
||||||
|
? <path d="M12 20V8M7 13l5-5 5 5M5 4h14" />
|
||||||
|
: <path d="M12 4v12M7 11l5 5 5-5M5 20h14" />}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Tooltip>{deprioritized ? 'Вернуть в список' : 'Убрать вниз'}</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
<TrafficChart
|
{!compact && <TrafficChart
|
||||||
samples={device.trafficHistory || []}
|
samples={device.trafficHistory || []}
|
||||||
scale={trafficScale}
|
scale={trafficScale}
|
||||||
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
|
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
|
||||||
@@ -532,7 +579,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
pinned={showDetails}
|
pinned={showDetails}
|
||||||
collapsing={collapsing}
|
collapsing={collapsing}
|
||||||
onCollapseEnd={() => finishPinCollapse(device.id)}
|
onCollapseEnd={() => finishPinCollapse(device.id)}
|
||||||
/>
|
/>}
|
||||||
</article>;
|
</article>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface Device extends Record<string, unknown> {
|
|||||||
lastSeenAt: string | null;
|
lastSeenAt: string | null;
|
||||||
status: DeviceStatus;
|
status: DeviceStatus;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
|
deprioritized?: boolean;
|
||||||
downloadBytes: ByteValue;
|
downloadBytes: ByteValue;
|
||||||
uploadBytes: ByteValue;
|
uploadBytes: ByteValue;
|
||||||
proxyDownloadBytes: ByteValue;
|
proxyDownloadBytes: ByteValue;
|
||||||
@@ -116,6 +117,8 @@ function validDevice(value: unknown): value is Device {
|
|||||||
&& nullableTimestamp(value.lastSeenAt)
|
&& nullableTimestamp(value.lastSeenAt)
|
||||||
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
|
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
|
||||||
&& typeof value.pinned === 'boolean'
|
&& typeof value.pinned === 'boolean'
|
||||||
|
&& (value.deprioritized === undefined || typeof value.deprioritized === 'boolean')
|
||||||
|
&& !(value.pinned === true && value.deprioritized === true)
|
||||||
&& bytes(value.downloadBytes)
|
&& bytes(value.downloadBytes)
|
||||||
&& bytes(value.uploadBytes)
|
&& bytes(value.uploadBytes)
|
||||||
&& bytes(value.proxyDownloadBytes)
|
&& bytes(value.proxyDownloadBytes)
|
||||||
|
|||||||
@@ -191,6 +191,7 @@
|
|||||||
|
|
||||||
.client-devices-error button,
|
.client-devices-error button,
|
||||||
.client-device-pin,
|
.client-device-pin,
|
||||||
|
.client-device-deprioritize,
|
||||||
.client-device-edit {
|
.client-device-edit {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -213,12 +214,42 @@
|
|||||||
row-gap: 6px;
|
row-gap: 6px;
|
||||||
padding: 10px 8px;
|
padding: 10px 8px;
|
||||||
border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
|
border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device.is-group-start {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-group-heading {
|
||||||
|
position: absolute;
|
||||||
|
top: -17px;
|
||||||
|
left: 8px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device:not(.is-group-start) > .client-device-group-heading {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device.is-pinned {
|
.client-device.is-pinned {
|
||||||
--client-device-chart-height: 72px;
|
--client-device-chart-height: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-device.is-deprioritized {
|
||||||
|
grid-template-rows: 34px;
|
||||||
|
row-gap: 0;
|
||||||
|
padding-block: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device.is-deprioritized .client-device-main {
|
||||||
|
grid-column: 1 / 4;
|
||||||
|
}
|
||||||
|
|
||||||
.client-device-main {
|
.client-device-main {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
@@ -231,7 +262,7 @@
|
|||||||
padding: 11px 0 0;
|
padding: 11px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-main > h3 {
|
.client-device-main > h4 {
|
||||||
height: 23px;
|
height: 23px;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -246,7 +277,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-main > h3.is-address-only {
|
.client-device-main > h4.is-address-only {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +401,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-device-edit,
|
.client-device-edit,
|
||||||
.client-device-pin {
|
.client-device-pin,
|
||||||
|
.client-device-deprioritize {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -384,7 +416,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-device-edit svg,
|
.client-device-edit svg,
|
||||||
.client-device-pin svg {
|
.client-device-pin svg,
|
||||||
|
.client-device-deprioritize svg {
|
||||||
width: 17px;
|
width: 17px;
|
||||||
height: 17px;
|
height: 17px;
|
||||||
fill: none;
|
fill: none;
|
||||||
@@ -398,7 +431,9 @@
|
|||||||
.client-device-edit:hover,
|
.client-device-edit:hover,
|
||||||
.client-device-edit:focus-visible,
|
.client-device-edit:focus-visible,
|
||||||
.client-device-pin:hover,
|
.client-device-pin:hover,
|
||||||
.client-device-pin:focus-visible {
|
.client-device-pin:focus-visible,
|
||||||
|
.client-device-deprioritize:hover,
|
||||||
|
.client-device-deprioritize:focus-visible {
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,7 +1023,8 @@
|
|||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-policy-wrap.client-tooltip-anchor > .client-tooltip {
|
.client-device-policy-wrap.client-tooltip-anchor > .client-tooltip,
|
||||||
|
.client-device-deprioritize-wrap.client-tooltip-anchor > .client-tooltip {
|
||||||
right: 0;
|
right: 0;
|
||||||
left: auto;
|
left: auto;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
@@ -996,10 +1032,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-device-policy-wrap.client-tooltip-anchor:hover > .client-tooltip,
|
.client-device-policy-wrap.client-tooltip-anchor:hover > .client-tooltip,
|
||||||
.client-device-policy-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
|
.client-device-policy-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip,
|
||||||
|
.client-device-deprioritize-wrap.client-tooltip-anchor:hover > .client-tooltip,
|
||||||
|
.client-device-deprioritize-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
|
||||||
transform: translate(0, 0);
|
transform: translate(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-device-deprioritize-wrap {
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 2;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device.is-deprioritized .client-device-deprioritize-wrap {
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-deprioritize[aria-pressed="true"] {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-deprioritize:hover:not(:disabled) svg,
|
||||||
|
.client-device-deprioritize:focus-visible svg {
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-deprioritize[aria-pressed="true"]:hover:not(:disabled) svg,
|
||||||
|
.client-device-deprioritize[aria-pressed="true"]:focus-visible svg {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
.client-devices-error button {
|
.client-devices-error button {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|||||||
@@ -67,6 +67,8 @@
|
|||||||
.client-device,
|
.client-device,
|
||||||
.client-device-pin,
|
.client-device-pin,
|
||||||
.client-device-pin svg,
|
.client-device-pin svg,
|
||||||
|
.client-device-deprioritize,
|
||||||
|
.client-device-deprioritize svg,
|
||||||
.client-device-policy,
|
.client-device-policy,
|
||||||
.client-device-policy svg,
|
.client-device-policy svg,
|
||||||
.client-device-alias-trigger,
|
.client-device-alias-trigger,
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export function sortDevicesByTraffic(devices: Device[] | undefined, direction: '
|
|||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const pinned = Number(right.device.pinned === true) - Number(left.device.pinned === true);
|
const pinned = Number(right.device.pinned === true) - Number(left.device.pinned === true);
|
||||||
if (pinned) return pinned;
|
if (pinned) return pinned;
|
||||||
|
const deprioritized = Number(left.device.deprioritized === true) - Number(right.device.deprioritized === true);
|
||||||
|
if (deprioritized) return deprioritized;
|
||||||
const leftTotal = byteString(left.device.uploadBytes) + byteString(left.device.downloadBytes)
|
const leftTotal = byteString(left.device.uploadBytes) + byteString(left.device.downloadBytes)
|
||||||
+ byteString(left.device.proxyUploadBytes) + byteString(left.device.proxyDownloadBytes);
|
+ byteString(left.device.proxyUploadBytes) + byteString(left.device.proxyDownloadBytes);
|
||||||
const rightTotal = byteString(right.device.uploadBytes) + byteString(right.device.downloadBytes)
|
const rightTotal = byteString(right.device.uploadBytes) + byteString(right.device.downloadBytes)
|
||||||
@@ -96,7 +98,11 @@ export function stabilizeDevicesByTraffic(
|
|||||||
seen.add(id);
|
seen.add(id);
|
||||||
}
|
}
|
||||||
const ordered = ids.map((id) => byId.get(id)).filter((device): device is Device => Boolean(device));
|
const ordered = ids.map((id) => byId.get(id)).filter((device): device is Device => Boolean(device));
|
||||||
const stable = [...ordered.filter(({ pinned }) => pinned), ...ordered.filter(({ pinned }) => !pinned)];
|
const stable = [
|
||||||
|
...ordered.filter(({ pinned }) => pinned),
|
||||||
|
...ordered.filter(({ pinned, deprioritized }) => !pinned && deprioritized !== true),
|
||||||
|
...ordered.filter(({ pinned, deprioritized }) => !pinned && deprioritized === true),
|
||||||
|
];
|
||||||
return { devices: stable, ids: stable.map(({ id }) => id) };
|
return { devices: stable, ids: stable.map(({ id }) => id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ test('malformed persisted devices and remote observations cannot enter canonical
|
|||||||
id: deviceId(mac),
|
id: deviceId(mac),
|
||||||
alias: '',
|
alias: '',
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
deprioritized: false,
|
||||||
hostname: null,
|
hostname: null,
|
||||||
manufacturer: null,
|
manufacturer: null,
|
||||||
source: 'neighbor',
|
source: 'neighbor',
|
||||||
@@ -183,6 +184,7 @@ test('device inventory discovers, merges, persists metadata and expires anonymou
|
|||||||
assert.equal(snapshot.devices[0].status, 'recent');
|
assert.equal(snapshot.devices[0].status, 'recent');
|
||||||
|
|
||||||
snapshot = service.update(snapshot.devices[0].id, { alias: 'Телевизор', pinned: true }, snapshot.revision);
|
snapshot = service.update(snapshot.devices[0].id, { alias: 'Телевизор', pinned: true }, snapshot.revision);
|
||||||
|
const namedId = snapshot.devices[0].id;
|
||||||
const restarted = createDeviceInventoryService({ store, observe: async () => observation, vendor, now: () => current });
|
const restarted = createDeviceInventoryService({ store, observe: async () => observation, vendor, now: () => current });
|
||||||
assert.deepEqual(restarted.snapshot().devices[0], snapshot.devices[0]);
|
assert.deepEqual(restarted.snapshot().devices[0], snapshot.devices[0]);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
@@ -190,15 +192,57 @@ test('device inventory discovers, merges, persists metadata and expires anonymou
|
|||||||
(error) => error.code === 'STATE_CONFLICT',
|
(error) => error.code === 'STATE_CONFLICT',
|
||||||
);
|
);
|
||||||
|
|
||||||
observation = { observedAt: current.toISOString(), observations: [], error: 'source unavailable' };
|
current = new Date('2026-07-01T10:06:00.000Z');
|
||||||
|
observation = {
|
||||||
|
observedAt: current.toISOString(),
|
||||||
|
observations: parseNeighborSnapshot([
|
||||||
|
{ dst: '192.168.50.18', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['REACHABLE'] },
|
||||||
|
], current.toISOString()),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
snapshot = await restarted.refresh();
|
snapshot = await restarted.refresh();
|
||||||
assert.equal(snapshot.devices.length, 1);
|
assert.equal(snapshot.devices.length, 1);
|
||||||
|
assert.equal(snapshot.devices[0].id, namedId);
|
||||||
|
assert.equal(snapshot.devices[0].ip, '192.168.50.18');
|
||||||
|
assert.equal(snapshot.devices[0].alias, 'Телевизор');
|
||||||
|
assert.equal(snapshot.devices[0].pinned, true);
|
||||||
|
|
||||||
|
observation = {
|
||||||
|
observedAt: current.toISOString(),
|
||||||
|
observations: parseNeighborSnapshot([
|
||||||
|
{ dst: '192.168.50.18', dev: 'br0', lladdr: '00:11:22:33:44:66', state: ['REACHABLE'] },
|
||||||
|
], current.toISOString()),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
snapshot = await restarted.refresh();
|
||||||
|
const replacement = snapshot.devices.find(({ mac: deviceMac }) => deviceMac === '00:11:22:33:44:66');
|
||||||
|
assert.notEqual(replacement.id, namedId);
|
||||||
|
assert.equal(replacement.alias, '');
|
||||||
|
assert.equal(replacement.pinned, false);
|
||||||
|
|
||||||
|
snapshot = restarted.update(namedId, { deprioritized: true }, snapshot.revision);
|
||||||
|
const deprioritized = snapshot.devices.find(({ id }) => id === namedId);
|
||||||
|
assert.equal(deprioritized.pinned, false);
|
||||||
|
assert.equal(deprioritized.deprioritized, true);
|
||||||
|
assert.throws(
|
||||||
|
() => restarted.update(namedId, { pinned: true, deprioritized: true }, snapshot.revision),
|
||||||
|
(error) => error.code === 'REQUEST_INVALID',
|
||||||
|
);
|
||||||
|
const persisted = createDeviceInventoryService({ store, observe: async () => observation, vendor, now: () => current });
|
||||||
|
assert.equal(persisted.snapshot().devices.find(({ id }) => id === namedId).deprioritized, true);
|
||||||
|
|
||||||
|
observation = { observedAt: current.toISOString(), observations: [], error: 'source unavailable' };
|
||||||
|
snapshot = await persisted.refresh();
|
||||||
|
assert.equal(snapshot.devices.length, 2);
|
||||||
assert.equal(snapshot.source.error, 'source unavailable');
|
assert.equal(snapshot.source.error, 'source unavailable');
|
||||||
|
|
||||||
snapshot = restarted.update(snapshot.devices[0].id, { alias: '', pinned: false }, snapshot.revision);
|
snapshot = persisted.update(namedId, { alias: '' }, snapshot.revision);
|
||||||
current = new Date('2026-08-02T10:00:00.000Z');
|
current = new Date('2026-08-02T10:00:00.000Z');
|
||||||
observation = { observedAt: current.toISOString(), observations: [], error: null };
|
observation = { observedAt: current.toISOString(), observations: [], error: null };
|
||||||
snapshot = await restarted.refresh();
|
snapshot = await persisted.refresh();
|
||||||
|
assert.deepEqual(snapshot.devices.map(({ id }) => id), [namedId]);
|
||||||
|
snapshot = persisted.update(namedId, { deprioritized: false }, snapshot.revision);
|
||||||
|
snapshot = await persisted.refresh();
|
||||||
assert.equal(snapshot.devices.length, 0);
|
assert.equal(snapshot.devices.length, 0);
|
||||||
|
|
||||||
const failed = readNeighborSnapshot(() => ({ status: 1, stderr: 'not available' }), () => current);
|
const failed = readNeighborSnapshot(() => ({ status: 1, stderr: 'not available' }), () => current);
|
||||||
|
|||||||
@@ -75,7 +75,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, extra: 0 };
|
const patch = { expectedRevision: 7, alias: 'Desk', pinned: false, deprioritized: true, 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({
|
||||||
@@ -85,7 +85,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, extra: 0 },
|
{ alias: 'Desk', pinned: false, deprioritized: true, extra: 0 },
|
||||||
7,
|
7,
|
||||||
]]);
|
]]);
|
||||||
assert.deepEqual(metadataResponse.payload, { revision: 3 });
|
assert.deepEqual(metadataResponse.payload, { revision: 3 });
|
||||||
|
|||||||
@@ -116,12 +116,22 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
||||||
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
||||||
assert.match(panel, /const \[pinCollapses, setPinCollapses\][\s\S]*animationDone[\s\S]*result: saved \? 'saved' : 'failed'/);
|
assert.match(panel, /const \[pinCollapses, setPinCollapses\][\s\S]*animationDone[\s\S]*result: saved \? 'saved' : 'failed'/);
|
||||||
assert.match(panel, /const expanded = device\.pinned && !collapsing[\s\S]*const showDetails = device\.pinned \|\| collapsing[\s\S]*pinned=\{showDetails\}[\s\S]*onCollapseEnd=/);
|
assert.match(panel, /const compact = deprioritized && !collapsing[\s\S]*const expanded = device\.pinned && !collapsing[\s\S]*const showDetails = !compact && \(device\.pinned \|\| collapsing\)[\s\S]*pinned=\{showDetails\}[\s\S]*onCollapseEnd=/);
|
||||||
|
assert.match(panel, /Закреплённые[\s\S]*Убраны вниз[\s\S]*Остальные/);
|
||||||
|
assert.match(panel, /client-device-group-heading[\s\S]*role=\{groupStart \? 'heading' : undefined\}[\s\S]*aria-level=\{groupStart \? 3 : undefined\}/);
|
||||||
|
assert.match(panel, /toggleDeprioritized[\s\S]*deprioritized: !deprioritized[\s\S]*deprioritized: true/);
|
||||||
|
assert.match(panel, /client-device-deprioritize[\s\S]*aria-pressed=\{deprioritized\}[\s\S]*Вернуть \$\{title\} в основной список[\s\S]*Убрать \$\{title\} вниз/);
|
||||||
|
assert.match(panel, /\{!compact && <TrafficChart/);
|
||||||
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-devices \{\s*width: min\(580px, 100vw\)/);
|
assert.match(styles, /\.client-devices \{\s*width: min\(580px, 100vw\)/);
|
||||||
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 \.client-device-main \{[\s\S]*grid-column: 1 \/ 4/);
|
||||||
|
assert.match(styles, /\.client-device-deprioritize-wrap \{[\s\S]*grid-column: 4;[\s\S]*grid-row: 2/);
|
||||||
|
assert.match(styles, /\.client-device\.is-deprioritized \.client-device-deprioritize-wrap \{[\s\S]*grid-row: 1/);
|
||||||
|
assert.match(styles, /\.client-device-group-heading \{[\s\S]*text-transform: uppercase/);
|
||||||
assert.match(styles, /\.client-device\.is-pinned \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-expand 520ms/);
|
assert.match(styles, /\.client-device\.is-pinned \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-expand 520ms/);
|
||||||
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-collapse 520ms/);
|
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-collapse 520ms/);
|
||||||
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-axis \{[\s\S]*top: 0;[\s\S]*height: 58px/);
|
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-axis \{[\s\S]*top: 0;[\s\S]*height: 58px/);
|
||||||
@@ -156,7 +166,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(styles, /\.client-device-name-separator \{[\s\S]*color: var\(--client-muted\)/);
|
assert.match(styles, /\.client-device-name-separator \{[\s\S]*color: var\(--client-muted\)/);
|
||||||
assert.match(styles, /\.client-device-ip\.is-copied \{[\s\S]*client-device-ip-copy 800ms/);
|
assert.match(styles, /\.client-device-ip\.is-copied \{[\s\S]*client-device-ip-copy 800ms/);
|
||||||
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding: 11px 0 0/);
|
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding: 11px 0 0/);
|
||||||
assert.match(styles, /\.client-device-main > h3\.is-address-only \{[\s\S]*flex: 0 1 auto/);
|
assert.match(styles, /\.client-device-main > h4\.is-address-only \{[\s\S]*flex: 0 1 auto/);
|
||||||
assert.match(styles, /\.client-device-last-seen \{[^}]*height: 10px[\s\S]*align-items: center/);
|
assert.match(styles, /\.client-device-last-seen \{[^}]*height: 10px[\s\S]*align-items: center/);
|
||||||
assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/);
|
assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/);
|
||||||
assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/);
|
assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/);
|
||||||
@@ -249,9 +259,10 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni
|
|||||||
{ id: 'd', uploadBytes: '15', downloadBytes: '5', proxyUploadBytes: '100' },
|
{ id: 'd', uploadBytes: '15', downloadBytes: '5', proxyUploadBytes: '100' },
|
||||||
{ id: 'c', uploadBytes: '10', downloadBytes: '10', proxyDownloadBytes: '100' },
|
{ id: 'c', uploadBytes: '10', downloadBytes: '10', proxyDownloadBytes: '100' },
|
||||||
{ id: 'e', pinned: true, uploadBytes: '0', downloadBytes: '0' },
|
{ id: 'e', pinned: true, uploadBytes: '0', downloadBytes: '0' },
|
||||||
|
{ id: 'f', deprioritized: true, uploadBytes: '999999', downloadBytes: '0' },
|
||||||
];
|
];
|
||||||
assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['e', 'b', 'a', 'c', 'd']);
|
assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['e', 'b', 'a', 'c', 'd', 'f']);
|
||||||
assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['e', 'c', 'd', 'a', 'b']);
|
assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['e', 'c', 'd', 'a', 'b', 'f']);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
stabilizeDevicesByTraffic([
|
stabilizeDevicesByTraffic([
|
||||||
{ id: 'a', uploadBytes: '999' },
|
{ id: 'a', uploadBytes: '999' },
|
||||||
@@ -266,4 +277,12 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni
|
|||||||
], 'desc', ['a', 'b']).ids,
|
], 'desc', ['a', 'b']).ids,
|
||||||
['b', 'a'],
|
['b', 'a'],
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
stabilizeDevicesByTraffic([
|
||||||
|
{ id: 'a', deprioritized: true, uploadBytes: '999' },
|
||||||
|
{ id: 'b', uploadBytes: '1' },
|
||||||
|
{ id: 'c', pinned: true, uploadBytes: '0' },
|
||||||
|
], 'desc', ['a', 'b', 'c']).ids,
|
||||||
|
['c', 'b', 'a'],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
|
|||||||
lastSeenAt: null,
|
lastSeenAt: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
pinned: true,
|
pinned: true,
|
||||||
|
deprioritized: false,
|
||||||
downloadBytes: '12',
|
downloadBytes: '12',
|
||||||
uploadBytes: '30',
|
uploadBytes: '30',
|
||||||
proxyDownloadBytes: '0',
|
proxyDownloadBytes: '0',
|
||||||
@@ -99,6 +100,8 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
|
|||||||
{ ...valid, devices: [{ ...valid.devices[0], uploadBytes: '-1' }] },
|
{ ...valid, devices: [{ ...valid.devices[0], uploadBytes: '-1' }] },
|
||||||
{ ...valid, devices: [{ ...valid.devices[0], proxyUploadBytes: 1 }] },
|
{ ...valid, devices: [{ ...valid.devices[0], proxyUploadBytes: 1 }] },
|
||||||
{ ...valid, devices: [{ ...valid.devices[0], status: 'connected' }] },
|
{ ...valid, devices: [{ ...valid.devices[0], status: 'connected' }] },
|
||||||
|
{ ...valid, devices: [{ ...valid.devices[0], deprioritized: 'yes' }] },
|
||||||
|
{ ...valid, devices: [{ ...valid.devices[0], pinned: true, deprioritized: true }] },
|
||||||
{ ...valid, devices: [{ ...valid.devices[0], desiredPolicy: 'automatic' }] },
|
{ ...valid, devices: [{ ...valid.devices[0], desiredPolicy: 'automatic' }] },
|
||||||
{ ...valid, devices: [{ ...valid.devices[0], policyStatus: 'queued' }] },
|
{ ...valid, devices: [{ ...valid.devices[0], policyStatus: 'queued' }] },
|
||||||
{ ...valid, devices: [{ ...valid.devices[0], confidence: 'unknown' }] },
|
{ ...valid, devices: [{ ...valid.devices[0], confidence: 'unknown' }] },
|
||||||
|
|||||||
@@ -36,26 +36,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: 804,
|
cascadeEdges: 821,
|
||||||
customProperties: 31,
|
customProperties: 31,
|
||||||
declarations: 2914,
|
declarations: 2941,
|
||||||
important: 0,
|
important: 0,
|
||||||
keyframes: 51,
|
keyframes: 51,
|
||||||
media: 10,
|
media: 10,
|
||||||
rules: 878,
|
rules: 888,
|
||||||
variableReferences: 336,
|
variableReferences: 338,
|
||||||
},
|
},
|
||||||
hashes: {
|
hashes: {
|
||||||
cascadeEdges: '18e5bdda435293145dbb52319f1e842cc8b7cc3e8beb60ec381c26d0410cfe44',
|
cascadeEdges: '1ffc2a2f986970988643129ff3e32f19c31bb8b1e018a3a474a976e23adc9776',
|
||||||
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
|
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
|
||||||
declarations: '01152af9f22aa7a7654356f81b8a6110488f9d70754b2517d1f6bafeff4b14fd',
|
declarations: '77314876411a21c88472dc277a59ca24192ca7d38de1ce8f367e7bd1007c5323',
|
||||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||||
duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47',
|
duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47',
|
||||||
keyframes: '0bed7ec3cd3a86ee091cf9b07c081ab2364ac5a17e55e95bc9c4a6430419b019',
|
keyframes: '0bed7ec3cd3a86ee091cf9b07c081ab2364ac5a17e55e95bc9c4a6430419b019',
|
||||||
ruleDeclarationSequences: '7ffd551563a8d801bcd5e5f9886b9551940dd9561240f0f8a771a85d44fc19db',
|
ruleDeclarationSequences: 'ce3831db5966264c17fa3af3e10fc6a41398cc619d061fa155ac43b47f0947ea',
|
||||||
selectors: '09962ebb3bc769f74ae785a44f8e032c0a83985ce14ea652135732b5af69042a',
|
selectors: '828cf6e95b03c890e3a969be20eb05be1f70872d6076e8f9a6a38cf6b415d012',
|
||||||
variableReferences: '5988cb6c9b658d380c5b51c04621c0579e1cc5e8abcd764d78a9d42b802252c9',
|
variableReferences: '9b6214da6e8e02b01deb6d5a98fe4c695a1fdaff4f65c936b805d7d5841db04d',
|
||||||
witnesses: '68d7c48f20f7a8ae2c0a6ddabe743dfb84fd02fba303e3cc9d3b902efd0f8758',
|
witnesses: 'c34721ec2cb99104eae77841ae88ddf77cde278d15c21675a020f3cfdd13a28a',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ test('tokens, shared primitives, and feature styles have one explicit 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, 709);
|
assert.equal(witnesses.length, 716);
|
||||||
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);
|
||||||
@@ -293,8 +293,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-BJnEzGgg.css']);
|
assert.deepEqual(assets, ['index-DQr1ElW4.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, 109140);
|
assert.equal(built.byteLength, 110647);
|
||||||
assert.equal(sha256(built), '9e1f7811080177f366b693a77e92c615aff1a74537b34d597100c9707a8aea95');
|
assert.equal(sha256(built), '91bf07a74af3312f9b048fdebd642e811fafc3bb2ec49b55592a35f085c25f41');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user