diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index 0c213b8..93bf881 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -60,7 +60,8 @@ const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit const devTorrent = (d: Dev) => !!torrentOf[0]?.enabled && d.id === masters[0]?.devs[1]?.id; const devOut = (m: MK, d: Dev) => ({ ...d, priority: shapingOf[0]?.enabled && (d.id === masters[0]?.devs[0]?.id || devTorrent(d)) ? "background" : "normal", - torrent: devTorrent(d), servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length }); + torrent: devTorrent(d), servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length, + ips: m.servers.filter(s => s !== 2).map(s => ({ server_id: s, ip: `10.9.0.${50 + d.id + s * 10}` })) }); const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting); app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION })); diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css index 499ea2e..21d570e 100644 --- a/awg-ui/src/App.css +++ b/awg-ui/src/App.css @@ -785,3 +785,14 @@ tbody td:last-child { padding-right: 20px; } .server-pick-name { font-weight: 500; } .server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); } .server-pick-ip { font-family: var(--mono); margin-left: auto; } + +/* Короткий идентификатор под именем (UUID мастер-ключа, ID устройства): + первые символы моноширинным, полностью — в подсказке, по клику копируется. */ +.name-stack { display: inline-flex; flex-direction: column; line-height: 1.25; vertical-align: middle; } +.copy-id { + align-self: flex-start; + background: none; border: 0; padding: 0; margin: 0; cursor: pointer; + font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--neutral-text); +} +.copy-id:hover { color: var(--primary); } +.copy-id:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; } diff --git a/awg-ui/src/tabs/masterkeys/index.tsx b/awg-ui/src/tabs/masterkeys/index.tsx index f3edc32..4228fc7 100644 --- a/awg-ui/src/tabs/masterkeys/index.tsx +++ b/awg-ui/src/tabs/masterkeys/index.tsx @@ -3,8 +3,8 @@ // LICENSE file in the root directory of this source tree. import { Fragment, useState, useEffect, useCallback } from 'react'; import QRCode from 'qrcode'; -import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps, type Priority } from '../../lib/shared'; -import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons'; +import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared'; +import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons'; import ServerPicker, { serverName } from '../../components/ServerPicker'; import PriorityChip from '../../components/PriorityChip'; import TorrentChip from '../../components/TorrentChip'; @@ -19,6 +19,7 @@ interface Device { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority; torrent?: boolean; servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ + ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт) } // Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) — @@ -32,6 +33,18 @@ const platformLabel = (d: Device): string => const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса'; +// Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake. +const statusChip = (d: Device) => ( + + + {d.online ? 'онлайн' : d.lastHandshake ? timeAgo(d.lastHandshake) + ' назад' : 'никогда'} + +); + +// IP устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом. +const ipOf = (d: Device): string => + (d.ips?.find(x => x.server_id === getServerId()) ?? d.ips?.[0])?.ip ?? '—'; + export default function MasterKeysPage({ token, showMsg }: PageProps) { const [keys, setKeys] = useState([]); const [enabled, setEnabled] = useState(true); @@ -254,17 +267,51 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { ); - // UUID ключа в строке списка — как ID устройств под ним (та же ширина и обрезка), - // полностью — в подсказке, по клику копируется. - const uuidCell = (k: MasterKey) => ( - - + // Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке, + // по клику копируется. + const nameCell = (k: MasterKey) => ( + + {k.label} + + + ); + // Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа. + const summary = (list: Device[]) => { + if (!list.length) return <>; + const on = list.filter(d => d.online).length; + const last = Math.max(...list.map(d => d.lastHandshake || 0)); + const torrent = list.some(d => d.torrent); + const prio = list.some(d => d.priority === 'background') ? 'background' : list.find(d => d.priority)?.priority; + return ( + <> + + + + {on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'} + + + + + {bytes(list.reduce((a, d) => a + d.rx, 0))} + {bytes(list.reduce((a, d) => a + d.tx, 0))} + + ); + }; + + const ipCell = (d: Device) => { + const all = d.ips ?? []; + const ip = {ipOf(d)}; + return all.length > 1 + ? `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{ip} + : ip; + }; + const caret = (k: MasterKey) => (