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
{title}

VPN не запущен.

; - 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\) &&