diff --git a/src/shared/versions.js b/src/shared/versions.js
index 8ca8604..9b852fa 100644
--- a/src/shared/versions.js
+++ b/src/shared/versions.js
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({
- macClient: '0.16.1',
- gatewayClient: '0.17.1',
+ macClient: '0.16.2',
+ gatewayClient: '0.17.2',
gatewayBackend: '0.17.1',
});
diff --git a/src/web/components/ConnectivityDiagnosticsPanel.jsx b/src/web/components/ConnectivityDiagnosticsPanel.jsx
index 815f133..62a55a2 100644
--- a/src/web/components/ConnectivityDiagnosticsPanel.jsx
+++ b/src/web/components/ConnectivityDiagnosticsPanel.jsx
@@ -63,19 +63,6 @@ function IpCell({ path, source, pending, route }) {
return {value.address};
}
-function PathDetails({ title, path }) {
- if (!path?.available) return ;
- return
- {title}
- {path.sites.map((site) => (
-
- {site.label}: {site.stage}{site.httpStatus ? ` · HTTP ${site.httpStatus}` : ''}
- {site.error ? ` · ${site.error}` : ''}
-
- ))}
- ;
-}
-
export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose }) {
const [result, setResult] = useState(null);
const [status, setStatus] = useState('idle');
@@ -172,7 +159,7 @@ export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose
-
+ {(error || result) &&
{error ?
{error.message}
{error.retryable && }
@@ -180,7 +167,7 @@ export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose
{summary[1]}
{checkedAt}
>}
-
+
}
@@ -266,13 +253,6 @@ export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose
- {result &&
- Технические детали
-
- }
);
diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx
index 8f1162a..a78882e 100644
--- a/src/web/components/DevicesPanel.jsx
+++ b/src/web/components/DevicesPanel.jsx
@@ -192,9 +192,12 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const [sortDirection, setSortDirection] = useState('desc');
const [trafficScale, setTrafficScale] = useState('linear');
const [copyFeedback, setCopyFeedback] = useState(null);
+ const [pencilAnimationId, setPencilAnimationId] = useState('');
const [trafficDeltas, setTrafficDeltas] = useState({});
const deviceNodes = useRef(new Map());
const previousPositions = useRef(new Map());
+ const previousOrder = useRef([]);
+ const previousScrollTop = useRef(0);
const movementAnimations = useRef(new Map());
const previousTraffic = useRef(new Map());
const copyTimer = useRef(null);
@@ -217,7 +220,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
setRefreshing(true);
try {
const next = await (discover ? api.devices.refresh() : api.devices.list());
- setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
+ setSnapshot((current) => !current || next.revision > current.revision ? next : current);
setError(null);
setStatus('ready');
} catch (requestError) {
@@ -278,6 +281,8 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
useLayoutEffect(() => {
if (!open) {
previousPositions.current.clear();
+ previousOrder.current = [];
+ previousScrollTop.current = 0;
for (const animation of movementAnimations.current.values()) animation.cancel();
movementAnimations.current.clear();
return;
@@ -287,10 +292,17 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
movementAnimations.current.get(id)?.cancel();
positions.set(id, node.getBoundingClientRect());
}
- if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ const order = devices.map(({ id }) => id);
+ const orderChanged = previousOrder.current.length > 0
+ && (order.length !== previousOrder.current.length
+ || order.some((id, index) => id !== previousOrder.current[index]));
+ const currentScrollTop = panelRef.current?.scrollTop || 0;
+ if (orderChanged && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
for (const [id, after] of positions) {
const before = previousPositions.current.get(id);
- const deltaY = before ? before.top - after.top : 0;
+ const deltaY = before
+ ? before.top - after.top + previousScrollTop.current - currentScrollTop
+ : 0;
if (Math.abs(deltaY) < 1) continue;
const animation = deviceNodes.current.get(id)?.animate([
{ transform: `translateY(${deltaY}px)` },
@@ -303,7 +315,9 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
}
}
previousPositions.current = positions;
- }, [devices, open]);
+ previousOrder.current = order;
+ previousScrollTop.current = currentScrollTop;
+ }, [devices, open, panelRef]);
async function updateDevice(device, patch) {
setSavingId(device.id);
@@ -314,14 +328,14 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
} catch (requestError) {
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
const latest = await api.devices.list();
- setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current);
+ setSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) {
throw requestError;
}
next = await api.devices.update(device.id, patch, latest.revision);
}
- setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
+ setSnapshot((current) => !current || next.revision > current.revision ? next : current);
setError(null);
return true;
} catch (requestError) {
@@ -347,18 +361,18 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
} catch (requestError) {
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
const latest = await api.devices.list();
- setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current);
+ setSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw requestError;
next = await api.devices.setPolicy(device.id, mode, latest.revision);
}
- setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
+ setSnapshot((current) => !current || next.revision > current.revision ? next : current);
setError(null);
} catch (requestError) {
if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') {
try {
const latest = await api.devices.list();
- setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current);
+ setSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
} catch {
// Keep the policy error as the actionable result.
}
@@ -575,19 +589,25 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
onClick={() => copyDeviceIp(device)}
>{device.ip} : !hasName && Неизвестное устройство}
- {!hasName &&
+
Изменить название
- }
+
>
)}
diff --git a/src/web/styles.css b/src/web/styles.css
index 4da9498..5d47170 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -946,7 +946,7 @@ p {
display: flex;
align-items: flex-end;
gap: 2px;
- padding-top: 11px;
+ padding: 11px 27px 0 0;
}
.client-device-main > h3 {
@@ -1040,10 +1040,11 @@ p {
}
.client-device-edit-wrap {
- flex: 0 0 auto;
+ position: absolute;
+ right: 0;
+ bottom: 0;
width: 23px;
height: 23px;
- align-self: flex-end;
opacity: 0.34;
transition: opacity 180ms ease, filter 240ms ease, transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -1093,9 +1094,16 @@ p {
color: var(--client-accent);
}
-.client-device-edit:hover svg,
-.client-device-edit:focus-visible svg {
- transform: translate(1px, -1px) rotate(-4deg);
+.client-device-edit.is-writing svg {
+ animation: client-device-pencil-write 620ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+@keyframes client-device-pencil-write {
+ 0%, 100% { transform: translate(0, 0) rotate(0); }
+ 22% { transform: translate(1px, -1px) rotate(-5deg); }
+ 44% { transform: translate(-1px, 1px) rotate(-2deg); }
+ 66% { transform: translate(1px, 0) rotate(-5deg); }
+ 84% { transform: translate(0, 1px) rotate(-2deg); }
}
.client-device-pin[aria-pressed="true"] {
@@ -4624,7 +4632,6 @@ p {
}
.client-diagnostics-feedback {
- min-height: 34px;
display: flex;
align-items: center;
gap: 10px;
@@ -4838,43 +4845,6 @@ p {
color: oklch(0.68 0.15 28);
}
-.client-diagnostics-details {
- margin: 0 8px;
- color: var(--client-muted);
- font-size: 9px;
-}
-
-.client-diagnostics-details summary {
- width: fit-content;
- padding: 6px 0;
- color: var(--client-text);
- font-weight: 700;
- cursor: pointer;
-}
-
-.client-diagnostics-details summary:focus-visible {
- outline: 2px solid var(--client-accent);
- outline-offset: 2px;
-}
-
-.client-diagnostics-details > div {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 18px;
- padding-top: 10px;
-}
-
-.client-diagnostics-details section {
- min-width: 0;
- display: grid;
- gap: 5px;
-}
-
-.client-diagnostics-details p {
- overflow-wrap: anywhere;
- line-height: 1.55;
-}
-
@media (max-width: 560px) {
.client-diagnostics-table th,
.client-diagnostics-table td {
@@ -4889,10 +4859,8 @@ p {
grid-template-columns: minmax(0, 1fr);
}
- .client-diagnostics-add-action,
- .client-diagnostics-details > div {
+ .client-diagnostics-add-action {
grid-column: 1;
- grid-template-columns: minmax(0, 1fr);
}
}
diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js
index e9dfd3b..6fbf985 100644
--- a/test/web/device-inventory-contract.test.js
+++ b/test/web/device-inventory-contract.test.js
@@ -28,6 +28,10 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*api\.devices\.list\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*api\.devices\.update\(device\.id, patch, latest\.revision\)/);
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
assert.match(panel, /movementAnimations\.current\.get\(id\)\?\.cancel\(\)/);
+ assert.match(panel, /const orderChanged = previousOrder\.current\.length > 0/);
+ assert.match(panel, /previousScrollTop\.current - currentScrollTop/);
+ assert.match(panel, /next\.revision > current\.revision/);
+ assert.doesNotMatch(panel, /revision >= current\.revision/);
assert.match(panel, /prefers-reduced-motion: reduce/);
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
assert.match(api, /setPolicy: \(id, mode, expectedRevision\) => request\(`\/api\/devices\/\$\{id\}\/policy`/);
@@ -38,7 +42,10 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
assert.match(panel, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/);
assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/);
- assert.match(panel, /\{!hasName && /);
+ assert.doesNotMatch(panel, /\{!hasName && span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value/);
diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js
index d20e947..286bbfe 100644
--- a/test/web/responsive-layout-contract.test.js
+++ b/test/web/responsive-layout-contract.test.js
@@ -118,7 +118,9 @@ test('connectivity diagnostics render stable compact tables before the first run
assert.match(diagnostics, /Добавить свой сервис/);
assert.match(diagnostics, /client-diagnostics-refresh/);
assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/);
- assert.match(rule('.client-diagnostics-feedback'), /min-height:\s*34px/);
+ assert.match(diagnostics, /\{\(error \|\| result\) &&