Show each server's Forgetting version in the panel

- awg-agent sends its install's VERSION (from the CLI's env, else the
  project's .env) in the auth frame; the hub keeps it in nodes.version
  (added by migration), so an offline node still shows the last one.
- GET /ui/nodes returns version per server (0 is the panel's own) and the
  panel's version at the top.
- ServerBar: a vX.Y.Z chip next to the AWG generation. A node older or
  newer than the panel, or one that sends no version (agent before
  0.3.4, shown as v?.?.?), gets a warm chip with an "update it" tooltip.
- App.css: tertiary-container tokens from design.md (light) and a warm
  dark pair; they did not exist yet.
- Mock server: panel 0.3.3, Frankfurt behind on 0.3.2, Amsterdam with no
  version.
This commit is contained in:
2026-09-27 18:03:57 +03:00
parent eb0ec2878c
commit 5c3bf21918
8 changed files with 98 additions and 24 deletions
+11
View File
@@ -23,6 +23,16 @@ const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnez
const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? ""; const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? "";
let joinSecret = process.env.JOIN_SECRET ?? ""; // одноразовый, живёт до первого успешного подключения let joinSecret = process.env.JOIN_SECRET ?? ""; // одноразовый, живёт до первого успешного подключения
// Версия Forgetting на этой ноде — панель показывает её в карточке сервера. CLI передаёт её из
// .env в корне проекта; при запуске вручную читаем тот же .env сами.
function envFromFile(file: string, key: string): string {
try {
const m = fs.readFileSync(file, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m"));
return m ? m[1].trim() : "";
} catch { return ""; }
}
const VERSION = process.env.VERSION || envFromFile(path.join(__dirname, "..", ".env"), "VERSION");
if (!CORE_HOST || !CORE_PORT || !CORE_PIN || !NODE_ID) { if (!CORE_HOST || !CORE_PORT || !CORE_PIN || !NODE_ID) {
console.error("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`"); console.error("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`");
process.exit(1); process.exit(1);
@@ -138,6 +148,7 @@ function connectOnce(): Promise<{ authFailed: boolean }> {
sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"), sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"),
}; };
if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw }; if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw };
if (VERSION) auth.version = VERSION;
ws.send(JSON.stringify(auth)); ws.send(JSON.stringify(auth));
} else if (m.t === "ready") { } else if (m.t === "ready") {
ready = true; backoff = 1000; armDead(); ready = true; backoff = 1000; armDead();
+11 -9
View File
@@ -15,6 +15,7 @@ const now = () => Math.floor(Date.now() / 1000);
const rnd = (n: number) => Math.floor(Math.random() * n); const rnd = (n: number) => Math.floor(Math.random() * n);
const b64 = (n: number) => crypto.randomBytes(n).toString("base64"); const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
const GEN = "3.1"; const GEN = "3.1";
const MOCK_VERSION = "0.3.3";
// ── пользователи (vpn://) ── // ── пользователи (vpn://) ──
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number } interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
@@ -34,12 +35,13 @@ const usersOf: Record<number, U[]> = {
}; };
const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"]); return Number.isInteger(n) && usersOf[n] ? n : 0; }; const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"]); return Number.isInteger(n) && usersOf[n] ? n : 0; };
const U_ = (q: express.Request) => usersOf[srv(q)]; const U_ = (q: express.Request) => usersOf[srv(q)];
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean } interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean; version: string | null }
const mnodes: MNode[] = [ const mnodes: MNode[] = [
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false }, { id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false, version: "0.3.2" }, // отстаёт от панели
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true }, { id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true, version: null },
{ id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: true, pending: false, version: null }, // старый агент: версию не шлёт
]; ];
let nodeId = 2; let nodeId = 3;
const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url"); const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url");
// ── API-ключи ── // ── API-ключи ──
@@ -58,7 +60,7 @@ const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_lim
const devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length }); const devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting); 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: "0.2.2-mock" })); app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" })); app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
app.post("/logout", (_q, r) => r.json({ ok: true })); app.post("/logout", (_q, r) => r.json({ ok: true }));
app.get("/health", (q, r) => { app.get("/health", (q, r) => {
@@ -68,15 +70,15 @@ app.get("/health", (q, r) => {
// ── серверы ── // ── серверы ──
const nodeView = () => [ const nodeView = () => [
{ id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } }, { id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, version: MOCK_VERSION, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } },
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null, ...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null, version: n.version,
health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })), health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })),
]; ];
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, nodes: nodeView() })); app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, version: MOCK_VERSION, nodes: nodeView() }));
app.post("/ui/nodes", (q, r) => { app.post("/ui/nodes", (q, r) => {
const name = String(q.body.name ?? ""); const name = String(q.body.name ?? "");
if (!name || name.length > 40) return r.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" }); if (!name || name.length > 40) return r.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" });
const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true }); const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true, version: null });
r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 }); r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 });
}); });
app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); }); app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); });
+14 -7
View File
@@ -25,7 +25,7 @@ export type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<
interface NodeRow { interface NodeRow {
id: number; name: string; pub: string | null; id: number; name: string; pub: string | null;
join_hash: Buffer | null; join_expires: number | null; join_hash: Buffer | null; join_expires: number | null;
created_at: number; last_seen: number | null; created_at: number; last_seen: number | null; version: string | null;
} }
interface Health { interface Health {
@@ -46,11 +46,12 @@ const RPC_TIMEOUT = 15_000;
const RPC_SLOW = 120_000; // /awg/*: рестарт и upgrade идут долго const RPC_SLOW = 120_000; // /awg/*: рестарт и upgrade идут долго
const NAME_RE = /^[^\r\n<>]{1,40}$/; const NAME_RE = /^[^\r\n<>]{1,40}$/;
const HOST_RE = /^[A-Za-z0-9.:_-]{1,253}$/; const HOST_RE = /^[A-Za-z0-9.:_-]{1,253}$/;
const VERSION_RE = /^[\w.+-]{1,32}$/;
// Сообщение, которое нода подписывает своим ключом. Дублируется в awg-agent. // Сообщение, которое нода подписывает своим ключом. Дублируется в awg-agent.
const authMsg = (nonce: string, nodeId: number) => Buffer.from(`awg-node-v1\n${nonce}\n${nodeId}`); const authMsg = (nonce: string, nodeId: number) => Buffer.from(`awg-node-v1\n${nonce}\n${nodeId}`);
export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string }) { export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string; version: string }) {
const { uidb, ctrlLocal } = deps; const { uidb, ctrlLocal } = deps;
const NODE_PORT = Number(process.env.NODE_PORT) || 0; const NODE_PORT = Number(process.env.NODE_PORT) || 0;
@@ -94,6 +95,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
last_seen INTEGER last_seen INTEGER
); );
`); `);
// Версия Forgetting на ноде — из кадра auth (агенты до 0.3.4 её не присылают). Колонка
// добавлена позже, у существующей таблицы её может не быть.
if (!(uidb.prepare("PRAGMA table_info(nodes)").all() as { name: string }[]).some(c => c.name === "version"))
uidb.exec("ALTER TABLE nodes ADD COLUMN version TEXT");
const q = { const q = {
all: uidb.prepare("SELECT * FROM nodes ORDER BY id"), all: uidb.prepare("SELECT * FROM nodes ORDER BY id"),
byId: uidb.prepare<[number]>("SELECT * FROM nodes WHERE id = ?"), byId: uidb.prepare<[number]>("SELECT * FROM nodes WHERE id = ?"),
@@ -101,7 +106,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
rename: uidb.prepare<[string, number]>("UPDATE nodes SET name = ? WHERE id = ?"), rename: uidb.prepare<[string, number]>("UPDATE nodes SET name = ? WHERE id = ?"),
rejoin: uidb.prepare<[Buffer, number, number]>("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL"), rejoin: uidb.prepare<[Buffer, number, number]>("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL"),
bind: uidb.prepare<[string, number]>("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?"), bind: uidb.prepare<[string, number]>("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?"),
seen: uidb.prepare<[number, number]>("UPDATE nodes SET last_seen = ? WHERE id = ?"), seen: uidb.prepare<[number, string | null, number]>("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?"),
delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"), delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"),
}; };
const now = () => Math.floor(Date.now() / 1000); const now = () => Math.floor(Date.now() / 1000);
@@ -225,7 +230,9 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
clearTimeout(authTimer); clearTimeout(authTimer);
if (joining) q.bind.run(pubB64 as string, row.id); if (joining) q.bind.run(pubB64 as string, row.id);
q.seen.run(now(), row.id); // Версия — только для показа в панели: не подписана, но и доверия не даёт.
const version = typeof m.version === "string" && VERSION_RE.test(m.version) ? m.version : null;
q.seen.run(now(), version, row.id);
const old = conns.get(row.id); const old = conns.get(row.id);
conn = { ws, seq: 0, alive: true, pending: new Map() }; conn = { ws, seq: 0, alive: true, pending: new Map() };
@@ -303,15 +310,15 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
const nodes: unknown[] = []; const nodes: unknown[] = [];
if (LOCAL) { if (LOCAL) {
const h = health.get(0) ?? null; const h = health.get(0) ?? null;
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, health: h }); nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h });
} }
for (const n of q.all.all() as NodeRow[]) { for (const n of q.all.all() as NodeRow[]) {
nodes.push({ nodes.push({
id: n.id, name: n.name, local: false, online: conns.has(n.id), id: n.id, name: n.name, local: false, online: conns.has(n.id),
pending: n.pub === null, last_seen: n.last_seen, health: health.get(n.id) ?? null, pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null,
}); });
} }
res.json({ hub: hubReady(), local: LOCAL, nodes }); res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes });
}); });
router.post("/", (req: Request, res: Response) => { router.post("/", (req: Request, res: Response) => {
+1 -1
View File
@@ -355,7 +355,7 @@ app.use("/api/v1", ext);
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный // sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется. // листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db. // Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db.
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname }); const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname, version: VERSION });
app.use("/ui/nodes", requireAuth, nodes.router); app.use("/ui/nodes", requireAuth, nodes.router);
// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды). // sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
+14
View File
@@ -25,6 +25,8 @@ html, body, #root { height: 100%; }
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85; --neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a; --error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
--warn-dot: #e08a2c; --warn-dot: #e08a2c;
--tertiary-container: #ffdcad; /* design.md: tertiary-container */
--on-tertiary-container: #7a4a14;
--danger-border: #e6c4bc; --danger-border: #e6c4bc;
--danger-text: #b34a3a; --danger-text: #b34a3a;
@@ -67,6 +69,8 @@ html, body, #root { height: 100%; }
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085; --neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449; --error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
--warn-dot: #ffb76b; --warn-dot: #ffb76b;
--tertiary-container: #4a3218; /* тёплый тёмный, как у статусов в тёмной теме */
--on-tertiary-container: #ffdcad;
--danger-border: #6e4540; --danger-border: #6e4540;
--danger-text: #ffb4ab; --danger-text: #ffb4ab;
@@ -449,6 +453,16 @@ body {
line-height: 1.2; line-height: 1.2;
} }
.server-gen-wrap { align-self: flex-start; margin-top: 2px; } .server-gen-wrap { align-self: flex-start; margin-top: 2px; }
.server-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
.server-chips .server-gen-wrap, .server-chips .server-gen-chip { margin-top: 0; }
.server-ver-chip {
font-family: var(--mono);
font-size: 11px;
padding: 2px 8px;
background: var(--neutral-bg);
color: var(--neutral-text);
}
.server-ver-chip--warn { background: var(--tertiary-container); color: var(--on-tertiary-container); }
.server-gen-chip { .server-gen-chip {
align-self: flex-start; align-self: flex-start;
margin-top: 2px; margin-top: 2px;
+3
View File
@@ -23,6 +23,7 @@ export default function App() {
const [statusText, setStatusText] = useState(''); const [statusText, setStatusText] = useState('');
const [nodes, setNodes] = useState<NodeInfo[]>([]); const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [hubEnabled, setHubEnabled] = useState(false); const [hubEnabled, setHubEnabled] = useState(false);
const [panelVersion, setPanelVersion] = useState<string | null>(null);
const [serverId, setServerIdState] = useState<number | null>(null); const [serverId, setServerIdState] = useState<number | null>(null);
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null); const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
@@ -55,6 +56,7 @@ export default function App() {
const list: NodeInfo[] = data.nodes ?? []; const list: NodeInfo[] = data.nodes ?? [];
setNodes(list); setNodes(list);
setHubEnabled(!!data.hub); setHubEnabled(!!data.hub);
setPanelVersion(data.version ?? null);
const cur = getServerId(); const cur = getServerId();
if (cur !== null && list.some(n => n.id === cur)) return; if (cur !== null && list.some(n => n.id === cur)) return;
let saved: number | null = null; let saved: number | null = null;
@@ -311,6 +313,7 @@ export default function App() {
nodes={nodes} nodes={nodes}
serverId={serverId} serverId={serverId}
hubEnabled={hubEnabled} hubEnabled={hubEnabled}
panelVersion={panelVersion}
onSelect={pickServer} onSelect={pickServer}
onAdd={() => setModal({})} onAdd={() => setModal({})}
onJoin={n => setModal({ node: n })} onJoin={n => setModal({ node: n })}
+32 -6
View File
@@ -1,17 +1,39 @@
// Copyright (c) 2026 Ivan Vasilev // Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the // This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { type NodeInfo, genLabel } from '../lib/shared'; import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons'; import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и // Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает // (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты). // серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
// Версия Forgetting на сервере. Нода, которая отстаёт от панели или не сообщает версию
// (агент старше 0.3.4), подсвечена: её стоит обновить — install.sh, «Обновить».
function versionChip(n: NodeInfo, panel: string | null) {
if (n.pending) return null;
let tip = '', warn = false;
if (!n.version) {
warn = true;
tip = 'Версия неизвестна: нода старше 0.3.4 — обнови её (install.sh → «Обновить»)';
} else if (panel && !n.local) {
const d = cmpVersion(n.version, panel);
if (d < 0) { warn = true; tip = `Панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
else if (d > 0) { warn = true; tip = `Нода новее панели (${panel}) — обнови панель`; }
}
const chip = (
<span className={`chip server-ver-chip${warn ? ' server-ver-chip--warn' : ''}`}>
{n.version ? `v${n.version}` : 'v?.?.?'}
</span>
);
return tip ? <span className="tip-wrap server-gen-wrap" data-tip={tip}>{chip}</span> : chip;
}
export default function ServerBar({ export default function ServerBar({
nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete, nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete,
onRestartAwg, restarting, showRestart = true, onRestartAwg, restarting, showRestart = true,
}: { }: {
nodes: NodeInfo[]; nodes: NodeInfo[];
panelVersion: string | null;
serverId: number | null; serverId: number | null;
hubEnabled: boolean; hubEnabled: boolean;
onSelect: (id: number) => void; onSelect: (id: number) => void;
@@ -52,9 +74,12 @@ export default function ServerBar({
) : !n.online ? ( ) : !n.online ? (
<span className="server-card-ip">нет связи</span> <span className="server-card-ip">нет связи</span>
) : h && ( ) : h && (
<>
<span className="server-card-ip">{h.ip}</span> <span className="server-card-ip">{h.ip}</span>
{(h.module || h.tools) ? ( )}
{/* Плашки: поколение AWG (только на связи) и версия Forgetting (известна и без связи) */}
{!n.pending && (
<span className="server-chips">
{h && n.online && ((h.module || h.tools) ? (
<span <span
className="tip-wrap server-gen-wrap" className="tip-wrap server-gen-wrap"
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`} data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
@@ -63,8 +88,9 @@ export default function ServerBar({
</span> </span>
) : ( ) : (
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span> <span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
)} ))}
</> {versionChip(n, panelVersion)}
</span>
)} )}
</div> </div>
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>} {h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
+11
View File
@@ -60,9 +60,20 @@ export interface NodeInfo {
online: boolean; online: boolean;
pending: boolean; // нода создана, но ещё ни разу не подключалась pending: boolean; // нода создана, но ещё ни разу не подключалась
last_seen: number | null; last_seen: number | null;
version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null; health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
} }
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
export function cmpVersion(a: string, b: string): number {
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d) return d;
}
return 0;
}
// Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id // Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та // подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
// перечитывает данные уже с новым сервером. // перечитывает данные уже с новым сервером.