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:
@@ -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
@@ -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
@@ -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
@@ -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://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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 у вкладки, поэтому та
|
||||||
// перечитывает данные уже с новым сервером.
|
// перечитывает данные уже с новым сервером.
|
||||||
|
|||||||
Reference in New Issue
Block a user