Add Gateway traffic totals and dashboard chart
This commit is contained in:
@@ -9,11 +9,11 @@ Harbor помогает пользоваться одной VPN-подписко
|
|||||||
| **Harbor Gateway** | На отдельной Linux-машине | Проводит через VPN весь интернет-трафик домашних устройств или работает как общий HTTP/SOCKS5-прокси |
|
| **Harbor Gateway** | На отдельной Linux-машине | Проводит через VPN весь интернет-трафик домашних устройств или работает как общий HTTP/SOCKS5-прокси |
|
||||||
| **Harbor Connect** | На macOS | Даёт приложениям на Mac локальный HTTP/SOCKS5-прокси |
|
| **Harbor Connect** | На macOS | Даёт приложениям на Mac локальный HTTP/SOCKS5-прокси |
|
||||||
|
|
||||||
В обоих режимах управление одинаковое: откройте веб-интерфейс, вставьте ссылку VPN-подписки, выберите сервер и нажмите кнопку подключения.
|
В Harbor Connect подписка и выбор сервера остаются на основном экране. Harbor Gateway открывается как панель маршрутизации даже без подписки: Home, «Устройства» и «Диагностика» доступны сразу, а VPN-подписка настраивается отдельной верхней кнопкой в правой панели.
|
||||||
|
|
||||||
## Что понадобится
|
## Что понадобится
|
||||||
|
|
||||||
- ссылка на подписку от VPN-провайдера;
|
- ссылка на подписку от VPN-провайдера, если Harbor должен направлять трафик через VPN;
|
||||||
- Docker с командой `docker compose`;
|
- Docker с командой `docker compose`;
|
||||||
- для ручной установки Gateway — Git;
|
- для ручной установки Gateway — Git;
|
||||||
- для Gateway — Linux-машина в одной локальной сети с устройствами;
|
- для Gateway — Linux-машина в одной локальной сети с устройствами;
|
||||||
@@ -60,12 +60,13 @@ http://АДРЕС-GATEWAY:3456
|
|||||||
|
|
||||||
Например, если Linux-машина имеет адрес `192.168.1.20`, интерфейс будет доступен по адресу `http://192.168.1.20:3456`.
|
Например, если Linux-машина имеет адрес `192.168.1.20`, интерфейс будет доступен по адресу `http://192.168.1.20:3456`.
|
||||||
|
|
||||||
### 4. Добавьте подписку
|
### 4. При необходимости добавьте подписку
|
||||||
|
|
||||||
1. Вставьте ссылку VPN-подписки.
|
1. Нажмите «Подписка» — верхнюю кнопку в правой панели Gateway.
|
||||||
2. Нажмите «Сохранить подписку».
|
2. Вставьте ссылку VPN-подписки.
|
||||||
3. Выберите сервер.
|
3. Нажмите «Сохранить подписку».
|
||||||
4. Включите VPN.
|
4. Выберите сервер.
|
||||||
|
5. Включите VPN.
|
||||||
|
|
||||||
После подключения Harbor покажет два варианта использования:
|
После подключения Harbor покажет два варианта использования:
|
||||||
|
|
||||||
@@ -76,12 +77,14 @@ http://АДРЕС-GATEWAY:3456
|
|||||||
|
|
||||||
### Устройства Gateway
|
### Устройства Gateway
|
||||||
|
|
||||||
После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название и последний контакт, два вертикальных счётчика `Gateway`/`Прокси`, затем иконку применённого маршрута. IP скрыт под названием: наведите или сфокусируйте название, чтобы увидеть адрес, и нажмите, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway.
|
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название и последний контакт, два вертикальных счётчика `Gateway`/`Прокси`, затем иконку применённого маршрута. IP скрыт под названием: наведите или сфокусируйте название, чтобы увидеть адрес, и нажмите, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные 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 секунд; история по часам пока не хранится.
|
Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
|
||||||
|
|
||||||
|
Home показывает фактически применённый VPN-сервер и общий график тех же счётчиков. `Учтено Harbor` — накопленная сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткий график последних 15-секундных интервалов после перезапуска начинает заполняться заново.
|
||||||
|
|
||||||
## Установка Harbor Connect на macOS
|
## Установка Harbor Connect на macOS
|
||||||
|
|
||||||
### 1. Запустите Docker Desktop
|
### 1. Запустите Docker Desktop
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { HarborError } from '../../shared/errors.js';
|
|||||||
import { isDeviceInterface } from '../adapters/neighbors.js';
|
import { isDeviceInterface } from '../adapters/neighbors.js';
|
||||||
import { fingerprintDirectDevices } from './devicePolicyService.js';
|
import { fingerprintDirectDevices } from './devicePolicyService.js';
|
||||||
|
|
||||||
export const DEVICE_INVENTORY_SCHEMA_VERSION = 2;
|
export const DEVICE_INVENTORY_SCHEMA_VERSION = 3;
|
||||||
const ONLINE_MS = 2 * 60 * 1000;
|
const ONLINE_MS = 2 * 60 * 1000;
|
||||||
const RECENT_MS = 24 * 60 * 60 * 1000;
|
const RECENT_MS = 24 * 60 * 60 * 1000;
|
||||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
@@ -47,6 +47,15 @@ const DEFAULT_PROXY_TRAFFIC = {
|
|||||||
rebaselineMacs: [],
|
rebaselineMacs: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_GLOBAL_TRAFFIC_SOURCE = {
|
||||||
|
epoch: null,
|
||||||
|
lastObservedAt: null,
|
||||||
|
uploadBytes: '0',
|
||||||
|
downloadBytes: '0',
|
||||||
|
baselinesByMac: {},
|
||||||
|
rebaselineMacs: [],
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_STATE = {
|
const DEFAULT_STATE = {
|
||||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -62,6 +71,10 @@ const DEFAULT_STATE = {
|
|||||||
totalsByMac: {},
|
totalsByMac: {},
|
||||||
rebaselineMacs: [],
|
rebaselineMacs: [],
|
||||||
proxy: DEFAULT_PROXY_TRAFFIC,
|
proxy: DEFAULT_PROXY_TRAFFIC,
|
||||||
|
global: {
|
||||||
|
gateway: DEFAULT_GLOBAL_TRAFFIC_SOURCE,
|
||||||
|
proxy: DEFAULT_GLOBAL_TRAFFIC_SOURCE,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
devices: [],
|
devices: [],
|
||||||
};
|
};
|
||||||
@@ -77,6 +90,57 @@ const parseStoredCounter = (value) => {
|
|||||||
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sumStoredTotals = (totalsByMac, key) => recordEntries(totalsByMac)
|
||||||
|
.reduce((total, [, value]) => total + BigInt(value?.[key] || '0'), 0n)
|
||||||
|
.toString();
|
||||||
|
|
||||||
|
function normalizeGlobalTrafficSource(value, fallback, version) {
|
||||||
|
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||||
|
const fallbackMacs = new Set([
|
||||||
|
...Object.keys(fallback.baselinesByMac),
|
||||||
|
...Object.keys(fallback.totalsByMac),
|
||||||
|
...fallback.rebaselineMacs,
|
||||||
|
]);
|
||||||
|
if (version < 3 || source !== value) {
|
||||||
|
return {
|
||||||
|
epoch: fallback.epoch,
|
||||||
|
lastObservedAt: fallback.lastObservedAt,
|
||||||
|
uploadBytes: sumStoredTotals(fallback.totalsByMac, 'uploadBytes'),
|
||||||
|
downloadBytes: sumStoredTotals(fallback.totalsByMac, 'downloadBytes'),
|
||||||
|
baselinesByMac: structuredClone(fallback.baselinesByMac),
|
||||||
|
rebaselineMacs: [...fallback.rebaselineMacs],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rebaselineMacs = new Set((Array.isArray(source.rebaselineMacs) ? source.rebaselineMacs : [])
|
||||||
|
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||||
|
const baselinesByMac = {};
|
||||||
|
let recovered = !source.baselinesByMac || typeof source.baselinesByMac !== 'object'
|
||||||
|
|| Array.isArray(source.baselinesByMac);
|
||||||
|
for (const [rawMac, baseline] of recordEntries(source.baselinesByMac)) {
|
||||||
|
const mac = normalizeMac(rawMac);
|
||||||
|
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
|
||||||
|
const downloadBytes = parseStoredCounter(baseline?.downloadBytes);
|
||||||
|
if (!MAC_PATTERN.test(mac) || typeof baseline?.epoch !== 'string' || !baseline.epoch
|
||||||
|
|| uploadBytes == null || downloadBytes == null) {
|
||||||
|
if (MAC_PATTERN.test(mac)) rebaselineMacs.add(mac);
|
||||||
|
recovered = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
|
||||||
|
}
|
||||||
|
if (recovered) for (const mac of fallbackMacs) rebaselineMacs.add(mac);
|
||||||
|
return {
|
||||||
|
epoch: typeof source.epoch === 'string' ? source.epoch : fallback.epoch,
|
||||||
|
lastObservedAt: typeof source.lastObservedAt === 'string' ? source.lastObservedAt : fallback.lastObservedAt,
|
||||||
|
uploadBytes: parseStoredCounter(source.uploadBytes)
|
||||||
|
?? sumStoredTotals(fallback.totalsByMac, 'uploadBytes'),
|
||||||
|
downloadBytes: parseStoredCounter(source.downloadBytes)
|
||||||
|
?? sumStoredTotals(fallback.totalsByMac, 'downloadBytes'),
|
||||||
|
baselinesByMac,
|
||||||
|
rebaselineMacs: [...rebaselineMacs],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeProxyTraffic(value, devices) {
|
function normalizeProxyTraffic(value, devices) {
|
||||||
const proxy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
const proxy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||||
if (Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) {
|
if (Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) {
|
||||||
@@ -264,6 +328,22 @@ export function migrateDeviceInventoryState(value) {
|
|||||||
recoveredTraffic = true;
|
recoveredTraffic = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const global = {
|
||||||
|
gateway: normalizeGlobalTrafficSource(traffic.global?.gateway, {
|
||||||
|
epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null,
|
||||||
|
lastObservedAt: typeof traffic.lastObservedAt === 'string' ? traffic.lastObservedAt : null,
|
||||||
|
baselinesByMac,
|
||||||
|
totalsByMac,
|
||||||
|
rebaselineMacs: [...rebaselineMacs],
|
||||||
|
}, version),
|
||||||
|
proxy: normalizeGlobalTrafficSource(traffic.global?.proxy, {
|
||||||
|
epoch: Object.values(proxyTraffic.baselinesByMac)[0]?.epoch || null,
|
||||||
|
lastObservedAt: proxyTraffic.lastObservedAt,
|
||||||
|
baselinesByMac: proxyTraffic.baselinesByMac,
|
||||||
|
totalsByMac: proxyTraffic.totalsByMac,
|
||||||
|
rebaselineMacs: proxyTraffic.rebaselineMacs,
|
||||||
|
}, version),
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
...state,
|
...state,
|
||||||
@@ -280,6 +360,7 @@ export function migrateDeviceInventoryState(value) {
|
|||||||
totalsByMac,
|
totalsByMac,
|
||||||
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
|
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
|
||||||
proxy: proxyTraffic,
|
proxy: proxyTraffic,
|
||||||
|
global,
|
||||||
},
|
},
|
||||||
devices,
|
devices,
|
||||||
};
|
};
|
||||||
@@ -292,6 +373,43 @@ function deviceStatus(lastSeenAt, now) {
|
|||||||
return 'offline';
|
return 'offline';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function accumulateGlobalTraffic(source, countersByMac, epoch, observedAt, label) {
|
||||||
|
const epochChanged = Boolean(source.epoch && source.epoch !== epoch);
|
||||||
|
const baselinesByMac = epochChanged ? {} : { ...source.baselinesByMac };
|
||||||
|
const rebaselineMacs = new Set(epochChanged ? [] : source.rebaselineMacs);
|
||||||
|
let uploadBytes = BigInt(source.uploadBytes);
|
||||||
|
let downloadBytes = BigInt(source.downloadBytes);
|
||||||
|
for (const [mac, processTotal] of countersByMac) {
|
||||||
|
const baseline = baselinesByMac[mac];
|
||||||
|
const recovering = rebaselineMacs.has(mac);
|
||||||
|
const sameEpoch = !recovering && baseline?.epoch === epoch;
|
||||||
|
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
|
||||||
|
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
|
||||||
|
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
|
||||||
|
throw new Error(`Dataplane ${label} traffic counter уменьшился внутри одного epoch`);
|
||||||
|
}
|
||||||
|
if (!recovering) {
|
||||||
|
uploadBytes += processTotal.upload - baselineUpload;
|
||||||
|
downloadBytes += processTotal.download - baselineDownload;
|
||||||
|
}
|
||||||
|
baselinesByMac[mac] = {
|
||||||
|
epoch,
|
||||||
|
uploadBytes: processTotal.upload.toString(),
|
||||||
|
downloadBytes: processTotal.download.toString(),
|
||||||
|
};
|
||||||
|
rebaselineMacs.delete(mac);
|
||||||
|
}
|
||||||
|
// ponytail: per-MAC baselines live for one dataplane epoch; use a dataplane-wide counter if MAC churn becomes large.
|
||||||
|
return {
|
||||||
|
epoch,
|
||||||
|
lastObservedAt: observedAt || source.lastObservedAt,
|
||||||
|
uploadBytes: uploadBytes.toString(),
|
||||||
|
downloadBytes: downloadBytes.toString(),
|
||||||
|
baselinesByMac,
|
||||||
|
rebaselineMacs: [...rebaselineMacs],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createDeviceInventoryService({
|
export function createDeviceInventoryService({
|
||||||
store,
|
store,
|
||||||
observe,
|
observe,
|
||||||
@@ -305,6 +423,8 @@ export function createDeviceInventoryService({
|
|||||||
let policyQueue = Promise.resolve();
|
let policyQueue = Promise.resolve();
|
||||||
const trafficHistoryByMac = new Map();
|
const trafficHistoryByMac = new Map();
|
||||||
const trafficCursorByMac = new Map();
|
const trafficCursorByMac = new Map();
|
||||||
|
let globalTrafficHistory = [];
|
||||||
|
let globalTrafficCursor = null;
|
||||||
|
|
||||||
function captureTrafficHistory(state) {
|
function captureTrafficHistory(state) {
|
||||||
const knownMacs = new Set(state.devices.map(({ mac }) => mac));
|
const knownMacs = new Set(state.devices.map(({ mac }) => mac));
|
||||||
@@ -332,6 +452,20 @@ export function createDeviceInventoryService({
|
|||||||
trafficHistoryByMac.delete(mac);
|
trafficHistoryByMac.delete(mac);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const gatewaySource = state.traffic.global.gateway;
|
||||||
|
const proxySource = state.traffic.global.proxy;
|
||||||
|
const gateway = BigInt(gatewaySource.uploadBytes) + BigInt(gatewaySource.downloadBytes);
|
||||||
|
const proxy = BigInt(proxySource.uploadBytes) + BigInt(proxySource.downloadBytes);
|
||||||
|
const signature = `${gatewaySource.lastObservedAt || ''}|${proxySource.lastObservedAt || ''}`;
|
||||||
|
const previous = globalTrafficCursor;
|
||||||
|
globalTrafficCursor = { signature, gateway, proxy };
|
||||||
|
if (previous && previous.signature !== signature) {
|
||||||
|
globalTrafficHistory = [...globalTrafficHistory, {
|
||||||
|
observedAt: [gatewaySource.lastObservedAt, proxySource.lastObservedAt].filter(Boolean).sort().at(-1),
|
||||||
|
gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0',
|
||||||
|
proxyBytes: proxy > previous.proxy ? (proxy - previous.proxy).toString() : '0',
|
||||||
|
}].slice(-TRAFFIC_HISTORY_LIMIT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializePolicy(action) {
|
function serializePolicy(action) {
|
||||||
@@ -406,9 +540,29 @@ export function createDeviceInventoryService({
|
|||||||
|| 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))
|
||||||
));
|
));
|
||||||
|
const gatewayTraffic = state.traffic.global.gateway;
|
||||||
|
const proxyTraffic = state.traffic.global.proxy;
|
||||||
|
const gatewayBytes = BigInt(gatewayTraffic.uploadBytes) + BigInt(gatewayTraffic.downloadBytes);
|
||||||
|
const proxyBytes = BigInt(proxyTraffic.uploadBytes) + BigInt(proxyTraffic.downloadBytes);
|
||||||
|
const contributingTimes = [
|
||||||
|
gatewayBytes > 0n ? gatewayTraffic.lastObservedAt : null,
|
||||||
|
proxyBytes > 0n ? proxyTraffic.lastObservedAt : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
const observedTimes = contributingTimes.length
|
||||||
|
? contributingTimes
|
||||||
|
: [gatewayTraffic.lastObservedAt, proxyTraffic.lastObservedAt].filter(Boolean);
|
||||||
return {
|
return {
|
||||||
revision: state.revision,
|
revision: state.revision,
|
||||||
trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT,
|
trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT,
|
||||||
|
traffic: {
|
||||||
|
gatewayBytes: gatewayBytes.toString(),
|
||||||
|
proxyBytes: proxyBytes.toString(),
|
||||||
|
totalBytes: (gatewayBytes + proxyBytes).toString(),
|
||||||
|
gatewayObservedAt: gatewayTraffic.lastObservedAt,
|
||||||
|
proxyObservedAt: proxyTraffic.lastObservedAt,
|
||||||
|
observedAt: observedTimes.sort()[0] || null,
|
||||||
|
history: globalTrafficHistory,
|
||||||
|
},
|
||||||
source: {
|
source: {
|
||||||
kind: 'neighbor',
|
kind: 'neighbor',
|
||||||
lastObservedAt: state.lastObservedAt,
|
lastObservedAt: state.lastObservedAt,
|
||||||
@@ -678,6 +832,13 @@ export function createDeviceInventoryService({
|
|||||||
};
|
};
|
||||||
rebaselineMacs.delete(mac);
|
rebaselineMacs.delete(mac);
|
||||||
}
|
}
|
||||||
|
const globalGateway = accumulateGlobalTraffic(
|
||||||
|
traffic.global.gateway,
|
||||||
|
processByMac,
|
||||||
|
trafficResult.epoch,
|
||||||
|
trafficResult.observedAt || traffic.lastObservedAt,
|
||||||
|
'Gateway',
|
||||||
|
);
|
||||||
for (const mac of Object.keys(totalsByMac)) {
|
for (const mac of Object.keys(totalsByMac)) {
|
||||||
if (!knownMacs.has(mac)) {
|
if (!knownMacs.has(mac)) {
|
||||||
delete totalsByMac[mac];
|
delete totalsByMac[mac];
|
||||||
@@ -714,6 +875,7 @@ export function createDeviceInventoryService({
|
|||||||
totalsByMac: proxyTotals,
|
totalsByMac: proxyTotals,
|
||||||
rebaselineMacs: [...proxyRebaseline],
|
rebaselineMacs: [...proxyRebaseline],
|
||||||
};
|
};
|
||||||
|
let globalProxy = traffic.global.proxy;
|
||||||
if (proxySampleError) {
|
if (proxySampleError) {
|
||||||
proxy = { ...proxy, lastError: proxySampleError };
|
proxy = { ...proxy, lastError: proxySampleError };
|
||||||
} else if (proxyRows) {
|
} else if (proxyRows) {
|
||||||
@@ -746,6 +908,13 @@ export function createDeviceInventoryService({
|
|||||||
};
|
};
|
||||||
nextProxyRebaseline.delete(mac);
|
nextProxyRebaseline.delete(mac);
|
||||||
}
|
}
|
||||||
|
const nextGlobalProxy = accumulateGlobalTraffic(
|
||||||
|
traffic.global.proxy,
|
||||||
|
proxyByMac,
|
||||||
|
trafficResult.epoch,
|
||||||
|
trafficResult.observedAt || proxy.lastObservedAt,
|
||||||
|
'proxy',
|
||||||
|
);
|
||||||
proxy = {
|
proxy = {
|
||||||
...proxy,
|
...proxy,
|
||||||
lastObservedAt: trafficResult.observedAt || proxy.lastObservedAt,
|
lastObservedAt: trafficResult.observedAt || proxy.lastObservedAt,
|
||||||
@@ -755,6 +924,7 @@ export function createDeviceInventoryService({
|
|||||||
totalsByMac: nextProxyTotals,
|
totalsByMac: nextProxyTotals,
|
||||||
rebaselineMacs: [...nextProxyRebaseline],
|
rebaselineMacs: [...nextProxyRebaseline],
|
||||||
};
|
};
|
||||||
|
globalProxy = nextGlobalProxy;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
proxy = { ...proxy, lastError: error.message || String(error) };
|
proxy = { ...proxy, lastError: error.message || String(error) };
|
||||||
}
|
}
|
||||||
@@ -770,6 +940,7 @@ export function createDeviceInventoryService({
|
|||||||
totalsByMac,
|
totalsByMac,
|
||||||
rebaselineMacs: [...rebaselineMacs],
|
rebaselineMacs: [...rebaselineMacs],
|
||||||
proxy,
|
proxy,
|
||||||
|
global: { gateway: globalGateway, proxy: globalProxy },
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
traffic = { ...traffic, lastError: error.message || String(error) };
|
traffic = { ...traffic, lastError: error.message || String(error) };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.17.16',
|
macClient: '0.18.0',
|
||||||
gatewayClient: '0.18.16',
|
gatewayClient: '0.19.0',
|
||||||
gatewayBackend: '0.18.1',
|
gatewayBackend: '0.19.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ import {
|
|||||||
subscriptionDaysLeft,
|
subscriptionDaysLeft,
|
||||||
subscriptionUsage,
|
subscriptionUsage,
|
||||||
} from '../utils/clientControls.js';
|
} from '../utils/clientControls.js';
|
||||||
import { formatBytes } from '../utils/format.js';
|
import { formatBytes, formatByteString, formatLastSeen } from '../utils/format.js';
|
||||||
import { instructionBlocks } from '../instructions.js';
|
import { instructionBlocks } from '../instructions.js';
|
||||||
import { operationBlocked } from '../state/operations.js';
|
import { operationBlocked } from '../state/operations.js';
|
||||||
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
|
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
|
||||||
import { DevicesPanel } from './DevicesPanel.jsx';
|
import { DevicesPanel } from './DevicesPanel.jsx';
|
||||||
import { ConnectivityDiagnosticsPanel } from './ConnectivityDiagnosticsPanel.jsx';
|
import { ConnectivityDiagnosticsPanel } from './ConnectivityDiagnosticsPanel.jsx';
|
||||||
import { ServerPicker } from './ServerPicker.jsx';
|
import { ServerPicker } from './ServerPicker.jsx';
|
||||||
|
import { TrafficChart } from './TrafficChart.jsx';
|
||||||
import { ERROR_DEFINITIONS } from '../../shared/errors.js';
|
import { ERROR_DEFINITIONS } from '../../shared/errors.js';
|
||||||
import { canAppendRouteRule } from '../../shared/routingRules.js';
|
import { canAppendRouteRule } from '../../shared/routingRules.js';
|
||||||
import {
|
import {
|
||||||
@@ -27,10 +28,11 @@ import {
|
|||||||
} from '../../shared/versions.js';
|
} from '../../shared/versions.js';
|
||||||
|
|
||||||
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
||||||
|
const DEVICE_AUTO_REFRESH_MS = 15_000;
|
||||||
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
||||||
|
|
||||||
function CloudTooltip({ children }) {
|
function CloudTooltip({ children, id }) {
|
||||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
return <span className="client-tooltip" id={id} role="tooltip">{children}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERSION_PARTS = [
|
const VERSION_PARTS = [
|
||||||
@@ -616,8 +618,12 @@ export function ClientOverviewPage({
|
|||||||
const connected = Boolean(state?.singboxRunning);
|
const connected = Boolean(state?.singboxRunning);
|
||||||
const hasSubscription = Boolean(state?.hasSubscription);
|
const hasSubscription = Boolean(state?.hasSubscription);
|
||||||
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
|
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
|
||||||
const showPower = hasSubscription && Boolean(selectedServerId);
|
const appliedServerId = state?.selection?.appliedServerId || '';
|
||||||
|
const appliedServer = servers.find(({ id }) => id === appliedServerId);
|
||||||
|
const desiredServer = servers.find(({ id }) => id === selectedServerId);
|
||||||
|
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
|
||||||
const canStart = Boolean(selectedServerId || state?.configExists);
|
const canStart = Boolean(selectedServerId || state?.configExists);
|
||||||
|
const powerUnavailable = isGateway && !connected && !canStart;
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
const [durationMode, setDurationMode] = useState(() => {
|
const [durationMode, setDurationMode] = useState(() => {
|
||||||
try {
|
try {
|
||||||
@@ -641,9 +647,15 @@ export function ClientOverviewPage({
|
|||||||
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||||
const [serversLeaving, setServersLeaving] = useState(false);
|
const [serversLeaving, setServersLeaving] = useState(false);
|
||||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||||
|
const [subscriptionOpen, setSubscriptionOpen] = useState(false);
|
||||||
const [localRulesOpen, setLocalRulesOpen] = useState(false);
|
const [localRulesOpen, setLocalRulesOpen] = useState(false);
|
||||||
const [devicesOpen, setDevicesOpen] = useState(false);
|
const [devicesOpen, setDevicesOpen] = useState(false);
|
||||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||||
|
const [deviceSnapshot, setDeviceSnapshot] = useState(null);
|
||||||
|
const [deviceStatus, setDeviceStatus] = useState('idle');
|
||||||
|
const [deviceError, setDeviceError] = useState(null);
|
||||||
|
const [devicesRefreshing, setDevicesRefreshing] = useState(false);
|
||||||
|
const [deviceRefreshCycle, setDeviceRefreshCycle] = useState(0);
|
||||||
const [localRulesDraft, setLocalRulesDraft] = useState([]);
|
const [localRulesDraft, setLocalRulesDraft] = useState([]);
|
||||||
const [localRulesRevision, setLocalRulesRevision] = useState(state?.route?.localRulesRevision || 0);
|
const [localRulesRevision, setLocalRulesRevision] = useState(state?.route?.localRulesRevision || 0);
|
||||||
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
|
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
|
||||||
@@ -656,6 +668,9 @@ export function ClientOverviewPage({
|
|||||||
const instructionsPanelRef = useRef(null);
|
const instructionsPanelRef = useRef(null);
|
||||||
const instructionsToggleRef = useRef(null);
|
const instructionsToggleRef = useRef(null);
|
||||||
const instructionsCloseRef = useRef(null);
|
const instructionsCloseRef = useRef(null);
|
||||||
|
const subscriptionPanelRef = useRef(null);
|
||||||
|
const subscriptionToggleRef = useRef(null);
|
||||||
|
const subscriptionCloseRef = useRef(null);
|
||||||
const localRulesPanelRef = useRef(null);
|
const localRulesPanelRef = useRef(null);
|
||||||
const localRulesToggleRef = useRef(null);
|
const localRulesToggleRef = useRef(null);
|
||||||
const localRulesCloseRef = useRef(null);
|
const localRulesCloseRef = useRef(null);
|
||||||
@@ -666,7 +681,9 @@ export function ClientOverviewPage({
|
|||||||
const diagnosticsToggleRef = useRef(null);
|
const diagnosticsToggleRef = useRef(null);
|
||||||
const diagnosticsCloseRef = useRef(null);
|
const diagnosticsCloseRef = useRef(null);
|
||||||
const localRulesBaselineRef = useRef('[]');
|
const localRulesBaselineRef = useRef('[]');
|
||||||
|
const confirmingDeleteRef = useRef(confirmingDelete);
|
||||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||||
|
confirmingDeleteRef.current = confirmingDelete;
|
||||||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||||
const usage = subscriptionUsage(state?.userInfo);
|
const usage = subscriptionUsage(state?.userInfo);
|
||||||
@@ -724,14 +741,54 @@ export function ClientOverviewPage({
|
|||||||
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
|
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
|
||||||
)).length
|
)).length
|
||||||
: 0;
|
: 0;
|
||||||
|
const globalTraffic = deviceSnapshot?.traffic;
|
||||||
|
const trafficSourceError = deviceSnapshot?.source?.traffic?.error
|
||||||
|
|| deviceSnapshot?.source?.traffic?.proxy?.error
|
||||||
|
|| (deviceStatus === 'error' ? deviceError : null);
|
||||||
|
const trafficFreshness = globalTraffic?.observedAt
|
||||||
|
? formatLastSeen(globalTraffic.observedAt, new Date(now)).relative
|
||||||
|
: 'Нет данных';
|
||||||
|
const switchingServer = Boolean(
|
||||||
|
selectedServerId && selectedServerId !== appliedServerId && desiredServer,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadDevices(quiet = false, discover = false) {
|
||||||
|
if (!isGateway) return;
|
||||||
|
if (!quiet) setDeviceStatus(deviceSnapshot ? 'refreshing' : 'loading');
|
||||||
|
setDevicesRefreshing(true);
|
||||||
|
try {
|
||||||
|
const next = await (discover ? api.devices.refresh() : api.devices.list());
|
||||||
|
setDeviceSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||||||
|
setDeviceError(null);
|
||||||
|
setDeviceStatus('ready');
|
||||||
|
} catch (requestError) {
|
||||||
|
setDeviceError(requestError);
|
||||||
|
setDeviceStatus('error');
|
||||||
|
} finally {
|
||||||
|
setDevicesRefreshing(false);
|
||||||
|
setDeviceRefreshCycle((cycle) => cycle + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
if (!connected || !state?.singboxStartedAt) return undefined;
|
if (!isGateway && (!connected || !state?.singboxStartedAt)) return undefined;
|
||||||
|
|
||||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [connected, state?.singboxStartedAt]);
|
}, [isGateway, connected, state?.singboxStartedAt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGateway) return undefined;
|
||||||
|
loadDevices();
|
||||||
|
return undefined;
|
||||||
|
}, [isGateway]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGateway || devicesRefreshing || deviceStatus === 'loading') return undefined;
|
||||||
|
const timer = setTimeout(() => loadDevices(true), DEVICE_AUTO_REFRESH_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isGateway, deviceRefreshCycle, devicesRefreshing, deviceStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingSubscription) subscriptionInputRef.current?.focus();
|
if (editingSubscription) subscriptionInputRef.current?.focus();
|
||||||
@@ -797,12 +854,14 @@ export function ClientOverviewPage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSubscription) {
|
if (!hasSubscription) {
|
||||||
setEditingSubscription(true);
|
setEditingSubscription(true);
|
||||||
setInstructionsOpen(false);
|
|
||||||
setLocalRulesOpen(false);
|
setLocalRulesOpen(false);
|
||||||
setDevicesOpen(false);
|
if (!isGateway) {
|
||||||
setDiagnosticsOpen(false);
|
setInstructionsOpen(false);
|
||||||
|
setDevicesOpen(false);
|
||||||
|
setDiagnosticsOpen(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [hasSubscription]);
|
}, [hasSubscription, isGateway]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
|
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
|
||||||
@@ -845,6 +904,29 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!subscriptionOpen) return undefined;
|
||||||
|
const frame = requestAnimationFrame(() => subscriptionCloseRef.current?.focus());
|
||||||
|
const closeSubscription = (event) => {
|
||||||
|
if (confirmingDeleteRef.current) return;
|
||||||
|
if (event.type === 'keydown' && event.key !== 'Escape') return;
|
||||||
|
if (event.type !== 'keydown' && (
|
||||||
|
subscriptionPanelRef.current?.contains(event.target) || subscriptionToggleRef.current?.contains(event.target)
|
||||||
|
)) return;
|
||||||
|
setSubscriptionOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', closeSubscription);
|
||||||
|
document.addEventListener('keydown', closeSubscription);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
document.removeEventListener('pointerdown', closeSubscription);
|
||||||
|
document.removeEventListener('keydown', closeSubscription);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (subscriptionPanelRef.current?.contains(document.activeElement)) subscriptionToggleRef.current?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [subscriptionOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instructionsOpen) return undefined;
|
if (!instructionsOpen) return undefined;
|
||||||
const frame = requestAnimationFrame(() => instructionsCloseRef.current?.focus());
|
const frame = requestAnimationFrame(() => instructionsCloseRef.current?.focus());
|
||||||
@@ -1048,6 +1130,7 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
function openLocalRules() {
|
function openLocalRules() {
|
||||||
const rules = state?.route?.localRules || [];
|
const rules = state?.route?.localRules || [];
|
||||||
|
setSubscriptionOpen(false);
|
||||||
setInstructionsOpen(false);
|
setInstructionsOpen(false);
|
||||||
setDevicesOpen(false);
|
setDevicesOpen(false);
|
||||||
setDiagnosticsOpen(false);
|
setDiagnosticsOpen(false);
|
||||||
@@ -1111,9 +1194,26 @@ export function ClientOverviewPage({
|
|||||||
document.startViewTransition(update);
|
document.startViewTransition(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const powerButton = <button
|
||||||
|
className="client-power"
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={connected}
|
||||||
|
aria-label={isGateway
|
||||||
|
? connected ? 'Остановить VPN' : 'Запустить VPN'
|
||||||
|
: connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
|
||||||
|
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
|
||||||
|
disabled={connectionBlocked || (!connected && !canStart)}
|
||||||
|
onClick={toggleConnection}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||||
|
</svg>
|
||||||
|
</button>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro ? ' is-intro' : ''}`}
|
className={`client-shell${!isGateway && !hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
|
||||||
>
|
>
|
||||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||||
<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">
|
||||||
@@ -1126,7 +1226,27 @@ export function ClientOverviewPage({
|
|||||||
blocked={gatewayAutoBlocked}
|
blocked={gatewayAutoBlocked}
|
||||||
onSetGatewayAuto={onSetGatewayAuto}
|
onSetGatewayAuto={onSetGatewayAuto}
|
||||||
/>
|
/>
|
||||||
{hasSubscription && subscriptionContentReady && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
|
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
|
||||||
|
{isGateway && <button
|
||||||
|
ref={subscriptionToggleRef}
|
||||||
|
className={`client-instructions-toggle client-subscription-toggle${subscriptionOpen ? ' is-open' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={subscriptionOpen}
|
||||||
|
aria-controls="client-subscription-drawer"
|
||||||
|
aria-label={subscriptionOpen ? 'Закрыть подписку' : 'Управление подпиской'}
|
||||||
|
onClick={() => {
|
||||||
|
if (localRulesOpen && !requestCloseLocalRules()) return;
|
||||||
|
setInstructionsOpen(false);
|
||||||
|
setDevicesOpen(false);
|
||||||
|
setDiagnosticsOpen(false);
|
||||||
|
setSubscriptionOpen((open) => !open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M5 5h14v14H5zM8 9h8M8 13h5" />
|
||||||
|
</svg>
|
||||||
|
<span>Подписка</span>
|
||||||
|
</button>}
|
||||||
<button
|
<button
|
||||||
ref={instructionsToggleRef}
|
ref={instructionsToggleRef}
|
||||||
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
||||||
@@ -1136,6 +1256,7 @@ export function ClientOverviewPage({
|
|||||||
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (localRulesOpen && !requestCloseLocalRules()) return;
|
if (localRulesOpen && !requestCloseLocalRules()) return;
|
||||||
|
setSubscriptionOpen(false);
|
||||||
setDevicesOpen(false);
|
setDevicesOpen(false);
|
||||||
setDiagnosticsOpen(false);
|
setDiagnosticsOpen(false);
|
||||||
setInstructionsOpen((open) => !open);
|
setInstructionsOpen((open) => !open);
|
||||||
@@ -1156,6 +1277,7 @@ export function ClientOverviewPage({
|
|||||||
aria-label={devicesOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
|
aria-label={devicesOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (localRulesOpen && !requestCloseLocalRules()) return;
|
if (localRulesOpen && !requestCloseLocalRules()) return;
|
||||||
|
setSubscriptionOpen(false);
|
||||||
setInstructionsOpen(false);
|
setInstructionsOpen(false);
|
||||||
setDiagnosticsOpen(false);
|
setDiagnosticsOpen(false);
|
||||||
setDevicesOpen((open) => !open);
|
setDevicesOpen((open) => !open);
|
||||||
@@ -1177,6 +1299,7 @@ export function ClientOverviewPage({
|
|||||||
aria-label={diagnosticsOpen ? 'Закрыть диагностику' : 'Проверить маршруты'}
|
aria-label={diagnosticsOpen ? 'Закрыть диагностику' : 'Проверить маршруты'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (localRulesOpen && !requestCloseLocalRules()) return;
|
if (localRulesOpen && !requestCloseLocalRules()) return;
|
||||||
|
setSubscriptionOpen(false);
|
||||||
setInstructionsOpen(false);
|
setInstructionsOpen(false);
|
||||||
setDevicesOpen(false);
|
setDevicesOpen(false);
|
||||||
setDiagnosticsOpen((open) => !open);
|
setDiagnosticsOpen((open) => !open);
|
||||||
@@ -1192,11 +1315,13 @@ export function ClientOverviewPage({
|
|||||||
ref={localRulesToggleRef}
|
ref={localRulesToggleRef}
|
||||||
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${localRulesPendingRestart ? ' has-pending' : ''}`}
|
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${localRulesPendingRestart ? ' has-pending' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={gatewayDirect}
|
disabled={gatewayDirect || (isGateway && !hasSubscription)}
|
||||||
aria-expanded={localRulesOpen}
|
aria-expanded={localRulesOpen}
|
||||||
aria-controls="client-local-rules"
|
aria-controls="client-local-rules"
|
||||||
aria-label={gatewayDirect
|
aria-label={gatewayDirect || (isGateway && !hasSubscription)
|
||||||
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
|
? hasSubscription
|
||||||
|
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
|
||||||
|
: 'Локальные правила недоступны: сначала добавьте подписку'
|
||||||
: localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
: localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||||||
onClick={() => localRulesOpen ? requestCloseLocalRules() : openLocalRules()}
|
onClick={() => localRulesOpen ? requestCloseLocalRules() : openLocalRules()}
|
||||||
>
|
>
|
||||||
@@ -1205,27 +1330,52 @@ export function ClientOverviewPage({
|
|||||||
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
|
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
|
||||||
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
|
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{gatewayDirect
|
<span>{gatewayDirect || (isGateway && !hasSubscription)
|
||||||
? 'Локальные правила недоступны: сейчас работают правила Gateway'
|
? hasSubscription
|
||||||
|
? 'Локальные правила недоступны: сейчас работают правила Gateway'
|
||||||
|
: 'Сначала добавьте подписку'
|
||||||
: localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
|
: localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>}
|
</nav>}
|
||||||
<main className={`client-panel${showPower ? '' : ' is-setup'}${hasSubscription ? ' has-subscription' : ''}`}>
|
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway ? ' is-gateway-home' : ''}`}>
|
||||||
|
{isGateway && <section className="client-gateway-summary" aria-labelledby="gateway-summary-title">
|
||||||
|
<span className="client-gateway-summary-kicker">Сейчас</span>
|
||||||
|
<h2 id="gateway-summary-title">
|
||||||
|
{appliedServer?.label || 'VPN-сервер не используется'}
|
||||||
|
</h2>
|
||||||
|
<div className="client-gateway-route-slot" role="status" aria-live="polite">
|
||||||
|
{switchingServer && <span>Переключаем на {desiredServer.label}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="client-gateway-traffic-heading">
|
||||||
|
<span>Учтено Harbor</span>
|
||||||
|
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="client-gateway-traffic-chart">
|
||||||
|
<TrafficChart
|
||||||
|
samples={globalTraffic?.history || []}
|
||||||
|
capacity={deviceSnapshot?.trafficHistoryCapacity || 120}
|
||||||
|
routeLabel="Gateway"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
|
||||||
|
{trafficSourceError
|
||||||
|
? `Трафик не обновляется · последние данные ${trafficFreshness}`
|
||||||
|
: globalTraffic?.observedAt ? `Обновлено ${trafficFreshness}` : 'Ожидаем первые данные трафика'}
|
||||||
|
</div>
|
||||||
|
</section>}
|
||||||
{showPower && (
|
{showPower && (
|
||||||
<section className="client-power-section" aria-labelledby="connection-title">
|
<section className="client-power-section" aria-labelledby="connection-title">
|
||||||
<button
|
{isGateway ? <span
|
||||||
className="client-power"
|
className="client-power-control client-tooltip-anchor"
|
||||||
type="button"
|
tabIndex={powerUnavailable ? 0 : undefined}
|
||||||
role="switch"
|
aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
|
||||||
aria-checked={connected}
|
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
|
||||||
aria-label={connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
|
|
||||||
disabled={connectionBlocked || (!connected && !canStart)}
|
|
||||||
onClick={toggleConnection}
|
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
{powerButton}
|
||||||
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
{powerUnavailable && <CloudTooltip id="gateway-power-unavailable">
|
||||||
</svg>
|
Сначала добавьте подписку и выберите сервер
|
||||||
</button>
|
</CloudTooltip>}
|
||||||
|
</span> : powerButton}
|
||||||
<div className={`client-route-rules-pending${pendingLocalRulesCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
|
<div className={`client-route-rules-pending${pendingLocalRulesCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
|
||||||
{pendingLocalRulesCount > 0 && (
|
{pendingLocalRulesCount > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -1334,12 +1484,24 @@ export function ClientOverviewPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
|
ref={isGateway ? subscriptionPanelRef : undefined}
|
||||||
aria-hidden={subscriptionWaiting}
|
id={isGateway ? 'client-subscription-drawer' : undefined}
|
||||||
|
className={isGateway
|
||||||
|
? `client-drawer client-subscription-drawer${subscriptionOpen ? ' is-open' : ''}`
|
||||||
|
: `client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
|
||||||
|
aria-label={isGateway ? 'Управление подпиской' : undefined}
|
||||||
|
aria-hidden={isGateway ? !subscriptionOpen : subscriptionWaiting}
|
||||||
aria-disabled={gatewayDirect}
|
aria-disabled={gatewayDirect}
|
||||||
inert={subscriptionWaiting || gatewayDirect ? true : undefined}
|
inert={(isGateway && !subscriptionOpen) || subscriptionWaiting || gatewayDirect ? true : undefined}
|
||||||
>
|
>
|
||||||
<div className="client-form-content">
|
{isGateway && <button
|
||||||
|
ref={subscriptionCloseRef}
|
||||||
|
className="client-drawer-close"
|
||||||
|
type="button"
|
||||||
|
aria-label="Закрыть подписку"
|
||||||
|
onClick={() => setSubscriptionOpen(false)}
|
||||||
|
>×</button>}
|
||||||
|
<div className={`client-form-content${isGateway ? ' client-drawer-sheet client-subscription-sheet' : ''}`}>
|
||||||
<div
|
<div
|
||||||
ref={subscriptionRef}
|
ref={subscriptionRef}
|
||||||
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||||||
@@ -1493,7 +1655,7 @@ export function ClientOverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{hasSubscription && subscriptionContentReady && <aside
|
{(isGateway || (hasSubscription && subscriptionContentReady)) && <aside
|
||||||
ref={instructionsPanelRef}
|
ref={instructionsPanelRef}
|
||||||
id="client-instructions"
|
id="client-instructions"
|
||||||
className={`client-drawer client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
className={`client-drawer client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
||||||
@@ -1530,14 +1692,22 @@ export function ClientOverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
</aside>}
|
</aside>}
|
||||||
|
|
||||||
{hasSubscription && subscriptionContentReady && isGateway && <DevicesPanel
|
{isGateway && <DevicesPanel
|
||||||
open={devicesOpen}
|
open={devicesOpen}
|
||||||
panelRef={devicesPanelRef}
|
panelRef={devicesPanelRef}
|
||||||
closeRef={devicesCloseRef}
|
closeRef={devicesCloseRef}
|
||||||
onClose={() => setDevicesOpen(false)}
|
onClose={() => setDevicesOpen(false)}
|
||||||
|
snapshot={deviceSnapshot}
|
||||||
|
status={deviceStatus}
|
||||||
|
error={deviceError}
|
||||||
|
refreshing={devicesRefreshing}
|
||||||
|
refreshCycle={deviceRefreshCycle}
|
||||||
|
onLoad={loadDevices}
|
||||||
|
onSnapshot={setDeviceSnapshot}
|
||||||
|
onError={setDeviceError}
|
||||||
/>}
|
/>}
|
||||||
|
|
||||||
{hasSubscription && subscriptionContentReady && <ConnectivityDiagnosticsPanel
|
{(isGateway || (hasSubscription && subscriptionContentReady)) && <ConnectivityDiagnosticsPanel
|
||||||
isGateway={isGateway}
|
isGateway={isGateway}
|
||||||
open={diagnosticsOpen}
|
open={diagnosticsOpen}
|
||||||
panelRef={diagnosticsPanelRef}
|
panelRef={diagnosticsPanelRef}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { copyText } from '../utils/clientControls.js';
|
import { copyText } from '../utils/clientControls.js';
|
||||||
import {
|
import {
|
||||||
@@ -8,16 +7,12 @@ import {
|
|||||||
formatLastSeen,
|
formatLastSeen,
|
||||||
positiveByteDelta,
|
positiveByteDelta,
|
||||||
stabilizeDevicesByTraffic,
|
stabilizeDevicesByTraffic,
|
||||||
trafficAxisMid,
|
|
||||||
trafficScaleRatio,
|
|
||||||
} from '../utils/format.js';
|
} from '../utils/format.js';
|
||||||
|
import { TrafficChart } from './TrafficChart.jsx';
|
||||||
|
|
||||||
const AUTO_REFRESH_MS = 15_000;
|
|
||||||
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 TRAFFIC_CHART_HEADROOM = 10;
|
|
||||||
const trafficChartY = (ratio) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
|
|
||||||
|
|
||||||
function Tooltip({ children }) {
|
function Tooltip({ children }) {
|
||||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||||
@@ -39,167 +34,13 @@ function TrafficValue({ value, delta }) {
|
|||||||
</strong>;
|
</strong>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chartTime(value) {
|
export function DevicesPanel({
|
||||||
return new Date(value).toLocaleTimeString('ru-RU', {
|
open, panelRef, closeRef, onClose, snapshot, status, error, refreshing, refreshCycle,
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
onLoad, onSnapshot, onError,
|
||||||
});
|
}) {
|
||||||
}
|
|
||||||
|
|
||||||
function smoothTrafficPath(points, valueKey) {
|
|
||||||
if (!points.length) return '';
|
|
||||||
return points.slice(1).reduce((path, point, index) => {
|
|
||||||
const previous = points[index];
|
|
||||||
const midX = (previous.x + point.x) / 2;
|
|
||||||
return `${path} C ${midX},${previous[valueKey]} ${midX},${point[valueKey]} ${point.x},${point[valueKey]}`;
|
|
||||||
}, `M ${points[0].x},${points[0][valueKey]}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
|
||||||
const [hovered, setHovered] = useState(null);
|
|
||||||
const previousPoints = useRef([]);
|
|
||||||
const previousScale = useRef(scale);
|
|
||||||
const max = samples.reduce((largest, sample) => {
|
|
||||||
const gateway = byteString(sample.gatewayBytes);
|
|
||||||
const proxy = byteString(sample.proxyBytes);
|
|
||||||
return gateway > largest ? gateway : proxy > largest ? proxy : largest;
|
|
||||||
}, 0n);
|
|
||||||
const mid = trafficAxisMid(max, scale);
|
|
||||||
const firstSlot = capacity - samples.length;
|
|
||||||
const points = samples.map((sample, index) => {
|
|
||||||
const gateway = byteString(sample.gatewayBytes);
|
|
||||||
const proxy = byteString(sample.proxyBytes);
|
|
||||||
return {
|
|
||||||
sample,
|
|
||||||
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
|
|
||||||
gateway,
|
|
||||||
proxy,
|
|
||||||
gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)),
|
|
||||||
proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const previous = points.slice(0, -1);
|
|
||||||
const penultimate = points.at(-2);
|
|
||||||
const newest = points.at(-1);
|
|
||||||
const hasProxy = points.some(({ proxy }) => proxy > 0n);
|
|
||||||
const scaleFrom = previousPoints.current;
|
|
||||||
const animateScale = previousScale.current !== scale
|
|
||||||
&& scaleFrom.length === points.length
|
|
||||||
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
previousPoints.current = points;
|
|
||||||
previousScale.current = scale;
|
|
||||||
}, [points, scale]);
|
|
||||||
|
|
||||||
function trackPointer(event) {
|
|
||||||
const bounds = event.currentTarget.getBoundingClientRect();
|
|
||||||
const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1));
|
|
||||||
const index = slot - firstSlot;
|
|
||||||
if (index < 0 || index >= points.length) {
|
|
||||||
setHovered(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setHovered({ ...points[index], clientX: event.clientX, clientY: event.clientY });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tooltip = hovered && typeof document !== 'undefined' && createPortal(
|
|
||||||
<span
|
|
||||||
className="client-device-traffic-point-tooltip"
|
|
||||||
style={{
|
|
||||||
left: `${Math.max(8, Math.min(hovered.clientX + 12, globalThis.innerWidth - 190))}px`,
|
|
||||||
top: `${hovered.clientY < 150 ? hovered.clientY + 14 : hovered.clientY - 12}px`,
|
|
||||||
transform: hovered.clientY < 150 ? 'none' : 'translateY(-100%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time>
|
|
||||||
<strong>Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
|
|
||||||
<span>{routeLabel} {formatByteString(hovered.gateway)}</span>
|
|
||||||
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
|
|
||||||
</span>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
|
|
||||||
return <span
|
|
||||||
className="client-device-traffic-chart"
|
|
||||||
role="img"
|
|
||||||
aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}
|
|
||||||
style={{
|
|
||||||
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
|
|
||||||
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
|
|
||||||
<span className="is-max">{formatByteString(max)}</span>
|
|
||||||
<span className="is-mid">{formatByteString(mid)}</span>
|
|
||||||
<span className="is-zero">0</span>
|
|
||||||
</span>}
|
|
||||||
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}>
|
|
||||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
||||||
{pinned && <g className="client-device-traffic-grid">
|
|
||||||
<line x1="0" x2="100" y1={TRAFFIC_CHART_HEADROOM} y2={TRAFFIC_CHART_HEADROOM} />
|
|
||||||
<line x1="0" x2="100" y1={(100 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
|
|
||||||
<line x1="0" x2="100" y1="100" y2="100" />
|
|
||||||
</g>}
|
|
||||||
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
|
|
||||||
{previous.length > 0 && <path className="is-gateway" d={smoothTrafficPath(previous, 'gatewayY')}>
|
|
||||||
{animateScale && <animate
|
|
||||||
key={`gateway-${scale}`}
|
|
||||||
attributeName="d"
|
|
||||||
from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')}
|
|
||||||
to={smoothTrafficPath(previous, 'gatewayY')}
|
|
||||||
dur="520ms"
|
|
||||||
calcMode="spline"
|
|
||||||
keyTimes="0;1"
|
|
||||||
keySplines="0.16 1 0.3 1"
|
|
||||||
fill="freeze"
|
|
||||||
/>}
|
|
||||||
</path>}
|
|
||||||
{hasProxy && previous.length > 0 && <path className="is-proxy" d={smoothTrafficPath(previous, 'proxyY')}>
|
|
||||||
{animateScale && <animate
|
|
||||||
key={`proxy-${scale}`}
|
|
||||||
attributeName="d"
|
|
||||||
from={smoothTrafficPath(scaleFrom.slice(0, -1), 'proxyY')}
|
|
||||||
to={smoothTrafficPath(previous, 'proxyY')}
|
|
||||||
dur="520ms"
|
|
||||||
calcMode="spline"
|
|
||||||
keyTimes="0;1"
|
|
||||||
keySplines="0.16 1 0.3 1"
|
|
||||||
fill="freeze"
|
|
||||||
/>}
|
|
||||||
</path>}
|
|
||||||
{penultimate && newest && <path className="is-gateway is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'gatewayY')}>
|
|
||||||
{animateScale && <animate key={`gateway-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" fill="freeze" />}
|
|
||||||
</path>}
|
|
||||||
{hasProxy && penultimate && newest && <path className="is-proxy is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'proxyY')}>
|
|
||||||
{animateScale && <animate key={`proxy-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'proxyY')} to={smoothTrafficPath([penultimate, newest], 'proxyY')} dur="520ms" fill="freeze" />}
|
|
||||||
</path>}
|
|
||||||
{!penultimate && newest && <line className="is-gateway is-point" x1={newest.x} x2={newest.x} y1={newest.gatewayY} y2={newest.gatewayY} />}
|
|
||||||
</g>
|
|
||||||
{hovered && <g className="client-device-traffic-cursor">
|
|
||||||
<line className="is-guide" x1={hovered.x} x2={hovered.x} y1={TRAFFIC_CHART_HEADROOM} y2="100" />
|
|
||||||
<line className="is-point is-gateway" x1={hovered.x} x2={hovered.x} y1={hovered.gatewayY} y2={hovered.gatewayY} />
|
|
||||||
{hovered.proxy > 0n && <line className="is-point is-proxy" x1={hovered.x} x2={hovered.x} y1={hovered.proxyY} y2={hovered.proxyY} />}
|
|
||||||
</g>}
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
{samples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
|
|
||||||
<time dateTime={samples[0].observedAt}>{chartTime(samples[0].observedAt)}</time>
|
|
||||||
<span>15 с</span>
|
|
||||||
<time dateTime={samples.at(-1).observedAt}>{chartTime(samples.at(-1).observedAt)}</time>
|
|
||||||
</span>}
|
|
||||||
{tooltip}
|
|
||||||
</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|
||||||
const [snapshot, setSnapshot] = useState(null);
|
|
||||||
const [status, setStatus] = useState('idle');
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [editingId, setEditingId] = useState('');
|
const [editingId, setEditingId] = useState('');
|
||||||
const [alias, setAlias] = useState('');
|
const [alias, setAlias] = useState('');
|
||||||
const [savingId, setSavingId] = useState('');
|
const [savingId, setSavingId] = useState('');
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [refreshCycle, setRefreshCycle] = useState(0);
|
|
||||||
const [sortDirection, setSortDirection] = useState('desc');
|
const [sortDirection, setSortDirection] = useState('desc');
|
||||||
const [trafficScale, setTrafficScale] = useState('linear');
|
const [trafficScale, setTrafficScale] = useState('linear');
|
||||||
const [copyFeedback, setCopyFeedback] = useState(null);
|
const [copyFeedback, setCopyFeedback] = useState(null);
|
||||||
@@ -227,35 +68,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
[snapshot?.devices, sortDirection],
|
[snapshot?.devices, sortDirection],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function load(quiet = false, discover = false) {
|
|
||||||
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
|
|
||||||
setRefreshing(true);
|
|
||||||
try {
|
|
||||||
const next = await (discover ? api.devices.refresh() : api.devices.list());
|
|
||||||
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
|
||||||
setError(null);
|
|
||||||
setStatus('ready');
|
|
||||||
} catch (requestError) {
|
|
||||||
setError(requestError);
|
|
||||||
setStatus('error');
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false);
|
|
||||||
setRefreshCycle((cycle) => cycle + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return undefined;
|
|
||||||
load();
|
|
||||||
return undefined;
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || refreshing || status === 'loading') return undefined;
|
|
||||||
const timer = setTimeout(() => load(true), AUTO_REFRESH_MS);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [open, refreshCycle, refreshing, status]);
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
clearTimeout(copyTimer.current);
|
clearTimeout(copyTimer.current);
|
||||||
clearTimeout(trafficDeltaTimer.current);
|
clearTimeout(trafficDeltaTimer.current);
|
||||||
@@ -340,18 +152,18 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
|
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
|
||||||
const latest = await api.devices.list();
|
const latest = await api.devices.list();
|
||||||
setSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
||||||
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||||
if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) {
|
if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) {
|
||||||
throw requestError;
|
throw requestError;
|
||||||
}
|
}
|
||||||
next = await api.devices.update(device.id, patch, latest.revision);
|
next = await api.devices.update(device.id, patch, latest.revision);
|
||||||
}
|
}
|
||||||
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
onSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||||||
setError(null);
|
onError(null);
|
||||||
return true;
|
return true;
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError(requestError);
|
onError(requestError);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setSavingId('');
|
setSavingId('');
|
||||||
@@ -377,23 +189,23 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
|
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
|
||||||
const latest = await api.devices.list();
|
const latest = await api.devices.list();
|
||||||
setSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
||||||
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||||
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw requestError;
|
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw requestError;
|
||||||
next = await api.devices.setPolicy(device.id, mode, latest.revision);
|
next = await api.devices.setPolicy(device.id, mode, latest.revision);
|
||||||
}
|
}
|
||||||
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
onSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||||||
setError(null);
|
onError(null);
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') {
|
if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') {
|
||||||
try {
|
try {
|
||||||
const latest = await api.devices.list();
|
const latest = await api.devices.list();
|
||||||
setSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the policy error as the actionable result.
|
// Keep the policy error as the actionable result.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setError(requestError);
|
onError(requestError);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingId('');
|
setSavingId('');
|
||||||
}
|
}
|
||||||
@@ -445,7 +257,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
aria-label={refreshing ? 'Обновляем устройства' : 'Обновить устройства сейчас'}
|
aria-label={refreshing ? 'Обновляем устройства' : 'Обновить устройства сейчас'}
|
||||||
aria-busy={refreshing}
|
aria-busy={refreshing}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
onClick={() => load(true, true)}
|
onClick={() => onLoad(true, true)}
|
||||||
>
|
>
|
||||||
<svg key={refreshCycle} className="client-devices-refresh-ring" viewBox="0 0 24 24" aria-hidden="true">
|
<svg key={refreshCycle} className="client-devices-refresh-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="10" pathLength="1" />
|
<circle cx="12" cy="12" r="10" pathLength="1" />
|
||||||
@@ -505,7 +317,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="client-devices-error" role="alert">
|
<div className="client-devices-error" role="alert">
|
||||||
<span>{error.message}</span>
|
<span>{error.message}</span>
|
||||||
<button type="button" onClick={() => load()}>Повторить</button>
|
<button type="button" onClick={() => onLoad()}>Повторить</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'loading' && <p className="client-devices-empty" role="status">Ищем устройства…</p>}
|
{status === 'loading' && <p className="client-devices-empty" role="status">Ищем устройства…</p>}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import {
|
||||||
|
byteString,
|
||||||
|
formatByteString,
|
||||||
|
trafficAxisMid,
|
||||||
|
trafficScaleRatio,
|
||||||
|
} from '../utils/format.js';
|
||||||
|
|
||||||
|
const TRAFFIC_CHART_HEADROOM = 10;
|
||||||
|
const trafficChartY = (ratio) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
|
||||||
|
|
||||||
|
function chartTime(value) {
|
||||||
|
return new Date(value).toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothTrafficPath(points, valueKey) {
|
||||||
|
if (!points.length) return '';
|
||||||
|
return points.slice(1).reduce((path, point, index) => {
|
||||||
|
const previous = points[index];
|
||||||
|
const midX = (previous.x + point.x) / 2;
|
||||||
|
return `${path} C ${midX},${previous[valueKey]} ${midX},${point[valueKey]} ${point.x},${point[valueKey]}`;
|
||||||
|
}, `M ${points[0].x},${points[0][valueKey]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trafficSeriesMax(samples) {
|
||||||
|
return samples.reduce((largest, sample) => {
|
||||||
|
const gateway = byteString(sample.gatewayBytes);
|
||||||
|
const proxy = byteString(sample.proxyBytes);
|
||||||
|
return gateway > largest
|
||||||
|
? (proxy > gateway ? proxy : gateway)
|
||||||
|
: (proxy > largest ? proxy : largest);
|
||||||
|
}, 0n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel, pinned = true }) {
|
||||||
|
const [hovered, setHovered] = useState(null);
|
||||||
|
const previousPoints = useRef([]);
|
||||||
|
const previousScale = useRef(scale);
|
||||||
|
const max = trafficSeriesMax(samples);
|
||||||
|
const mid = trafficAxisMid(max, scale);
|
||||||
|
const firstSlot = capacity - samples.length;
|
||||||
|
const points = samples.map((sample, index) => {
|
||||||
|
const gateway = byteString(sample.gatewayBytes);
|
||||||
|
const proxy = byteString(sample.proxyBytes);
|
||||||
|
return {
|
||||||
|
sample,
|
||||||
|
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
|
||||||
|
gateway,
|
||||||
|
proxy,
|
||||||
|
gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)),
|
||||||
|
proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const previous = points.slice(0, -1);
|
||||||
|
const penultimate = points.at(-2);
|
||||||
|
const newest = points.at(-1);
|
||||||
|
const hasProxy = points.some(({ proxy }) => proxy > 0n);
|
||||||
|
const scaleFrom = previousPoints.current;
|
||||||
|
const animateScale = previousScale.current !== scale
|
||||||
|
&& scaleFrom.length === points.length
|
||||||
|
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
previousPoints.current = points;
|
||||||
|
previousScale.current = scale;
|
||||||
|
}, [points, scale]);
|
||||||
|
|
||||||
|
function trackPointer(event) {
|
||||||
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
|
const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1));
|
||||||
|
const index = slot - firstSlot;
|
||||||
|
if (index < 0 || index >= points.length) {
|
||||||
|
setHovered(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHovered({ ...points[index], clientX: event.clientX, clientY: event.clientY });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltip = hovered && typeof document !== 'undefined' && createPortal(
|
||||||
|
<span
|
||||||
|
className="client-device-traffic-point-tooltip"
|
||||||
|
style={{
|
||||||
|
left: `${Math.max(8, Math.min(hovered.clientX + 12, globalThis.innerWidth - 190))}px`,
|
||||||
|
top: `${hovered.clientY < 150 ? hovered.clientY + 14 : hovered.clientY - 12}px`,
|
||||||
|
transform: hovered.clientY < 150 ? 'none' : 'translateY(-100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time>
|
||||||
|
<strong>Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
|
||||||
|
<span>{routeLabel} {formatByteString(hovered.gateway)}</span>
|
||||||
|
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
|
||||||
|
</span>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
return <span
|
||||||
|
className="client-device-traffic-chart"
|
||||||
|
role="img"
|
||||||
|
aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}
|
||||||
|
style={{
|
||||||
|
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
|
||||||
|
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
|
||||||
|
<span className="is-max">{formatByteString(max)}</span>
|
||||||
|
<span className="is-mid">{formatByteString(mid)}</span>
|
||||||
|
<span className="is-zero">0</span>
|
||||||
|
</span>}
|
||||||
|
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}>
|
||||||
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
{pinned && <g className="client-device-traffic-grid">
|
||||||
|
<line x1="0" x2="100" y1={TRAFFIC_CHART_HEADROOM} y2={TRAFFIC_CHART_HEADROOM} />
|
||||||
|
<line x1="0" x2="100" y1={(100 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
|
||||||
|
<line x1="0" x2="100" y1="100" y2="100" />
|
||||||
|
</g>}
|
||||||
|
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
|
||||||
|
{previous.length > 0 && <path className="is-gateway" d={smoothTrafficPath(previous, 'gatewayY')}>
|
||||||
|
{animateScale && <animate key={`gateway-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||||
|
</path>}
|
||||||
|
{hasProxy && previous.length > 0 && <path className="is-proxy" d={smoothTrafficPath(previous, 'proxyY')}>
|
||||||
|
{animateScale && <animate key={`proxy-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'proxyY')} to={smoothTrafficPath(previous, 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||||
|
</path>}
|
||||||
|
{penultimate && newest && <path className="is-gateway is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'gatewayY')}>
|
||||||
|
{animateScale && <animate key={`gateway-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" fill="freeze" />}
|
||||||
|
</path>}
|
||||||
|
{hasProxy && penultimate && newest && <path className="is-proxy is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'proxyY')}>
|
||||||
|
{animateScale && <animate key={`proxy-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'proxyY')} to={smoothTrafficPath([penultimate, newest], 'proxyY')} dur="520ms" fill="freeze" />}
|
||||||
|
</path>}
|
||||||
|
{!penultimate && newest && <line className="is-gateway is-point" x1={newest.x} x2={newest.x} y1={newest.gatewayY} y2={newest.gatewayY} />}
|
||||||
|
</g>
|
||||||
|
{hovered && <g className="client-device-traffic-cursor">
|
||||||
|
<line className="is-guide" x1={hovered.x} x2={hovered.x} y1={TRAFFIC_CHART_HEADROOM} y2="100" />
|
||||||
|
<line className="is-point is-gateway" x1={hovered.x} x2={hovered.x} y1={hovered.gatewayY} y2={hovered.gatewayY} />
|
||||||
|
{hovered.proxy > 0n && <line className="is-point is-proxy" x1={hovered.x} x2={hovered.x} y1={hovered.proxyY} y2={hovered.proxyY} />}
|
||||||
|
</g>}
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{samples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
|
||||||
|
<time dateTime={samples[0].observedAt}>{chartTime(samples[0].observedAt)}</time>
|
||||||
|
<span>15 с</span>
|
||||||
|
<time dateTime={samples.at(-1).observedAt}>{chartTime(samples.at(-1).observedAt)}</time>
|
||||||
|
</span>}
|
||||||
|
{tooltip}
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
@@ -2615,6 +2615,83 @@ p {
|
|||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-gateway-summary {
|
||||||
|
grid-column: 1;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: end;
|
||||||
|
width: min(360px, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-right: clamp(20px, 4vw, 64px);
|
||||||
|
text-align: left;
|
||||||
|
animation: client-power-arrive 850ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-gateway-summary-kicker,
|
||||||
|
.client-gateway-traffic-heading > span {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-gateway-summary h2 {
|
||||||
|
min-height: 29px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-gateway-route-slot {
|
||||||
|
min-height: 18px;
|
||||||
|
color: var(--client-accent);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-gateway-traffic-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-gateway-traffic-heading strong {
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-gateway-traffic-chart {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-gateway-traffic-chart .client-device-traffic-chart {
|
||||||
|
grid-column: auto;
|
||||||
|
grid-row: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-gateway-traffic-freshness {
|
||||||
|
min-height: 28px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 8.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-gateway-traffic-freshness.is-stale {
|
||||||
|
color: oklch(0.68 0.14 72);
|
||||||
|
}
|
||||||
|
|
||||||
.client-panel.has-subscription .client-form {
|
.client-panel.has-subscription .client-form {
|
||||||
height: var(--client-work-height);
|
height: var(--client-work-height);
|
||||||
}
|
}
|
||||||
@@ -2687,6 +2764,19 @@ p {
|
|||||||
min-height: 76px;
|
min-height: 76px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-power-control {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power-control:focus-visible {
|
||||||
|
outline: 1px solid color-mix(in oklch, var(--client-accent) 64%, transparent);
|
||||||
|
outline-offset: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-power {
|
.client-power {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 96px;
|
width: 96px;
|
||||||
@@ -3035,6 +3125,23 @@ p {
|
|||||||
transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1);
|
transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-subscription-drawer {
|
||||||
|
width: min(480px, 100vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-sheet {
|
||||||
|
min-height: 100%;
|
||||||
|
align-content: start;
|
||||||
|
padding: 82px 72px 72px 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-drawer .client-subscription,
|
||||||
|
.client-subscription-drawer .client-usage,
|
||||||
|
.client-subscription-drawer .client-servers {
|
||||||
|
width: min(100%, 300px);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 921px) {
|
@media (min-width: 921px) {
|
||||||
.client-panel.has-subscription {
|
.client-panel.has-subscription {
|
||||||
--client-work-height: min(640px, calc(100vh - 120px));
|
--client-work-height: min(640px, calc(100vh - 120px));
|
||||||
@@ -4425,6 +4532,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-power-section,
|
.client-power-section,
|
||||||
|
.client-gateway-summary,
|
||||||
.client-form,
|
.client-form,
|
||||||
.client-panel.is-setup .client-form {
|
.client-panel.is-setup .client-form {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
@@ -4432,6 +4540,16 @@ p {
|
|||||||
width: min(100%, 380px);
|
width: min(100%, 380px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-power-section {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-gateway-summary {
|
||||||
|
order: 2;
|
||||||
|
justify-self: center;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.client-form {
|
.client-form {
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
@@ -4560,6 +4678,10 @@ p {
|
|||||||
padding: 40px 58px 60px 18px;
|
padding: 40px 58px 60px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-subscription-sheet {
|
||||||
|
padding: 70px 58px 60px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-local-rules-sheet {
|
.client-local-rules-sheet {
|
||||||
padding: 40px 58px 60px 18px;
|
padding: 40px 58px 60px 18px;
|
||||||
}
|
}
|
||||||
@@ -5054,6 +5176,10 @@ p {
|
|||||||
.client-subscription-refresh,
|
.client-subscription-refresh,
|
||||||
.client-subscription-delete,
|
.client-subscription-delete,
|
||||||
.client-power-section,
|
.client-power-section,
|
||||||
|
.client-gateway-summary,
|
||||||
|
.client-gateway-traffic-chart,
|
||||||
|
.client-gateway-traffic-freshness,
|
||||||
|
.client-subscription-drawer,
|
||||||
.client-form,
|
.client-form,
|
||||||
.client-usage,
|
.client-usage,
|
||||||
.client-usage > strong,
|
.client-usage > strong,
|
||||||
|
|||||||
@@ -196,6 +196,15 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
|||||||
error: null,
|
error: null,
|
||||||
proxy: { lastObservedAt: observedAt, error: null },
|
proxy: { lastObservedAt: observedAt, error: null },
|
||||||
});
|
});
|
||||||
|
assert.deepEqual(snapshot.traffic, {
|
||||||
|
gatewayBytes: '9007199254741093',
|
||||||
|
proxyBytes: '3000',
|
||||||
|
totalBytes: '9007199254744093',
|
||||||
|
gatewayObservedAt: observedAt,
|
||||||
|
proxyObservedAt: observedAt,
|
||||||
|
observedAt,
|
||||||
|
history: [],
|
||||||
|
});
|
||||||
|
|
||||||
snapshot = await service.refresh();
|
snapshot = await service.refresh();
|
||||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
|
||||||
@@ -216,6 +225,8 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
|||||||
assert.equal(snapshot.devices[0].downloadBytes, '150');
|
assert.equal(snapshot.devices[0].downloadBytes, '150');
|
||||||
assert.equal(snapshot.devices[0].proxyUploadBytes, '1010');
|
assert.equal(snapshot.devices[0].proxyUploadBytes, '1010');
|
||||||
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2050');
|
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2050');
|
||||||
|
assert.equal(snapshot.traffic.gatewayBytes, '9007199254741145');
|
||||||
|
assert.equal(snapshot.traffic.proxyBytes, '3060');
|
||||||
|
|
||||||
service = createService();
|
service = createService();
|
||||||
snapshot = await service.refresh();
|
snapshot = await service.refresh();
|
||||||
@@ -239,6 +250,7 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
|||||||
assert.equal(snapshot.devices[0].downloadBytes, '170');
|
assert.equal(snapshot.devices[0].downloadBytes, '170');
|
||||||
assert.equal(snapshot.devices[0].proxyUploadBytes, '1013');
|
assert.equal(snapshot.devices[0].proxyUploadBytes, '1013');
|
||||||
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2054');
|
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2054');
|
||||||
|
assert.equal(snapshot.traffic.totalBytes, '9007199254744242');
|
||||||
|
|
||||||
traffic = {
|
traffic = {
|
||||||
...traffic,
|
...traffic,
|
||||||
@@ -250,6 +262,7 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
|||||||
|
|
||||||
traffic = {
|
traffic = {
|
||||||
...traffic,
|
...traffic,
|
||||||
|
observedAt: '2026-08-07T12:00:15.000Z',
|
||||||
devices: [{
|
devices: [{
|
||||||
...traffic.devices[0],
|
...traffic.devices[0],
|
||||||
uploadBytes: '12',
|
uploadBytes: '12',
|
||||||
@@ -265,12 +278,17 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
|||||||
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2054');
|
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2054');
|
||||||
assert.equal(snapshot.source.traffic.error, null);
|
assert.equal(snapshot.source.traffic.error, null);
|
||||||
assert.match(snapshot.source.traffic.proxy.error, /уменьшился/);
|
assert.match(snapshot.source.traffic.proxy.error, /уменьшился/);
|
||||||
|
assert.equal(snapshot.traffic.proxyBytes, '3067');
|
||||||
|
assert.equal(snapshot.traffic.gatewayObservedAt, '2026-08-07T12:00:15.000Z');
|
||||||
|
assert.equal(snapshot.traffic.proxyObservedAt, observedAt);
|
||||||
|
assert.equal(snapshot.traffic.observedAt, observedAt);
|
||||||
|
|
||||||
trafficError = new Error('traffic unavailable');
|
trafficError = new Error('traffic unavailable');
|
||||||
snapshot = await service.refresh();
|
snapshot = await service.refresh();
|
||||||
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741007');
|
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741007');
|
||||||
assert.equal(snapshot.source.traffic.lastObservedAt, observedAt);
|
assert.equal(snapshot.source.traffic.lastObservedAt, '2026-08-07T12:00:15.000Z');
|
||||||
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
|
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
|
||||||
|
assert.equal(snapshot.traffic.totalBytes, '9007199254744249');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('device traffic history stays in bounded service memory and survives client snapshots', async (t) => {
|
test('device traffic history stays in bounded service memory and survives client snapshots', async (t) => {
|
||||||
@@ -313,9 +331,55 @@ test('device traffic history stays in bounded service memory and survives client
|
|||||||
gatewayBytes: '30',
|
gatewayBytes: '30',
|
||||||
proxyBytes: '5',
|
proxyBytes: '5',
|
||||||
}]);
|
}]);
|
||||||
|
assert.deepEqual(snapshot.traffic.history, [{
|
||||||
|
observedAt,
|
||||||
|
gatewayBytes: '30',
|
||||||
|
proxyBytes: '5',
|
||||||
|
}]);
|
||||||
assert.deepEqual(service.snapshot().devices[0].trafficHistory, snapshot.devices[0].trafficHistory);
|
assert.deepEqual(service.snapshot().devices[0].trafficHistory, snapshot.devices[0].trafficHistory);
|
||||||
assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /trafficHistory/);
|
assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /trafficHistory/);
|
||||||
assert.deepEqual(createService().snapshot().devices[0].trafficHistory, []);
|
assert.deepEqual(createService().snapshot().devices[0].trafficHistory, []);
|
||||||
|
assert.deepEqual(createService().snapshot().traffic.history, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global traffic stays monotonic when a device expires and returns in the same epoch', async (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-global-traffic-retention-'));
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const store = createJsonStore({
|
||||||
|
filePath: path.join(directory, 'devices.json'),
|
||||||
|
defaultValue: {},
|
||||||
|
migrate: migrateDeviceInventoryState,
|
||||||
|
});
|
||||||
|
const mac = '00:11:22:33:44:55';
|
||||||
|
let observedAt = '2026-07-01T12:00:00.000Z';
|
||||||
|
let present = true;
|
||||||
|
let uploadBytes = '100';
|
||||||
|
const service = createDeviceInventoryService({
|
||||||
|
store,
|
||||||
|
observe: () => ({
|
||||||
|
observedAt,
|
||||||
|
error: null,
|
||||||
|
observations: present
|
||||||
|
? [{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true }]
|
||||||
|
: [],
|
||||||
|
}),
|
||||||
|
observeTraffic: () => ({
|
||||||
|
epoch: 'epoch-a', generation: 'rules-a', observedAt, source: { error: null },
|
||||||
|
devices: [{ mac, uploadBytes, downloadBytes: '0' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal((await service.refresh()).traffic.totalBytes, '100');
|
||||||
|
present = false;
|
||||||
|
observedAt = '2026-08-07T12:00:00.000Z';
|
||||||
|
let snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices.length, 0);
|
||||||
|
assert.equal(snapshot.traffic.totalBytes, '100');
|
||||||
|
present = true;
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.traffic.totalBytes, '100');
|
||||||
|
uploadBytes = '110';
|
||||||
|
assert.equal((await service.refresh()).traffic.totalBytes, '110');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('legacy dataplane samples preserve saved proxy totals while Gateway totals keep advancing', async (t) => {
|
test('legacy dataplane samples preserve saved proxy totals while Gateway totals keep advancing', async (t) => {
|
||||||
@@ -462,7 +526,7 @@ test('device policy is independent from pinning, persists, and keeps the last ap
|
|||||||
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
||||||
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
||||||
assert.equal(snapshot.devices[0].policyStatus, 'applied');
|
assert.equal(snapshot.devices[0].policyStatus, 'applied');
|
||||||
assert.equal(store.read().schemaVersion, 2);
|
assert.equal(store.read().schemaVersion, DEVICE_INVENTORY_SCHEMA_VERSION);
|
||||||
assert.equal(store.read().policy.schemaVersion, 1);
|
assert.equal(store.read().policy.schemaVersion, 1);
|
||||||
|
|
||||||
service = createService();
|
service = createService();
|
||||||
@@ -585,7 +649,7 @@ test('device inventory backs up and normalizes a malformed additive policy check
|
|||||||
});
|
});
|
||||||
|
|
||||||
const migrated = store.read();
|
const migrated = store.read();
|
||||||
assert.equal(migrated.schemaVersion, 2);
|
assert.equal(migrated.schemaVersion, DEVICE_INVENTORY_SCHEMA_VERSION);
|
||||||
assert.equal(migrated.policy.schemaVersion, 1);
|
assert.equal(migrated.policy.schemaVersion, 1);
|
||||||
assert.equal(migrated.policy.dataplaneEpoch, null);
|
assert.equal(migrated.policy.dataplaneEpoch, null);
|
||||||
assert.deepEqual(migrated.policy.byMac, {});
|
assert.deepEqual(migrated.policy.byMac, {});
|
||||||
@@ -666,6 +730,12 @@ test('device inventory backs up malformed v2 traffic and re-baselines without do
|
|||||||
new Set(migrated.traffic.rebaselineMacs),
|
new Set(migrated.traffic.rebaselineMacs),
|
||||||
new Set([firstMac, secondMac, missingBaselineMac, missingTotalMac, expiredMac]),
|
new Set([firstMac, secondMac, missingBaselineMac, missingTotalMac, expiredMac]),
|
||||||
);
|
);
|
||||||
|
assert.equal(migrated.traffic.global.gateway.uploadBytes, '1300');
|
||||||
|
assert.equal(migrated.traffic.global.gateway.downloadBytes, '1500');
|
||||||
|
assert.deepEqual(
|
||||||
|
new Set(migrated.traffic.global.gateway.rebaselineMacs),
|
||||||
|
new Set([firstMac, secondMac, missingBaselineMac, missingTotalMac, expiredMac]),
|
||||||
|
);
|
||||||
|
|
||||||
let counters = [
|
let counters = [
|
||||||
{ mac: firstMac, uploadBytes: '200', downloadBytes: '300' },
|
{ mac: firstMac, uploadBytes: '200', downloadBytes: '300' },
|
||||||
@@ -703,6 +773,7 @@ test('device inventory backs up malformed v2 traffic and re-baselines without do
|
|||||||
assert.equal(byMac.get(missingBaselineMac).uploadBytes, '800');
|
assert.equal(byMac.get(missingBaselineMac).uploadBytes, '800');
|
||||||
assert.equal(byMac.get(missingTotalMac).uploadBytes, '0');
|
assert.equal(byMac.get(missingTotalMac).uploadBytes, '0');
|
||||||
assert.equal(byMac.has(expiredMac), false);
|
assert.equal(byMac.has(expiredMac), false);
|
||||||
|
assert.equal(snapshot.traffic.totalBytes, '2800');
|
||||||
assert.deepEqual(store.read().traffic.rebaselineMacs, []);
|
assert.deepEqual(store.read().traffic.rebaselineMacs, []);
|
||||||
assert.equal(snapshot.source.traffic.error, null);
|
assert.equal(snapshot.source.traffic.error, null);
|
||||||
counters = [
|
counters = [
|
||||||
@@ -721,6 +792,7 @@ test('device inventory backs up malformed v2 traffic and re-baselines without do
|
|||||||
assert.equal(byMac.get(missingBaselineMac).downloadBytes, '905');
|
assert.equal(byMac.get(missingBaselineMac).downloadBytes, '905');
|
||||||
assert.equal(byMac.get(missingTotalMac).uploadBytes, '20');
|
assert.equal(byMac.get(missingTotalMac).uploadBytes, '20');
|
||||||
assert.equal(byMac.get(missingTotalMac).downloadBytes, '20');
|
assert.equal(byMac.get(missingTotalMac).downloadBytes, '20');
|
||||||
|
assert.equal(snapshot.traffic.totalBytes, '2945');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('malformed proxy totals are backed up and an expired recovery marker is cleared', async (t) => {
|
test('malformed proxy totals are backed up and an expired recovery marker is cleared', async (t) => {
|
||||||
|
|||||||
@@ -16,14 +16,19 @@ import {
|
|||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||||
const panel = fs.readFileSync(path.join(root, 'src/web/components/DevicesPanel.jsx'), 'utf8');
|
const panel = fs.readFileSync(path.join(root, 'src/web/components/DevicesPanel.jsx'), 'utf8');
|
||||||
|
const chart = fs.readFileSync(path.join(root, 'src/web/components/TrafficChart.jsx'), 'utf8');
|
||||||
const api = fs.readFileSync(path.join(root, 'src/web/api.js'), 'utf8');
|
const api = fs.readFileSync(path.join(root, 'src/web/api.js'), 'utf8');
|
||||||
const server = fs.readFileSync(path.join(root, 'src/server/index.js'), 'utf8');
|
const server = fs.readFileSync(path.join(root, 'src/server/index.js'), 'utf8');
|
||||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||||
|
|
||||||
test('Gateway device inventory uses the existing accessible responsive drawer', () => {
|
test('Gateway device inventory uses the existing accessible responsive drawer', () => {
|
||||||
assert.match(overview, /isGateway && <button[\s\S]*client-devices-toggle/);
|
assert.match(overview, /isGateway && <button[\s\S]*client-devices-toggle/);
|
||||||
assert.match(panel, /api\.devices\.list\(\)/);
|
assert.match(overview, /api\.devices\.list\(\)/);
|
||||||
assert.match(panel, /api\.devices\.refresh\(\)/);
|
assert.match(overview, /api\.devices\.refresh\(\)/);
|
||||||
|
assert.match(overview, /DEVICE_AUTO_REFRESH_MS = 15_000/);
|
||||||
|
assert.match(overview, /setDeviceSnapshot\(\(current\) => !current \|\| next\.revision > current\.revision/);
|
||||||
|
assert.match(overview, /snapshot=\{deviceSnapshot\}[\s\S]*onSnapshot=\{setDeviceSnapshot\}/);
|
||||||
|
assert.doesNotMatch(panel, /AUTO_REFRESH_MS|const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
|
||||||
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
||||||
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*api\.devices\.list\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*api\.devices\.update\(device\.id, patch, latest\.revision\)/);
|
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*api\.devices\.list\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*api\.devices\.update\(device\.id, patch, latest\.revision\)/);
|
||||||
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
||||||
@@ -75,21 +80,21 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\.trafficHistoryCapacity/);
|
assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\.trafficHistoryCapacity/);
|
||||||
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
|
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
|
||||||
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
|
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
|
||||||
assert.match(panel, /previousScale\.current !== scale[\s\S]*attributeName="d"[\s\S]*dur="520ms"/);
|
assert.match(chart, /previousScale\.current !== scale[\s\S]*attributeName="d"[\s\S]*dur="520ms"/);
|
||||||
assert.match(panel, /function smoothTrafficPath[\s\S]*const midX = \(previous\.x \+ point\.x\) \/ 2[\s\S]* C /);
|
assert.match(chart, /function smoothTrafficPath[\s\S]*const midX = \(previous\.x \+ point\.x\) \/ 2[\s\S]* C /);
|
||||||
assert.match(panel, /TRAFFIC_CHART_HEADROOM = 10[\s\S]*trafficChartY = \(ratio\) => 100 - ratio \* \(100 - TRAFFIC_CHART_HEADROOM\)/);
|
assert.match(chart, /TRAFFIC_CHART_HEADROOM = 10[\s\S]*trafficChartY = \(ratio\) => 100 - ratio \* \(100 - TRAFFIC_CHART_HEADROOM\)/);
|
||||||
assert.match(panel, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, max, scale\)\)/);
|
assert.match(chart, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, max, scale\)\)/);
|
||||||
assert.match(panel, /client-device-traffic-grid[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\}[\s\S]*y1=\{\(100 \+ TRAFFIC_CHART_HEADROOM\) \/ 2\}/);
|
assert.match(chart, /client-device-traffic-grid[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\}[\s\S]*y1=\{\(100 \+ TRAFFIC_CHART_HEADROOM\) \/ 2\}/);
|
||||||
assert.match(panel, /client-device-traffic-cursor[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\} y2="100"/);
|
assert.match(chart, /client-device-traffic-cursor[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\} y2="100"/);
|
||||||
assert.doesNotMatch(panel, /key=\{latest\}/);
|
assert.doesNotMatch(panel, /key=\{latest\}/);
|
||||||
assert.match(panel, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
|
assert.match(chart, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
|
||||||
assert.match(panel, /onPointerMove=\{trackPointer\}/);
|
assert.match(chart, /onPointerMove=\{trackPointer\}/);
|
||||||
assert.match(panel, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
|
assert.match(chart, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
|
||||||
assert.match(panel, /client-device-traffic-grid[\s\S]*path className="is-gateway"[\s\S]*path className="is-proxy"/);
|
assert.match(chart, /client-device-traffic-grid[\s\S]*path className="is-gateway"[\s\S]*path className="is-proxy"/);
|
||||||
assert.match(panel, /client-device-traffic-cursor[\s\S]*className="is-point is-gateway"[\s\S]*className="is-point is-proxy"/);
|
assert.match(chart, /client-device-traffic-cursor[\s\S]*className="is-point is-gateway"[\s\S]*className="is-point is-proxy"/);
|
||||||
assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/);
|
assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/);
|
||||||
assert.match(panel, /\{hovered\.proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(hovered\.proxy\)\}<\/span>\}/);
|
assert.match(chart, /\{hovered\.proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(hovered\.proxy\)\}<\/span>\}/);
|
||||||
assert.match(panel, /<time dateTime=\{samples\[0\]\.observedAt\}>[\s\S]*<span>15 с<\/span>/);
|
assert.match(chart, /<time dateTime=\{samples\[0\]\.observedAt\}>[\s\S]*<span>15 с<\/span>/);
|
||||||
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
|
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
|
||||||
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
|
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
|
||||||
assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|<Tooltip>\{trafficLabel\}<\/Tooltip>/);
|
assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|<Tooltip>\{trafficLabel\}<\/Tooltip>/);
|
||||||
@@ -160,6 +165,30 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Gateway Home reuses the canonical device snapshot for applied route and global traffic', () => {
|
||||||
|
assert.match(overview, /const appliedServerId = state\?\.selection\?\.appliedServerId \|\| ''/);
|
||||||
|
assert.match(overview, /appliedServer\?\.label \|\| 'VPN-сервер не используется'/);
|
||||||
|
assert.match(overview, /selectedServerId !== appliedServerId[\s\S]*Переключаем на \{desiredServer\.label\}/);
|
||||||
|
assert.match(overview, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
|
||||||
|
assert.match(overview, /samples=\{globalTraffic\?\.history \|\| \[\]\}[\s\S]*capacity=\{deviceSnapshot\?\.trafficHistoryCapacity \|\| 120\}[\s\S]*routeLabel="Gateway"/);
|
||||||
|
assert.doesNotMatch(overview, /<TrafficChart[\s\S]{0,240}scale=/);
|
||||||
|
assert.match(overview, /deviceStatus === 'error' \? deviceError : null/);
|
||||||
|
assert.match(overview, /if \(!isGateway && \(!connected \|\| !state\?\.singboxStartedAt\)\) return undefined/);
|
||||||
|
assert.match(overview, /trafficSourceError[\s\S]*Трафик не обновляется · последние данные/);
|
||||||
|
assert.match(overview, /client-subscription-drawer\$\{subscriptionOpen \? ' is-open' : ''\}/);
|
||||||
|
assert.match(overview, /const confirmingDeleteRef = useRef\(confirmingDelete\)[\s\S]*if \(confirmingDeleteRef\.current\) return/);
|
||||||
|
assert.match(overview, /onClick=\{\(\) => setConfirmingDelete\(true\)\}[\s\S]*open=\{confirmingDelete\}[\s\S]*onCancel=\{\(\) => setConfirmingDelete\(false\)\}/);
|
||||||
|
assert.match(overview, /aria-label=\{isGateway[\s\S]*Остановить Harbor Connect[\s\S]*Запустить Harbor Connect/);
|
||||||
|
assert.match(overview, /className="client-power-control client-tooltip-anchor"[\s\S]*aria-describedby=\{powerUnavailable \? 'gateway-power-unavailable'[\s\S]*Сначала добавьте подписку и выберите сервер/);
|
||||||
|
assert.match(overview, /const powerButton = <button[\s\S]*className="client-power"[\s\S]*\{isGateway \? <span[\s\S]*<\/span> : powerButton\}/);
|
||||||
|
assert.match(overview, /isGateway && <DevicesPanel[\s\S]*snapshot=\{deviceSnapshot\}/);
|
||||||
|
assert.doesNotMatch(panel, /const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('traffic chart scale compares Gateway and Proxy before choosing the maximum', () => {
|
||||||
|
assert.match(chart, /function trafficSeriesMax[\s\S]*proxy > gateway \? proxy : gateway[\s\S]*proxy > largest \? proxy : largest/);
|
||||||
|
});
|
||||||
|
|
||||||
test('device last-seen copy is compact with precise accessible and relative forms', () => {
|
test('device last-seen copy is compact with precise accessible and relative forms', () => {
|
||||||
const lastSeen = new Date(2026, 7, 7, 13, 15);
|
const lastSeen = new Date(2026, 7, 7, 13, 15);
|
||||||
const now = new Date(2026, 7, 7, 13, 27);
|
const now = new Date(2026, 7, 7, 13, 27);
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
|
|||||||
assert.match(styles, /\.client-panel\.has-subscription \{[\s\S]*transform:\s*translateY\(-9vh\)/);
|
assert.match(styles, /\.client-panel\.has-subscription \{[\s\S]*transform:\s*translateY\(-9vh\)/);
|
||||||
assert.match(styles, /\.client-panel\.has-subscription \.client-power-section \{[\s\S]*height:\s*var\(--client-work-height\);[\s\S]*padding-top:\s*var\(--client-power-top\)/);
|
assert.match(styles, /\.client-panel\.has-subscription \.client-power-section \{[\s\S]*height:\s*var\(--client-work-height\);[\s\S]*padding-top:\s*var\(--client-power-top\)/);
|
||||||
assert.match(styles, /\.client-panel\.has-subscription \.client-form-content \{[\s\S]*padding-top:\s*var\(--client-power-top\)/);
|
assert.match(styles, /\.client-panel\.has-subscription \.client-form-content \{[\s\S]*padding-top:\s*var\(--client-power-top\)/);
|
||||||
|
assert.match(rule('.client-gateway-summary'), /grid-column:\s*1/);
|
||||||
|
assert.match(rule('.client-gateway-summary'), /justify-self:\s*end/);
|
||||||
|
assert.match(component, /client-panel\$\{showPower \? '' : ' is-setup'\}[\s\S]*is-gateway-home/);
|
||||||
|
assert.match(component, /const showPower = isGateway \|\| \(hasSubscription && Boolean\(selectedServerId\)\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('page reload plays one stable startup sequence', () => {
|
test('page reload plays one stable startup sequence', () => {
|
||||||
@@ -68,6 +72,9 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
|
|||||||
|
|
||||||
assert.match(responsive, /grid-template-columns:\s*minmax\(0, 1fr\)/);
|
assert.match(responsive, /grid-template-columns:\s*minmax\(0, 1fr\)/);
|
||||||
assert.match(responsive, /\.client-power-section,[\s\S]*\.client-form,[\s\S]*grid-column:\s*1/);
|
assert.match(responsive, /\.client-power-section,[\s\S]*\.client-form,[\s\S]*grid-column:\s*1/);
|
||||||
|
assert.match(responsive, /\.client-gateway-summary/);
|
||||||
|
assert.match(responsive, /\.client-power-section \{[\s\S]*order:\s*1/);
|
||||||
|
assert.match(responsive, /\.client-gateway-summary \{[\s\S]*order:\s*2/);
|
||||||
assert.match(responsive, /width:\s*min\(100%, 380px\)/);
|
assert.match(responsive, /width:\s*min\(100%, 380px\)/);
|
||||||
assert.match(responsive, /max-height:\s*none/);
|
assert.match(responsive, /max-height:\s*none/);
|
||||||
assert.match(responsive, /overflow-y:\s*visible/);
|
assert.match(responsive, /overflow-y:\s*visible/);
|
||||||
@@ -81,6 +88,7 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
|
|||||||
assert.match(styles, /\.client-instructions\s*\{[\s\S]*width:\s*min\(470px, 100vw\)/);
|
assert.match(styles, /\.client-instructions\s*\{[\s\S]*width:\s*min\(470px, 100vw\)/);
|
||||||
assert.match(styles, /\.client-local-rules\s*\{[\s\S]*width:\s*min\(480px, 100vw\)/);
|
assert.match(styles, /\.client-local-rules\s*\{[\s\S]*width:\s*min\(480px, 100vw\)/);
|
||||||
assert.match(styles, /\.client-instructions\.client-diagnostics\s*\{[\s\S]*width:\s*min\(580px, 100vw\)/);
|
assert.match(styles, /\.client-instructions\.client-diagnostics\s*\{[\s\S]*width:\s*min\(580px, 100vw\)/);
|
||||||
|
assert.match(styles, /\.client-subscription-drawer\s*\{[\s\S]*width:\s*min\(480px, 100vw\)/);
|
||||||
assert.match(styles, /\.client-confirmation-dialog\s*\{[\s\S]*width:\s*min\(430px, calc\(100vw - 48px\)\)/);
|
assert.match(styles, /\.client-confirmation-dialog\s*\{[\s\S]*width:\s*min\(430px, calc\(100vw - 48px\)\)/);
|
||||||
|
|
||||||
for (const viewport of [320, 390, 768]) {
|
for (const viewport of [320, 390, 768]) {
|
||||||
@@ -100,6 +108,8 @@ test('secondary menus share one right rail and both drawers open from the right'
|
|||||||
const zIndex = (selector) => Number(/z-index:\s*(\d+)/.exec(rule(selector))?.[1]);
|
const zIndex = (selector) => Number(/z-index:\s*(\d+)/.exec(rule(selector))?.[1]);
|
||||||
|
|
||||||
assert.match(component, /<nav className="client-secondary-menu" aria-label="Дополнительные меню">/);
|
assert.match(component, /<nav className="client-secondary-menu" aria-label="Дополнительные меню">/);
|
||||||
|
assert.match(component, /client-subscription-toggle[\s\S]*client-instructions-toggle[\s\S]*client-devices-toggle/);
|
||||||
|
assert.match(component, /aria-controls="client-subscription-drawer"/);
|
||||||
assert.match(component, /client-instructions-toggle[\s\S]*client-local-rules-toggle/);
|
assert.match(component, /client-instructions-toggle[\s\S]*client-local-rules-toggle/);
|
||||||
assert.match(component, /client-instructions-toggle[\s\S]*client-devices-toggle[\s\S]*client-diagnostics-toggle[\s\S]*client-local-rules-toggle/);
|
assert.match(component, /client-instructions-toggle[\s\S]*client-devices-toggle[\s\S]*client-diagnostics-toggle[\s\S]*client-local-rules-toggle/);
|
||||||
assert.match(component, /client-diagnostics-toggle/);
|
assert.match(component, /client-diagnostics-toggle/);
|
||||||
@@ -132,6 +142,8 @@ test('secondary menus share one right rail and both drawers open from the right'
|
|||||||
assert.match(rule('.client-local-rules'), /width:\s*min\(480px, 100vw\)/);
|
assert.match(rule('.client-local-rules'), /width:\s*min\(480px, 100vw\)/);
|
||||||
assert.match(component, /className={`client-drawer client-instructions/);
|
assert.match(component, /className={`client-drawer client-instructions/);
|
||||||
assert.match(component, /className={`client-drawer client-local-rules/);
|
assert.match(component, /className={`client-drawer client-local-rules/);
|
||||||
|
assert.match(component, /client-drawer client-subscription-drawer/);
|
||||||
|
assert.match(component, /setSubscriptionOpen\(false\)[\s\S]*setDevicesOpen\(false\)[\s\S]*setDiagnosticsOpen\(false\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('connectivity diagnostics render stable compact tables before the first run', () => {
|
test('connectivity diagnostics render stable compact tables before the first run', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user