From 44367e0ef33a37721c79ce85a8b22c356520fd2f Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 7 Aug 2026 13:39:25 +0300 Subject: [PATCH] Refine device inventory layout and last-seen details --- src/shared/versions.js | 4 +- src/web/components/DevicesPanel.jsx | 132 +++++++------ src/web/styles.css | 217 +++++++++++++++------ src/web/utils/format.js | 35 ++++ test/web/device-inventory-contract.test.js | 22 ++- 5 files changed, 291 insertions(+), 119 deletions(-) diff --git a/src/shared/versions.js b/src/shared/versions.js index 8ef6ba0..b5d9ee1 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.9.0', - gatewayClient: '0.9.0', + macClient: '0.9.1', + gatewayClient: '0.9.1', gatewayBackend: '0.9.0', }); diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index 5fb0ce8..9582fd0 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { api } from '../api.js'; +import { formatLastSeen } from '../utils/format.js'; const STATUS_LABELS = { online: 'В сети', @@ -7,20 +8,8 @@ const STATUS_LABELS = { offline: 'Не в сети', }; -const CONFIDENCE_LABELS = { - high: 'точная MAC', - medium: 'частная MAC', - low: 'приблизительно', -}; - -function seenAt(value) { - if (!value) return 'нет данных'; - return new Date(value).toLocaleString('ru-RU', { - day: '2-digit', - month: 'short', - hour: '2-digit', - minute: '2-digit', - }); +function Tooltip({ children }) { + return {children}; } export function DevicesPanel({ open, panelRef, closeRef, onClose }) { @@ -101,7 +90,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { {snapshot?.source?.error && (

- Источник временно недоступен. Показаны последние сохранённые данные. + Список временно не обновляется. Показаны последние сохранённые данные.

)} {error && ( @@ -117,53 +106,84 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
{devices.map((device) => { - const title = device.alias || device.hostname || device.manufacturer || device.ip; + const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство'; const editing = editingId === device.id; const saving = savingId === device.id; + const seen = formatLastSeen(device.lastSeenAt); + const uncertainIdentity = device.confidence !== 'high'; return
-
- {STATUS_LABELS[device.status]} -

{title}

- {device.manufacturer && device.manufacturer !== title &&

{device.manufacturer}

} -
- + {STATUS_LABELS[device.status]} + {editing ? ( +
saveAlias(event, device)}> + setAlias(event.target.value)} + /> + + +
+ ) : ( +
+

{title}

+ + + Изменить название + +
+ )} + + + {device.pinned ? 'Открепить' : 'Закрепить'} +
-
-
IP
{device.ip || '—'}
-
MAC
{device.mac || '—'}
-
Интерфейс
{device.interface || '—'}
-
Последний раз
-
Источник
{device.source} · {CONFIDENCE_LABELS[device.confidence]}
-
- - {editing ? ( -
saveAlias(event, device)}> - - - -
- ) : ( - - )} +
+
+ {title !== device.ip && device.ip && {device.ip}} + {device.mac && + {device.mac} + {uncertainIdentity && + ⓘ + {device.confidence === 'medium' + ? 'Устройство использует приватный MAC, производитель может не определиться' + : 'Устройство определено приблизительно'} + } + } + {device.interface && {device.interface}} +
+ + + {seen.tooltip} + +
+ {device.manufacturer &&

{device.manufacturer}

}
; })}
diff --git a/src/web/styles.css b/src/web/styles.css index 66f8f52..b2d22b0 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -746,7 +746,7 @@ p { .client-devices-error button, .client-device-pin, -.client-device-rename, +.client-device-edit, .client-device-alias button { padding: 0; border: 0; @@ -761,42 +761,49 @@ p { .client-device { display: grid; - gap: 14px; - padding: 20px 8px; + gap: 7px; + padding: 14px 8px; border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent); } .client-device-heading { display: grid; - grid-template-columns: minmax(0, 1fr) 32px; - gap: 12px; - align-items: start; + grid-template-columns: 68px minmax(0, 1fr) 32px; + align-items: center; + gap: 8px; + min-height: 32px; } -.client-device-heading > div { +.client-device-title { min-width: 0; + display: flex; + align-items: center; + gap: 4px; } -.client-device-heading h3 { +.client-device-title h3 { overflow: hidden; - margin: 4px 0 0; - font-size: 15px; + margin: 0; + font-size: 14px; letter-spacing: -0.03em; text-overflow: ellipsis; white-space: nowrap; } -.client-device-heading p, .client-device-status { + display: flex; + align-items: center; + gap: 7px; color: var(--client-muted); font-size: 9px; + white-space: nowrap; } .client-device-status::before { + flex: 0 0 auto; width: 6px; height: 6px; display: inline-block; - margin-right: 7px; border-radius: 50%; background: currentColor; content: ''; @@ -810,11 +817,56 @@ p { opacity: 0.56; } -.client-device-pin { +.client-device-edit-wrap, +.client-device-pin-wrap { + width: 28px; + height: 28px; + display: grid; + place-items: center; +} + +.client-device-edit-wrap { + flex: 0 0 auto; + opacity: 0.34; + transition: opacity 180ms ease; +} + +.client-device:hover .client-device-edit-wrap, +.client-device:focus-within .client-device-edit-wrap { + opacity: 1; +} + +.client-device-pin-wrap { width: 32px; height: 32px; +} + +.client-device-edit, +.client-device-pin { + width: 28px; + height: 28px; + display: grid; + place-items: center; color: var(--client-muted); - font-size: 16px; +} + +.client-device-edit svg, +.client-device-pin svg { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; + transition: color 180ms ease, filter 220ms ease; +} + +.client-device-edit:hover, +.client-device-edit:focus-visible, +.client-device-pin:hover, +.client-device-pin:focus-visible { + color: var(--client-accent); } .client-device-pin[aria-pressed="true"] { @@ -822,70 +874,118 @@ p { } .client-device-meta { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 9px 18px; - margin: 0; -} - -.client-device-meta div { min-width: 0; -} - -.client-device-meta dt { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 10px 16px; + margin-left: 76px; color: var(--client-muted); - font-size: 8px; - font-weight: 700; - letter-spacing: 0.07em; - text-transform: uppercase; + font-size: 9px; + line-height: 1.5; } -.client-device-meta dd { +.client-device-addresses { + min-width: 0; + display: flex; + flex-wrap: wrap; + gap: 3px 0; +} + +.client-device-addresses > span { + white-space: nowrap; +} + +.client-device-addresses > span + span::before { + margin: 0 6px; + color: color-mix(in oklch, var(--client-muted) 58%, transparent); + content: '·'; +} + +.client-device-mac { + display: inline-flex; + align-items: center; +} + +.client-device-identity { + position: relative; + margin-left: 4px; + color: var(--client-accent); + cursor: help; +} + +.client-device-last-seen { + flex: 0 0 auto; + margin-left: auto; + color: var(--client-text); + cursor: help; + white-space: nowrap; +} + +.client-device-last-seen > .client-tooltip, +.client-device-pin-wrap > .client-tooltip { + right: 0; + left: auto; + transform: translate(0, 2px); +} + +.client-device-last-seen:hover > .client-tooltip, +.client-device-last-seen:focus-visible > .client-tooltip, +.client-device-pin-wrap:hover > .client-tooltip, +.client-device-pin-wrap:has(> :focus-visible) > .client-tooltip { + transform: translate(0, 0); +} + +.client-device-last-seen:focus-visible, +.client-device-identity:focus-visible { + border-radius: 3px; + outline: 2px solid var(--client-accent); + outline-offset: 3px; +} + +.client-device-manufacturer { overflow: hidden; - margin: 3px 0 0; + margin: 0 0 0 76px; + color: var(--client-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } -.client-device-rename, -.client-device-alias button, -.client-devices-error button { - justify-self: start; - font-size: 9px; - font-weight: 700; -} - .client-device-alias { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: end; - gap: 10px; -} - -.client-device-alias label { - display: grid; - gap: 5px; - color: var(--client-muted); - font-size: 8px; - text-transform: uppercase; + grid-template-columns: minmax(0, 1fr) 28px 28px; + align-items: center; + gap: 3px; } .client-device-alias input { min-width: 0; - padding: 8px 0; + padding: 5px 0; border: 0; border-bottom: 1px solid var(--client-border); outline: 0; background: transparent; color: var(--client-text); - font-size: 10px; + font-size: 12px; } .client-device-alias input:focus { border-color: var(--client-accent); } +.client-device-alias button { + width: 28px; + height: 28px; + font-size: 14px; +} + +.client-devices-error button { + justify-self: start; + font-size: 9px; + font-weight: 700; +} + .client-devices button:focus-visible { outline: 2px solid var(--client-accent); outline-offset: 2px; @@ -896,6 +996,12 @@ p { opacity: 0.35; } +@media (hover: none) { + .client-device-edit-wrap { + opacity: 0.72; + } +} + .client-instructions-toggle:hover, .client-instructions-toggle:focus-visible, .client-local-rules-toggle:hover, @@ -3737,10 +3843,6 @@ p { padding: 40px 58px 60px 18px; } - .client-device-meta { - grid-template-columns: 1fr; - } - .client-local-rules-sheet { padding: 40px 58px 60px 18px; } @@ -3841,7 +3943,10 @@ p { .client-instruction-summary > i::after, .client-device, .client-device-pin, - .client-device-rename, + .client-device-pin svg, + .client-device-edit, + .client-device-edit svg, + .client-device-edit-wrap, .client-subscription-edit, .client-subscription-edit::after, .client-subscription-submit, diff --git a/src/web/utils/format.js b/src/web/utils/format.js index 3152be1..b2d3291 100644 --- a/src/web/utils/format.js +++ b/src/web/utils/format.js @@ -29,3 +29,38 @@ export function formatTime(iso) { if (!iso) return ""; return new Date(iso).toLocaleTimeString("ru-RU", { hour12: false }); } + +export function formatLastSeen(iso, now = new Date()) { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return { label: "Нет данных", tooltip: "Нет данных" }; + const current = new Date(now); + const day = (value) => Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()); + const daysAgo = Math.round((day(current) - day(date)) / 86_400_000); + const time = date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); + const dateLabel = daysAgo === 0 + ? "Сегодня" + : daysAgo === 1 + ? "Вчера" + : date.toLocaleDateString("ru-RU", { + day: "numeric", + month: "long", + ...(date.getFullYear() === current.getFullYear() ? {} : { year: "numeric" }), + }); + const elapsedMs = Math.max(0, current.getTime() - date.getTime()); + const units = elapsedMs < 60 * 60 * 1000 + ? [Math.max(1, Math.floor(elapsedMs / 60_000)), "minute"] + : elapsedMs < 24 * 60 * 60 * 1000 + ? [Math.floor(elapsedMs / 3_600_000), "hour"] + : [Math.floor(elapsedMs / 86_400_000), "day"]; + const relative = elapsedMs < 60_000 + ? "только что" + : new Intl.RelativeTimeFormat("ru-RU", { numeric: "always" }).format(-units[0], units[1]); + const full = date.toLocaleString("ru-RU", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + return { label: `${dateLabel}, ${time}`, tooltip: `${full} (${relative})` }; +} diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index 6413376..a085e7c 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { formatLastSeen } from '../../src/web/utils/format.js'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8'); @@ -13,12 +14,23 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(overview, /isGateway && Изменить название<\/Tooltip>/); + assert.match(panel, /client-device-addresses/); + assert.doesNotMatch(panel, /точная MAC|частная MAC|
Источник<\/dt>/); assert.match(panel, /aria-pressed=\{device\.pinned\}/); - assert.match(panel, /maxLength="64" autoFocus/); + assert.match(panel, /maxLength="64"[\s\S]*autoFocus/); assert.match(styles, /\.client-devices \{\s*width: min\(560px, 100vw\)/); - assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-device-meta \{\s*grid-template-columns: 1fr/); assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-pin/); }); + +test('device last-seen copy is compact with a precise relative tooltip', () => { + const lastSeen = new Date(2026, 7, 7, 13, 15); + const now = new Date(2026, 7, 7, 13, 27); + assert.deepEqual( + formatLastSeen(lastSeen.toISOString(), now), + { + label: 'Сегодня, 13:15', + tooltip: '7 августа 2026 г. в 13:15 (12 минут назад)', + }, + ); +});