Add device traffic reset with outbound baselines
This commit is contained in:
@@ -7,6 +7,7 @@ interface DeviceInventoryPort {
|
||||
snapshot(): unknown;
|
||||
refresh(): Promise<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>;
|
||||
}
|
||||
|
||||
@@ -39,6 +40,15 @@ export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDep
|
||||
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);
|
||||
if (deviceMatch) {
|
||||
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||
|
||||
@@ -89,6 +89,7 @@ interface InventoryTrafficState {
|
||||
baselinesByMac: Record<string, CounterBaseline>;
|
||||
totalsByMac: Record<string, TrafficTotal>;
|
||||
rebaselineMacs: string[];
|
||||
outboundBaselinesByDeviceId: Record<string, OutboundTrafficBaseline>;
|
||||
proxy: ProxyTrafficState;
|
||||
global: { gateway: GlobalTrafficSource; proxy: GlobalTrafficSource };
|
||||
[key: string]: unknown;
|
||||
@@ -164,6 +165,15 @@ interface OutboundTrafficCursor {
|
||||
unknown: bigint;
|
||||
}
|
||||
|
||||
interface OutboundTrafficBaseline {
|
||||
routeEpoch: string;
|
||||
directEpoch: string;
|
||||
vpnBytes: string;
|
||||
directTrackedBytes: string;
|
||||
directIpv4Bytes: string;
|
||||
unknownBytes: string;
|
||||
}
|
||||
|
||||
interface DeviceObservation {
|
||||
mac: string;
|
||||
ip: string;
|
||||
@@ -250,6 +260,7 @@ const DEFAULT_STATE: InventoryState = {
|
||||
baselinesByMac: {},
|
||||
totalsByMac: {},
|
||||
rebaselineMacs: [],
|
||||
outboundBaselinesByDeviceId: {},
|
||||
proxy: DEFAULT_PROXY_TRAFFIC,
|
||||
global: {
|
||||
gateway: DEFAULT_GLOBAL_TRAFFIC_SOURCE,
|
||||
@@ -599,6 +610,32 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
||||
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 = {
|
||||
gateway: normalizeGlobalTrafficSource(record(traffic.global).gateway, {
|
||||
epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null,
|
||||
@@ -630,6 +667,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
||||
baselinesByMac,
|
||||
totalsByMac,
|
||||
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
|
||||
outboundBaselinesByDeviceId,
|
||||
proxy: proxyTraffic,
|
||||
global,
|
||||
},
|
||||
@@ -837,15 +875,28 @@ export function createDeviceInventoryService({
|
||||
const total = totalFor(device.id);
|
||||
const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total };
|
||||
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);
|
||||
if (observedAt) outboundTrafficByDeviceId.set(device.id, {
|
||||
observedAt,
|
||||
singboxObservedAt: routeObservedAt || null,
|
||||
directIpv4ObservedAt: directObservedAt || null,
|
||||
vpnBytes: total.vpn.toString(),
|
||||
directTrackedBytes: total.directTracked.toString(),
|
||||
directIpv4Bytes: total.directIpv4.toString(),
|
||||
unknownBytes: total.unknown.toString(),
|
||||
vpnBytes: visible.vpn.toString(),
|
||||
directTrackedBytes: visible.directTracked.toString(),
|
||||
directIpv4Bytes: visible.directIpv4.toString(),
|
||||
unknownBytes: visible.unknown.toString(),
|
||||
});
|
||||
if (!previous || previous.signature === signature || !observedAt) continue;
|
||||
const routeDelta = (value: bigint, before: bigint) => (
|
||||
@@ -1542,6 +1593,70 @@ export function createDeviceInventoryService({
|
||||
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) {
|
||||
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision)
|
||||
|| expectedRevision < 0 || !POLICY_MODES.has(mode)) {
|
||||
@@ -1585,5 +1700,5 @@ export function createDeviceInventoryService({
|
||||
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({
|
||||
macClient: '0.25.5',
|
||||
gatewayClient: '0.26.4',
|
||||
gatewayBackend: '0.26.4',
|
||||
macClient: '0.25.6',
|
||||
gatewayClient: '0.26.5',
|
||||
gatewayBackend: '0.26.5',
|
||||
});
|
||||
|
||||
export interface ParsedVersion {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
const componentActions = {
|
||||
listDevices: api.devices.list,
|
||||
refreshDevices: api.devices.refresh,
|
||||
resetDeviceTraffic: api.devices.resetTraffic,
|
||||
updateDevice: api.devices.update,
|
||||
setDevicePolicy: api.devices.setPolicy,
|
||||
pingServers: api.servers.ping,
|
||||
|
||||
@@ -156,6 +156,10 @@ export const api = {
|
||||
devices: {
|
||||
list: () => request('/api/devices'),
|
||||
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(
|
||||
`/api/devices/${id}`,
|
||||
{
|
||||
|
||||
@@ -83,6 +83,7 @@ interface VersionBadgeProps {
|
||||
interface ComponentActions {
|
||||
listDevices: () => Promise<unknown>;
|
||||
refreshDevices: () => Promise<unknown>;
|
||||
resetDeviceTraffic: (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>;
|
||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||
@@ -505,6 +506,7 @@ export function ClientOverviewPage({
|
||||
isGateway,
|
||||
listDevices: actions.listDevices,
|
||||
refreshDevices: actions.refreshDevices,
|
||||
resetDeviceTraffic: actions.resetDeviceTraffic,
|
||||
updateDevice: actions.updateDevice,
|
||||
setDevicePolicy: actions.setDevicePolicy,
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ interface DevicesFeatureOptions {
|
||||
isGateway: boolean;
|
||||
listDevices: () => Promise<unknown>;
|
||||
refreshDevices: () => Promise<unknown>;
|
||||
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
||||
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||
setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise<unknown>;
|
||||
}
|
||||
@@ -40,6 +41,7 @@ export function useDevicesFeature({
|
||||
isGateway,
|
||||
listDevices,
|
||||
refreshDevices,
|
||||
resetDeviceTraffic,
|
||||
updateDevice: requestDeviceUpdate,
|
||||
setDevicePolicy,
|
||||
}: DevicesFeatureOptions) {
|
||||
@@ -50,6 +52,8 @@ export function useDevicesFeature({
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshCycle, setRefreshCycle] = useState(0);
|
||||
const [savingId, setSavingId] = useState('');
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = 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(() => {
|
||||
if (!isGateway) return undefined;
|
||||
load();
|
||||
@@ -152,6 +179,7 @@ export function useDevicesFeature({
|
||||
if (!isOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||
const closeDevices = (event: PointerEvent | KeyboardEvent) => {
|
||||
if (resetOpen) return;
|
||||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||||
if (event.type !== 'keydown' && (
|
||||
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();
|
||||
});
|
||||
};
|
||||
}, [isOpen]);
|
||||
}, [isOpen, resetOpen]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
@@ -178,12 +206,17 @@ export function useDevicesFeature({
|
||||
refreshing,
|
||||
refreshCycle,
|
||||
savingId,
|
||||
resetOpen,
|
||||
resetting,
|
||||
panelRef,
|
||||
toggleRef,
|
||||
closeRef,
|
||||
load,
|
||||
updateDevice,
|
||||
updatePolicy,
|
||||
requestTrafficReset: () => setResetOpen(true),
|
||||
cancelTrafficReset: () => setResetOpen(false),
|
||||
confirmResetTraffic,
|
||||
close: () => setIsOpen(false),
|
||||
toggle: () => setIsOpen((open) => !open),
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import { copyText } from '../../utils/clientControls.js';
|
||||
import {
|
||||
@@ -70,9 +71,14 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
refreshing,
|
||||
refreshCycle,
|
||||
savingId,
|
||||
resetOpen,
|
||||
resetting,
|
||||
load: onLoad,
|
||||
updateDevice,
|
||||
updatePolicy,
|
||||
requestTrafficReset,
|
||||
cancelTrafficReset,
|
||||
confirmResetTraffic,
|
||||
close: onClose,
|
||||
} = feature;
|
||||
const [editingId, setEditingId] = useState('');
|
||||
@@ -278,7 +284,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
return <>
|
||||
<Drawer
|
||||
panelRef={panelRef}
|
||||
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 === 'log'} onClick={() => setTrafficScale('log')}>Лог</button>
|
||||
</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>
|
||||
<h2 id="client-devices-title">Устройства</h2>
|
||||
<div className="client-instructions-intro">
|
||||
@@ -623,5 +640,17 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
})}
|
||||
</div>
|
||||
</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 {
|
||||
width: max-content;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -66,6 +67,54 @@
|
||||
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 {
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
|
||||
@@ -83,6 +83,8 @@
|
||||
.client-device-edit svg,
|
||||
.client-device-edit-wrap,
|
||||
.client-devices-refresh,
|
||||
.client-devices-reset,
|
||||
.client-devices-reset svg,
|
||||
.client-devices-sort,
|
||||
.client-devices-sort-icon,
|
||||
.client-devices-refresh-ring circle,
|
||||
|
||||
Reference in New Issue
Block a user