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 ? (
+
+ ) : (
+
+
{title}
+
+
+ Изменить название
+
+
+ )}
+
+
+ {device.pinned ? 'Открепить' : 'Закрепить'}
+
-
- - IP
- {device.ip || '—'}
- - MAC
- {device.mac || '—'}
- - Интерфейс
- {device.interface || '—'}
- - Последний раз
- - Источник
- {device.source} · {CONFIDENCE_LABELS[device.confidence]}
-
-
- {editing ? (
-
- ) : (
-
- )}
+
+
+ {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 &&