diff --git a/src/shared/versions.js b/src/shared/versions.js
index 558bf59..ec13bd6 100644
--- a/src/shared/versions.js
+++ b/src/shared/versions.js
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({
- macClient: '0.13.4',
- gatewayClient: '0.14.4',
+ macClient: '0.13.5',
+ gatewayClient: '0.14.5',
gatewayBackend: '0.14.0',
});
diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx
index cefb72d..8c5155c 100644
--- a/src/web/components/DevicesPanel.jsx
+++ b/src/web/components/DevicesPanel.jsx
@@ -5,11 +5,14 @@ import {
byteString,
formatByteString,
formatLastSeen,
+ positiveByteDelta,
sortDevicesByTraffic,
} from '../utils/format.js';
const AUTO_REFRESH_MS = 15_000;
const DEVICE_MOVE_MS = 520;
+const COPY_FEEDBACK_MS = 5_000;
+const TRAFFIC_DELTA_MS = 2_200;
function Tooltip({ children }) {
return {children};
@@ -24,6 +27,13 @@ function TextMorph({ from, to }) {
;
}
+function TrafficValue({ value, delta }) {
+ return
+ {value}
+ {delta ? `+${delta}` : ''}
+ ;
+}
+
export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const [snapshot, setSnapshot] = useState(null);
const [status, setStatus] = useState('idle');
@@ -35,9 +45,12 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const [refreshCycle, setRefreshCycle] = useState(0);
const [sortDirection, setSortDirection] = useState('desc');
const [copyFeedback, setCopyFeedback] = useState(null);
+ const [trafficDeltas, setTrafficDeltas] = 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 devices = useMemo(
() => sortDevicesByTraffic(snapshot?.devices, sortDirection),
[snapshot?.devices, sortDirection],
@@ -72,7 +85,37 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
return () => clearTimeout(timer);
}, [open, refreshCycle, refreshing, status]);
- useEffect(() => () => clearTimeout(copyTimer.current), []);
+ useEffect(() => () => {
+ clearTimeout(copyTimer.current);
+ clearTimeout(trafficDeltaTimer.current);
+ }, []);
+
+ useEffect(() => {
+ if (!open) {
+ previousTraffic.current.clear();
+ clearTimeout(trafficDeltaTimer.current);
+ setTrafficDeltas({});
+ return;
+ }
+
+ const next = new Map();
+ const deltas = {};
+ for (const device of snapshot?.devices || []) {
+ const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
+ const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
+ const previous = previousTraffic.current.get(device.id);
+ next.set(device.id, { gateway, proxy });
+ if (!previous) continue;
+ const gatewayDelta = positiveByteDelta(previous.gateway, gateway);
+ const proxyDelta = positiveByteDelta(previous.proxy, proxy);
+ if (gatewayDelta || proxyDelta) deltas[device.id] = { gateway: gatewayDelta, proxy: proxyDelta };
+ }
+ previousTraffic.current = next;
+ if (!Object.keys(deltas).length) return;
+ setTrafficDeltas(deltas);
+ clearTimeout(trafficDeltaTimer.current);
+ trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
+ }, [snapshot?.devices, open]);
useLayoutEffect(() => {
if (!open) {
@@ -171,7 +214,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
} catch {
setCopyFeedback({ id: device.id, failed: true });
}
- copyTimer.current = setTimeout(() => setCopyFeedback(null), 800);
+ copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
}
return (
@@ -266,7 +309,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
)}
- {copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
+ {copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'copied!' : ''}
{devices.map((device) => {
@@ -279,6 +322,8 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const proxyTotal = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
const gatewayTraffic = formatByteString(gatewayTotal.toString());
const proxyTraffic = formatByteString(proxyTotal.toString());
+ const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString());
+ const trafficDelta = trafficDeltas[device.id] || {};
const copied = copyFeedback?.id === device.id;
const policyBusy = device.policyStatus === 'applying';
const policyFailed = device.policyStatus === 'failed';
@@ -350,7 +395,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
{title}
{device.ip}
- {copied && copyFeedback.failed ? 'Ошибка' : 'Скопировано'}
+ {copied && copyFeedback.failed ? 'Ошибка' : 'copied!'}
:
{title}
}
@@ -384,8 +429,9 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
- Gateway{gatewayTraffic}
- Прокси{proxyTraffic}
+ Gateway
+ Прокси
+ Всего{totalTraffic}
diff --git a/src/web/styles.css b/src/web/styles.css
index 2647dc3..ab8b2c1 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -909,6 +909,7 @@ p {
}
.client-device-main {
+ height: 34px;
min-width: 0;
display: flex;
align-items: center;
@@ -916,8 +917,11 @@ p {
}
.client-device-main > h3 {
+ height: 32px;
flex: 0 1 auto;
min-width: 0;
+ display: flex;
+ align-items: center;
overflow: hidden;
margin: 0;
font-size: 15px;
@@ -927,9 +931,11 @@ p {
}
.client-device-name {
+ height: 32px;
width: 100%;
min-width: 0;
display: grid;
+ align-items: center;
padding: 0;
overflow: hidden;
border: 0;
@@ -945,22 +951,32 @@ p {
grid-area: 1 / 1;
overflow: hidden;
opacity: 0;
+ filter: blur(8px);
text-overflow: ellipsis;
white-space: nowrap;
+ transform: translateY(0.18em) scale(0.96);
+ transform-origin: left center;
+ transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1), transform 480ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-device-name > .client-device-name-primary {
opacity: 1;
+ filter: blur(0);
+ transform: translateY(0) scale(1);
}
.client-device-name:hover .client-device-name-primary,
.client-device-name:focus-visible .client-device-name-primary {
opacity: 0;
+ filter: blur(8px);
+ transform: translateY(-0.18em) scale(1.04);
}
.client-device-name:hover .client-device-name-ip,
.client-device-name:focus-visible .client-device-name-ip {
opacity: 1;
+ filter: blur(0);
+ transform: translateY(0) scale(1);
}
.client-device-name.is-copied .client-device-name-primary,
@@ -968,11 +984,21 @@ p {
.client-device-name.is-copy-error .client-device-name-primary,
.client-device-name.is-copy-error .client-device-name-ip {
opacity: 0;
+ filter: blur(8px);
+ transform: translateY(-0.18em) scale(1.04);
}
.client-device-name.is-copied .client-device-name-feedback,
.client-device-name.is-copy-error .client-device-name-feedback {
opacity: 1;
+ filter: blur(0);
+ transform: translateY(0) scale(1);
+}
+
+.client-device-name-feedback {
+ font-size: 9px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
}
.client-device-name.is-copied .client-device-name-feedback {
@@ -1066,8 +1092,11 @@ p {
}
.client-device-last-seen {
+ height: 32px;
flex: 0 1 auto;
min-width: 0;
+ display: flex;
+ align-items: center;
max-width: 116px;
overflow: hidden;
font-size: 10px;
@@ -1077,6 +1106,12 @@ p {
white-space: nowrap;
}
+.client-device-last-seen time {
+ height: 100%;
+ display: flex;
+ align-items: center;
+}
+
.client-device-last-seen.is-online {
color: var(--client-accent);
font-weight: 700;
@@ -1155,7 +1190,7 @@ p {
gap: 2px;
}
-.client-device-traffic-sources span {
+.client-device-traffic-sources > span {
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
align-items: baseline;
@@ -1178,6 +1213,41 @@ p {
white-space: nowrap;
}
+.client-device-traffic-value {
+ min-width: 0;
+ display: inline-grid;
+}
+
+.client-device-traffic-value > span {
+ grid-area: 1 / 1;
+ overflow: hidden;
+ justify-self: end;
+ opacity: 0;
+ filter: blur(6px);
+ text-overflow: ellipsis;
+ transform: translateY(0.18em) scale(0.96);
+ transform-origin: right center;
+ transition: opacity 260ms ease, filter 360ms cubic-bezier(0.16, 1, 0.3, 1), transform 360ms cubic-bezier(0.16, 1, 0.3, 1);
+ white-space: nowrap;
+}
+
+.client-device-traffic-value > .is-total,
+.client-device-traffic-value.has-delta > .is-delta {
+ opacity: 1;
+ filter: blur(0);
+ transform: translateY(0) scale(1);
+}
+
+.client-device-traffic-value.has-delta > .is-total {
+ opacity: 0;
+ filter: blur(6px);
+ transform: translateY(-0.18em) scale(1.04);
+}
+
+.client-device-traffic-value > .is-delta {
+ color: var(--client-accent);
+}
+
.client-device-traffic-sources .is-proxy {
color: var(--client-accent);
}
@@ -1186,6 +1256,15 @@ p {
color: color-mix(in oklch, var(--client-accent) 72%, var(--client-muted));
}
+.client-device-traffic-sources > .is-total {
+ margin-top: 2px;
+ color: var(--client-text);
+}
+
+.client-device-traffic-sources > .is-total b {
+ color: var(--client-muted);
+}
+
.client-device-policy-wrap {
width: 34px;
position: relative;
@@ -4243,6 +4322,8 @@ p {
.client-device-pin svg,
.client-device-policy,
.client-device-policy svg,
+ .client-device-name > span,
+ .client-device-traffic-value > span,
.client-device-edit,
.client-device-edit svg,
.client-device-edit-wrap,
diff --git a/src/web/utils/format.js b/src/web/utils/format.js
index 8a916ef..022dd41 100644
--- a/src/web/utils/format.js
+++ b/src/web/utils/format.js
@@ -31,6 +31,12 @@ export function formatByteString(value) {
return `${tenths / 10n},${tenths % 10n} ${units[unit]}`;
}
+export function positiveByteDelta(previous, current) {
+ const before = byteString(previous);
+ const after = byteString(current);
+ return after > before ? formatByteString((after - before).toString()) : '';
+}
+
export function sortDevicesByTraffic(devices, direction = 'desc') {
const factor = direction === 'asc' ? 1 : -1;
return (Array.isArray(devices) ? devices : [])
diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js
index e257147..e28dda9 100644
--- a/test/web/device-inventory-contract.test.js
+++ b/test/web/device-inventory-contract.test.js
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
import {
formatByteString,
formatLastSeen,
+ positiveByteDelta,
sortDevicesByTraffic,
} from '../../src/web/utils/format.js';
@@ -31,7 +32,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /Изменить название<\/Tooltip>/);
assert.match(panel, /copyText\(device\.ip\)/);
assert.match(panel, /client-device-name-primary[\s\S]*client-device-name-ip[\s\S]*client-device-name-feedback/);
- assert.match(panel, /client-device-name-feedback[\s\S]*'Скопировано'/);
+ assert.match(panel, /client-device-name-feedback[\s\S]*'copied!'/);
+ assert.match(panel, /COPY_FEEDBACK_MS = 5_000/);
assert.match(panel, /client-device-name-feedback[\s\S]*client-device-edit-wrap[\s\S]*client-device-last-seen/);
assert.doesNotMatch(panel, /client-device-title/);
assert.match(panel, /online \? 'В сети' : /);
@@ -46,8 +48,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/);
assert.match(panel, /Трафик временно не обновляется/);
assert.match(panel, /Учитывается только трафик, который прошёл через Harbor/);
- assert.match(panel, /Gateway<\/b>\{gatewayTraffic\}<\/strong>/);
- assert.match(panel, /className="is-proxy">Прокси<\/b>\{proxyTraffic\}<\/strong>/);
+ assert.match(panel, /Gateway<\/b>Прокси<\/b>Всего<\/b>\{totalTraffic\}<\/strong>/);
+ 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>/);
assert.match(panel, /source\?\.traffic\?\.proxy\?\.error/);
assert.match(panel, /client-device-pin-wrap[\s\S]*client-device-main[\s\S]*client-device-traffic[\s\S]*client-device-policy-wrap/);
@@ -66,16 +71,21 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device \{[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\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-sources \{[\s\S]*display: grid/);
- assert.match(styles, /\.client-device-traffic-sources span \{[\s\S]*grid-template-columns: 46px minmax\(0, 1fr\)/);
+ assert.match(styles, /\.client-device-traffic-sources > span \{[\s\S]*grid-template-columns: 46px minmax\(0, 1fr\)/);
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
assert.match(styles, /\.client-device-name \{[\s\S]*font: 700 15px\/1\.2/);
assert.match(styles, /\.client-device-last-seen \{[\s\S]*font-size: 10px/);
assert.match(styles, /\.client-device-traffic-sources strong \{[\s\S]*font-size: 10px/);
assert.match(styles, /\.client-device-name > span \{[^}]*opacity: 0/);
assert.match(styles, /\.client-device-name > \.client-device-name-primary \{[^}]*opacity: 1/);
- assert.match(styles, /\.client-device-name:hover \.client-device-name-ip[\s\S]*opacity: 1/);
+ assert.match(styles, /\.client-device-name > span \{[^}]*transition: opacity 360ms[^}]*transform 480ms/);
+ assert.match(styles, /\.client-device-name:hover \.client-device-name-primary[\s\S]*translateY\(-0\.18em\)/);
+ assert.match(styles, /\.client-device-name:hover \.client-device-name-ip[\s\S]*opacity: 1[\s\S]*translateY\(0\) scale\(1\)/);
assert.match(styles, /\.client-device-name\.is-copied \.client-device-name-feedback[\s\S]*opacity: 1/);
- assert.doesNotMatch(styles, /\.client-device-name > span \{[^}]*transition:/);
+ assert.match(styles, /\.client-device-name-feedback \{[^}]*font-size: 9px/);
+ assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: center/);
+ assert.match(styles, /\.client-device-last-seen \{[^}]*height: 32px[\s\S]*align-items: center/);
+ assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/);
assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/);
assert.match(styles, /\.client-text-morph-value \{[\s\S]*transition: opacity 360ms[\s\S]*filter 480ms/);
assert.match(styles, /\.client-device-last-seen:hover \.client-text-morph-value\.is-relative[\s\S]*opacity: 1/);
@@ -85,7 +95,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-edit:hover svg[\s\S]*translate\(1px, -1px\) rotate\(-4deg\)/);
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
- assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-text-morph-value/);
+ assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-name > span[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-text-morph-value/);
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
});
@@ -107,6 +117,9 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni
assert.equal(formatByteString('9007199254740993'), '8,0 ПБ');
assert.equal(formatByteString('1536'), '1,5 КБ');
assert.equal(formatByteString('invalid'), '0 Б');
+ assert.equal(positiveByteDelta('1048576', '3145728'), '2,0 МБ');
+ assert.equal(positiveByteDelta('3145728', '3145728'), '');
+ assert.equal(positiveByteDelta('3145728', '1048576'), '');
const devices = [
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },