-
- {samples.map((sample) => {
- const gateway = byteString(sample.gateway);
- const proxy = byteString(sample.proxy);
- const { height, gatewayShare } = trafficSampleMetrics(gateway, proxy, max);
- return
+ const latest = samples.at(-1)?.observedAt || 'empty';
+ return
+
+ {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
;
})}
+ {samples.length > 0 &&
+
+ 15 с
+
+ }
;
}
@@ -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 }) {
Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика
+
+
+
+
Устройства
@@ -496,7 +503,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
{policyTooltip}
-
+
;
})}
diff --git a/src/web/styles.css b/src/web/styles.css
index 46b3a87..08e56ff 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -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;
diff --git a/src/web/utils/format.js b/src/web/utils/format.js
index 6d0712d..3558ed7 100644
--- a/src/web/utils/format.js
+++ b/src/web/utils/format.js
@@ -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,
};
}
diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js
index a00d3a5..28b67a1 100644
--- a/test/server/device-inventory.test.js
+++ b/test/server/device-inventory.test.js
@@ -273,6 +273,51 @@ test('device traffic totals persist exact deltas across polls and process epochs
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) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-proxy-legacy-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js
index 037ae42..bc7a724 100644
--- a/test/web/device-inventory-contract.test.js
+++ b/test/web/device-inventory-contract.test.js
@@ -52,8 +52,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /client-device-traffic-total[\s\S]*Всего<\/b>Gateway<\/b>Прокси<\/b>[\s\S]*15 с<\/span>/);
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|\{trafficLabel\}<\/Tooltip>/);
@@ -71,7 +74,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
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-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\)/);
@@ -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('1', '0', '1000'), { height: 10, gatewayShare: 100 });
assert.deepEqual(trafficSampleMetrics('0', '0', '0'), { height: 0, gatewayShare: 0 });
+ assert.deepEqual(trafficSampleMetrics('10', '0', '100', 'log'), { height: 52, gatewayShare: 100 });
const devices = [
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },