Add device traffic reset with outbound baselines
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-12 23:55:34 +03:00
parent 08cc013def
commit b86812d02b
18 changed files with 369 additions and 33 deletions
+1
View File
@@ -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,
+4
View File
@@ -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,
});
+34 -1
View File
@@ -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),
};
+31 -2
View File
@@ -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}
/>
</>;
}
+50 -1
View File
@@ -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;
+2
View File
@@ -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,