Add persistent device traffic history charts
This commit is contained in:
@@ -9,6 +9,7 @@ export const DEVICE_INVENTORY_SCHEMA_VERSION = 2;
|
|||||||
const ONLINE_MS = 2 * 60 * 1000;
|
const ONLINE_MS = 2 * 60 * 1000;
|
||||||
const RECENT_MS = 24 * 60 * 60 * 1000;
|
const RECENT_MS = 24 * 60 * 60 * 1000;
|
||||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const TRAFFIC_HISTORY_LIMIT = 120;
|
||||||
const COUNTER_PATTERN = /^\d+$/;
|
const COUNTER_PATTERN = /^\d+$/;
|
||||||
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
||||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
||||||
@@ -302,6 +303,36 @@ export function createDeviceInventoryService({
|
|||||||
}) {
|
}) {
|
||||||
let refreshPromise = null;
|
let refreshPromise = null;
|
||||||
let policyQueue = Promise.resolve();
|
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) {
|
function serializePolicy(action) {
|
||||||
const result = policyQueue.then(action, action);
|
const result = policyQueue.then(action, action);
|
||||||
@@ -363,6 +394,7 @@ export function createDeviceInventoryService({
|
|||||||
proxyUploadBytes: proxyTraffic?.uploadBytes || '0',
|
proxyUploadBytes: proxyTraffic?.uploadBytes || '0',
|
||||||
proxyDownloadBytes: proxyTraffic?.downloadBytes || '0',
|
proxyDownloadBytes: proxyTraffic?.downloadBytes || '0',
|
||||||
proxyTrafficObservedAt: proxyTraffic?.observedAt || null,
|
proxyTrafficObservedAt: proxyTraffic?.observedAt || null,
|
||||||
|
trafficHistory: trafficHistoryByMac.get(device.mac) || [],
|
||||||
desiredPolicy: policy.desired,
|
desiredPolicy: policy.desired,
|
||||||
appliedPolicy: policy.applied,
|
appliedPolicy: policy.applied,
|
||||||
policyStatus: policy.status,
|
policyStatus: policy.status,
|
||||||
@@ -376,6 +408,7 @@ export function createDeviceInventoryService({
|
|||||||
));
|
));
|
||||||
return {
|
return {
|
||||||
revision: state.revision,
|
revision: state.revision,
|
||||||
|
trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT,
|
||||||
source: {
|
source: {
|
||||||
kind: 'neighbor',
|
kind: 'neighbor',
|
||||||
lastObservedAt: state.lastObservedAt,
|
lastObservedAt: state.lastObservedAt,
|
||||||
@@ -534,7 +567,7 @@ export function createDeviceInventoryService({
|
|||||||
identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`);
|
identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`);
|
||||||
}
|
}
|
||||||
return serializePolicy(async () => {
|
return serializePolicy(async () => {
|
||||||
store.update((stored) => {
|
const nextState = store.update((stored) => {
|
||||||
const state = migrateDeviceInventoryState(stored);
|
const state = migrateDeviceInventoryState(stored);
|
||||||
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||||
for (const observation of observations) {
|
for (const observation of observations) {
|
||||||
@@ -752,6 +785,7 @@ export function createDeviceInventoryService({
|
|||||||
devices,
|
devices,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
captureTrafficHistory(nextState);
|
||||||
if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError));
|
if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError));
|
||||||
return reconcileLocked(policyResult, false);
|
return reconcileLocked(policyResult, false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.13.6',
|
macClient: '0.14.0',
|
||||||
gatewayClient: '0.14.6',
|
gatewayClient: '0.15.0',
|
||||||
gatewayBackend: '0.14.0',
|
gatewayBackend: '0.15.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const AUTO_REFRESH_MS = 15_000;
|
|||||||
const DEVICE_MOVE_MS = 520;
|
const DEVICE_MOVE_MS = 520;
|
||||||
const COPY_FEEDBACK_MS = 5_000;
|
const COPY_FEEDBACK_MS = 5_000;
|
||||||
const TRAFFIC_DELTA_MS = 2_200;
|
const TRAFFIC_DELTA_MS = 2_200;
|
||||||
const TRAFFIC_HISTORY_LIMIT = 18;
|
|
||||||
|
|
||||||
function Tooltip({ children }) {
|
function Tooltip({ children }) {
|
||||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||||
@@ -36,24 +35,42 @@ function TrafficValue({ value, delta }) {
|
|||||||
</strong>;
|
</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 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;
|
return total > largest ? total : largest;
|
||||||
}, 0n);
|
}, 0n);
|
||||||
const latest = samples.at(-1)?.id || 'empty';
|
const latest = samples.at(-1)?.observedAt || 'empty';
|
||||||
return <span className="client-device-traffic-chart" aria-hidden="true">
|
return <span className="client-device-traffic-chart" role="img" aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}`}>
|
||||||
<span className="client-device-traffic-track" key={latest}>
|
<span className="client-device-traffic-track" key={latest} style={{ '--sample-count': Math.max(1, capacity) }}>
|
||||||
{samples.map((sample) => {
|
{samples.map((sample, index) => {
|
||||||
const gateway = byteString(sample.gateway);
|
const gateway = byteString(sample.gatewayBytes);
|
||||||
const proxy = byteString(sample.proxy);
|
const proxy = byteString(sample.proxyBytes);
|
||||||
const { height, gatewayShare } = trafficSampleMetrics(gateway, proxy, max);
|
const total = gateway + proxy;
|
||||||
return <i className="client-device-traffic-bar" key={sample.id} style={{ height: `${height}%` }}>
|
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-gateway" style={{ height: `${gatewayShare}%` }} />
|
||||||
<span className="is-proxy" style={{ height: `${100 - gatewayShare}%` }} />
|
<span className="is-proxy" style={{ height: `${100 - gatewayShare}%` }} />
|
||||||
</i>;
|
</i>;
|
||||||
})}
|
})}
|
||||||
</span>
|
</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>;
|
</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,15 +84,14 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [refreshCycle, setRefreshCycle] = useState(0);
|
const [refreshCycle, setRefreshCycle] = useState(0);
|
||||||
const [sortDirection, setSortDirection] = useState('desc');
|
const [sortDirection, setSortDirection] = useState('desc');
|
||||||
|
const [trafficScale, setTrafficScale] = useState('linear');
|
||||||
const [copyFeedback, setCopyFeedback] = useState(null);
|
const [copyFeedback, setCopyFeedback] = useState(null);
|
||||||
const [trafficDeltas, setTrafficDeltas] = useState({});
|
const [trafficDeltas, setTrafficDeltas] = useState({});
|
||||||
const [trafficHistory, setTrafficHistory] = useState({});
|
|
||||||
const deviceNodes = useRef(new Map());
|
const deviceNodes = useRef(new Map());
|
||||||
const previousPositions = useRef(new Map());
|
const previousPositions = useRef(new Map());
|
||||||
const previousTraffic = useRef(new Map());
|
const previousTraffic = useRef(new Map());
|
||||||
const copyTimer = useRef(null);
|
const copyTimer = useRef(null);
|
||||||
const trafficDeltaTimer = useRef(null);
|
const trafficDeltaTimer = useRef(null);
|
||||||
const trafficSequence = useRef(0);
|
|
||||||
const devices = useMemo(
|
const devices = useMemo(
|
||||||
() => sortDevicesByTraffic(snapshot?.devices, sortDirection),
|
() => sortDevicesByTraffic(snapshot?.devices, sortDirection),
|
||||||
[snapshot?.devices, sortDirection],
|
[snapshot?.devices, sortDirection],
|
||||||
@@ -125,7 +141,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
|
|
||||||
const next = new Map();
|
const next = new Map();
|
||||||
const deltas = {};
|
const deltas = {};
|
||||||
const historyChanges = {};
|
|
||||||
for (const device of snapshot?.devices || []) {
|
for (const device of snapshot?.devices || []) {
|
||||||
const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
|
const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
|
||||||
const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
|
const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
|
||||||
@@ -137,22 +152,10 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
if (!gatewayDelta && !proxyDelta) continue;
|
if (!gatewayDelta && !proxyDelta) continue;
|
||||||
const totalDelta = positiveByteDelta(previous.gateway + previous.proxy, gateway + proxy);
|
const totalDelta = positiveByteDelta(previous.gateway + previous.proxy, gateway + proxy);
|
||||||
deltas[device.id] = { gateway: gatewayDelta, proxy: proxyDelta, total: totalDelta };
|
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;
|
previousTraffic.current = next;
|
||||||
if (!Object.keys(deltas).length) return;
|
if (!Object.keys(deltas).length) return;
|
||||||
setTrafficDeltas(deltas);
|
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);
|
clearTimeout(trafficDeltaTimer.current);
|
||||||
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
|
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
|
||||||
}, [snapshot?.devices, open]);
|
}, [snapshot?.devices, open]);
|
||||||
@@ -309,6 +312,10 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
</button>
|
</button>
|
||||||
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
|
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<h2 id="client-devices-title">Устройства</h2>
|
<h2 id="client-devices-title">Устройства</h2>
|
||||||
<div className="client-instructions-intro">
|
<div className="client-instructions-intro">
|
||||||
@@ -496,7 +503,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
</button>
|
</button>
|
||||||
<Tooltip>{policyTooltip}</Tooltip>
|
<Tooltip>{policyTooltip}</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
<TrafficChart samples={trafficHistory[device.id] || []} />
|
<TrafficChart
|
||||||
|
samples={device.trafficHistory || []}
|
||||||
|
scale={trafficScale}
|
||||||
|
capacity={snapshot.trafficHistoryCapacity || device.trafficHistory?.length || 1}
|
||||||
|
/>
|
||||||
</article>;
|
</article>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+41
-9
@@ -753,6 +753,28 @@ p {
|
|||||||
position: relative;
|
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 {
|
.client-devices-sort {
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -902,7 +924,7 @@ p {
|
|||||||
.client-device {
|
.client-device {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 34px minmax(0, 1fr) 112px 34px;
|
grid-template-columns: 34px minmax(0, 1fr) 112px 34px;
|
||||||
grid-template-rows: 34px 28px;
|
grid-template-rows: 34px 42px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
column-gap: 8px;
|
column-gap: 8px;
|
||||||
row-gap: 6px;
|
row-gap: 6px;
|
||||||
@@ -1302,7 +1324,7 @@ p {
|
|||||||
.client-device-traffic-chart {
|
.client-device-traffic-chart {
|
||||||
grid-column: 2 / 4;
|
grid-column: 2 / 4;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
height: 28px;
|
height: 42px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -1310,7 +1332,7 @@ p {
|
|||||||
.client-device-traffic-chart::after {
|
.client-device-traffic-chart::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 13px;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: color-mix(in oklch, var(--client-border) 58%, transparent);
|
background: color-mix(in oklch, var(--client-border) 58%, transparent);
|
||||||
@@ -1319,24 +1341,34 @@ p {
|
|||||||
|
|
||||||
.client-device-traffic-track {
|
.client-device-traffic-track {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 0 1px;
|
inset: 0 0 14px;
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--sample-count), minmax(1px, 1fr));
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: flex-end;
|
gap: 1px;
|
||||||
gap: 3px;
|
|
||||||
animation: client-device-traffic-shift 420ms cubic-bezier(0.16, 1, 0.3, 1);
|
animation: client-device-traffic-shift 420ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-traffic-bar {
|
.client-device-traffic-bar {
|
||||||
width: 5px;
|
|
||||||
min-height: 2px;
|
min-height: 2px;
|
||||||
flex: 0 0 5px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 2px 2px 0 0;
|
border-radius: 2px 2px 0 0;
|
||||||
flex-direction: column-reverse;
|
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 {
|
.client-device-traffic-bar > span {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -37,13 +37,16 @@ export function positiveByteDelta(previous, current) {
|
|||||||
return after > before ? formatByteString((after - before).toString()) : '';
|
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 gateway = byteString(gatewayValue);
|
||||||
const proxy = byteString(proxyValue);
|
const proxy = byteString(proxyValue);
|
||||||
const total = gateway + proxy;
|
const total = gateway + proxy;
|
||||||
const max = byteString(maxValue);
|
const max = byteString(maxValue);
|
||||||
|
const ratio = scale === 'log'
|
||||||
|
? Math.log1p(Number(total)) / Math.log1p(Number(max))
|
||||||
|
: Number(total * 100n / (max || 1n)) / 100;
|
||||||
return {
|
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,
|
gatewayShare: total ? Number(gateway * 100n / total) : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,51 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
|||||||
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
|
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('device traffic history stays in bounded service memory and survives client snapshots', async (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-history-'));
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const filePath = path.join(directory, 'devices.json');
|
||||||
|
const store = createJsonStore({ filePath, defaultValue: {}, migrate: migrateDeviceInventoryState });
|
||||||
|
const mac = '00:11:22:33:44:55';
|
||||||
|
let observedAt = '2026-08-07T12:00:00.000Z';
|
||||||
|
let uploadBytes = '100';
|
||||||
|
let proxyDownloadBytes = '20';
|
||||||
|
const createService = () => createDeviceInventoryService({
|
||||||
|
store,
|
||||||
|
observe: () => ({
|
||||||
|
observedAt,
|
||||||
|
error: null,
|
||||||
|
observations: [{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true }],
|
||||||
|
}),
|
||||||
|
observeTraffic: () => ({
|
||||||
|
epoch: 'epoch-a',
|
||||||
|
generation: 'rules-a',
|
||||||
|
observedAt,
|
||||||
|
source: { error: null },
|
||||||
|
devices: [{
|
||||||
|
ip: '192.168.50.7', mac, interface: 'eth0', uploadBytes, downloadBytes: '0',
|
||||||
|
proxyUploadBytes: '0', proxyDownloadBytes,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const service = createService();
|
||||||
|
assert.deepEqual((await service.refresh()).devices[0].trafficHistory, []);
|
||||||
|
|
||||||
|
observedAt = '2026-08-07T12:00:15.000Z';
|
||||||
|
uploadBytes = '130';
|
||||||
|
proxyDownloadBytes = '25';
|
||||||
|
const snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.trafficHistoryCapacity, 120);
|
||||||
|
assert.deepEqual(snapshot.devices[0].trafficHistory, [{
|
||||||
|
observedAt,
|
||||||
|
gatewayBytes: '30',
|
||||||
|
proxyBytes: '5',
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(service.snapshot().devices[0].trafficHistory, snapshot.devices[0].trafficHistory);
|
||||||
|
assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /trafficHistory/);
|
||||||
|
assert.deepEqual(createService().snapshot().devices[0].trafficHistory, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('legacy dataplane samples preserve saved proxy totals while Gateway totals keep advancing', async (t) => {
|
test('legacy dataplane samples preserve saved proxy totals while Gateway totals keep advancing', async (t) => {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-proxy-legacy-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-proxy-legacy-'));
|
||||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
|||||||
@@ -52,8 +52,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(panel, /client-device-traffic-total[\s\S]*<b>Всего<\/b><TrafficValue value=\{totalTraffic\} delta=\{trafficDelta\.total\}/);
|
assert.match(panel, /client-device-traffic-total[\s\S]*<b>Всего<\/b><TrafficValue value=\{totalTraffic\} delta=\{trafficDelta\.total\}/);
|
||||||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*<b>Gateway<\/b><TrafficValue value=\{gatewayTraffic\} delta=\{trafficDelta\.gateway\}/);
|
assert.match(panel, /client-device-traffic-breakdown[\s\S]*<b>Gateway<\/b><TrafficValue value=\{gatewayTraffic\} delta=\{trafficDelta\.gateway\}/);
|
||||||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*className="is-proxy"><b>Прокси<\/b><TrafficValue value=\{proxyTraffic\} delta=\{trafficDelta\.proxy\}/);
|
assert.match(panel, /client-device-traffic-breakdown[\s\S]*className="is-proxy"><b>Прокси<\/b><TrafficValue value=\{proxyTraffic\} delta=\{trafficDelta\.proxy\}/);
|
||||||
assert.match(panel, /TRAFFIC_HISTORY_LIMIT = 18[\s\S]*slice\(-TRAFFIC_HISTORY_LIMIT\)/);
|
assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\.trafficHistoryCapacity/);
|
||||||
assert.match(panel, /TrafficChart samples=\{trafficHistory\[device\.id\] \|\| \[\]\}/);
|
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
|
||||||
|
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
|
||||||
|
assert.match(panel, /title=\{`\$\{time\} · Всего \$\{formatByteString\(total\)\} · Gateway/);
|
||||||
|
assert.match(panel, /<time dateTime=\{samples\[0\]\.observedAt\}>[\s\S]*<span>15 с<\/span>/);
|
||||||
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
|
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
|
||||||
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
|
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
|
||||||
assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|<Tooltip>\{trafficLabel\}<\/Tooltip>/);
|
assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|<Tooltip>\{trafficLabel\}<\/Tooltip>/);
|
||||||
@@ -71,7 +74,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
|
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
|
||||||
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
||||||
assert.match(styles, /\.client-devices \{\s*width: min\(580px, 100vw\)/);
|
assert.match(styles, /\.client-devices \{\s*width: min\(580px, 100vw\)/);
|
||||||
assert.match(styles, /\.client-device \{[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*grid-template-rows: 34px 28px;[\s\S]*padding: 10px 8px/);
|
assert.match(styles, /\.client-device \{[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*grid-template-rows: 34px 42px;[\s\S]*padding: 10px 8px/);
|
||||||
assert.match(styles, /\.client-device-main \{[\s\S]*display: flex;[\s\S]*align-items: center/);
|
assert.match(styles, /\.client-device-main \{[\s\S]*display: flex;[\s\S]*align-items: center/);
|
||||||
assert.match(styles, /\.client-device-traffic-total,[\s\S]*\.client-device-traffic-breakdown > span \{[\s\S]*grid-template-columns: 46px minmax\(0, 1fr\)/);
|
assert.match(styles, /\.client-device-traffic-total,[\s\S]*\.client-device-traffic-breakdown > span \{[\s\S]*grid-template-columns: 46px minmax\(0, 1fr\)/);
|
||||||
assert.match(styles, /\.client-device-traffic:hover \.client-device-traffic-breakdown,[\s\S]*opacity: 1[\s\S]*translateY\(0\)/);
|
assert.match(styles, /\.client-device-traffic:hover \.client-device-traffic-breakdown,[\s\S]*opacity: 1[\s\S]*translateY\(0\)/);
|
||||||
@@ -129,6 +132,7 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni
|
|||||||
assert.deepEqual(trafficSampleMetrics('75', '25', '200'), { height: 50, gatewayShare: 75 });
|
assert.deepEqual(trafficSampleMetrics('75', '25', '200'), { height: 50, gatewayShare: 75 });
|
||||||
assert.deepEqual(trafficSampleMetrics('1', '0', '1000'), { height: 10, gatewayShare: 100 });
|
assert.deepEqual(trafficSampleMetrics('1', '0', '1000'), { height: 10, gatewayShare: 100 });
|
||||||
assert.deepEqual(trafficSampleMetrics('0', '0', '0'), { height: 0, gatewayShare: 0 });
|
assert.deepEqual(trafficSampleMetrics('0', '0', '0'), { height: 0, gatewayShare: 0 });
|
||||||
|
assert.deepEqual(trafficSampleMetrics('10', '0', '100', 'log'), { height: 52, gatewayShare: 100 });
|
||||||
|
|
||||||
const devices = [
|
const devices = [
|
||||||
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },
|
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },
|
||||||
|
|||||||
Reference in New Issue
Block a user