Add persistent device traffic history charts
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-07 19:34:59 +03:00
parent cf90fd5b4e
commit ef4847a3e1
7 changed files with 174 additions and 45 deletions
+35 -1
View File
@@ -9,6 +9,7 @@ export const DEVICE_INVENTORY_SCHEMA_VERSION = 2;
const ONLINE_MS = 2 * 60 * 1000;
const RECENT_MS = 24 * 60 * 60 * 1000;
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const TRAFFIC_HISTORY_LIMIT = 120;
const COUNTER_PATTERN = /^\d+$/;
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
@@ -302,6 +303,36 @@ export function createDeviceInventoryService({
}) {
let refreshPromise = null;
let policyQueue = Promise.resolve();
const trafficHistoryByMac = new Map();
const trafficCursorByMac = new Map();
function captureTrafficHistory(state) {
const knownMacs = new Set(state.devices.map(({ mac }) => mac));
for (const device of state.devices) {
const traffic = state.traffic.totalsByMac[device.mac];
const proxy = state.traffic.proxy.totalsByMac[device.mac];
const signature = `${traffic?.observedAt || ''}|${proxy?.observedAt || ''}`;
if (signature === '|') continue;
const gateway = BigInt(traffic?.uploadBytes || '0') + BigInt(traffic?.downloadBytes || '0');
const proxyTotal = BigInt(proxy?.uploadBytes || '0') + BigInt(proxy?.downloadBytes || '0');
const previous = trafficCursorByMac.get(device.mac);
trafficCursorByMac.set(device.mac, { signature, gateway, proxy: proxyTotal });
if (!previous || previous.signature === signature) continue;
const observedAt = [traffic?.observedAt, proxy?.observedAt].filter(Boolean).sort().at(-1);
const samples = trafficHistoryByMac.get(device.mac) || [];
trafficHistoryByMac.set(device.mac, [...samples, {
observedAt,
gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0',
proxyBytes: proxyTotal > previous.proxy ? (proxyTotal - previous.proxy).toString() : '0',
}].slice(-TRAFFIC_HISTORY_LIMIT));
}
for (const mac of trafficCursorByMac.keys()) {
if (!knownMacs.has(mac)) {
trafficCursorByMac.delete(mac);
trafficHistoryByMac.delete(mac);
}
}
}
function serializePolicy(action) {
const result = policyQueue.then(action, action);
@@ -363,6 +394,7 @@ export function createDeviceInventoryService({
proxyUploadBytes: proxyTraffic?.uploadBytes || '0',
proxyDownloadBytes: proxyTraffic?.downloadBytes || '0',
proxyTrafficObservedAt: proxyTraffic?.observedAt || null,
trafficHistory: trafficHistoryByMac.get(device.mac) || [],
desiredPolicy: policy.desired,
appliedPolicy: policy.applied,
policyStatus: policy.status,
@@ -376,6 +408,7 @@ export function createDeviceInventoryService({
));
return {
revision: state.revision,
trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT,
source: {
kind: 'neighbor',
lastObservedAt: state.lastObservedAt,
@@ -534,7 +567,7 @@ export function createDeviceInventoryService({
identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`);
}
return serializePolicy(async () => {
store.update((stored) => {
const nextState = store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
for (const observation of observations) {
@@ -752,6 +785,7 @@ export function createDeviceInventoryService({
devices,
};
});
captureTrafficHistory(nextState);
if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError));
return reconcileLocked(policyResult, false);
});
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.13.6',
gatewayClient: '0.14.6',
gatewayBackend: '0.14.0',
macClient: '0.14.0',
gatewayClient: '0.15.0',
gatewayBackend: '0.15.0',
});
export function parseVersion(value) {
+38 -27
View File
@@ -14,7 +14,6 @@ const AUTO_REFRESH_MS = 15_000;
const DEVICE_MOVE_MS = 520;
const COPY_FEEDBACK_MS = 5_000;
const TRAFFIC_DELTA_MS = 2_200;
const TRAFFIC_HISTORY_LIMIT = 18;
function Tooltip({ children }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
@@ -36,24 +35,42 @@ function TrafficValue({ value, delta }) {
</strong>;
}
function TrafficChart({ samples }) {
function chartTime(value) {
return new Date(value).toLocaleTimeString('ru-RU', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
});
}
function TrafficChart({ samples, scale, capacity }) {
const max = samples.reduce((largest, sample) => {
const total = byteString(sample.gateway) + byteString(sample.proxy);
const total = byteString(sample.gatewayBytes) + byteString(sample.proxyBytes);
return total > largest ? total : largest;
}, 0n);
const latest = samples.at(-1)?.id || 'empty';
return <span className="client-device-traffic-chart" aria-hidden="true">
<span className="client-device-traffic-track" key={latest}>
{samples.map((sample) => {
const gateway = byteString(sample.gateway);
const proxy = byteString(sample.proxy);
const { height, gatewayShare } = trafficSampleMetrics(gateway, proxy, max);
return <i className="client-device-traffic-bar" key={sample.id} style={{ height: `${height}%` }}>
const latest = samples.at(-1)?.observedAt || 'empty';
return <span className="client-device-traffic-chart" role="img" aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}`}>
<span className="client-device-traffic-track" key={latest} style={{ '--sample-count': Math.max(1, capacity) }}>
{samples.map((sample, index) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
const total = gateway + proxy;
const time = chartTime(sample.observedAt);
const { height, gatewayShare } = trafficSampleMetrics(gateway, proxy, max, scale);
return <i
className="client-device-traffic-bar"
key={`${sample.observedAt}-${index}`}
style={{ height: `${height}%`, gridColumnStart: capacity - samples.length + index + 1 }}
title={`${time} · Всего ${formatByteString(total)} · Gateway ${formatByteString(gateway)} · Прокси ${formatByteString(proxy)}`}
>
<span className="is-gateway" style={{ height: `${gatewayShare}%` }} />
<span className="is-proxy" style={{ height: `${100 - gatewayShare}%` }} />
</i>;
})}
</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>}
</span>;
}
@@ -67,15 +84,14 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const [refreshing, setRefreshing] = useState(false);
const [refreshCycle, setRefreshCycle] = useState(0);
const [sortDirection, setSortDirection] = useState('desc');
const [trafficScale, setTrafficScale] = useState('linear');
const [copyFeedback, setCopyFeedback] = useState(null);
const [trafficDeltas, setTrafficDeltas] = useState({});
const [trafficHistory, setTrafficHistory] = useState({});
const deviceNodes = useRef(new Map());
const previousPositions = useRef(new Map());
const previousTraffic = useRef(new Map());
const copyTimer = useRef(null);
const trafficDeltaTimer = useRef(null);
const trafficSequence = useRef(0);
const devices = useMemo(
() => sortDevicesByTraffic(snapshot?.devices, sortDirection),
[snapshot?.devices, sortDirection],
@@ -125,7 +141,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const next = new Map();
const deltas = {};
const historyChanges = {};
for (const device of snapshot?.devices || []) {
const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
@@ -137,22 +152,10 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
if (!gatewayDelta && !proxyDelta) continue;
const totalDelta = positiveByteDelta(previous.gateway + previous.proxy, gateway + proxy);
deltas[device.id] = { gateway: gatewayDelta, proxy: proxyDelta, total: totalDelta };
historyChanges[device.id] = {
id: ++trafficSequence.current,
gateway: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0',
proxy: proxy > previous.proxy ? (proxy - previous.proxy).toString() : '0',
};
}
previousTraffic.current = next;
if (!Object.keys(deltas).length) return;
setTrafficDeltas(deltas);
setTrafficHistory((current) => {
const updated = { ...current };
for (const [id, change] of Object.entries(historyChanges)) {
updated[id] = [...(current[id] || []), change].slice(-TRAFFIC_HISTORY_LIMIT);
}
return updated;
});
clearTimeout(trafficDeltaTimer.current);
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
}, [snapshot?.devices, open]);
@@ -309,6 +312,10 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
</button>
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
</span>
<span className="client-devices-scale" role="group" aria-label="Масштаб графика трафика">
<button type="button" aria-pressed={trafficScale === 'linear'} onClick={() => setTrafficScale('linear')}>Лин</button>
<button type="button" aria-pressed={trafficScale === 'log'} onClick={() => setTrafficScale('log')}>Лог</button>
</span>
</div>
<h2 id="client-devices-title">Устройства</h2>
<div className="client-instructions-intro">
@@ -496,7 +503,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
</button>
<Tooltip>{policyTooltip}</Tooltip>
</span>
<TrafficChart samples={trafficHistory[device.id] || []} />
<TrafficChart
samples={device.trafficHistory || []}
scale={trafficScale}
capacity={snapshot.trafficHistoryCapacity || device.trafficHistory?.length || 1}
/>
</article>;
})}
</div>
+41 -9
View File
@@ -753,6 +753,28 @@ p {
position: relative;
}
.client-devices-scale {
display: flex;
padding: 2px;
border: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
border-radius: 999px;
}
.client-devices-scale button {
padding: 4px 6px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--client-muted);
font: 700 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
cursor: pointer;
}
.client-devices-scale button[aria-pressed="true"] {
background: color-mix(in oklch, var(--client-accent) 14%, transparent);
color: var(--client-accent);
}
.client-devices-sort {
min-height: 28px;
display: flex;
@@ -902,7 +924,7 @@ p {
.client-device {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 112px 34px;
grid-template-rows: 34px 28px;
grid-template-rows: 34px 42px;
align-items: start;
column-gap: 8px;
row-gap: 6px;
@@ -1302,7 +1324,7 @@ p {
.client-device-traffic-chart {
grid-column: 2 / 4;
grid-row: 2;
height: 28px;
height: 42px;
position: relative;
overflow: hidden;
}
@@ -1310,7 +1332,7 @@ p {
.client-device-traffic-chart::after {
position: absolute;
right: 0;
bottom: 0;
bottom: 13px;
left: 0;
height: 1px;
background: color-mix(in oklch, var(--client-border) 58%, transparent);
@@ -1319,24 +1341,34 @@ p {
.client-device-traffic-track {
position: absolute;
inset: 0 0 1px;
display: flex;
inset: 0 0 14px;
display: grid;
grid-template-columns: repeat(var(--sample-count), minmax(1px, 1fr));
align-items: flex-end;
justify-content: flex-end;
gap: 3px;
gap: 1px;
animation: client-device-traffic-shift 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-device-traffic-bar {
width: 5px;
min-height: 2px;
flex: 0 0 5px;
display: flex;
overflow: hidden;
border-radius: 2px 2px 0 0;
flex-direction: column-reverse;
}
.client-device-traffic-time {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
justify-content: space-between;
color: var(--client-muted);
font-size: 8px;
line-height: 10px;
}
.client-device-traffic-bar > span {
width: 100%;
display: block;
+5 -2
View File
@@ -37,13 +37,16 @@ export function positiveByteDelta(previous, current) {
return after > before ? formatByteString((after - before).toString()) : '';
}
export function trafficSampleMetrics(gatewayValue, proxyValue, maxValue) {
export function trafficSampleMetrics(gatewayValue, proxyValue, maxValue, scale = 'linear') {
const gateway = byteString(gatewayValue);
const proxy = byteString(proxyValue);
const total = gateway + proxy;
const max = byteString(maxValue);
const ratio = scale === 'log'
? Math.log1p(Number(total)) / Math.log1p(Number(max))
: Number(total * 100n / (max || 1n)) / 100;
return {
height: max && total ? Math.max(10, Math.min(100, Number(total * 100n / max))) : 0,
height: max && total ? Math.max(10, Math.min(100, Math.round(ratio * 100))) : 0,
gatewayShare: total ? Number(gateway * 100n / total) : 0,
};
}