Show device identity details and resolve hostnames
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-11 12:39:27 +03:00
parent 501c498edf
commit 3566f4bc0b
10 changed files with 256 additions and 82 deletions
+32 -20
View File
@@ -35,6 +35,8 @@ interface PinCollapse {
result: 'pending' | 'saved' | 'failed';
}
type DeviceCopyField = 'IP' | 'MAC' | 'Hostname';
function requestMessage(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const message: unknown = Reflect.get(value, 'message');
@@ -77,7 +79,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const [alias, setAlias] = useState('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear');
const [copyFeedback, setCopyFeedback] = useState<Record<string, { failed: boolean }>>({});
const [copyFeedback, setCopyFeedback] = useState<Record<string, { field: DeviceCopyField; failed: boolean }>>({});
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
const [pencilAnimationId, setPencilAnimationId] = useState('');
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
@@ -189,15 +191,14 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
setEditingId((current) => current === device.id ? '' : current);
}
async function copyDeviceIp(device: Device) {
if (!device.ip) return;
async function copyDeviceValue(device: Device, field: DeviceCopyField, value: string) {
const activeTimer = copyTimers.current.get(device.id);
if (activeTimer) clearTimeout(activeTimer);
const attempt = {};
copyAttempts.current.set(device.id, attempt);
let feedback: { failed: boolean };
try {
await copyText(device.ip);
await copyText(value);
feedback = { failed: false };
} catch {
feedback = { failed: true };
@@ -205,11 +206,11 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
if (copyAttempts.current.get(device.id) !== attempt) return;
const announcement = {
id: device.id,
message: feedback.failed ? `Не удалось скопировать IP ${device.ip}` : `IP ${device.ip} скопирован`,
message: feedback.failed ? `Не удалось скопировать ${field} ${value}` : `${field} ${value} скопирован`,
};
const pendingTimer = copyTimers.current.get(device.id);
if (pendingTimer) clearTimeout(pendingTimer);
setCopyFeedback((current) => ({ ...current, [device.id]: feedback }));
setCopyFeedback((current) => ({ ...current, [device.id]: { ...feedback, field } }));
setCopyAnnouncement(announcement);
copyTimers.current.set(device.id, setTimeout(() => {
setCopyFeedback((current) => {
@@ -387,7 +388,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const groupStart = group !== previousGroup;
const hasName = Boolean(device.alias || device.hostname);
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
const identityTooltipId = `device-identity-${device.id}`;
const editing = editingId === device.id;
const saving = savingId === device.id;
const seen = formatLastSeen(device.lastSeenAt);
@@ -400,7 +400,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const hasProxyTraffic = proxyTotal > 0n;
const trafficDelta = trafficDeltas[device.id] || {};
const feedback = copyFeedback[device.id];
const copied = Boolean(feedback);
const policyBusy = device.policyStatus === 'applying';
const policyFailed = device.policyStatus === 'failed';
const policyPending = device.policyStatus === 'pending';
@@ -453,7 +452,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</span>}
<div className="client-device-main">
<h4 className={`client-device-name-heading client-tooltip-anchor${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}>
<h4
className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}
tabIndex={!hasName && !editing ? 0 : undefined}
>
{editing ? (
<input
className="client-device-alias-input"
@@ -462,7 +464,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
maxLength={64}
autoFocus
aria-label="Название устройства"
aria-describedby={identityTooltipId}
aria-busy={saving}
disabled={saving}
onChange={(event) => setAlias(event.target.value)}
@@ -475,18 +476,29 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
className={`client-device-alias-trigger${device.alias ? ' is-custom-name' : ''}`}
type="button"
aria-label={`Изменить название ${title}`}
aria-describedby={identityTooltipId}
onClick={() => startEditing(device)}
>{title}</button>}
{(hasName || (editing && Boolean(alias))) && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
{device.ip ? <button
className={`client-device-ip${copied ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
aria-describedby={identityTooltipId}
onClick={() => copyDeviceIp(device)}
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>}
<Tooltip id={identityTooltipId}>Hostname: {device.hostname || '—'} · MAC: {device.mac}</Tooltip>
{!hasName && !editing && <span className="client-device-fallback-name">{title}</span>}
{!editing && <span className="client-device-identity-details" role="group" aria-label={`Технические данные устройства ${title}`}>
{device.ip && <button
className={`client-device-identity-copy${feedback?.field === 'IP' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать IP ${device.ip}`}
onClick={() => copyDeviceValue(device, 'IP', device.ip!)}
><b>IP</b><span>{device.ip}</span></button>}
<button
className={`client-device-identity-copy${feedback?.field === 'MAC' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать MAC ${device.mac}`}
onClick={() => copyDeviceValue(device, 'MAC', device.mac)}
><b>MAC</b><span>{device.mac}</span></button>
{device.hostname && <button
className={`client-device-identity-copy${feedback?.field === 'Hostname' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать Hostname ${device.hostname}`}
onClick={() => copyDeviceValue(device, 'Hostname', device.hostname!)}
><b>Host</b><span>{device.hostname}</span></button>}
</span>}
</h4>
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
<button
+83 -23
View File
@@ -273,7 +273,8 @@
.client-device-main > h4 {
height: 23px;
flex: 1 1 auto;
position: relative;
flex: 0 1 auto;
min-width: 0;
display: flex;
align-items: center;
@@ -287,13 +288,9 @@
white-space: nowrap;
}
.client-device-main > h4.is-address-only {
flex: 0 1 auto;
}
.client-device-alias-trigger,
.client-device-alias-input,
.client-device-ip {
.client-device-fallback-name {
min-width: 0;
padding: 0;
overflow: hidden;
@@ -349,46 +346,109 @@
color: var(--client-accent);
}
.client-device-name-separator {
flex: 0 0 auto;
color: var(--client-muted);
font: var(--type-body);
letter-spacing: var(--type-body-tracking);
text-transform: var(--type-body-transform);
.client-device-fallback-name {
flex: 0 1 auto;
}
.client-device-ip {
flex: 0 0 auto;
.client-device-identity-details {
position: absolute;
top: 100%;
left: 0;
z-index: 20;
width: max-content;
min-width: 230px;
max-width: min(290px, calc(100vw - 88px));
display: grid;
gap: 2px;
padding: 7px;
border-radius: 8px;
background: color-mix(in oklch, var(--client-bg) 92%, transparent);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
box-shadow: 0 10px 28px oklch(0.08 0.015 145 / 0.18);
opacity: 0;
visibility: hidden;
filter: blur(2px);
pointer-events: none;
transform: translateY(-2px);
transition: opacity 90ms ease, filter 120ms ease, transform 180ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 180ms;
}
.client-device-name-heading:hover > .client-device-identity-details,
.client-device-name-heading:focus-visible > .client-device-identity-details,
.client-device-name-heading:focus-within > .client-device-identity-details {
opacity: 1;
visibility: visible;
filter: blur(0);
pointer-events: auto;
transform: translateY(0);
transition-delay: 20ms, 20ms, 20ms, 0s;
}
.client-device:hover,
.client-device:focus-within {
z-index: 8;
}
.client-device-identity-copy {
min-width: 0;
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 5px 7px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--client-text);
text-align: left;
cursor: copy;
transition: color 180ms ease, background 180ms ease, filter 300ms ease;
}
.client-device-identity-copy b {
color: var(--client-muted);
font: var(--type-label);
letter-spacing: var(--type-label-tracking);
text-transform: var(--type-label-transform);
}
.client-device-identity-copy span {
min-width: 0;
overflow: hidden;
font: var(--type-data);
letter-spacing: var(--type-data-tracking);
text-transform: var(--type-data-transform);
font-variant-numeric: var(--numeric-tabular);
cursor: copy;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-device-ip:hover,
.client-device-ip:focus-visible {
.client-device-identity-copy:hover,
.client-device-identity-copy:focus-visible {
background: color-mix(in oklch, var(--client-accent) 9%, transparent);
color: var(--client-accent);
}
.client-device-ip.is-copied {
.client-device-identity-copy.is-copied {
--client-device-copy-color: var(--client-accent);
animation: client-device-ip-copy 800ms ease-out;
animation: client-device-copy 800ms ease-out;
}
.client-device-ip.is-copy-error {
.client-device-identity-copy.is-copy-error {
--client-device-copy-color: oklch(0.68 0.15 28);
animation: client-device-ip-copy 800ms ease-out;
animation: client-device-copy 800ms ease-out;
}
.client-device-alias-trigger:focus-visible,
.client-device-ip:focus-visible {
.client-device-name-heading:focus-visible,
.client-device-identity-copy:focus-visible {
border-radius: 3px;
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
@keyframes client-device-ip-copy {
@keyframes client-device-copy {
35% {
color: var(--client-device-copy-color);
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-device-copy-color) 48%, transparent));
+2 -1
View File
@@ -67,7 +67,8 @@
.client-device-policy svg,
.client-device-alias-trigger,
.client-device-alias-input,
.client-device-ip,
.client-device-identity-details,
.client-device-identity-copy,
.client-device-traffic-value > span,
.client-device-traffic-breakdown,
.client-device-traffic-breakdown > span,