Add device traffic reset with outbound baselines
This commit is contained in:
@@ -77,7 +77,9 @@ http://АДРЕС-GATEWAY:3456
|
|||||||
|
|
||||||
### Устройства Gateway
|
### Устройства Gateway
|
||||||
|
|
||||||
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory.
|
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, выбранный график трафика и иконку применённого маршрута. По умолчанию график показывает приблизительный выход `VPN`/`Direct`; переключатель `Вход` возвращает накопленную разбивку `Gateway`/`Прокси`. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory.
|
||||||
|
|
||||||
|
Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters.
|
||||||
|
|
||||||
У однозначно распознанного устройства маршрут можно переключить последней иконкой между `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 сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface DeviceInventoryPort {
|
|||||||
snapshot(): unknown;
|
snapshot(): unknown;
|
||||||
refresh(): Promise<unknown>;
|
refresh(): Promise<unknown>;
|
||||||
update(deviceId: string, patch: Record<string, unknown>, expectedRevision: unknown): unknown;
|
update(deviceId: string, patch: Record<string, unknown>, expectedRevision: unknown): unknown;
|
||||||
|
resetTraffic(expectedRevision: unknown): Promise<unknown>;
|
||||||
setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise<unknown>;
|
setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +40,15 @@ export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDep
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/devices/traffic') {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'DELETE') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const body = await dependencies.readBody(req);
|
||||||
|
sendJson(res, 200, await dependencies.deviceInventory.resetTraffic(body.expectedRevision));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const deviceMatch = pathname.match(DEVICE_PATH);
|
const deviceMatch = pathname.match(DEVICE_PATH);
|
||||||
if (deviceMatch) {
|
if (deviceMatch) {
|
||||||
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ interface InventoryTrafficState {
|
|||||||
baselinesByMac: Record<string, CounterBaseline>;
|
baselinesByMac: Record<string, CounterBaseline>;
|
||||||
totalsByMac: Record<string, TrafficTotal>;
|
totalsByMac: Record<string, TrafficTotal>;
|
||||||
rebaselineMacs: string[];
|
rebaselineMacs: string[];
|
||||||
|
outboundBaselinesByDeviceId: Record<string, OutboundTrafficBaseline>;
|
||||||
proxy: ProxyTrafficState;
|
proxy: ProxyTrafficState;
|
||||||
global: { gateway: GlobalTrafficSource; proxy: GlobalTrafficSource };
|
global: { gateway: GlobalTrafficSource; proxy: GlobalTrafficSource };
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -164,6 +165,15 @@ interface OutboundTrafficCursor {
|
|||||||
unknown: bigint;
|
unknown: bigint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OutboundTrafficBaseline {
|
||||||
|
routeEpoch: string;
|
||||||
|
directEpoch: string;
|
||||||
|
vpnBytes: string;
|
||||||
|
directTrackedBytes: string;
|
||||||
|
directIpv4Bytes: string;
|
||||||
|
unknownBytes: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface DeviceObservation {
|
interface DeviceObservation {
|
||||||
mac: string;
|
mac: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -250,6 +260,7 @@ const DEFAULT_STATE: InventoryState = {
|
|||||||
baselinesByMac: {},
|
baselinesByMac: {},
|
||||||
totalsByMac: {},
|
totalsByMac: {},
|
||||||
rebaselineMacs: [],
|
rebaselineMacs: [],
|
||||||
|
outboundBaselinesByDeviceId: {},
|
||||||
proxy: DEFAULT_PROXY_TRAFFIC,
|
proxy: DEFAULT_PROXY_TRAFFIC,
|
||||||
global: {
|
global: {
|
||||||
gateway: DEFAULT_GLOBAL_TRAFFIC_SOURCE,
|
gateway: DEFAULT_GLOBAL_TRAFFIC_SOURCE,
|
||||||
@@ -599,6 +610,32 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
|||||||
recoveredTraffic = true;
|
recoveredTraffic = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const knownDeviceIds = new Set(devices.map(({ id }) => id));
|
||||||
|
const outboundBaselinesByDeviceId: Record<string, OutboundTrafficBaseline> = {};
|
||||||
|
if (traffic.outboundBaselinesByDeviceId !== undefined
|
||||||
|
&& record(traffic.outboundBaselinesByDeviceId) !== traffic.outboundBaselinesByDeviceId) {
|
||||||
|
recoveredTraffic = true;
|
||||||
|
}
|
||||||
|
for (const [id, baseline] of recordEntries(traffic.outboundBaselinesByDeviceId)) {
|
||||||
|
const vpnBytes = parseStoredCounter(baseline.vpnBytes);
|
||||||
|
const directTrackedBytes = parseStoredCounter(baseline.directTrackedBytes);
|
||||||
|
const directIpv4Bytes = parseStoredCounter(baseline.directIpv4Bytes);
|
||||||
|
const unknownBytes = parseStoredCounter(baseline.unknownBytes);
|
||||||
|
if (!DEVICE_ID_PATTERN.test(id) || !knownDeviceIds.has(id)
|
||||||
|
|| typeof baseline.routeEpoch !== 'string' || typeof baseline.directEpoch !== 'string'
|
||||||
|
|| vpnBytes == null || directTrackedBytes == null || directIpv4Bytes == null || unknownBytes == null) {
|
||||||
|
recoveredTraffic = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
outboundBaselinesByDeviceId[id] = {
|
||||||
|
routeEpoch: baseline.routeEpoch,
|
||||||
|
directEpoch: baseline.directEpoch,
|
||||||
|
vpnBytes,
|
||||||
|
directTrackedBytes,
|
||||||
|
directIpv4Bytes,
|
||||||
|
unknownBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
const global = {
|
const global = {
|
||||||
gateway: normalizeGlobalTrafficSource(record(traffic.global).gateway, {
|
gateway: normalizeGlobalTrafficSource(record(traffic.global).gateway, {
|
||||||
epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null,
|
epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null,
|
||||||
@@ -630,6 +667,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
|||||||
baselinesByMac,
|
baselinesByMac,
|
||||||
totalsByMac,
|
totalsByMac,
|
||||||
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
|
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
|
||||||
|
outboundBaselinesByDeviceId,
|
||||||
proxy: proxyTraffic,
|
proxy: proxyTraffic,
|
||||||
global,
|
global,
|
||||||
},
|
},
|
||||||
@@ -837,15 +875,28 @@ export function createDeviceInventoryService({
|
|||||||
const total = totalFor(device.id);
|
const total = totalFor(device.id);
|
||||||
const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total };
|
const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total };
|
||||||
const previous = outboundTrafficCursorByDeviceId.get(device.id);
|
const previous = outboundTrafficCursorByDeviceId.get(device.id);
|
||||||
|
const baseline = state.traffic.outboundBaselinesByDeviceId[device.id];
|
||||||
|
const routeBaseline = baseline?.routeEpoch === routeEpoch ? baseline : null;
|
||||||
|
const directBaseline = baseline?.directEpoch === directEpoch ? baseline : null;
|
||||||
|
const routeVpn = BigInt(routeBaseline?.vpnBytes || '0');
|
||||||
|
const routeDirect = BigInt(routeBaseline?.directTrackedBytes || '0');
|
||||||
|
const routeUnknown = BigInt(routeBaseline?.unknownBytes || '0');
|
||||||
|
const directIpv4Baseline = BigInt(directBaseline?.directIpv4Bytes || '0');
|
||||||
|
const visible = {
|
||||||
|
vpn: total.vpn >= routeVpn ? total.vpn - routeVpn : 0n,
|
||||||
|
directTracked: total.directTracked >= routeDirect ? total.directTracked - routeDirect : 0n,
|
||||||
|
directIpv4: total.directIpv4 >= directIpv4Baseline ? total.directIpv4 - directIpv4Baseline : 0n,
|
||||||
|
unknown: total.unknown >= routeUnknown ? total.unknown - routeUnknown : 0n,
|
||||||
|
};
|
||||||
outboundTrafficCursorByDeviceId.set(device.id, current);
|
outboundTrafficCursorByDeviceId.set(device.id, current);
|
||||||
if (observedAt) outboundTrafficByDeviceId.set(device.id, {
|
if (observedAt) outboundTrafficByDeviceId.set(device.id, {
|
||||||
observedAt,
|
observedAt,
|
||||||
singboxObservedAt: routeObservedAt || null,
|
singboxObservedAt: routeObservedAt || null,
|
||||||
directIpv4ObservedAt: directObservedAt || null,
|
directIpv4ObservedAt: directObservedAt || null,
|
||||||
vpnBytes: total.vpn.toString(),
|
vpnBytes: visible.vpn.toString(),
|
||||||
directTrackedBytes: total.directTracked.toString(),
|
directTrackedBytes: visible.directTracked.toString(),
|
||||||
directIpv4Bytes: total.directIpv4.toString(),
|
directIpv4Bytes: visible.directIpv4.toString(),
|
||||||
unknownBytes: total.unknown.toString(),
|
unknownBytes: visible.unknown.toString(),
|
||||||
});
|
});
|
||||||
if (!previous || previous.signature === signature || !observedAt) continue;
|
if (!previous || previous.signature === signature || !observedAt) continue;
|
||||||
const routeDelta = (value: bigint, before: bigint) => (
|
const routeDelta = (value: bigint, before: bigint) => (
|
||||||
@@ -1542,6 +1593,70 @@ export function createDeviceInventoryService({
|
|||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetTraffic(expectedRevision: unknown) {
|
||||||
|
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision)
|
||||||
|
|| expectedRevision < 0) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
if (refreshPromise) await refreshPromise;
|
||||||
|
const nextState = store.update((stored) => {
|
||||||
|
const state = migrateDeviceInventoryState(stored);
|
||||||
|
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
|
const totalsByMac: Record<string, TrafficTotal> = {};
|
||||||
|
const proxyTotalsByMac: Record<string, TrafficTotal> = {};
|
||||||
|
const outboundBaselinesByDeviceId: Record<string, OutboundTrafficBaseline> = {};
|
||||||
|
const rebaselineMacs = new Set(state.traffic.rebaselineMacs);
|
||||||
|
const proxyRebaselineMacs = new Set(state.traffic.proxy.rebaselineMacs);
|
||||||
|
for (const device of state.devices) {
|
||||||
|
const traffic = state.traffic.totalsByMac[device.mac];
|
||||||
|
const proxy = state.traffic.proxy.totalsByMac[device.mac];
|
||||||
|
totalsByMac[device.mac] = {
|
||||||
|
uploadBytes: '0',
|
||||||
|
downloadBytes: '0',
|
||||||
|
observedAt: traffic?.observedAt || state.traffic.lastObservedAt,
|
||||||
|
};
|
||||||
|
proxyTotalsByMac[device.mac] = {
|
||||||
|
uploadBytes: '0',
|
||||||
|
downloadBytes: '0',
|
||||||
|
observedAt: proxy?.observedAt || state.traffic.proxy.lastObservedAt,
|
||||||
|
};
|
||||||
|
if (!state.traffic.baselinesByMac[device.mac]) rebaselineMacs.add(device.mac);
|
||||||
|
if (!state.traffic.proxy.baselinesByMac[device.mac]) proxyRebaselineMacs.add(device.mac);
|
||||||
|
const outbound = outboundTrafficCursorByDeviceId.get(device.id);
|
||||||
|
if (outbound) outboundBaselinesByDeviceId[device.id] = {
|
||||||
|
routeEpoch: outbound.routeEpoch,
|
||||||
|
directEpoch: outbound.directEpoch,
|
||||||
|
vpnBytes: outbound.vpn.toString(),
|
||||||
|
directTrackedBytes: outbound.directTracked.toString(),
|
||||||
|
directIpv4Bytes: outbound.directIpv4.toString(),
|
||||||
|
unknownBytes: outbound.unknown.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
revision: state.revision + 1,
|
||||||
|
traffic: {
|
||||||
|
...state.traffic,
|
||||||
|
totalsByMac,
|
||||||
|
rebaselineMacs: [...rebaselineMacs],
|
||||||
|
outboundBaselinesByDeviceId,
|
||||||
|
proxy: {
|
||||||
|
...state.traffic.proxy,
|
||||||
|
totalsByMac: proxyTotalsByMac,
|
||||||
|
rebaselineMacs: [...proxyRebaselineMacs],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
trafficHistoryByMac.clear();
|
||||||
|
trafficCursorByMac.clear();
|
||||||
|
captureTrafficHistory(nextState);
|
||||||
|
captureOutboundTrafficHistory(nextState);
|
||||||
|
outboundTrafficHistoryByDeviceId.clear();
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
function setPolicy(id: string, mode: unknown, expectedRevision: unknown) {
|
function setPolicy(id: string, mode: unknown, expectedRevision: unknown) {
|
||||||
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision)
|
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision)
|
||||||
|| expectedRevision < 0 || !POLICY_MODES.has(mode)) {
|
|| expectedRevision < 0 || !POLICY_MODES.has(mode)) {
|
||||||
@@ -1585,5 +1700,5 @@ export function createDeviceInventoryService({
|
|||||||
return serializePolicy(() => reconcileLocked(observed, true));
|
return serializePolicy(() => reconcileLocked(observed, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { snapshot, metricsSnapshot, refresh, update, setPolicy, reconcilePolicies };
|
return { snapshot, metricsSnapshot, refresh, update, resetTraffic, setPolicy, reconcilePolicies };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.25.5',
|
macClient: '0.25.6',
|
||||||
gatewayClient: '0.26.4',
|
gatewayClient: '0.26.5',
|
||||||
gatewayBackend: '0.26.4',
|
gatewayBackend: '0.26.5',
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ParsedVersion {
|
export interface ParsedVersion {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
const componentActions = {
|
const componentActions = {
|
||||||
listDevices: api.devices.list,
|
listDevices: api.devices.list,
|
||||||
refreshDevices: api.devices.refresh,
|
refreshDevices: api.devices.refresh,
|
||||||
|
resetDeviceTraffic: api.devices.resetTraffic,
|
||||||
updateDevice: api.devices.update,
|
updateDevice: api.devices.update,
|
||||||
setDevicePolicy: api.devices.setPolicy,
|
setDevicePolicy: api.devices.setPolicy,
|
||||||
pingServers: api.servers.ping,
|
pingServers: api.servers.ping,
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ export const api = {
|
|||||||
devices: {
|
devices: {
|
||||||
list: () => request('/api/devices'),
|
list: () => request('/api/devices'),
|
||||||
refresh: () => request('/api/devices/refresh', { method: 'POST' }),
|
refresh: () => request('/api/devices/refresh', { method: 'POST' }),
|
||||||
|
resetTraffic: (expectedRevision: unknown) => request('/api/devices/traffic', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ expectedRevision }),
|
||||||
|
}),
|
||||||
update: (id: string, patch: Record<string, unknown>, expectedRevision: unknown) => request(
|
update: (id: string, patch: Record<string, unknown>, expectedRevision: unknown) => request(
|
||||||
`/api/devices/${id}`,
|
`/api/devices/${id}`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ interface VersionBadgeProps {
|
|||||||
interface ComponentActions {
|
interface ComponentActions {
|
||||||
listDevices: () => Promise<unknown>;
|
listDevices: () => Promise<unknown>;
|
||||||
refreshDevices: () => Promise<unknown>;
|
refreshDevices: () => Promise<unknown>;
|
||||||
|
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
||||||
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||||
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
||||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||||
@@ -505,6 +506,7 @@ export function ClientOverviewPage({
|
|||||||
isGateway,
|
isGateway,
|
||||||
listDevices: actions.listDevices,
|
listDevices: actions.listDevices,
|
||||||
refreshDevices: actions.refreshDevices,
|
refreshDevices: actions.refreshDevices,
|
||||||
|
resetDeviceTraffic: actions.resetDeviceTraffic,
|
||||||
updateDevice: actions.updateDevice,
|
updateDevice: actions.updateDevice,
|
||||||
setDevicePolicy: actions.setDevicePolicy,
|
setDevicePolicy: actions.setDevicePolicy,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface DevicesFeatureOptions {
|
|||||||
isGateway: boolean;
|
isGateway: boolean;
|
||||||
listDevices: () => Promise<unknown>;
|
listDevices: () => Promise<unknown>;
|
||||||
refreshDevices: () => Promise<unknown>;
|
refreshDevices: () => Promise<unknown>;
|
||||||
|
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
||||||
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||||
setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise<unknown>;
|
setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,7 @@ export function useDevicesFeature({
|
|||||||
isGateway,
|
isGateway,
|
||||||
listDevices,
|
listDevices,
|
||||||
refreshDevices,
|
refreshDevices,
|
||||||
|
resetDeviceTraffic,
|
||||||
updateDevice: requestDeviceUpdate,
|
updateDevice: requestDeviceUpdate,
|
||||||
setDevicePolicy,
|
setDevicePolicy,
|
||||||
}: DevicesFeatureOptions) {
|
}: DevicesFeatureOptions) {
|
||||||
@@ -50,6 +52,8 @@ export function useDevicesFeature({
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [refreshCycle, setRefreshCycle] = useState(0);
|
const [refreshCycle, setRefreshCycle] = useState(0);
|
||||||
const [savingId, setSavingId] = useState('');
|
const [savingId, setSavingId] = useState('');
|
||||||
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
const panelRef = useRef<HTMLElement>(null);
|
const panelRef = useRef<HTMLElement>(null);
|
||||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||||
const closeRef = useRef<HTMLButtonElement>(null);
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -136,6 +140,29 @@ export function useDevicesFeature({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmResetTraffic() {
|
||||||
|
if (!snapshot) return;
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
let next: DeviceSnapshot;
|
||||||
|
try {
|
||||||
|
next = parseDeviceSnapshot(await resetDeviceTraffic(snapshot.revision));
|
||||||
|
} catch (caught) {
|
||||||
|
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||||
|
const latest = parseDeviceSnapshot(await listDevices());
|
||||||
|
publish(latest);
|
||||||
|
next = parseDeviceSnapshot(await resetDeviceTraffic(latest.revision));
|
||||||
|
}
|
||||||
|
publish(next);
|
||||||
|
setError(null);
|
||||||
|
setResetOpen(false);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught);
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isGateway) return undefined;
|
if (!isGateway) return undefined;
|
||||||
load();
|
load();
|
||||||
@@ -152,6 +179,7 @@ export function useDevicesFeature({
|
|||||||
if (!isOpen) return undefined;
|
if (!isOpen) return undefined;
|
||||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||||
const closeDevices = (event: PointerEvent | KeyboardEvent) => {
|
const closeDevices = (event: PointerEvent | KeyboardEvent) => {
|
||||||
|
if (resetOpen) return;
|
||||||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||||||
if (event.type !== 'keydown' && (
|
if (event.type !== 'keydown' && (
|
||||||
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
||||||
@@ -168,7 +196,7 @@ export function useDevicesFeature({
|
|||||||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen, resetOpen]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -178,12 +206,17 @@ export function useDevicesFeature({
|
|||||||
refreshing,
|
refreshing,
|
||||||
refreshCycle,
|
refreshCycle,
|
||||||
savingId,
|
savingId,
|
||||||
|
resetOpen,
|
||||||
|
resetting,
|
||||||
panelRef,
|
panelRef,
|
||||||
toggleRef,
|
toggleRef,
|
||||||
closeRef,
|
closeRef,
|
||||||
load,
|
load,
|
||||||
updateDevice,
|
updateDevice,
|
||||||
updatePolicy,
|
updatePolicy,
|
||||||
|
requestTrafficReset: () => setResetOpen(true),
|
||||||
|
cancelTrafficReset: () => setResetOpen(false),
|
||||||
|
confirmResetTraffic,
|
||||||
close: () => setIsOpen(false),
|
close: () => setIsOpen(false),
|
||||||
toggle: () => setIsOpen((open) => !open),
|
toggle: () => setIsOpen((open) => !open),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Drawer } from '../../ui/Drawer.js';
|
import { Drawer } from '../../ui/Drawer.js';
|
||||||
|
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||||||
import { Tooltip } from '../../ui/Tooltip.js';
|
import { Tooltip } from '../../ui/Tooltip.js';
|
||||||
import { copyText } from '../../utils/clientControls.js';
|
import { copyText } from '../../utils/clientControls.js';
|
||||||
import {
|
import {
|
||||||
@@ -70,9 +71,14 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
refreshing,
|
refreshing,
|
||||||
refreshCycle,
|
refreshCycle,
|
||||||
savingId,
|
savingId,
|
||||||
|
resetOpen,
|
||||||
|
resetting,
|
||||||
load: onLoad,
|
load: onLoad,
|
||||||
updateDevice,
|
updateDevice,
|
||||||
updatePolicy,
|
updatePolicy,
|
||||||
|
requestTrafficReset,
|
||||||
|
cancelTrafficReset,
|
||||||
|
confirmResetTraffic,
|
||||||
close: onClose,
|
close: onClose,
|
||||||
} = feature;
|
} = feature;
|
||||||
const [editingId, setEditingId] = useState('');
|
const [editingId, setEditingId] = useState('');
|
||||||
@@ -278,7 +284,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <>
|
||||||
<Drawer
|
<Drawer
|
||||||
panelRef={panelRef}
|
panelRef={panelRef}
|
||||||
closeRef={closeRef}
|
closeRef={closeRef}
|
||||||
@@ -333,6 +339,17 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
<button type="button" aria-pressed={trafficScale === 'linear'} onClick={() => setTrafficScale('linear')}>Лин</button>
|
<button type="button" aria-pressed={trafficScale === 'linear'} onClick={() => setTrafficScale('linear')}>Лин</button>
|
||||||
<button type="button" aria-pressed={trafficScale === 'log'} onClick={() => setTrafficScale('log')}>Лог</button>
|
<button type="button" aria-pressed={trafficScale === 'log'} onClick={() => setTrafficScale('log')}>Лог</button>
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
className="client-devices-reset"
|
||||||
|
type="button"
|
||||||
|
disabled={!snapshot || resetting}
|
||||||
|
onClick={requestTrafficReset}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 12a8 8 0 1 0 2.3-5.7M4 5v7h7" />
|
||||||
|
</svg>
|
||||||
|
<span>Сбросить данные</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h2 id="client-devices-title">Устройства</h2>
|
<h2 id="client-devices-title">Устройства</h2>
|
||||||
<div className="client-instructions-intro">
|
<div className="client-instructions-intro">
|
||||||
@@ -623,5 +640,17 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
<ConfirmationDialog
|
||||||
|
open={resetOpen}
|
||||||
|
id="client-devices-reset"
|
||||||
|
kicker="Данные устройств"
|
||||||
|
title="Сбросить статистику?"
|
||||||
|
description="Вход и выход для всех устройств начнут считаться заново. Общая скорость и история Prometheus/Grafana останутся без изменений."
|
||||||
|
cancelLabel="Оставить данные"
|
||||||
|
confirmLabel="Сбросить"
|
||||||
|
busy={resetting}
|
||||||
|
onCancel={cancelTrafficReset}
|
||||||
|
onConfirm={confirmResetTraffic}
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-devices-kicker {
|
.client-devices-kicker {
|
||||||
width: max-content;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,54 @@
|
|||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-devices-reset {
|
||||||
|
min-height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
font: var(--type-label);
|
||||||
|
letter-spacing: var(--type-label-tracking);
|
||||||
|
text-transform: var(--type-label-transform);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 220ms ease, filter 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-reset svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-reset:hover:not(:disabled),
|
||||||
|
.client-devices-reset:focus-visible {
|
||||||
|
filter: drop-shadow(0 0 5px currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-reset:hover:not(:disabled) svg,
|
||||||
|
.client-devices-reset:focus-visible svg {
|
||||||
|
transform: rotate(-360deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-reset:focus-visible {
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-reset:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
.client-devices-sort {
|
.client-devices-sort {
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -83,6 +83,8 @@
|
|||||||
.client-device-edit svg,
|
.client-device-edit svg,
|
||||||
.client-device-edit-wrap,
|
.client-device-edit-wrap,
|
||||||
.client-devices-refresh,
|
.client-devices-refresh,
|
||||||
|
.client-devices-reset,
|
||||||
|
.client-devices-reset svg,
|
||||||
.client-devices-sort,
|
.client-devices-sort,
|
||||||
.client-devices-sort-icon,
|
.client-devices-sort-icon,
|
||||||
.client-devices-refresh-ring circle,
|
.client-devices-refresh-ring circle,
|
||||||
|
|||||||
@@ -517,7 +517,9 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat
|
|||||||
let trackedDirect = ['10', '20'];
|
let trackedDirect = ['10', '20'];
|
||||||
let unknown = ['1', '2'];
|
let unknown = ['1', '2'];
|
||||||
let directIpv4 = ['40', '60'];
|
let directIpv4 = ['40', '60'];
|
||||||
const service = createDeviceInventoryService({
|
let gateway = ['100', '200'];
|
||||||
|
let proxyIngress = ['10', '20'];
|
||||||
|
const createService = () => createDeviceInventoryService({
|
||||||
store,
|
store,
|
||||||
observe: () => ({
|
observe: () => ({
|
||||||
observedAt,
|
observedAt,
|
||||||
@@ -531,7 +533,8 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat
|
|||||||
source: { error: null },
|
source: { error: null },
|
||||||
direct: { uploadBytes: directIpv4[0], downloadBytes: directIpv4[1] },
|
direct: { uploadBytes: directIpv4[0], downloadBytes: directIpv4[1] },
|
||||||
devices: [{
|
devices: [{
|
||||||
ip: '192.168.50.7', mac, interface: 'eth0', uploadBytes: '100', downloadBytes: '200',
|
ip: '192.168.50.7', mac, interface: 'eth0', uploadBytes: gateway[0], downloadBytes: gateway[1],
|
||||||
|
proxyUploadBytes: proxyIngress[0], proxyDownloadBytes: proxyIngress[1],
|
||||||
directUploadBytes: directIpv4[0], directDownloadBytes: directIpv4[1],
|
directUploadBytes: directIpv4[0], directDownloadBytes: directIpv4[1],
|
||||||
}],
|
}],
|
||||||
}),
|
}),
|
||||||
@@ -547,6 +550,7 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat
|
|||||||
series: [],
|
series: [],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
let service = createService();
|
||||||
|
|
||||||
let snapshot = await service.refresh();
|
let snapshot = await service.refresh();
|
||||||
assert.deepEqual(snapshot.devices[0].outboundTraffic, {
|
assert.deepEqual(snapshot.devices[0].outboundTraffic, {
|
||||||
@@ -582,7 +586,64 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat
|
|||||||
unknownBytes: '9',
|
unknownBytes: '9',
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
|
const globalBeforeReset = structuredClone(snapshot.traffic);
|
||||||
|
const resetRevision = snapshot.revision;
|
||||||
|
snapshot = await service.resetTraffic(resetRevision);
|
||||||
|
assert.equal(snapshot.devices[0].uploadBytes, '0');
|
||||||
|
assert.equal(snapshot.devices[0].downloadBytes, '0');
|
||||||
|
assert.equal(snapshot.devices[0].proxyUploadBytes, '0');
|
||||||
|
assert.equal(snapshot.devices[0].proxyDownloadBytes, '0');
|
||||||
|
assert.deepEqual(snapshot.devices[0].trafficHistory, []);
|
||||||
|
assert.deepEqual(snapshot.devices[0].outboundTraffic, {
|
||||||
|
observedAt,
|
||||||
|
singboxObservedAt: observedAt,
|
||||||
|
directIpv4ObservedAt: observedAt,
|
||||||
|
vpnBytes: '0',
|
||||||
|
directTrackedBytes: '0',
|
||||||
|
directIpv4Bytes: '0',
|
||||||
|
unknownBytes: '0',
|
||||||
|
});
|
||||||
|
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory, []);
|
||||||
|
assert.deepEqual(snapshot.traffic, globalBeforeReset);
|
||||||
|
assert.deepEqual(store.read().traffic.outboundBaselinesByDeviceId[id], {
|
||||||
|
routeEpoch: 'epoch-a',
|
||||||
|
directEpoch: 'epoch-a',
|
||||||
|
vpnBytes: '400',
|
||||||
|
directTrackedBytes: '80',
|
||||||
|
directIpv4Bytes: '160',
|
||||||
|
unknownBytes: '12',
|
||||||
|
});
|
||||||
|
assert.equal(service.metricsSnapshot().domainTraffic.routes[0].uploadBytes, '150');
|
||||||
|
assert.equal(service.metricsSnapshot().directTraffic.series[0].uploadBytes, '70');
|
||||||
|
await assert.rejects(
|
||||||
|
service.resetTraffic(resetRevision),
|
||||||
|
(error) => error.code === 'STATE_CONFLICT',
|
||||||
|
);
|
||||||
|
|
||||||
observedAt = '2026-08-12T12:00:30.000Z';
|
observedAt = '2026-08-12T12:00:30.000Z';
|
||||||
|
gateway = ['115', '225'];
|
||||||
|
proxyIngress = ['13', '24'];
|
||||||
|
vpn = ['160', '260'];
|
||||||
|
trackedDirect = ['35', '55'];
|
||||||
|
unknown = ['4', '8'];
|
||||||
|
directIpv4 = ['75', '95'];
|
||||||
|
service = createService();
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices[0].uploadBytes, '15');
|
||||||
|
assert.equal(snapshot.devices[0].downloadBytes, '25');
|
||||||
|
assert.equal(snapshot.devices[0].proxyUploadBytes, '3');
|
||||||
|
assert.equal(snapshot.devices[0].proxyDownloadBytes, '4');
|
||||||
|
assert.deepEqual(snapshot.devices[0].outboundTraffic, {
|
||||||
|
observedAt,
|
||||||
|
singboxObservedAt: observedAt,
|
||||||
|
directIpv4ObservedAt: observedAt,
|
||||||
|
vpnBytes: '20',
|
||||||
|
directTrackedBytes: '10',
|
||||||
|
directIpv4Bytes: '10',
|
||||||
|
unknownBytes: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
observedAt = '2026-08-12T12:00:45.000Z';
|
||||||
epoch = 'epoch-b';
|
epoch = 'epoch-b';
|
||||||
vpn = ['3', '4'];
|
vpn = ['3', '4'];
|
||||||
trackedDirect = ['1', '2'];
|
trackedDirect = ['1', '2'];
|
||||||
|
|||||||
@@ -34,9 +34,13 @@ function createHarness({ inventory = {}, body = {} } = {}) {
|
|||||||
calls.push(['update', ...args]);
|
calls.push(['update', ...args]);
|
||||||
return inventory.update ?? { revision: 3 };
|
return inventory.update ?? { revision: 3 };
|
||||||
},
|
},
|
||||||
|
resetTraffic: async (...args) => {
|
||||||
|
calls.push(['resetTraffic', ...args]);
|
||||||
|
return inventory.resetTraffic ?? { revision: 4 };
|
||||||
|
},
|
||||||
setPolicy: async (...args) => {
|
setPolicy: async (...args) => {
|
||||||
calls.push(['setPolicy', ...args]);
|
calls.push(['setPolicy', ...args]);
|
||||||
return inventory.setPolicy ?? { revision: 4 };
|
return inventory.setPolicy ?? { revision: 5 };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const route = createDeviceInventoryRoute({
|
const route = createDeviceInventoryRoute({
|
||||||
@@ -97,13 +101,23 @@ test('device route forwards metadata patch and policy arguments without coercion
|
|||||||
url: `/api/devices/${deviceId}/policy?source=ui`,
|
url: `/api/devices/${deviceId}/policy?source=ui`,
|
||||||
}, policyResponse), true);
|
}, policyResponse), true);
|
||||||
assert.deepEqual(policy.calls, [['setPolicy', deviceId, 42, '8']]);
|
assert.deepEqual(policy.calls, [['setPolicy', deviceId, 42, '8']]);
|
||||||
assert.deepEqual(policyResponse.payload, { revision: 4 });
|
assert.deepEqual(policyResponse.payload, { revision: 5 });
|
||||||
|
|
||||||
|
const reset = createHarness({ body: { expectedRevision: '9', ignored: true } });
|
||||||
|
const resetResponse = response();
|
||||||
|
assert.equal(await reset.route.handle({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/devices/traffic?source=ui',
|
||||||
|
}, resetResponse), true);
|
||||||
|
assert.deepEqual(reset.calls, [['resetTraffic', '9']]);
|
||||||
|
assert.deepEqual(resetResponse.payload, { revision: 4 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('device route preserves endpoint gating and strict lowercase IDs', async () => {
|
test('device route preserves endpoint gating and strict lowercase IDs', async () => {
|
||||||
for (const [method, url] of [
|
for (const [method, url] of [
|
||||||
['POST', '/api/devices'],
|
['POST', '/api/devices'],
|
||||||
['GET', '/api/devices/refresh'],
|
['GET', '/api/devices/refresh'],
|
||||||
|
['GET', '/api/devices/traffic'],
|
||||||
['GET', `/api/devices/${deviceId}`],
|
['GET', `/api/devices/${deviceId}`],
|
||||||
['POST', `/api/devices/${deviceId}/policy`],
|
['POST', `/api/devices/${deviceId}/policy`],
|
||||||
]) {
|
]) {
|
||||||
@@ -130,6 +144,7 @@ test('device route preserves endpoint gating and strict lowercase IDs', async ()
|
|||||||
for (const [method, url] of [
|
for (const [method, url] of [
|
||||||
['GET', '/api/devices'],
|
['GET', '/api/devices'],
|
||||||
['POST', '/api/devices/refresh'],
|
['POST', '/api/devices/refresh'],
|
||||||
|
['DELETE', '/api/devices/traffic'],
|
||||||
['PUT', `/api/devices/${deviceId}`],
|
['PUT', `/api/devices/${deviceId}`],
|
||||||
['PUT', `/api/devices/${deviceId}/policy`],
|
['PUT', `/api/devices/${deviceId}/policy`],
|
||||||
]) {
|
]) {
|
||||||
@@ -149,6 +164,7 @@ test('device route propagates synchronous and asynchronous service errors unchan
|
|||||||
snapshot: () => { throw syncError; },
|
snapshot: () => { throw syncError; },
|
||||||
refresh: async () => ({}),
|
refresh: async () => ({}),
|
||||||
update: () => ({}),
|
update: () => ({}),
|
||||||
|
resetTraffic: async () => ({}),
|
||||||
setPolicy: async () => ({}),
|
setPolicy: async () => ({}),
|
||||||
},
|
},
|
||||||
readBody: async () => ({}),
|
readBody: async () => ({}),
|
||||||
@@ -164,6 +180,7 @@ test('device route propagates synchronous and asynchronous service errors unchan
|
|||||||
snapshot: () => ({}),
|
snapshot: () => ({}),
|
||||||
refresh: async () => { throw asyncError; },
|
refresh: async () => { throw asyncError; },
|
||||||
update: () => ({}),
|
update: () => ({}),
|
||||||
|
resetTraffic: async () => ({}),
|
||||||
setPolicy: async () => ({}),
|
setPolicy: async () => ({}),
|
||||||
},
|
},
|
||||||
readBody: async () => ({}),
|
readBody: async () => ({}),
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
|
|||||||
}],
|
}],
|
||||||
[() => api.devices.list(), '/api/devices', {}],
|
[() => api.devices.list(), '/api/devices', {}],
|
||||||
[() => api.devices.refresh(), '/api/devices/refresh', { method: 'POST' }],
|
[() => api.devices.refresh(), '/api/devices/refresh', { method: 'POST' }],
|
||||||
|
[() => api.devices.resetTraffic(8), '/api/devices/traffic', {
|
||||||
|
method: 'DELETE', body: JSON.stringify({ expectedRevision: 8 }),
|
||||||
|
}],
|
||||||
[() => api.devices.update('dev_1', { alias: 'TV' }, 8), '/api/devices/dev_1', {
|
[() => api.devices.update('dev_1', { alias: 'TV' }, 8), '/api/devices/dev_1', {
|
||||||
method: 'PUT', body: JSON.stringify({ alias: 'TV', expectedRevision: 8 }),
|
method: 'PUT', body: JSON.stringify({ alias: 'TV', expectedRevision: 8 }),
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const routing = source('features/routing/RoutingFeature.tsx');
|
|||||||
const diagnostics = source('features/diagnostics/ConnectivityDiagnosticsPanel.tsx');
|
const diagnostics = source('features/diagnostics/ConnectivityDiagnosticsPanel.tsx');
|
||||||
|
|
||||||
test('App owns one stable mapping from typed transport to component actions', () => {
|
test('App owns one stable mapping from typed transport to component actions', () => {
|
||||||
assert.match(app, /const componentActions = \{[\s\S]*listDevices: api\.devices\.list[\s\S]*refreshDevices: api\.devices\.refresh[\s\S]*updateDevice: api\.devices\.update[\s\S]*setDevicePolicy: api\.devices\.setPolicy[\s\S]*pingServers: api\.servers\.ping[\s\S]*runConnectivityDiagnostics: api\.diagnostics\.connectivity[\s\S]*\};/);
|
assert.match(app, /const componentActions = \{[\s\S]*listDevices: api\.devices\.list[\s\S]*refreshDevices: api\.devices\.refresh[\s\S]*resetDeviceTraffic: api\.devices\.resetTraffic[\s\S]*updateDevice: api\.devices\.update[\s\S]*setDevicePolicy: api\.devices\.setPolicy[\s\S]*pingServers: api\.servers\.ping[\s\S]*runConnectivityDiagnostics: api\.diagnostics\.connectivity[\s\S]*\};/);
|
||||||
assert.doesNotMatch(app, /validateSubscription: api\.subscription\.validate/);
|
assert.doesNotMatch(app, /validateSubscription: api\.subscription\.validate/);
|
||||||
assert.equal((app.match(/actions=\{componentActions\}/g) || []).length, 1);
|
assert.equal((app.match(/actions=\{componentActions\}/g) || []).length, 1);
|
||||||
assert.doesNotMatch(app, /componentActions\s*=\s*useMemo|componentActions\s*=\s*\([^)]*\)\s*=>/);
|
assert.doesNotMatch(app, /componentActions\s*=\s*useMemo|componentActions\s*=\s*\([^)]*\)\s*=>/);
|
||||||
@@ -25,6 +25,7 @@ test('presentational components use only injected narrow actions', () => {
|
|||||||
assert.match(subscription, /isSubscriptionUrlValid\(normalizedUrl\)/);
|
assert.match(subscription, /isSubscriptionUrlValid\(normalizedUrl\)/);
|
||||||
assert.doesNotMatch(subscription, /validateSubscription\(|AbortController/);
|
assert.doesNotMatch(subscription, /validateSubscription\(|AbortController/);
|
||||||
assert.match(overview, /refreshDevices: actions\.refreshDevices/);
|
assert.match(overview, /refreshDevices: actions\.refreshDevices/);
|
||||||
|
assert.match(overview, /resetDeviceTraffic: actions\.resetDeviceTraffic/);
|
||||||
assert.match(deviceFeature, /discover \? refreshDevices\(\) : listDevices\(\)/);
|
assert.match(deviceFeature, /discover \? refreshDevices\(\) : listDevices\(\)/);
|
||||||
assert.match(overview, /<ServerPicker[\s\S]*pingServers=\{actions\.pingServers\}/);
|
assert.match(overview, /<ServerPicker[\s\S]*pingServers=\{actions\.pingServers\}/);
|
||||||
assert.match(overview, /useDevicesFeature\(\{[\s\S]*listDevices: actions\.listDevices[\s\S]*refreshDevices: actions\.refreshDevices[\s\S]*updateDevice: actions\.updateDevice[\s\S]*setDevicePolicy: actions\.setDevicePolicy/);
|
assert.match(overview, /useDevicesFeature\(\{[\s\S]*listDevices: actions\.listDevices[\s\S]*refreshDevices: actions\.refreshDevices[\s\S]*updateDevice: actions\.updateDevice[\s\S]*setDevicePolicy: actions\.setDevicePolicy/);
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.doesNotMatch(panel, /revision >= current\.revision/);
|
assert.doesNotMatch(panel, /revision >= current\.revision/);
|
||||||
assert.match(panel, /prefers-reduced-motion: reduce/);
|
assert.match(panel, /prefers-reduced-motion: reduce/);
|
||||||
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
||||||
|
assert.match(api, /resetTraffic:[\s\S]*request\('\/api\/devices\/traffic'[\s\S]*method: 'DELETE'/);
|
||||||
assert.match(api, /setPolicy:[\s\S]*`\/api\/devices\/\$\{id\}\/policy`/);
|
assert.match(api, /setPolicy:[\s\S]*`\/api\/devices\/\$\{id\}\/policy`/);
|
||||||
assert.match(server, /createDeviceInventoryRoute\(\{/);
|
assert.match(server, /createDeviceInventoryRoute\(\{/);
|
||||||
assert.match(deviceRoute, /pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
|
assert.match(deviceRoute, /pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
|
||||||
|
assert.match(deviceRoute, /pathname === '\/api\/devices\/traffic'[\s\S]*deviceInventory\.resetTraffic/);
|
||||||
assert.match(deviceRoute, /DEVICE_POLICY_PATH[\s\S]*deviceInventory\.setPolicy/);
|
assert.match(deviceRoute, /DEVICE_POLICY_PATH[\s\S]*deviceInventory\.setPolicy/);
|
||||||
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
||||||
assert.match(panel, /copyText\(value\)/);
|
assert.match(panel, /copyText\(value\)/);
|
||||||
@@ -198,6 +200,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(styles, /\.client-device-traffic-plot svg \{[\s\S]*height: 100%;[\s\S]*overflow: hidden/);
|
assert.match(styles, /\.client-device-traffic-plot svg \{[\s\S]*height: 100%;[\s\S]*overflow: hidden/);
|
||||||
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
|
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
|
||||||
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
||||||
|
assert.match(panel, /className="client-devices-reset"[\s\S]*Сбросить данные/);
|
||||||
|
assert.match(styles, /\.client-devices-reset \{[\s\S]*oklch\(0\.68 0\.15 28\)[\s\S]*\.client-devices-reset:hover:not\(:disabled\) svg[\s\S]*rotate\(-360deg\)/);
|
||||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-identity-details[\s\S]*\.client-device-identity-copy[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value[\s\S]*transition: none;[\s\S]*animation: none/);
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-identity-details[\s\S]*\.client-device-identity-copy[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value[\s\S]*transition: none;[\s\S]*animation: none/);
|
||||||
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
|
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
|
||||||
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ test('devices feature is the sole public owner and legacy component paths are go
|
|||||||
assert.equal((page.match(/<DevicesToggle/g) || []).length, 1);
|
assert.equal((page.match(/<DevicesToggle/g) || []).length, 1);
|
||||||
assert.equal((page.match(/<GatewayTrafficSummary/g) || []).length, 1);
|
assert.equal((page.match(/<GatewayTrafficSummary/g) || []).length, 1);
|
||||||
assert.equal((page.match(/<DevicesPanel/g) || []).length, 1);
|
assert.equal((page.match(/<DevicesPanel/g) || []).length, 1);
|
||||||
assert.match(page, /useDevicesFeature\(\{[\s\S]*listDevices: actions\.listDevices[\s\S]*refreshDevices: actions\.refreshDevices[\s\S]*updateDevice: actions\.updateDevice[\s\S]*setDevicePolicy: actions\.setDevicePolicy[\s\S]*\}\)/);
|
assert.match(page, /useDevicesFeature\(\{[\s\S]*listDevices: actions\.listDevices[\s\S]*refreshDevices: actions\.refreshDevices[\s\S]*resetDeviceTraffic: actions\.resetDeviceTraffic[\s\S]*updateDevice: actions\.updateDevice[\s\S]*setDevicePolicy: actions\.setDevicePolicy[\s\S]*\}\)/);
|
||||||
assert.match(page, /<DevicesPanel feature=\{devicesFeature\} \/>/);
|
assert.match(page, /<DevicesPanel feature=\{devicesFeature\} \/>/);
|
||||||
assert.doesNotMatch(page, /DEVICE_AUTO_REFRESH_MS|deviceSnapshot|deviceStatus|deviceError|devicesRefreshing|deviceRefreshCycle|devicesPanelRef|devicesToggleRef|devicesCloseRef|function loadDevices|client-devices-toggle|className="client-gateway-summary"/);
|
assert.doesNotMatch(page, /DEVICE_AUTO_REFRESH_MS|deviceSnapshot|deviceStatus|deviceError|devicesRefreshing|deviceRefreshCycle|devicesPanelRef|devicesToggleRef|devicesCloseRef|function loadDevices|client-devices-toggle|className="client-gateway-summary"/);
|
||||||
assert.doesNotMatch([feature, panel].join('\n'), /from ['"][^'"]*\/api\/|ConnectionPanel|SubscriptionFeature|RoutingFeature|DiagnosticsPanel/);
|
assert.doesNotMatch([feature, panel].join('\n'), /from ['"][^'"]*\/api\/|ConnectionPanel|SubscriptionFeature|RoutingFeature|DiagnosticsPanel/);
|
||||||
@@ -34,6 +34,9 @@ test('device controller preserves Gateway-only polling, monotonic publication an
|
|||||||
assert.match(feature, /setSnapshot\(\(current\) => !current \|\| next\.revision > current\.revision \? next : current\)/);
|
assert.match(feature, /setSnapshot\(\(current\) => !current \|\| next\.revision > current\.revision \? next : current\)/);
|
||||||
assert.match(feature, /finally \{[\s\S]*setRefreshing\(false\)[\s\S]*setRefreshCycle/);
|
assert.match(feature, /finally \{[\s\S]*setRefreshing\(false\)[\s\S]*setRefreshCycle/);
|
||||||
assert.match(feature, /closeRef\.current\?\.focus\(\)[\s\S]*panelRef\.current\?\.contains[\s\S]*toggleRef\.current\?\.focus\(\)/);
|
assert.match(feature, /closeRef\.current\?\.focus\(\)[\s\S]*panelRef\.current\?\.contains[\s\S]*toggleRef\.current\?\.focus\(\)/);
|
||||||
|
assert.match(feature, /resetOpen[\s\S]*if \(resetOpen\) return/);
|
||||||
|
assert.match(feature, /resetDeviceTraffic\(snapshot\.revision\)[\s\S]*STATE_CONFLICT[\s\S]*resetDeviceTraffic\(latest\.revision\)/);
|
||||||
|
assert.match(panel, /<ConfirmationDialog[\s\S]*title="Сбросить статистику\?"[\s\S]*confirmLabel="Сбросить"[\s\S]*busy=\{resetting\}/);
|
||||||
assert.match(page, /<DevicesToggle[\s\S]*devicesFeature\.toggle\(\)/);
|
assert.match(page, /<DevicesToggle[\s\S]*devicesFeature\.toggle\(\)/);
|
||||||
assert.match(page, /<GatewayTrafficSummary feature=\{devicesFeature\} now=\{now\}/);
|
assert.match(page, /<GatewayTrafficSummary feature=\{devicesFeature\} now=\{now\}/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,26 +37,26 @@ const expectedImports = [
|
|||||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||||
const acceptedLedger = {
|
const acceptedLedger = {
|
||||||
counts: {
|
counts: {
|
||||||
cascadeEdges: 813,
|
cascadeEdges: 815,
|
||||||
customProperties: 103,
|
customProperties: 103,
|
||||||
declarations: 3270,
|
declarations: 3299,
|
||||||
important: 0,
|
important: 0,
|
||||||
keyframes: 56,
|
keyframes: 56,
|
||||||
media: 13,
|
media: 13,
|
||||||
rules: 941,
|
rules: 947,
|
||||||
variableReferences: 812,
|
variableReferences: 815,
|
||||||
},
|
},
|
||||||
hashes: {
|
hashes: {
|
||||||
cascadeEdges: '38c9f42cdc5839efbfc06fd12e21d943742fd71a7170b239059c9041e675697d',
|
cascadeEdges: '8ed02f069be17d132a23eed9e9480b31fa583f91771dffbe4d44b485042035e0',
|
||||||
customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
|
customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
|
||||||
declarations: '8ed706131420b5e15efcaaffa64282d4d44e6bcf8473938c16ec55f375013661',
|
declarations: '0f20800db4323aac387f740f9b55ef8d7b3b2e78bac8765941dbda4f1e832499',
|
||||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||||
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
|
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
|
||||||
keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce',
|
keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce',
|
||||||
ruleDeclarationSequences: 'f054d14a153e93f07aebc28ada717e004ac8618b9f766d4701cc748a271fad9a',
|
ruleDeclarationSequences: 'bc194eaaf4351ea21cefa140fe084e8a086720dc0fc1b0159dad623e76a0d4e6',
|
||||||
selectors: '4e8f1d8af4830eb6ca40a5b94d3b1b234817f22eb7073c643b4773ee36f8249c',
|
selectors: 'ddb869866dc84197f7a6bef2e8d07310ec2e9fcc6822b18952de800f4c5d0d59',
|
||||||
variableReferences: 'd3a740a583156df0b42ce0f52687617a5b5507b394251070c63d355560921f03',
|
variableReferences: '2ccbb06fe9f4d0e00dc40aa117add2bed00321b92beee7c581436ecc8c9da4ca',
|
||||||
witnesses: '834f4fcda87e58c8cd49011d6d8f3e88393c09a88b1d255f3e8d43ccb00ea3e6',
|
witnesses: 'd8f04b8250b68423a593cb471e535712cb5b9f7d85d4ebd8d34c44f2855432da',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
|||||||
|
|
||||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||||
const witnesses = readStyleWitnesses(root);
|
const witnesses = readStyleWitnesses(root);
|
||||||
assert.equal(witnesses.length, 787);
|
assert.equal(witnesses.length, 807);
|
||||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||||
@@ -405,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
|||||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||||
|
|
||||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||||
assert.deepEqual(assets, ['index-BT8lRf1r.css']);
|
assert.deepEqual(assets, ['index-Cen_yNNI.css']);
|
||||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||||
assert.equal(built.byteLength, 126715);
|
assert.equal(built.byteLength, 127660);
|
||||||
assert.equal(sha256(built), '0d1d0c03b2d89265a373ebd8e80aa5f5b2a37bb1dd4d5cb350802dcb7ddf673a');
|
assert.equal(sha256(built), 'f0f09ebbe31597130121828157e9e68fc77141db5975d32f9e3062b70e5f894b');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user