Panel redesign, per-server I1 masking, node minimum version 0.5.0, installer output
ci / check (push) Successful in 23m54s

- UI: settings tab with sections (server, API keys, Prometheus, audit, migrate,
  about); server settings moved there; master keys table rework (menu, chips,
  version, compact phone cards); three-state node version chip
- I1: random per-server template (DNS / QUIC Initial / STUN) kept in users.db,
  GET/POST /api/masking, change from the master keys banner
- Hub rejects nodes older than 0.5.0 (close 4026), /ui/nodes reports outdated
- install.sh: spinner for long commands (full output in the log), step progress
  bar, cards; installs older than 0.5.0 require a full reinstall with data reset
- Bump to Beta 0.5.0
This commit is contained in:
2026-10-29 22:59:09 +03:00
parent c7d1333125
commit 5b29993d5d
57 changed files with 3154 additions and 1106 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting BRAND=Forgetting
CHANNEL=Beta CHANNEL=Beta
VERSION=0.4.7 VERSION=0.5.0
+2 -2
View File
@@ -99,7 +99,7 @@ URL в обоих случаях — адрес сервера и порт, вы
### Публичный API (`/api/v1`) ### Публичный API (`/api/v1`)
Ключ создаётся во вкладке «API-ключи» и уходит в заголовке `X-Api-Key`. Ключ создаётся в «Настройках», на вкладке «API-ключи», и уходит в заголовке `X-Api-Key`.
Открытое значение показывается один раз при создании — на сервере лежит только Открытое значение показывается один раз при создании — на сервере лежит только
SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля: SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля:
`psk_key` и `pub_key` не покидают сервер. `psk_key` и `pub_key` не покидают сервер.
@@ -187,7 +187,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
| Метод | Ответ | | Метод | Ответ |
|---|---| |---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.7" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина | | `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.5.0" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` | | `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` | | `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-agent", "name": "awg-agent",
"version": "0.4.7", "version": "0.5.0",
"private": true, "private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl", "description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-control", "name": "awg-control",
"version": "0.4.7", "version": "0.5.0",
"description": "", "description": "",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+102 -30
View File
@@ -15,7 +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"; const MOCK_VERSION = "0.5.0";
// ── пользователи (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 }
@@ -37,9 +37,10 @@ const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"])
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; version: string | null } 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, version: "0.3.2" }, // отстаёт от панели { id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false, version: "0.5.0" },
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true, version: null }, { 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 }, // старый агент: версию не шлёт { id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: false, pending: false, version: "0.4.5" }, // старше 0.5.0: хаб не подключает
{ id: 4, name: "Helsinki", ip: "192.0.2.44", gen: "3.1", online: true, pending: false, version: "0.5.1" }, // новее панели — жёлтый чип
]; ];
let nodeId = 3; 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");
@@ -53,8 +54,8 @@ interface Dev { id: number; device_id: string; device_name: string; platform: st
interface MK { id: number; uuid: string; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] } interface MK { id: number; uuid: string; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] }
let mkId = 1, devId = 1; let mkId = 1, devId = 1;
const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) }); const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) });
const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }, const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 2, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1, 2] }, // все места заняты; Tokyo (2) ещё не получил устройства
{ id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0] }, { id: mkId++, uuid: crypto.randomUUID(), label: "Офис", device_limit: 5, created_at: now() - 86400, secret: crypto.randomBytes(16), devs: [], servers: [0, 1] }, // «все серверы»
{ id: mkId++, uuid: crypto.randomUUID(), label: "Друзья", device_limit: 3, created_at: now() - 3600, secret: crypto.randomBytes(16), devs: [], servers: [1] }]; { id: mkId++, uuid: crypto.randomUUID(), label: "Друзья", device_limit: 3, created_at: now() - 3600, secret: crypto.randomBytes(16), devs: [], servers: [1] }];
const signPub = crypto.randomBytes(32); const signPub = crypto.randomBytes(32);
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) }); const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
@@ -75,9 +76,17 @@ app.get("/health", (q, r) => {
}); });
// ── серверы ── // ── серверы ──
// Как MinNodeVersion в хабе: ноду старше хаб не подключает (в моке она просто offline + outdated)
const MIN_NODE = "0.5.0";
const cmpVer = (a: string, b: string) => {
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;
};
const nodeView = () => [ const nodeView = () => [
{ 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" } }, { 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, version: n.version, ...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,
outdated: !n.pending && (!n.version || cmpVer(n.version, MIN_NODE) < 0),
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, version: MOCK_VERSION, nodes: nodeView() })); app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, version: MOCK_VERSION, nodes: nodeView() }));
@@ -106,23 +115,46 @@ const MON_LOGS = {
install: ["1/4 установка пакета prometheus", "Reading package lists…", "Setting up prometheus (2.45.0)…", "2/4 токен и конфиг", "3/4 слушаем только 127.0.0.1:9090, хранение 31d", "4/4 запуск", "✓ Prometheus работает на 127.0.0.1:9090, метрики панели скрейпятся каждые 15 с."], install: ["1/4 установка пакета prometheus", "Reading package lists…", "Setting up prometheus (2.45.0)…", "2/4 токен и конфиг", "3/4 слушаем только 127.0.0.1:9090, хранение 31d", "4/4 запуск", "✓ Prometheus работает на 127.0.0.1:9090, метрики панели скрейпятся каждые 15 с."],
remove: ["1/3 остановка сервиса", "2/3 токен", "3/3 удаление пакета и накопленных данных", "Removing prometheus (2.45.0)…", "Purging configuration files…", "✓ Prometheus удалён вместе с накопленными графиками."], remove: ["1/3 остановка сервиса", "2/3 токен", "3/3 удаление пакета и накопленных данных", "Removing prometheus (2.45.0)…", "Purging configuration files…", "✓ Prometheus удалён вместе с накопленными графиками."],
}; };
let monJob: { action: "install" | "remove"; start: number } | null = null; // Как internal/ui/monitor.go: Prometheus у панели один. target — чей awg-ctrl ставит и удаляет его:
// где он уже стоит; если нигде — свой (панель + VPN) или ?server= (панель без VPN, MOCK_ROLE=core).
// Ход операции у каждого сервера свой (его awg-ctrl). Ставить можно, только если awg-ctrl сервера
// на связи и новый: Tokyo без связи, у Amsterdam старый агент (/api/monitor/job → 404) — у них
// can_install: false и команда вручную; у Frankfurt осталась неудачная прошлая установка.
type MonJob = { action: "install" | "remove"; start: number; lines?: string[]; exit?: number };
const monJobs: Record<number, MonJob> = {
1: { action: "install", start: now() * 1000 - 86400e3, exit: 100, lines: ["1/4 установка пакета prometheus", "Reading package lists…",
"E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 1873 (unattended-upgr)", "E: Unable to acquire the dpkg frontend lock, is another process using it?"] },
};
let monReady = false; let monReady = false;
let monHost = 0; // где стоит (для панели без VPN — нода; у панели с VPN всегда 0, «этот сервер»)
const monHostOf = (id: number) => ({ id, name: nodeView().find(n => n.id === id)?.name ?? `#${id}` });
const MON_MS = 6000; const MON_MS = 6000;
const monRunning = () => !!monJob && Date.now() - monJob.start < MON_MS; const monRunning = (j?: MonJob) => !!j && j.exit === undefined && Date.now() - j.start < MON_MS;
app.get("/ui/monitor", (_q, r) => { const monTarget = (q: express.Request) => monReady || !MOCK_CORE ? monHost : Number(q.query.server ?? 0);
if (monJob && !monRunning()) monReady = monJob.action === "install"; const monCan = (id: number) => { const n = mnodes.find(x => x.id === id); return !n || (n.online && n.version !== null); };
let job = null; app.get("/ui/monitor", (q, r) => {
if (monJob) { const target = monTarget(q), can = monCan(target), mj = monJobs[target];
const lines = MON_LOGS[monJob.action]; if (mj && mj.exit === undefined && !monRunning(mj)) { // операция дошла до конца
const n = monRunning() ? Math.max(1, Math.floor(((Date.now() - monJob.start) / MON_MS) * lines.length)) : lines.length; mj.exit = 0;
job = { action: monJob.action, running: monRunning(), exit: monRunning() ? null : 0, log: lines.slice(0, n).join("\n") }; monReady = mj.action === "install";
if (monReady) monHost = target;
} }
// MOCK_ROLE=core/retire — панель без VPN: Prometheus на ноде #0 (сервер, с которого она переехала) let job = null;
r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", host: MOCK_CORE ? { id: 0, name: "VPN" } : null, job }); if (can && mj) {
const lines = mj.lines ?? MON_LOGS[mj.action];
const n = monRunning(mj) ? Math.max(1, Math.floor(((Date.now() - mj.start) / MON_MS) * lines.length)) : lines.length;
job = { action: mj.action, running: monRunning(mj), exit: monRunning(mj) ? null : mj.exit ?? 0, log: lines.slice(0, n).join("\n") };
}
r.json({ ready: monReady, can_install: can, command: "awg-ctrl monitor install", host: MOCK_CORE ? monHostOf(target) : null, job });
}); });
for (const action of ["install", "remove"] as const) { for (const action of ["install", "remove"] as const) {
app.post(`/ui/monitor/${action}`, (_q, r) => { if (!monRunning()) monJob = { action, start: Date.now() }; r.status(202).json({ running: true, action }); }); app.post(`/ui/monitor/${action}`, (q, r) => {
const target = monTarget(q), n = mnodes.find(x => x.id === target);
if (n && !n.online) return r.status(502).json({ error: "awg-ctrl недоступен" });
if (n && n.version === null) return r.status(501).json({ error: "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную" });
if (!monRunning(monJobs[target])) monJobs[target] = { action, start: Date.now() };
r.status(202).json({ running: true, action });
});
} }
// Перенос панели (internal/ui/migrate.go). Мок — standalone: переносить можно, принимать нельзя; // Перенос панели (internal/ui/migrate.go). Мок — standalone: переносить можно, принимать нельзя;
// MOCK_ROLE=core — наоборот (код «принимается» через 10 с, ещё через 10 с — «перезапуск»); // MOCK_ROLE=core — наоборот (код «принимается» через 10 с, ещё через 10 с — «перезапуск»);
@@ -183,19 +215,38 @@ app.get("/ui/monitor/overview", (q, r) => {
const cpu = pts((_, t) => 38 + 22 * wave(t, per * 0.8, 2) + 12 * wave(t, per / 5) + rnd(6)); const cpu = pts((_, t) => 38 + 22 * wave(t, per * 0.8, 2) + 12 * wave(t, per / 5) + rnd(6));
const mem = pts((_, t) => 54 + 6 * wave(t, per * 3) + rnd(2)); const mem = pts((_, t) => 54 + 6 * wave(t, per * 3) + rnd(2));
const scale = (a: [number, number][], k: number) => a.map(([t, v]) => [t, v * k] as [number, number]); const scale = (a: [number, number][], k: number) => a.map(([t, v]) => [t, v * k] as [number, number]);
// Один сервер — тот, что выбран в сервер-баре (?server=<id>): ядро (0) или нода
const id = Number(q.query.server ?? 0);
const k = id === 0 ? 0.7 : 0.3;
const down1 = scale(down, k), up1 = scale(up, k);
const used = (a: [number, number][]) => a.reduce((n, [, v]) => n + v, 0) * step / 8; // бит/с × с → байты const used = (a: [number, number][]) => a.reduce((n, [, v]) => n + v, 0) * step / 8; // бит/с × с → байты
if (id === 3) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] } }); // Ряды одного сервера: ядро (0) — основная нагрузка, нода Frankfurt (1) — поменьше
r.json({ range: q.query.range ?? "1h", step, from, to, const one = (id: number) => {
servers: [{ id, name: id === 0 ? "VPN (mock)" : "Frankfurt", ok: true, down: down1.at(-1)![1], up: up1.at(-1)![1], online: id === 0 ? 7 : 3, const k = id === 0 ? 0.7 : 0.3;
heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0, const d = scale(down, k), u = scale(up, k);
cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30, return {
used_down: used(down1), used_up: used(up1) }], row: { id, name: id === 0 ? "VPN (mock)" : "Frankfurt", ok: true, down: d.at(-1)![1], up: u.at(-1)![1], online: id === 0 ? 7 : 3,
total: { down: down1, up: up1, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0), heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0,
cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) } }); cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30,
used_down: used(d), used_up: used(u) },
series: { down: d, up: u, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0),
cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) },
};
};
const empty = { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] };
// Без ?server= — все серверы (как monitor.go): ряды трафика, устройств и потерь суммируются, процессор
// и память — среднее. Метрики отдают ядро и Frankfurt; у Amsterdam (3) старый агент — его нет.
if (q.query.server === undefined) {
const all = [one(0), one(1)];
const zip = (key: keyof typeof empty, avg: boolean) => all[0].series[key].map(([t], n) => {
const sum = all.reduce((acc, x) => acc + x.series[key][n][1], 0);
return [t, avg ? sum / all.length : sum] as [number, number];
});
return r.json({ range: q.query.range ?? "1h", step, from, to, servers: all.map(x => x.row),
total: { down: zip("down", false), up: zip("up", false), online: zip("online", false), heavy: zip("heavy", false),
drops: zip("drops", false), cpu: zip("cpu", true), mem: zip("mem", true) } });
}
// Один сервер (?server=<id>)
const id = Number(q.query.server);
if (id !== 0 && id !== 1) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: empty });
const x = one(id);
r.json({ range: q.query.range ?? "1h", step, from, to, servers: [x.row], total: x.series });
}); });
// Prometheus: токен метрик и сам /metrics (без проверки токена — мок). // Prometheus: токен метрик и сам /metrics (без проверки токена — мок).
@@ -211,7 +262,9 @@ app.delete("/ui/metrics-token", (_q, r) => { promTok = null; r.json(promView());
app.get("/metrics", (_q, r) => r.type("text/plain").send('awg_panel_info{version="mock"} 1\nawg_servers 4\n')); app.get("/metrics", (_q, r) => r.type("text/plain").send('awg_panel_info{version="mock"} 1\nawg_servers 4\n'));
// Деление канала: состояние по серверам; usersOf[1] играет «старую ноду» без маршрута (404 → «обнови сервер»). // Деление канала: состояние по серверам; usersOf[1] играет «старую ноду» без маршрута (404 → «обнови сервер»).
const shapingOf: Record<number, { enabled: boolean; downMbit: number; upMbit: number; auto: boolean }> = {}; // На сервере 0 деление канала включено сразу — чтобы был пример «фонового» приоритета: пользователь
// 10.9.0.2 и «MacBook Ивана» в мастер-ключе «Семья».
const shapingOf: Record<number, { enabled: boolean; downMbit: number; upMbit: number; auto: boolean }> = { 0: { enabled: true, downMbit: 100, upMbit: 50, auto: true } };
const shapingView = (id: number) => { const shapingView = (id: number) => {
const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true }; const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true };
const on = settings.enabled; const on = settings.enabled;
@@ -254,6 +307,25 @@ app.put("/api/torrent", (q, r) => {
r.json(torrentView(id)); r.json(torrentView(id));
}); });
// Маскировка первого пакета (I1): у каждого сервера своя; Frankfurt (1) — старая нода без маршрута (404).
// «Сменить» берёт случайный шаблон из примеров — как GenI1 в awg-ctrl, только без случайных полей.
const I1_SAMPLES = [
{ kind: "dns", label: "DNS-запрос к ya.ru", i1: "<r 2><b 0x01000001000000000001027961027275000001000100002904d0000000000000>" },
{ kind: "quic", label: "QUIC Initial, 1250 байт", i1: "<b 0xc30000000108><r 8><b 0x00><b 0x0044e2><r 1232>" },
{ kind: "stun", label: "STUN binding request", i1: "<b 0x000100002112a442><r 12>" },
{ kind: "dns", label: "DNS-запрос к www.google.com", i1: "<r 2><b 0x010000010000000000000377777706676f6f676c6503636f6d0000410001>" },
{ kind: "quic", label: "QUIC Initial, 1200 байт", i1: "<b 0xc90000000108><r 8><b 0x08><r 8><b 0x0044a6><r 1190>" },
];
const maskingOf: Record<number, number> = { 0: 0, 2: 1, 3: 2 };
app.get("/api/masking", (q, r) => tsid(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(I1_SAMPLES[maskingOf[tsid(q)] ?? 3]));
app.post("/api/masking", (q, r) => {
const id = tsid(q);
if (id === 1) return r.status(404).json({ error: "Not found" });
const cur = maskingOf[id] ?? 3;
maskingOf[id] = (cur + 1 + Math.floor(Math.random() * (I1_SAMPLES.length - 1))) % I1_SAMPLES.length;
r.json(I1_SAMPLES[maskingOf[id]]);
});
app.get("/api/users", (q, r) => r.json({ users: U_(q) })); app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
// Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый». // Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
// С меткой «торренты» — тоже фоновый (пока включено деление канала). // С меткой «торренты» — тоже фоновый (пока включено деление канала).
+2 -1
View File
@@ -67,9 +67,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
const crtF = file(process.env.NODE_TLS_CERT_FILE, "node_tls.crt"); const crtF = file(process.env.NODE_TLS_CERT_FILE, "node_tls.crt");
try { try {
if (!fs.existsSync(keyF) && !fs.existsSync(crtF)) { if (!fs.existsSync(keyF) && !fs.existsSync(crtF)) {
// CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI.
const r = spawnSync("openssl", [ const r = spawnSync("openssl", [
"req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node", "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=" + crypto.randomBytes(6).toString("hex"),
], { stdio: "ignore" }); ], { stdio: "ignore" });
if (r.status !== 0) throw new Error("openssl"); if (r.status !== 0) throw new Error("openssl");
fs.chmodSync(keyF, 0o600); fs.chmodSync(crtF, 0o600); fs.chmodSync(keyF, 0o600); fs.chmodSync(crtF, 0o600);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-ui", "name": "awg-ui",
"version": "0.4.7", "version": "0.5.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+511 -265
View File
File diff suppressed because it is too large Load Diff
+185 -68
View File
@@ -3,15 +3,17 @@
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import './tokens.css';
import './App.css'; import './App.css';
import { import {
TOKEN_KEY, THEME_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId, TOKEN_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId, serversLabel, openTab, MIN_NODE_VERSION,
type NodeInfo, type Brand, type NodeInfo, type Brand,
} from './lib/shared'; } from './lib/shared';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons'; import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh, IcoChevronDown } from './components/icons';
import ServerBar from './components/ServerBar'; import ServerBar from './components/ServerBar';
import DrumTitle from './components/DrumTitle';
import { useTheme, setThemeMode } from './lib/theme';
import AddServerModal from './components/AddServerModal'; import AddServerModal from './components/AddServerModal';
import ServerSettingsModal from './components/ServerSettingsModal';
import { TABS, TABS_BY_GROUP } from './tabs'; import { TABS, TABS_BY_GROUP } from './tabs';
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token, // Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
@@ -27,14 +29,17 @@ export default function App() {
const [panelVersion, setPanelVersion] = useState<string | null>(null); 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 [settingsFor, setSettingsFor] = useState<NodeInfo | null>(null);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [serversOpen, setServersOpen] = useState(false); // выпадающий список серверов
const serverSelectRef = useRef<HTMLDivElement>(null);
const [activeTab, setActiveTab] = useState(TABS[0].id); const [activeTab, setActiveTab] = useState(TABS[0].id);
// Встроенный Prometheus: null — ещё не знаем; fresh — только что поставили (пункт «Монитор» въезжает в меню);
// leaving — только что удалили (пункт ещё в меню, пока доигрывает уход)
const [prom, setProm] = useState<{ ready: boolean; fresh: boolean; leaving: boolean } | null>(null);
const [restarting, setRestarting] = useState(false); const [restarting, setRestarting] = useState(false);
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' }); const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
const [theme, setTheme] = useState<'light' | 'dark'>(() => const { theme } = useTheme();
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
const touchStartX = useRef(0); const touchStartX = useRef(0);
const touchCurrentX = useRef(0); const touchCurrentX = useRef(0);
@@ -197,18 +202,78 @@ export default function App() {
.catch(() => { /* остаётся фолбэк */ }); .catch(() => { /* остаётся фолбэк */ });
}, []); }, []);
// Применять и сохранять тему
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(THEME_KEY, theme);
}, [theme]);
// Блокировать прокрутку фона когда drawer открыт // Блокировать прокрутку фона когда drawer открыт
useEffect(() => { useEffect(() => {
document.body.style.overflow = drawerOpen ? 'hidden' : ''; document.body.style.overflow = drawerOpen ? 'hidden' : '';
return () => { document.body.style.overflow = ''; }; return () => { document.body.style.overflow = ''; };
}, [drawerOpen]); }, [drawerOpen]);
// На широком окне (≥1200px) меню постоянное — модальный drawer там не нужен
useEffect(() => {
const mq = window.matchMedia('(min-width: 1200px)');
const h = () => { if (mq.matches) setDrawerOpen(false); };
mq.addEventListener('change', h);
return () => mq.removeEventListener('change', h);
}, []);
// Список серверов закрывается кликом мимо него и по Escape
useEffect(() => {
if (!serversOpen) return;
const down = (e: MouseEvent) => {
if (!serverSelectRef.current?.contains(e.target as Node)) setServersOpen(false);
};
const key = (e: KeyboardEvent) => { if (e.key === 'Escape') setServersOpen(false); };
document.addEventListener('mousedown', down);
document.addEventListener('keydown', key);
return () => { document.removeEventListener('mousedown', down); document.removeEventListener('keydown', key); };
}, [serversOpen]);
// Есть ли встроенный Prometheus — от него зависит пункт «Монитор» (requires: 'prometheus').
// Узнаём при входе, раз в минуту (могли поставить из CLI) и сразу от вкладок (announcePrometheus).
// Анимации — только при переходах «не было → есть» и «было → нет», не при каждой загрузке страницы.
const notePrometheus = useCallback((ready: boolean) => {
setProm(p => (p?.ready === ready ? p : { ready, fresh: p !== null && ready, leaving: !!p?.ready && !ready }));
}, []);
// Уход длится --duration-medium2 (300 мс); потом пункт убираем из меню совсем
useEffect(() => {
if (!prom?.leaving) return;
const t = setTimeout(() => setProm(p => (p?.leaving ? { ...p, leaving: false } : p)), 300);
return () => clearTimeout(t);
}, [prom?.leaving]);
useEffect(() => {
if (!token) { setProm(null); return; }
const load = () => apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token)
.then(r => notePrometheus(!!r.ready))
.catch(() => { /* не знаем — меню не трогаем */ });
void load();
const t = setInterval(load, 60000);
const h = (e: Event) => notePrometheus((e as CustomEvent<boolean>).detail);
window.addEventListener('awg:prometheus', h);
return () => { clearInterval(t); window.removeEventListener('awg:prometheus', h); };
}, [token, notePrometheus]);
// tabVisible — вкладка доступна; в меню она ещё и на время ухода
const tabVisible = (t: { requires?: 'prometheus' }) => t.requires !== 'prometheus' || !!prom?.ready;
const tabLeaving = (t: { requires?: 'prometheus' }) => t.requires === 'prometheus' && !!prom?.leaving;
const navGroups = TABS_BY_GROUP
.map(g => ({ ...g, tabs: g.tabs.filter(t => tabVisible(t) || tabLeaving(t)) }))
.filter(g => g.tabs.length > 0);
// Активная вкладка пропала из меню (Prometheus удалили) — на первую
useEffect(() => {
const t = TABS.find(x => x.id === activeTab);
if (t && !tabVisible(t)) setActiveTab(TABS[0].id);
});
// Переход на другую вкладку из самой вкладки (openTab в lib/shared)
useEffect(() => {
const h = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
if (TABS.some(t => t.id === id)) setActiveTab(id);
};
window.addEventListener('awg:open-tab', h);
return () => window.removeEventListener('awg:open-tab', h);
}, []);
// Закрывать drawer по Escape // Закрывать drawer по Escape
useEffect(() => { useEffect(() => {
if (!drawerOpen) return; if (!drawerOpen) return;
@@ -223,31 +288,25 @@ export default function App() {
const chrome = tab.chrome ?? {}; const chrome = tab.chrome ?? {};
const showServerBar = chrome.serverBar !== false; const showServerBar = chrome.serverBar !== false;
const showRestart = chrome.restart !== false; const showRestart = chrome.restart !== false;
const current = nodes.find(n => n.id === serverId) ?? null;
// Позиция вкладки в меню (по группам) — направление прокрутки заголовка-барабана
const tabOrder = navGroups.flatMap(g => g.tabs).findIndex(t => t.id === tab.id);
const themeToggle = (
<button
className="theme-toggle"
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
onClick={() => setThemeMode(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
);
return ( return (
<div className="app"> <div className="app">
<button
className="theme-toggle"
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))}
>
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
{token && showRestart && serverId !== null && (
<button
className={`awg-restart${restarting ? ' spinning' : ''}`}
aria-label="Перезапустить AWG"
title="Перезапустить AWG-интерфейс"
onClick={restartAwg}
disabled={restarting}
>
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
</button>
)}
{!token ? ( {!token ? (
<div className="login-screen"> <div className="login-screen">
<span className="theme-toggle--fixed">{themeToggle}</span>
<div className="login-card"> <div className="login-card">
<span className="logo-name">{brand.brand}</span> <span className="logo-name">{brand.brand}</span>
<p className="login-sub">Войдите, чтобы продолжить</p> <p className="login-sub">Войдите, чтобы продолжить</p>
@@ -285,7 +344,8 @@ export default function App() {
> >
<IcoMenu /> <IcoMenu />
</button> </button>
<span className="top-bar-title">{tab.label}</span> <span className="top-bar-title"><DrumTitle text={tab.label} order={tabOrder} /></span>
<span className="top-bar-end">{themeToggle}</span>
</header> </header>
{/* Затемнение под drawer */} {/* Затемнение под drawer */}
@@ -301,6 +361,15 @@ export default function App() {
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
{/* Кнопка меню rail (600–1199px): раскрывает его в модальный drawer */}
<button
className="rail-menu"
aria-label={drawerOpen ? 'Свернуть меню' : 'Развернуть меню'}
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen(o => !o)}
>
<IcoMenu />
</button>
<div className="sidebar-logo"> <div className="sidebar-logo">
<span className="logo-name">{brand.brand}</span> <span className="logo-name">{brand.brand}</span>
<span className="sidebar-version"> <span className="sidebar-version">
@@ -308,48 +377,105 @@ export default function App() {
</span> </span>
</div> </div>
<hr className="drawer-divider" /> <hr className="drawer-divider" />
{TABS_BY_GROUP.map(g => ( {navGroups.map(g => (
<nav key={g.id} className="nav-group" aria-label={g.label}> <nav key={g.id} className="nav-group" aria-label={g.label}>
<span className="nav-group-title">{g.label}</span> <span className="nav-group-title">{g.label}</span>
{g.tabs.map(t => ( {g.tabs.map(t => (
<div <button
key={t.id} key={t.id}
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`} type="button"
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}${tabLeaving(t) ? ' nav-item--leave' : t.requires === 'prometheus' && prom?.fresh ? ' nav-item--enter' : ''}`}
aria-current={t.id === activeTab ? 'page' : undefined}
aria-hidden={tabLeaving(t) || undefined}
tabIndex={tabLeaving(t) ? -1 : undefined}
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }} onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
> >
<t.Icon /> {t.label} <span className="nav-icon"><t.Icon /></span>
</div> <span className="nav-label">{t.label}</span>
</button>
))} ))}
</nav> </nav>
))} ))}
<div className="sidebar-spacer" /> <div className="sidebar-spacer" />
<button className="btn btn--danger" onClick={logout}> <button className="btn btn--danger sidebar-logout" aria-label="Выйти" onClick={logout}>
<IcoLogout /> Выйти <IcoLogout /> <span className="nav-label">Выйти</span>
</button> </button>
</aside> </aside>
<main className="main"> <main className="main">
<h2 className="page-title">{tab.label}</h2> {/* Шапка страницы: заголовок слева; справа — выбор сервера, перезапуск AWG и тема.
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */} На телефоне заголовок и тема — в верхнем баре, выбор сервера — во всю ширину. */}
{showServerBar && ( <div className="page-head">
<ServerBar <h2 className="page-title"><DrumTitle text={tab.label} order={tabOrder} /></h2>
nodes={nodes} <div className="page-actions">
serverId={serverId} {/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают.
hubEnabled={hubEnabled} Текущий сервер — кнопкой, остальные — выпадающим списком под ней. */}
panelVersion={panelVersion} {showServerBar && (
onSelect={pickServer} <div className="server-select" ref={serverSelectRef}>
onAdd={() => setModal({})} <button
onJoin={n => setModal({ node: n })} type="button"
onDelete={deleteNode} className={`server-current${serversOpen ? ' server-current--open' : ''}`}
// Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного aria-expanded={serversOpen}
onSettings={n => { pickServer(n.id); setSettingsFor(n); }} aria-controls="server-menu"
onRestartAwg={restartAwg} onClick={() => setServersOpen(o => !o)}
restarting={restarting} >
showRestart={showRestart} <span
/> className={`status-dot${current?.online ? ' status-dot--online' : ''}`}
)} role="img"
aria-label={current?.online ? 'на связи' : 'нет связи'}
/>
<span className="server-current-info">
<span className="server-card-name">
{current ? (current.health?.server || current.name) : 'Сервер не выбран'}
</span>
<span className="server-card-ip">
{!current ? 'добавьте ноду'
: current.pending ? 'ждёт подключения'
: current.outdated ? `нужно обновить до ${MIN_NODE_VERSION}`
: !current.online ? 'нет связи'
: current.health?.ip}
</span>
</span>
<span className="server-current-count">{serversLabel(nodes.length)}</span>
<span className="server-current-caret"><IcoChevronDown /></span>
</button>
{serversOpen && (
<div className="server-menu" id="server-menu" aria-label="Серверы">
<ServerBar
nodes={nodes}
serverId={serverId}
hubEnabled={hubEnabled}
panelVersion={panelVersion}
onSelect={id => { pickServer(id); setServersOpen(false); }}
onAdd={() => { setServersOpen(false); setModal({}); }}
onJoin={n => { setServersOpen(false); setModal({ node: n }); }}
onDelete={deleteNode}
// Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного
onSettings={n => { setServersOpen(false); pickServer(n.id); openTab('settings', 'server'); }}
onRestartAwg={restartAwg}
restarting={restarting}
showRestart={showRestart}
/>
</div>
)}
</div>
)}
{showRestart && serverId !== null && (
<button
className={`awg-restart${restarting ? ' spinning' : ''}`}
aria-label="Перезапустить AWG"
title="Перезапустить AWG-интерфейс"
onClick={restartAwg}
disabled={restarting}
>
<IcoRefresh /> {restarting ? 'Перезапуск…' : 'Перезапустить AWG'}
</button>
)}
<span className="page-theme">{themeToggle}</span>
</div>
</div>
{serverId === null && showServerBar ? ( {serverId === null && showServerBar ? (
<p className="login-sub"> <p className="login-sub">
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели. Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
@@ -373,15 +499,6 @@ export default function App() {
/> />
)} )}
{settingsFor && (
<ServerSettingsModal
token={token}
node={settingsFor}
onClose={() => setSettingsFor(null)}
showMsg={showMsg}
/>
)}
{msg && <div className="snack">{msg}</div>} {msg && <div className="snack">{msg}</div>}
</div> </div>
); );
+1 -1
View File
@@ -85,6 +85,7 @@ export default function AddServerModal({ token, node, onClose, onChanged, showMs
{error && <p className="login-error">{error}</p>} {error && <p className="login-error">{error}</p>}
<div className="node-modal-actions"> <div className="node-modal-actions">
<button className="btn btn--text" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
{result ? ( {result ? (
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}> <button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать <IcoCopy /> Скопировать
@@ -94,7 +95,6 @@ export default function AddServerModal({ token, node, onClose, onChanged, showMs
Создать Создать
</button> </button>
)} )}
<button className="btn btn--outline" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
</div> </div>
</div> </div>
</div> </div>
+19
View File
@@ -0,0 +1,19 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState } from 'react';
// Заголовок-«барабан»: при смене текста старый уходит гранью барабана, новый выкатывается на его
// место. order — позиция вкладки в меню: дальше по списку — барабан крутится вверх, обратно — вниз.
// Первый показ — без анимации. Стили — App.css, «Заголовок-барабан».
export default function DrumTitle({ text, order }: { text: string; order: number }) {
const [s, setS] = useState({ text, order, from: null as string | null, up: true, n: 0 });
// Текст сменился — запоминаем прежний прямо в рендере, чтобы новый не мелькнул без анимации
if (s.text !== text) setS({ text, order, from: s.text, up: order >= s.order, n: s.n + 1 });
return (
<span key={s.n} className={`drum${s.from !== null ? ` drum--roll drum--${s.up ? 'up' : 'down'}` : ''}`}>
{s.from !== null && <span className="drum-out" aria-hidden="true">{s.from}</span>}
<span className="drum-in">{text}</span>
</span>
);
}
+20 -17
View File
@@ -1,27 +1,31 @@
// 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, cmpVersion } from '../lib/shared'; import { type NodeInfo, genLabel, cmpVersion, MIN_NODE_VERSION } from '../lib/shared';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy, IcoSettings } from './icons'; import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy, IcoSettings } from './icons';
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и // Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает // (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты). // серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
// Версия Forgetting на сервере. Нода, которая отстаёт от панели или не сообщает версию // Версия Forgetting на сервере, три состояния: совпадает с панелью — нейтральный чип; нода отстаёт
// (агент старше 0.3.4), подсвечена: её стоит обновить — install.sh, «Обновить». // от панели (но не старше MIN_NODE_VERSION) или новее неё — жёлтый, стоит обновить; старше
// MIN_NODE_VERSION или не сообщает версию (агент старше 0.3.4) — красный: хаб такую ноду не подключает.
function versionChip(n: NodeInfo, panel: string | null) { function versionChip(n: NodeInfo, panel: string | null) {
if (n.pending) return null; if (n.pending) return null;
let tip = '', warn = false; let tip = '', level: '' | 'warn' | 'bad' = '';
if (!n.version) { if (!n.version) {
warn = true; level = 'bad';
tip = 'Версия неизвестна: нода старше 0.3.4 — обнови её (install.sh → «Обновить»)'; tip = `Версия неизвестна: нода старше 0.3.4, панель с ней не работает — обнови её до ${MIN_NODE_VERSION} (install.sh → «Обновить»)`;
} else if (!n.local && cmpVersion(n.version, MIN_NODE_VERSION) < 0) {
level = 'bad';
tip = `Слишком старая версия: панель с ней не работает, нужна не ниже ${MIN_NODE_VERSION} — обнови ноду (install.sh → «Обновить»)`;
} else if (panel && !n.local) { } else if (panel && !n.local) {
const d = cmpVersion(n.version, panel); const d = cmpVersion(n.version, panel);
if (d < 0) { warn = true; tip = `Панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; } if (d < 0) { level = 'warn'; tip = `Устарела: панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
else if (d > 0) { warn = true; tip = `Нода новее панели (${panel}) — обнови панель`; } else if (d > 0) { level = 'warn'; tip = `Нода новее панели (${panel}) — обнови панель`; }
} }
const chip = ( const chip = (
<span className={`chip server-ver-chip${warn ? ' server-ver-chip--warn' : ''}`}> <span className={`chip server-ver-chip${level ? ` server-ver-chip--${level}` : ''}`}>
{n.version ? `v${n.version}` : 'v?.?.?'} {n.version ? `v${n.version}` : 'v?.?.?'}
</span> </span>
); );
@@ -60,18 +64,17 @@ export default function ServerBar({
onClick={() => onSelect(n.id)} onClick={() => onSelect(n.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }}
> >
{/* toggle — виден на мобильном вместо чипа */} <span
<div className={`server-toggle${n.online ? ' server-toggle--on' : ''}`} aria-hidden="true"> className={`status-dot${n.online ? ' status-dot--online' : ''}`}
<div className="server-toggle-thumb" /> role="img"
</div> aria-label={n.online ? 'на связи' : 'нет связи'}
{/* чип онлайн/офлайн — виден на десктопе */} />
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-online-chip`}>
<span className="chip-dot" />
</span>
<div className="server-card-info"> <div className="server-card-info">
<span className="server-card-name">{h?.server || n.name}</span> <span className="server-card-name">{h?.server || n.name}</span>
{n.pending ? ( {n.pending ? (
<span className="server-card-ip">ждёт подключения</span> <span className="server-card-ip">ждёт подключения</span>
) : n.outdated ? (
<span className="server-card-ip server-card-ip--bad">нужно обновить до {MIN_NODE_VERSION}</span>
) : !n.online ? ( ) : !n.online ? (
<span className="server-card-ip">нет связи</span> <span className="server-card-ip">нет связи</span>
) : h && ( ) : h && (
+90
View File
@@ -0,0 +1,90 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useRef } from 'react';
import { type NodeInfo, genLabel, serversLabel, MIN_NODE_VERSION } from '../lib/shared';
import { IcoChevronDown } from './icons';
// Выбор одного сервера внутри страницы: чьи графики показывать в «Мониторе».
// Выглядит как выбор сервера в шапке страницы: кнопка с текущим сервером (точка связи, имя, IP,
// «N серверов», стрелка) и под ней меню-список; выбранная строка залита primary-container. Выбор
// локальный: сервер в шапке панели он не меняет. Серверы без связи видны, но недоступны (на них
// ничего не поставить) — кроме pickOffline: история на графиках у них есть. all — первой строкой
// «Все серверы» (ALL_SERVERS).
// Меню раскрывается от правого края кнопки, как в шапке.
export const ALL_SERVERS = -1;
export default function ServerSelect({ nodes, value, onChange, name, all, pickOffline }: {
nodes: NodeInfo[];
value: number;
onChange: (id: number) => void;
name: string; // id меню (aria-controls)
all?: boolean;
pickOffline?: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Закрывается кликом мимо и по Escape — как меню серверов в шапке
useEffect(() => {
if (!open) return;
const down = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
const key = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', down);
document.addEventListener('keydown', key);
return () => { document.removeEventListener('mousedown', down); document.removeEventListener('keydown', key); };
}, [open]);
const online = nodes.filter(n => n.online).length;
const current = nodes.find(n => n.id === value) ?? null;
const isAll = value === ALL_SERVERS;
const pick = (id: number) => { setOpen(false); if (id !== value) onChange(id); };
const sub = (n: NodeInfo) => n.pending ? 'ждёт подключения' : n.outdated ? `нужно обновить до ${MIN_NODE_VERSION}` : !n.online ? 'нет связи' : n.health?.ip ?? '';
return (
<div className="server-select" ref={ref}>
<button type="button" className={`server-current${open ? ' server-current--open' : ''}`}
aria-expanded={open} aria-controls={name} onClick={() => setOpen(o => !o)}>
<span className={`status-dot${(isAll ? online > 0 : current?.online) ? ' status-dot--online' : ''}`}
role="img" aria-label={(isAll ? online > 0 : current?.online) ? 'на связи' : 'нет связи'} />
<span className="server-current-info">
<span className="server-card-name">
{isAll ? 'Все серверы' : current ? (current.health?.server || current.name) : 'Сервер не выбран'}
</span>
<span className="server-card-ip">{isAll ? `на связи ${online} из ${nodes.length}` : current ? sub(current) : ''}</span>
</span>
<span className="server-current-count">{serversLabel(nodes.length)}</span>
<span className="server-current-caret"><IcoChevronDown /></span>
</button>
{open && (
<div className="server-menu" id={name} role="listbox" aria-label="Серверы">
{all && (
<button type="button" role="option" aria-selected={isAll}
className={`server-option${isAll ? ' server-option--active' : ''}`} onClick={() => pick(ALL_SERVERS)}>
<span className="status-dot server-option-nodot" aria-hidden="true" />{/* место точки — текст в один столбец */}
<span className="server-current-info">
<span className="server-card-name">Все серверы</span>
<span className="server-option-sub">{serversLabel(nodes.length)} · на связи {online}</span>
</span>
</button>
)}
{nodes.map(n => {
const off = !n.online && !pickOffline;
return (
<button key={n.id} type="button" role="option" aria-selected={n.id === value} disabled={off}
className={`server-option${n.id === value ? ' server-option--active' : ''}`} onClick={() => pick(n.id)}>
<span className={`status-dot${n.online ? ' status-dot--online' : ''}`} role="img"
aria-label={n.online ? 'на связи' : 'нет связи'} />
<span className="server-current-info">
<span className="server-card-name">{n.health?.server || n.name}</span>
<span className="server-card-ip">{sub(n)}</span>
</span>
{n.health?.gen && <span className="server-option-sub">{genLabel(n.health.gen)}</span>}
</button>
);
})}
</div>
)}
</div>
);
}
+25
View File
@@ -11,6 +11,11 @@ export const IcoMenu = () => (
<line x1="3" y1="18" x2="21" y2="18"/> <line x1="3" y1="18" x2="21" y2="18"/>
</svg> </svg>
); );
export const IcoChevronDown = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
);
export const IcoUsers = () => ( export const IcoUsers = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
@@ -108,7 +113,27 @@ export const IcoMove = () => (
</svg> </svg>
); );
export const IcoGear = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>
</svg>
);
export const IcoCheck = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.5 10 17.5 19 7"/>
</svg>
);
// «Ещё» (MD3 more_vert): меню действий строки
export const IcoMore = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/>
</svg>
);
export const ICONS: Record<string, () => JSX.Element> = { export const ICONS: Record<string, () => JSX.Element> = {
settings: IcoGear,
users: IcoUsers, users: IcoUsers,
key: IcoKey, key: IcoKey,
shield: IcoShield, shield: IcoShield,
+66
View File
@@ -0,0 +1,66 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import QRCode, { type QRCodeErrorCorrectionLevel } from 'qrcode';
// Фирменный QR: скруглённые модули (соседи сливаются в «капли», одиночный — круг) и угловые
// «глаза» с фиолетовой серединкой. Рисуется в SVG по матрице библиотеки, отдаётся data-URL для <img>.
//
// Цвета — светлой темы и не зависят от текущей: сканеры читают только тёмный код на светлом фоне,
// а токены тёмной темы светлые. Логотипа нет: у vpn:// (уровень 'L') нет запаса коррекции ошибок.
const DARK = '#3a2e22'; // on-surface светлой темы
const ACCENT = '#6a45b8'; // primary светлой темы
const LIGHT = '#ffffff';
const MARGIN = 2; // тихая зона в модулях — как было у toDataURL
const R = 0.5; // радиус открытого угла модуля (0.5 — одиночный модуль становится кругом)
// Квадрат 1×1 в (x, y) со скруглением только тех углов, у которых оба соседа по сторонам пусты.
function cell(x: number, y: number, tl: boolean, tr: boolean, br: boolean, bl: boolean): string {
const a = tl ? R : 0, b = tr ? R : 0, c = br ? R : 0, d = bl ? R : 0;
return `M${x + a} ${y}H${x + 1 - b}` + (b ? `a${b} ${b} 0 0 1 ${b} ${b}` : '')
+ `V${y + 1 - c}` + (c ? `a${c} ${c} 0 0 1 ${-c} ${c}` : '')
+ `H${x + d}` + (d ? `a${d} ${d} 0 0 1 ${-d} ${-d}` : '')
+ `V${y + a}` + (a ? `a${a} ${a} 0 0 1 ${a} ${-a}` : '') + 'Z';
}
// Скруглённый прямоугольник как подпуть (для «глаз»).
function rrect(x: number, y: number, w: number, h: number, r: number): string {
return `M${x + r} ${y}H${x + w - r}a${r} ${r} 0 0 1 ${r} ${r}V${y + h - r}a${r} ${r} 0 0 1 ${-r} ${r}`
+ `H${x + r}a${r} ${r} 0 0 1 ${-r} ${-r}V${y + r}a${r} ${r} 0 0 1 ${r} ${-r}Z`;
}
// Бросает, если текст не помещается в QR на этом уровне коррекции.
export function qrDataUrl(text: string, errorCorrectionLevel: QRCodeErrorCorrectionLevel): string {
const { modules } = QRCode.create(text, { errorCorrectionLevel });
const n = modules.size;
const finder = (x: number, y: number) =>
(x < 7 && y < 7) || (x >= n - 7 && y < 7) || (x < 7 && y >= n - 7);
const on = (x: number, y: number) =>
x >= 0 && y >= 0 && x < n && y < n && !finder(x, y) && !!modules.get(y, x);
// Все модули — одним путём: соседние квадраты сливаются без щелей сглаживания.
let body = '';
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
if (!on(x, y)) continue;
const up = on(x, y - 1), down = on(x, y + 1), left = on(x - 1, y), right = on(x + 1, y);
body += cell(x, y, !up && !left, !up && !right, !down && !right, !down && !left);
}
}
// «Глаза»: кольцо 7×7 (внешний минус внутренний контур, evenodd) и серединка 3×3.
let rings = '', pupils = '';
for (const [x, y] of [[0, 0], [n - 7, 0], [0, n - 7]]) {
rings += rrect(x, y, 7, 7, 2) + rrect(x + 1, y + 1, 5, 5, 1.5);
pupils += rrect(x + 2, y + 2, 3, 3, 1);
}
const s = n + MARGIN * 2;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${-MARGIN} ${-MARGIN} ${s} ${s}" width="560" height="560">`
+ `<rect x="${-MARGIN}" y="${-MARGIN}" width="${s}" height="${s}" fill="${LIGHT}"/>`
+ `<path d="${body}" fill="${DARK}"/>`
+ `<path d="${rings}" fill="${DARK}" fill-rule="evenodd"/>`
+ `<path d="${pupils}" fill="${ACCENT}"/>`
+ '</svg>';
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}
+26
View File
@@ -56,6 +56,19 @@ export interface Brand { brand: string; channel: string; version: string }
export const TOKEN_KEY = 'awg_token'; export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme'; export const THEME_KEY = 'awg_theme';
export const SERVER_KEY = 'awg_server'; export const SERVER_KEY = 'awg_server';
export const SETTINGS_SECTION_KEY = 'awg_settings_section'; // открытый раздел «Настроек»
// Встроенный Prometheus установлен или удалён: вкладка «Монитор» в меню есть только при нём.
// Кто узнал состояние (раздел установки, сам «Монитор») — сообщает App, тот прячет или показывает пункт.
export const announcePrometheus = (ready: boolean) =>
window.dispatchEvent(new CustomEvent('awg:prometheus', { detail: ready }));
// Открыть другую вкладку панели (App слушает событие). section — раздел «Настроек»: его вкладка
// читает из SETTINGS_SECTION_KEY при монтировании, поэтому пишем до перехода.
export function openTab(id: string, section?: string) {
if (section) try { localStorage.setItem(SETTINGS_SECTION_KEY, section); } catch { /* не критично */ }
window.dispatchEvent(new CustomEvent('awg:open-tab', { detail: id }));
}
// Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes. // Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes.
export interface NodeInfo { export interface NodeInfo {
@@ -66,6 +79,7 @@ export interface NodeInfo {
pending: boolean; // нода создана, но ещё ни разу не подключалась pending: boolean; // нода создана, но ещё ни разу не подключалась
last_seen: number | null; last_seen: number | null;
version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4 version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4
outdated?: boolean; // старше MIN_NODE_VERSION: панель её не подключает, пока не обновят
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;
} }
@@ -90,6 +104,18 @@ export interface TorrentStatus {
error?: string; error?: string;
} }
// «1 сервер», «3 сервера», «5 серверов»
export function serversLabel(n: number): string {
const m10 = n % 10, m100 = n % 100;
const word = m10 === 1 && m100 !== 11 ? 'сервер'
: m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'сервера' : 'серверов';
return `${n} ${word}`;
}
// Минимальная версия Forgetting на ноде (в хабе — MinNodeVersion, internal/ui/nodes.go): старше — хаб
// ноду не подключает, чип версии красный; между ней и версией панели — жёлтый (просто стоит обновить).
export const MIN_NODE_VERSION = '0.5.0';
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b. // Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
export function cmpVersion(a: string, b: string): number { export function cmpVersion(a: string, b: string): number {
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number); const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
// Тема панели: светлая, тёмная или как в системе. Одно хранилище на всё приложение —
// переключатель в шапке и вкладка «Настройки» читают и меняют его через useTheme().
import { useSyncExternalStore } from 'react';
import { THEME_KEY } from './shared';
export type ThemeMode = 'light' | 'dark' | 'system';
export type Theme = 'light' | 'dark';
const dark = window.matchMedia('(prefers-color-scheme: dark)');
const listeners = new Set<() => void>();
function readMode(): ThemeMode {
try {
const v = localStorage.getItem(THEME_KEY);
if (v === 'dark' || v === 'system') return v;
} catch { /* не критично */ }
return 'light'; // без сохранённого выбора — светлая, как было до режима «как в системе»
}
let mode = readMode();
const resolved = (): Theme => (mode === 'system' ? (dark.matches ? 'dark' : 'light') : mode);
function apply() {
document.documentElement.setAttribute('data-theme', resolved());
listeners.forEach(l => l());
}
// «Как в системе» следует за сменой темы ОС на лету
dark.addEventListener('change', () => { if (mode === 'system') apply(); });
apply();
export function setThemeMode(next: ThemeMode) {
mode = next;
try { localStorage.setItem(THEME_KEY, next); } catch { /* не критично */ }
apply();
}
const subscribe = (l: () => void) => { listeners.add(l); return () => { listeners.delete(l); }; };
// mode — выбор пользователя, theme — что реально применено.
export function useTheme(): { mode: ThemeMode; theme: Theme } {
const snap = useSyncExternalStore(subscribe, () => `${mode}:${resolved()}`);
const [m, t] = snap.split(':') as [ThemeMode, Theme];
return { mode: m, theme: t };
}
-7
View File
@@ -1,7 +0,0 @@
{
"id": "apikeys",
"label": "API-ключи",
"icon": "key",
"group": "main",
"order": 2
}
-11
View File
@@ -1,11 +0,0 @@
{
"id": "audit",
"label": "Аудит",
"icon": "shield",
"group": "server",
"order": 1.8,
"chrome": {
"serverBar": false,
"restart": false
}
}
+6 -1
View File
@@ -4,7 +4,7 @@
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/: // Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика // index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент) // <name>.css — стили вкладки (импортит сам компонент)
// metadata.json — { id, label, icon, group?, order?, chrome? } (манифест: описание, группа меню, порядок) // metadata.json — { id, label, icon, group?, order?, requires?, chrome? } (манифест: описание, группа меню, порядок)
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части // Реестр сам находит все папки через import.meta.glob и собирает «живые» части
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто // (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно. // создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
@@ -35,6 +35,7 @@ export interface TabMeta {
icon: string; // имя иконки → ICONS icon: string; // имя иконки → ICONS
group: TabGroupId; // группа меню; без поля — 'main' group: TabGroupId; // группа меню; без поля — 'main'
order?: number; // порядок внутри группы (по возрастанию; без него — 0) order?: number; // порядок внутри группы (по возрастанию; без него — 0)
requires?: 'prometheus'; // вкладка в меню только при условии: 'prometheus' — встроенный Prometheus установлен
chrome?: TabChrome; chrome?: TabChrome;
} }
@@ -66,6 +67,9 @@ function toMeta(name: string, raw: unknown): TabMeta {
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) { if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`); throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`);
} }
if (m.requires !== undefined && m.requires !== 'prometheus') {
throw new Error(`tabs/${name}/metadata.json: "requires" может быть только "prometheus"`);
}
const group = (m.group ?? 'main') as string; const group = (m.group ?? 'main') as string;
if (!TAB_GROUPS.some(g => g.id === group)) { if (!TAB_GROUPS.some(g => g.id === group)) {
throw new Error(`tabs/${name}/metadata.json: "group" должно быть одним из: ${TAB_GROUPS.map(g => g.id).join(', ')}`); throw new Error(`tabs/${name}/metadata.json: "group" должно быть одним из: ${TAB_GROUPS.map(g => g.id).join(', ')}`);
@@ -76,6 +80,7 @@ function toMeta(name: string, raw: unknown): TabMeta {
icon: str('icon'), icon: str('icon'),
group: group as TabGroupId, group: group as TabGroupId,
order: m.order as number | undefined, order: m.order as number | undefined,
requires: m.requires as 'prometheus' | undefined,
chrome: m.chrome as TabChrome | undefined, chrome: m.chrome as TabChrome | undefined,
}; };
} }
@@ -109,12 +109,12 @@ export default function BulkServersModal({ token, nodes, keys, defaults, onClose
{preview?.hidden.map(h => ( {preview?.hidden.map(h => (
<p className="mk-notice" key={h.id}> <p className="mk-notice" key={h.id}>
Ключ API «{h.label}» перестанет видеть {h.keys} мастер-ключ(ей): у них появится сервер вне его области. Ключ API «{h.label}» перестанет видеть {h.keys} мастер-ключ(ей): у них появится сервер вне его области.
Расширьте его область на вкладке «API-ключи», если он ими управляет. Расширьте его область в «Настройках», на вкладке «API-ключи», если он ими управляет.
</p> </p>
))} ))}
{error && <p className="login-error">{error}</p>} {error && <p className="login-error">{error}</p>}
<div className="node-modal-actions"> <div className="node-modal-actions">
<button className="btn btn--outline" onClick={onClose}>Отмена</button> <button className="btn btn--text" onClick={onClose}>Отмена</button>
<button className="btn btn--primary" disabled={busy || nothing || !!error} onClick={apply}> <button className="btn btn--primary" disabled={busy || nothing || !!error} onClick={apply}>
{busy ? 'Применяем…' : 'Применить'} {busy ? 'Применяем…' : 'Применить'}
</button> </button>
+280 -110
View File
@@ -2,9 +2,9 @@
// 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 { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import QRCode from 'qrcode'; import { apiFetch, copyText, bytes, timeAgo, getServerId, cmpVersion, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared'; import { qrDataUrl } from '../../lib/qr';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons'; import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoMore } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker'; import ServerPicker, { serverName } from '../../components/ServerPicker';
import PriorityChip from '../../components/PriorityChip'; import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip'; import TorrentChip from '../../components/TorrentChip';
@@ -23,18 +23,12 @@ interface Device {
ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт) ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт)
} }
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
// панель догонит, когда нода появится.
const syncLabel = (d: Device): string =>
d.servers_ok < d.servers_total ? ` · на ${d.servers_ok} из ${d.servers_total} серверов` : '';
// Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа.
const platformLabel = (d: Device): string =>
d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—';
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса'; const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
// Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake. // Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake.
// Приоритет показываем, только когда он не обычный: «обычный» в каждой строке — шум
const bgOnly = (p?: Priority) => p === 'background' ? p : undefined;
const statusChip = (d: Device) => ( const statusChip = (d: Device) => (
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}> <span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" /> <span className="chip-dot" />
@@ -55,11 +49,18 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
const [tls, setTls] = useState(false); const [tls, setTls] = useState(false);
const [nodes, setNodes] = useState<NodeInfo[]>([]); // серверы, которые можно включить в ключ const [nodes, setNodes] = useState<NodeInfo[]>([]); // серверы, которые можно включить в ключ
// Маскировка первого пакета (I1) сервера из шапки: false — сервер её не умеет (старый awg-ctrl) или недоступен
const [masking, setMasking] = useState<'ready' | 'busy' | false>(false);
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [limit, setLimit] = useState('3'); const [limit, setLimit] = useState('3');
const [devs, setDevs] = useState<Record<number, Device[]>>({}); const [devs, setDevs] = useState<Record<number, Device[]>>({});
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
// Меню «⋮» ключа или устройства. position: fixed по кнопке: .table-card прокручивается вбок
// и обрезал бы меню у нижних строк. full — в меню ключа и «Скопировать ссылку» (в таблице;
// в карточке на телефоне ссылка — отдельной кнопкой).
const [menu, setMenu] = useState<{ kind: 'key' | 'device'; id: number; full: boolean; top: number; right: number; up: boolean } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [limitEdit, setLimitEdit] = useState(''); const [limitEdit, setLimitEdit] = useState('');
const [failed, setFailed] = useState<Set<number>>(new Set()); // ключи, чьи устройства не загрузились const [failed, setFailed] = useState<Set<number>>(new Set()); // ключи, чьи устройства не загрузились
const [down, setDown] = useState<Record<number, number[]>>({}); // серверы ключа, не ответившие на статистику const [down, setDown] = useState<Record<number, number[]>>({}); // серверы ключа, не ответившие на статистику
@@ -121,6 +122,21 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, [label, limit, nodes, defaults, token, act]); }, [label, limit, nodes, defaults, token, act]);
const live = useMemo(() => keys.filter(k => !k.deleting), [keys]); const live = useMemo(() => keys.filter(k => !k.deleting), [keys]);
useEffect(() => {
let alive = true;
apiFetch('GET', '/api/masking', token).then(() => { if (alive) setMasking('ready'); }).catch(() => {});
return () => { alive = false; };
}, [token]);
// Новый случайный I1 на сервере из шапки. Интерфейс не трогается: I1 нужен только клиентам,
// sen:// подхватят его со следующим обновлением профиля (≤ 60 с), выданные vpn:// остаются со старым.
const regenMasking = async () => {
setMasking('busy');
try { await apiFetch('POST', '/api/masking', token); showMsg('Маскировка сменена'); }
catch (e: any) { showMsg(e?.response?.data?.error ?? 'Не удалось сменить маскировку'); }
finally { setMasking('ready'); }
};
const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]); const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
const remove = useCallback(async (k: MasterKey) => { const remove = useCallback(async (k: MasterKey) => {
@@ -141,8 +157,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const showQR = useCallback(async (k: MasterKey) => { const showQR = useCallback(async (k: MasterKey) => {
try { try {
const { link, tls: t } = await fetchLink(k); const { link, tls: t } = await fetchLink(k);
const dataUrl = await QRCode.toDataURL(link, { width: 560, margin: 2, errorCorrectionLevel: 'H' }); setModal({ label: k.label, link, tls: t, dataUrl: qrDataUrl(link, 'H') });
setModal({ label: k.label, link, tls: t, dataUrl });
} catch (e) { showMsg(errText(e)); } } catch (e) { showMsg(errText(e)); }
}, [fetchLink, showMsg]); }, [fetchLink, showMsg]);
@@ -195,12 +210,48 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</span> </span>
); );
useEffect(() => {
if (!menu) return;
menuRef.current?.querySelector<HTMLButtonElement>('button')?.focus();
const close = () => setMenu(null);
const down = (e: MouseEvent) => { if (!menuRef.current?.contains(e.target as Node)) close(); };
const key = (e: KeyboardEvent) => {
if (e.key === 'Escape') { close(); document.querySelector<HTMLButtonElement>(`[data-menu-for="${menu.kind}-${menu.id}"]`)?.focus(); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const items = [...(menuRef.current?.querySelectorAll<HTMLButtonElement>('button') ?? [])];
const i = items.indexOf(document.activeElement as HTMLButtonElement);
items[(i + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length]?.focus();
};
document.addEventListener('mousedown', down);
document.addEventListener('keydown', key);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
document.removeEventListener('mousedown', down);
document.removeEventListener('keydown', key);
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [menu]);
const openMenu = (kind: 'key' | 'device', id: number, el: HTMLElement, full = false) => {
if (menu?.kind === kind && menu.id === id) return setMenu(null);
const r = el.getBoundingClientRect();
const up = r.bottom + 160 > window.innerHeight; // у нижнего края — вверх от кнопки
setMenu({ kind, id, full, top: up ? r.top - 4 : r.bottom + 4, right: window.innerWidth - r.right, up });
};
const moreButton = (kind: 'key' | 'device', id: number, label: string, full = false) => (
<button className="btn-icon mk-more" aria-label={label} aria-haspopup="menu" aria-expanded={menu?.kind === kind && menu.id === id}
data-menu-for={`${kind}-${id}`} onClick={e => openMenu(kind, id, e.currentTarget, full)}><IcoMore /></button>
);
// В таблице — QR и «⋮» (остальное в меню), чтобы колонка действий всегда помещалась.
// Подсказки — .tip-wrap, не title: нативная появляется с задержкой и бесполезна на тач-экране.
const actions = (k: MasterKey) => ( const actions = (k: MasterKey) => (
<div className="actions"> <div className="actions">
<button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button> <span className="tip-wrap tip-wrap--end" data-tip="QR-код"><button className="btn-icon" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button></span>
<button className="btn-icon" title="Скопировать ссылку" aria-label="Скопировать ссылку" onClick={() => copyLink(k)}><IcoCopy /></button> {moreButton('key', k.id, `Ещё: ${k.label}`, true)}
<button className="btn-icon" title="Настройки мастер-ключа" aria-label="Настройки мастер-ключа" aria-expanded={tools === k.id} onClick={() => openTools(k)}><IcoSettings /></button>
<button className="btn-icon btn-icon--danger" title="Удалить мастер-ключ" aria-label="Удалить мастер-ключ" onClick={() => remove(k)}><IcoTrash /></button>
</div> </div>
); );
@@ -275,7 +326,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
// Полоска занятых слотов: числа по краям внутри; цвет по заполнению. // Полоска занятых слотов: числа по краям внутри; цвет по заполнению.
const meter = (k: MasterKey, n: number) => ( const meter = (k: MasterKey, n: number) => (
<div className={`mk-meter${n >= k.device_limit ? ' mk-meter--full' : n * 3 >= k.device_limit * 2 ? ' mk-meter--warn' : ''}`} <div className={`mk-meter${n >= k.device_limit ? ' mk-meter--full' : ''}`}
role="progressbar" aria-label="Устройства" aria-valuemin={0} role="progressbar" aria-label="Устройства" aria-valuemin={0}
aria-valuemax={k.device_limit} aria-valuenow={n} aria-valuemax={k.device_limit} aria-valuenow={n}
title={`Устройств: ${n} из ${k.device_limit}`}> title={`Устройств: ${n} из ${k.device_limit}`}>
@@ -288,22 +339,35 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</div> </div>
); );
const newPsk = async (d: Device) => {
try {
const r = await apiFetch('POST', `/ui/devices/${d.id}/psk`, token);
showMsg(r.pending ? `PSK обновлён; ещё на ${r.pending} — когда сервер будет на связи` : 'PSK обновлён');
await load();
} catch (e) { showMsg(errText(e)); }
};
const revoke = (d: Device) => {
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
};
const deviceActions = (d: Device) => ( const deviceActions = (d: Device) => (
<div className="actions"> <div className="actions">
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={async () => { <span className="tip-wrap tip-wrap--end" data-tip="Новый PSK"><button className="btn-icon" aria-label="Новый PSK" onClick={() => newPsk(d)}><IcoRefresh /></button></span>
try { <span className="tip-wrap tip-wrap--end" data-tip="Отозвать устройство"><button className="btn-icon btn-icon--danger" aria-label="Отозвать устройство" onClick={() => revoke(d)}><IcoTrash /></button></span>
const r = await apiFetch('POST', `/ui/devices/${d.id}/psk`, token);
showMsg(r.pending ? `PSK обновлён; ещё на ${r.pending} — когда сервер будет на связи` : 'PSK обновлён');
await load();
} catch (e) { showMsg(errText(e)); }
}}><IcoRefresh /></button>
<button className="btn-icon btn-icon--danger" title="Отозвать устройство" aria-label="Отозвать устройство" onClick={() => {
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
}}><IcoTrash /></button>
</div> </div>
); );
// Короткий ID устройства: полностью — в подсказке, по клику копируется
const deviceId = (d: Device) => (
<span className="tip-wrap tip-wrap--start" data-tip={`ID устройства ${d.device_id} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать ID устройства ${d.device_id}`}
onClick={() => copyText(d.device_id).then(() => showMsg('ID устройства скопирован'), () => showMsg('Не удалось скопировать'))}>
{d.device_id.slice(0, 8)}…
</button>
</span>
);
// Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке, // Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке,
// по клику копируется. // по клику копируется.
const nameCell = (k: MasterKey) => ( const nameCell = (k: MasterKey) => (
@@ -325,72 +389,146 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
return d?.length ? <span className="mk-flag" title="Онлайн и трафик с этих серверов не получены"> · нет связи: {serverNames(d)}</span> : null; return d?.length ? <span className="mk-flag" title="Онлайн и трафик с этих серверов не получены"> · нет связи: {serverNames(d)}</span> : null;
}; };
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа. // Серверы ключа, куда ещё не добавлено устройство: был не в сети, когда оно подключалось или меняло
const summary = (list: Device[]) => { // ключ; панель добавит его сама, когда сервер появится (reconcile). Без ips (старая панель) — неизвестно.
if (!list.length) return <><td></td><td></td><td colSpan={2}></td><td></td><td></td></>; const missingOn = (k: MasterKey, d: Device) => d.ips ? k.servers.filter(s => !d.ips!.some(x => x.server_id === s)) : [];
// «из 3 серверов», «из 21 сервера»
const ofServers = (n: number) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'сервера' : 'серверов'}`;
const syncFlag = (k: MasterKey, d: Device) => {
const miss = missingOn(k, d);
if (miss.length) return `${k.servers.length - miss.length} из ${ofServers(k.servers.length)}`;
return d.servers_ok < d.servers_total ? `${d.servers_ok} из ${ofServers(d.servers_total)}` : '';
};
const syncTip = (k: MasterKey, d: Device) => {
const miss = missingOn(k, d);
return miss.length ? `Нет на ${miss.map(s => serverName(nodes, s)).join(', ')} — добавится само, когда сервер будет на связи`
: 'Не на всех серверах ключа — добавится само, когда они будут на связи';
};
// Серверы ключа — в его строке под колонками IP и «Платформа»: «все серверы» (у ключа с одним
// сервером — его имя) светло-зелёным или «2 из 3 серверов» тёплым, если на каком-то ещё нет всех
// устройств ключа.
// Имена — в подсказке.
const keyServers = (k: MasterKey, list: Device[]) => {
const behind = (s: number) => list.some(d => missingOn(k, d).includes(s));
const late = k.servers.filter(behind);
const ready = k.servers.filter(s => !behind(s));
const names = (ids: number[]) => ids.map(s => serverName(nodes, s)).join(', ');
const text = late.length ? `${ready.length} из ${ofServers(k.servers.length)}`
: k.servers.length === 1 ? serverName(nodes, k.servers[0]) : 'все серверы';
const tip = late.length ? `Есть: ${names(ready) || 'нигде'}. Нет: ${names(late)} — добавятся сами, когда сервер будет на связи`
: k.servers.length > 1 ? names(k.servers) : null;
const chip = <span className={`chip ${late.length ? 'chip--warm' : 'chip--online'} mk-srv`}>{text}</span>; // всё на месте — светло-зелёный
return tip ? <span className="tip-wrap tip-wrap--end" data-tip={tip}>{chip}</span> : chip;
};
// Статус ключа по его устройствам: онлайн / когда были / «нет устройств», плюс фоновый приоритет и торренты
const keyStatus = (list: Device[], empty: boolean) => {
if (!list.length) return empty ? <span className="mk-flag">нет устройств</span> : null;
const on = list.filter(d => d.online).length; const on = list.filter(d => d.online).length;
const last = Math.max(...list.map(d => d.lastHandshake || 0)); const last = Math.max(...list.map(d => d.lastHandshake || 0));
const torrent = list.some(d => d.torrent); const torrent = list.some(d => d.torrent);
const prio = list.some(d => d.priority === 'background') ? 'background' : list.find(d => d.priority)?.priority; const prio = bgOnly(list.some(d => d.priority === 'background') ? 'background' : undefined);
return ( return (
<> <>
<td> <span className={`chip chip--${on ? 'online' : 'offline'}`}>
<span className={`chip chip--${on ? 'online' : 'offline'}`}> <span className="chip-dot" />
<span className="chip-dot" /> {on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'}
{on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'} </span>
</span> <PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} />
</td> </>
<td><span className="user-chips"><PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} /></span></td> );
<td colSpan={2}></td> };
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
const summary = (k: MasterKey, list: Device[], empty: boolean) => {
if (!list.length) return <><td>{keyStatus(list, empty)}</td><td colSpan={2}>{keyServers(k, list)}</td><td></td><td></td></>;
return (
<>
<td><span className="user-chips">{keyStatus(list, empty)}</span></td>
<td colSpan={2}>{keyServers(k, list)}</td>
<td>{bytes(list.reduce((a, d) => a + d.rx, 0))}</td> <td>{bytes(list.reduce((a, d) => a + d.rx, 0))}</td>
<td>{bytes(list.reduce((a, d) => a + d.tx, 0))}</td> <td>{bytes(list.reduce((a, d) => a + d.tx, 0))}</td>
</> </>
); );
}; };
// Адрес с другого сервера подписан его именем; подсказка со всеми адресами — когда их больше одного. // Один адрес — выбранного в шапке сервера, остальные — «+N» и подсказка со всеми. Адрес с другого
// сервера подписан его именем строкой ниже, иначе его примут за адрес выбранного.
const ipCell = (d: Device) => { const ipCell = (d: Device) => {
const all = d.ips ?? []; const all = d.ips ?? [];
const { ip, serverId, other } = ipPick(d); const { ip, serverId, other } = ipPick(d);
const text = <code className="mk-ip">{ip}{other && serverId !== undefined ? ` · ${serverName(nodes, serverId)}` : ''}</code>; const body = (
<span className="name-stack">
<span className="mk-ip-line">
<code className="mk-ip">{ip}</code>
{all.length > 1 && <span className="mk-ip-more">+{all.length - 1}</span>}
</span>
{other && serverId !== undefined && <span className="mk-sub">{serverName(nodes, serverId)}</span>}
</span>
);
return all.length > 1 return all.length > 1
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{text}</span> ? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{body}</span>
: text; : body;
}; };
const caret = (k: MasterKey) => ( // Самая новая версия SenAWG среди устройств панели: кто отстал — тёплой плашкой, как ноды в меню серверов
const newest = useMemo(() => Object.values(devs).flat().map(d => d.version).filter(Boolean)
.reduce<string | null>((a, v) => !a || cmpVersion(v, a) > 0 ? v : a, null), [devs]);
const versionTag = (d: Device, tip: 'start' | 'end') => {
if (!d.version) return null;
const old = !!newest && cmpVersion(d.version, newest) < 0;
return old
? <span className={`tip-wrap tip-wrap--${tip}`} data-tip={`Есть новее: ${newest} — обнови SenAWG на устройстве`}><span className="chip chip--warm mk-ver">{d.version}</span></span>
: <span className="mk-sub">{d.version}</span>;
};
const platformCell = (d: Device) => (
<span className="name-stack">
{d.platform || '—'}
{versionTag(d, 'end')}
</span>
);
// Раскрывать нечего — вместо стрелки пустое место той же ширины, имена остаются в одну линию
const caret = (k: MasterKey, n: number) => n === 0 ? <span className="mk-caret-gap" aria-hidden="true" /> : (
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`} <button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)} aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
onClick={() => fold(k.id)}>▾</button> onClick={() => fold(k.id)}>▾</button>
); );
const deviceNode = (k: MasterKey, d: Device) => ( // Телефон: устройство — строка списка в карточке ключа, а не карточка в карточке. Имя, статус и «⋮»;
<li className="mk-node mk-node--device" key={d.id}> // ниже мелко — платформа, версия, IP; трафик, приоритет и то, на скольких серверах оно есть.
<div className="mk-device"> const deviceRow = (k: MasterKey, d: Device) => {
<div className="mk-device-main"> const all = d.ips ?? [];
<span className="mk-device-title"> const { ip, serverId, other } = ipPick(d);
<span className="mk-device-name">{d.device_name || 'Устройство'}</span> return (
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code> <li className="mk-dev" key={d.id}>
<div className="mk-dev-head">
<span className="name-stack mk-dev-title">
<span className="mk-dev-name">{d.device_name || 'Устройство'}</span>
{deviceId(d)}
</span> </span>
<span className="mk-device-sub">{platformLabel(d)} {statusChip(d)}
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span> {moreButton('device', d.id, `Ещё: ${d.device_name || 'устройство'}`)}
</div> </div>
{statusChip(d)} <div className="mk-dev-meta">
{/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */} <span>{d.platform || '—'}</span>
<div className="mk-device-ip"> {versionTag(d, 'start')}
<IcoGlobe /> <span aria-hidden="true">·</span>
{ipCell(d)} <code className="mk-ip">{ip}</code>
<PriorityChip priority={d.priority} torrent={d.torrent} /> {all.length > 1 && <span className="mk-ip-more" aria-label={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(', ')}>+{all.length - 1}</span>}
{other && serverId !== undefined && <span>{serverName(nodes, serverId)}</span>}
</div>
<div className="mk-dev-meta">
<span className="mk-dev-traffic">↓ {bytes(d.rx)} ↑ {bytes(d.tx)}</span>
<PriorityChip priority={bgOnly(d.priority)} torrent={d.torrent} />
<TorrentChip torrent={d.torrent} /> <TorrentChip torrent={d.torrent} />
{syncFlag(k, d) && <span>{syncFlag(k, d)}</span>}
{d.rekey_requested && <span>ждёт смены ключа</span>}
</div> </div>
<div className="mk-device-traffic"> </li>
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span> );
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span> };
</div>
{deviceActions(d)}
</div>
</li>
);
return ( return (
<> <>
@@ -401,6 +539,19 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<p className="mk-notice">Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.</p> <p className="mk-notice">Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.</p>
)} )}
{masking && (
<div className="mk-warn" role="note">
<span className="mk-warn-text">
<b>Маскировка первого пакета · {serverName(nodes, getServerId() ?? 0)}</b>
Перед подключением клиент шлёт пакет, похожий на обычный трафик. Новый шаблон устройства sen://
получат в течение минуты.
</span>
<button className="btn btn--tonal" onClick={regenMasking} disabled={masking === 'busy'}>
<IcoRefresh /> Сменить
</button>
</div>
)}
<div className="toolbar"> <div className="toolbar">
<input className="field" placeholder="Метка (напр. Семья)" value={label} <input className="field" placeholder="Метка (напр. Семья)" value={label}
onChange={e => setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} /> onChange={e => setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} />
@@ -408,6 +559,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} /> aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
<div className="toolbar-btns"> <div className="toolbar-btns">
<button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button> <button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button>
<button className="btn btn--tonal" onClick={load}><IcoRefresh /> Обновить</button>
<button className="btn btn--tonal" onClick={() => setBulk(true)} disabled={!live.length || !nodes.length} <button className="btn btn--tonal" onClick={() => setBulk(true)} disabled={!live.length || !nodes.length}
title="Добавить или убрать серверы у всех мастер-ключей сразу"><IcoSettings /> Серверы для всех</button> title="Добавить или убрать серверы у всех мастер-ключей сразу"><IcoSettings /> Серверы для всех</button>
</div> </div>
@@ -418,65 +570,56 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<table className="mk-table"> <table className="mk-table">
<thead> <thead>
<tr> <tr>
<th>Имя</th><th>Статус</th><th>Приоритет</th><th>IP</th><th>Платформа</th> <th>Имя</th><th>Статус</th><th>IP</th><th>Платформа</th>
<th>↓ rx</th><th>↑ tx</th><th>Устройства</th><th></th> <th>↓ rx</th><th>↑ tx</th><th>Устройства</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{keys.length === 0 ? ( {keys.length === 0 ? (
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr> <tr><td colSpan={8} className="empty">Нет мастер-ключей</td></tr>
) : keys.map(k => { ) : keys.map(k => {
if (k.deleting) return ( if (k.deleting) return (
<tr key={k.id} className="mk-tr-master mk-last"> <tr key={k.id} className="mk-tr-master mk-last">
<td><span className="mk-name"><IcoKey />{nameCell(k)}</span></td> <td><span className="mk-name"><IcoKey />{nameCell(k)}</span></td>
<td colSpan={8}>{deletingChip(k)}</td> <td colSpan={7}>{deletingChip(k)}</td>
</tr> </tr>
); );
const list = devs[k.id] ?? []; const list = devs[k.id] ?? [];
const count = devs[k.id]?.length ?? k.devices; const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const open = !folded.has(k.id); const open = !folded.has(k.id);
return ( return (
<Fragment key={k.id}> <Fragment key={k.id}>
<tr className="mk-tr-master"> <tr className="mk-tr-master">
<td><span className="mk-name">{caret(k)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td> <td><span className="mk-name">{caret(k, count)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
{summary(list)} {summary(k, list, count === 0)}
<td>{meter(k, count)}</td> <td>{meter(k, count)}</td>
<td className="td-actions mk-td-actions">{actions(k)}</td> <td className="td-actions mk-td-actions">{actions(k)}</td>
</tr> </tr>
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>} {tools === k.id && <tr className="mk-tr-tools"><td colSpan={8}>{toolbox(k)}</td></tr>}
{open && list.map((d, i) => ( {open && list.map((d, i) => (
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}> <tr key={d.id} className={`mk-tr-device${i === list.length - 1 ? ' mk-last' : ''}`}>
<td> <td>
<span className="mk-indent"> <span className="mk-indent">
<span className="name-stack"> <span className="name-stack">
{d.device_name || 'Устройство'} {d.device_name || 'Устройство'}
<span className="tip-wrap" data-tip={`ID устройства ${d.device_id} · нажми, чтобы скопировать`}> {/* пометки — в строке ID, не рядом с именем: так колонка «Имя» уже и таблица влезает */}
<button className="copy-id" aria-label={`Скопировать ID устройства ${d.device_id}`} <span className="mk-id-line">
onClick={() => copyText(d.device_id).then(() => showMsg('ID устройства скопирован'), () => showMsg('Не удалось скопировать'))}> {deviceId(d)}
{d.device_id.slice(0, 8)}… {syncFlag(k, d) && <span className="tip-wrap tip-wrap--start mk-flag" data-tip={syncTip(k, d)}>· {syncFlag(k, d)}</span>}
</button> {d.rekey_requested && <span className="mk-flag">· ждёт смены ключа</span>}
</span> </span>
</span> </span>
</span> </span>
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}
</td> </td>
<td>{statusChip(d)}</td> <td><span className="user-chips">{statusChip(d)}<PriorityChip priority={bgOnly(d.priority)} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td>
<td><span className="user-chips"><PriorityChip priority={d.priority} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td> <td>{ipCell(d)}</td>
<td className="td-mono">{ipCell(d)}</td> <td>{platformCell(d)}</td>
<td>{platformLabel(d)}</td>
<td>{bytes(d.rx)}</td> <td>{bytes(d.rx)}</td>
<td>{bytes(d.tx)}</td> <td>{bytes(d.tx)}</td>
<td></td> <td></td>
<td className="td-actions mk-td-actions">{deviceActions(d)}</td> <td className="td-actions mk-td-actions">{deviceActions(d)}</td>
</tr> </tr>
))} ))}
{open && free > 0 && (
<tr className="mk-tr-free mk-last">
<td colSpan={9}><span className="mk-indent">Свободно: {free}</span></td>
</tr>
)}
</Fragment> </Fragment>
); );
})} })}
@@ -484,7 +627,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</table> </table>
</div> </div>
{/* Мобильный: таблица скрыта (.table-card), то же — деревом карточек */} {/* Телефон и планшет: таблица скрыта (.table-card) — карточка на ключ: шапка, сводка, действия,
ниже списком его устройства */}
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : ( {keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
<ul className="mk-tree"> <ul className="mk-tree">
{keys.map(k => { {keys.map(k => {
@@ -492,39 +636,65 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<li className="mk-root" key={k.id}> <li className="mk-root" key={k.id}>
<div className="mk-master"> <div className="mk-master">
<IcoKey /> <IcoKey />
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span> <span className="mk-master-label">{nameCell(k)}</span>
{deletingChip(k)}
</div> </div>
<div className="mk-master-sum">{deletingChip(k)}</div>
</li> </li>
); );
const list = devs[k.id] ?? []; const list = devs[k.id] ?? [];
const count = devs[k.id]?.length ?? k.devices; const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const isFolded = folded.has(k.id); const isFolded = folded.has(k.id);
return ( return (
<li className="mk-root" key={k.id}> <li className="mk-root" key={k.id}>
<div className="mk-master"> <div className="mk-master">
{caret(k)} {caret(k, count)}
<IcoKey /> <IcoKey />
<span className="mk-master-label">{k.label}{dataFlag(k)}<code className="mk-master-uuid">{k.uuid}</code></span> <span className="mk-master-label">{nameCell(k)}</span>
{meter(k, count)} {meter(k, count)}
{actions(k)} </div>
<div className="mk-master-sum">
{keyStatus(list, count === 0)}
{keyServers(k, list)}
{dataFlag(k)}
</div>
<div className="mk-master-actions">
<button className="btn btn--tonal" onClick={() => showQR(k)}><IcoQR /> QR</button>
<button className="btn btn--outline" onClick={() => copyLink(k)}><IcoCopy /> Ссылка</button>
{moreButton('key', k.id, `Ещё: ${k.label}`)}
</div> </div>
{tools === k.id && toolbox(k)} {tools === k.id && toolbox(k)}
{!isFolded && ( {!isFolded && list.length > 0 && <ul className="mk-devs">{list.map(d => deviceRow(k, d))}</ul>}
<ul className="mk-children">
{list.map(d => deviceNode(k, d))}
{free > 0 && (
<li className="mk-node mk-node--free"><div className="mk-free">Свободно: {free}</div></li>
)}
</ul>
)}
</li> </li>
); );
})} })}
</ul> </ul>
)} )}
{menu && (() => {
const run = (f: () => void) => () => { setMenu(null); f(); };
const at = { top: menu.top, right: menu.right };
const cls = `mk-menu${menu.up ? ' mk-menu--up' : ''}`;
if (menu.kind === 'device') {
const d = Object.values(devs).flat().find(x => x.id === menu.id);
if (!d) return null;
return (
<div className={cls} role="menu" aria-label={`Устройство ${d.device_name}`} ref={menuRef} style={at}>
<button role="menuitem" className="mk-menu-item" onClick={run(() => newPsk(d))}><IcoRefresh /> Новый PSK</button>
<button role="menuitem" className="mk-menu-item mk-menu-item--danger" onClick={run(() => revoke(d))}><IcoTrash /> Отозвать</button>
</div>
);
}
const k = keys.find(x => x.id === menu.id);
if (!k) return null;
return (
<div className={cls} role="menu" aria-label={`Мастер-ключ ${k.label}`} ref={menuRef} style={at}>
{menu.full && <button role="menuitem" className="mk-menu-item" onClick={run(() => copyLink(k))}><IcoCopy /> Скопировать ссылку</button>}
<button role="menuitem" className="mk-menu-item" onClick={run(() => openTools(k))}><IcoSettings /> Настройки ключа</button>
<button role="menuitem" className="mk-menu-item mk-menu-item--danger" onClick={run(() => remove(k))}><IcoTrash /> Удалить</button>
</div>
);
})()}
{bulk && ( {bulk && (
<BulkServersModal token={token} nodes={nodes} keys={live} defaults={defaults} onClose={() => setBulk(false)} <BulkServersModal token={token} nodes={nodes} keys={live} defaults={defaults} onClose={() => setBulk(false)}
onDone={n => { setBulk(false); showMsg(n ? `Серверы обновлены у ${n} ключей` : 'Сохранено'); load(); }} /> onDone={n => { setBulk(false); showMsg(n ? `Серверы обновлены у ${n} ключей` : 'Сохранено'); load(); }} />
@@ -538,8 +708,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }} /> onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }} />
<p className="qr-hint">Отсканируй в SenAWG · нажми на QR чтобы скопировать{modal.tls ? '' : ' · без TLS'}</p> <p className="qr-hint">Отсканируй в SenAWG · нажми на QR чтобы скопировать{modal.tls ? '' : ' · без TLS'}</p>
<div className="qr-actions"> <div className="qr-actions">
<button className="btn btn--text" onClick={() => setModal(null)}>Закрыть</button>
<button className="btn btn--primary" onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }}><IcoCopy /> Скопировать</button> <button className="btn btn--primary" onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setModal(null)}>Закрыть</button>
</div> </div>
</div> </div>
</div> </div>
+128 -150
View File
@@ -7,70 +7,84 @@ LICENSE file in the root directory of this source tree.
Общая дизайн-система — в App.css. */ Общая дизайн-система — в App.css. */
.mk-notice { .mk-notice {
font-size: 13px; font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--error-text); color: var(--on-error-container);
background: var(--error-bg); background: var(--error-container);
border-radius: 16px; border-radius: var(--shape-large);
padding: 10px 16px; padding: 10px 16px;
} }
.mk-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; } /* Предупреждение о маскировке: тёплый tertiary, как чип «устарела» у версии сервера */
.mk-warn {
display: flex; align-items: center; gap: 12px;
background: var(--tertiary-container); color: var(--on-tertiary-container);
border-radius: var(--shape-large);
padding: 10px 12px 10px 16px;
}
.mk-warn-text {
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
}
.mk-warn-text b { font-weight: var(--weight-medium); }
.mk-warn .btn { flex-shrink: 0; }
.mk-empty { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); text-align: center; padding: 48px 0; }
.mk-limit-input { width: 88px !important; } .mk-limit-input { width: 88px !important; }
/* ── таблица (десктоп) ── */ /* ── таблица (десктоп) ── */
.mk-table td, .mk-table th { white-space: nowrap; } .mk-table td, .mk-table th { white-space: nowrap; }
.mk-table .td-actions { width: 150px; } .mk-table .td-actions { width: 80px; } /* QR и «⋮» */
.mk-table .mk-device-id { display: inline-block; max-width: 170px; vertical-align: middle; }
.mk-table th, .mk-table td { padding-left: 10px; padding-right: 10px; } .mk-table th, .mk-table td { padding-left: 10px; padding-right: 10px; }
.mk-table th:first-child, .mk-table td:first-child { padding-left: 20px; } .mk-table th:first-child, .mk-table td:first-child { padding-left: 20px; }
.mk-table th:last-child, .mk-table td:last-child { padding-right: 16px; } .mk-table th:last-child, .mk-table td:last-child { padding-right: 16px; }
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; } .mk-tr-master td { background: var(--surface-container); font-weight: var(--weight-medium); padding-top: 6px; padding-bottom: 6px; }
.mk-tr-master:hover td { background: var(--surface-container); } .mk-tr-master:hover td { background: var(--surface-container); }
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); } .mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
.mk-master-uuid {
display: block;
font-family: var(--mono); font-size: 12px; font-weight: 400;
color: var(--neutral-text);
overflow: hidden; text-overflow: ellipsis;
}
.mk-name .mk-caret { margin-left: -8px; } .mk-name .mk-caret { margin-left: -8px; }
.mk-tr-master .mk-name { color: var(--on-surface); } .mk-tr-master .mk-name { color: var(--on-surface); }
.mk-tr-master .mk-name svg { color: var(--primary); } .mk-tr-master .mk-name svg { color: var(--primary); }
.mk-tr-tools td { background: var(--surface-container); padding-top: 4px; padding-bottom: 12px; } /* слева/справа — общие отступы колонок таблицы: блок стоит под заголовком «Имя» */ .mk-tr-tools td { background: var(--surface-container); padding-top: 4px; padding-bottom: 12px; } /* слева/справа — общие отступы колонок таблицы: блок стоит под заголовком «Имя» */
.mk-tr-tools:hover { background: transparent; } .mk-tr-tools:hover { background: transparent; }
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; } .mk-tr-device td { padding-top: 10px; padding-bottom: 10px; }
.mk-flag { color: var(--neutral-text); font-size: 12px; } .mk-flag { color: var(--neutral-text); font-size: var(--body-small-size); line-height: var(--body-small-line); }
.mk-id-line { display: inline-flex; align-items: baseline; gap: 6px; }
/* чипы приоритета и торрентов в одной ячейке — как во вкладке «Пользователи» */ /* чипы приоритета и торрентов в одной ячейке — как во вкладке «Пользователи» */
.mk-table .user-chips { display: inline-flex; flex-wrap: nowrap; gap: 6px; } .mk-table .user-chips { display: inline-flex; flex-wrap: nowrap; gap: 6px; }
.mk-ip { font-family: var(--mono); } .mk-ip { font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
/* вторая строка ячейки: сервер чужого адреса, версия клиента */
.mk-sub { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mk-ip-line { display: inline-flex; align-items: center; gap: 6px; }
.mk-ip-more {
font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface-variant);
background: var(--surface-container-high); border-radius: var(--shape-full); padding: 0 6px;
}
.mk-srv, .mk-ver { font-weight: var(--weight-regular); }
.mk-ver { align-self: flex-start; padding-top: 0; padding-bottom: 0; font-family: var(--mono); }
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */ /* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
.mk-tr-device td:first-child, .mk-tr-free td:first-child { .mk-tr-device td:first-child {
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat; background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
} }
.mk-tr-device.mk-last td:first-child, .mk-tr-free.mk-last td:first-child { background-size: 2px 50%; } .mk-tr-device.mk-last td:first-child { background-size: 2px 50%; }
.mk-indent { position: relative; display: inline-block; padding-left: 36px; } .mk-indent { position: relative; display: inline-block; padding-left: 36px; }
.mk-indent::before { .mk-indent::before {
content: ''; position: absolute; left: 10px; top: 50%; width: 16px; content: ''; position: absolute; left: 10px; top: 50%; width: 16px;
border-top: 2px solid var(--outline); border-top: 2px solid var(--outline);
} }
.mk-tr-free td { color: var(--neutral-dot); font-size: 12px; }
.mk-tr-free:hover { background: transparent; }
/* ── дерево (мобильный) ── */ /* ── дерево (мобильный) ── */
.mk-tree, .mk-children { list-style: none; margin: 0; padding: 0; } .mk-tree, .mk-devs { list-style: none; margin: 0; padding: 0; }
.mk-tree { display: none; flex-direction: column; gap: 12px; } /* только мобильный */ .mk-tree { display: none; flex-direction: column; gap: 12px; } /* только мобильный */
.mk-root { .mk-root {
background: var(--surface-bright); background: var(--surface-bright);
border: 1px solid var(--outline-variant); border: 1px solid var(--outline-variant);
border-radius: var(--r-card); border-radius: var(--shape-extra-large);
padding: 12px 18px 14px; padding: 12px 16px;
} }
/* родитель */ /* шапка ключа: стрелка, ключ, метка с коротким UUID, полоска устройств */
.mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; } .mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; }
.mk-master-label { .mk-master-label {
font-size: 16px; font-size: var(--title-medium-size); line-height: var(--title-medium-line);
font-weight: 500; font-weight: var(--weight-medium);
color: var(--on-surface); color: var(--on-surface);
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -83,13 +97,38 @@ LICENSE file in the root directory of this source tree.
border: 0; border-radius: 50%; border: 0; border-radius: 50%;
background: transparent; background: transparent;
color: var(--neutral-text); color: var(--neutral-text);
font-size: 16px; font-size: var(--body-large-size); line-height: var(--body-large-line);
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
transition: transform 0.15s; transition: transform var(--duration-short3) var(--ease-standard);
} }
.mk-caret:hover { background: var(--state-neutral); }
.mk-caret--folded { transform: rotate(-90deg); } .mk-caret--folded { transform: rotate(-90deg); }
.mk-caret-gap { width: 28px; flex-shrink: 0; } /* ключ без устройств: место стрелки */
.mk-name .mk-caret-gap { margin-left: -8px; }
/* Меню «⋮» ключа (MD3 menu): плавает над таблицей, у кнопки справа */
.mk-menu {
position: fixed; z-index: 30;
min-width: 200px; padding: 6px;
background: var(--surface-container);
border-radius: var(--shape-large);
box-shadow: var(--elevation-2);
animation: mk-menu-in var(--duration-short4) var(--ease-emphasized-decelerate);
transform-origin: top right;
}
.mk-menu--up { transform: translateY(-100%); transform-origin: bottom right; }
@keyframes mk-menu-in { from { opacity: 0; scale: .92; } }
.mk-menu-item {
display: flex; align-items: center; gap: 12px; width: 100%;
min-height: 40px; padding: 0 12px;
border: 0; border-radius: var(--shape-medium); background: transparent;
font-family: var(--font); font-size: var(--label-large-size); line-height: var(--label-large-line); font-weight: var(--weight-medium);
color: var(--on-surface); text-align: left; cursor: pointer;
}
.mk-menu-item svg { color: var(--on-surface-variant); flex-shrink: 0; }
.mk-menu-item--danger, .mk-menu-item--danger svg { color: var(--error); }
@media (pointer: coarse) { .mk-menu-item { min-height: 48px; } }
@media (prefers-reduced-motion: reduce) { .mk-menu { animation: none; } }
.mk-tools { .mk-tools {
display: grid; display: grid;
@@ -103,10 +142,10 @@ LICENSE file in the root directory of this source tree.
display: flex; flex-direction: column; gap: 10px; display: flex; flex-direction: column; gap: 10px;
background: var(--surface-bright); background: var(--surface-bright);
border: 1px solid var(--outline-variant); border: 1px solid var(--outline-variant);
border-radius: 16px; border-radius: var(--shape-large);
padding: 12px 16px 14px; padding: 12px 16px 14px;
} }
.mk-box-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); } .mk-box-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface-variant); }
.mk-box .mk-box-title { margin-bottom: 2px; } .mk-box .mk-box-title { margin-bottom: 2px; }
/* строка настройки: слева название и пояснение, справа контрол */ /* строка настройки: слева название и пояснение, справа контрол */
.mk-setting { .mk-setting {
@@ -117,25 +156,26 @@ LICENSE file in the root directory of this source tree.
.mk-box-title + .mk-setting { border-top: 0; padding-top: 4px; } .mk-box-title + .mk-setting { border-top: 0; padding-top: 4px; }
.mk-setting:last-child { padding-bottom: 0; } .mk-setting:last-child { padding-bottom: 0; }
.mk-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .mk-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.mk-setting-name { font-size: 14px; font-weight: 500; color: var(--on-surface); } .mk-setting-name { font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mk-setting-hint { font-size: 12px; color: var(--neutral-text); } .mk-setting-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.mk-uuid { .mk-uuid {
font-family: var(--mono); font-size: 12px; color: var(--on-surface); font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
} }
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; } .mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: var(--label-large-size); line-height: var(--label-large-line); }
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; } .mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
/* прогресс занятых слотов с числом внутри; полный — цветом «ошибка». /* прогресс занятых слотов с числом внутри: обычная заливка primary, цветом — только когда мест
не осталось (тёплый: это повод присмотреться, а не ошибка).
Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */ Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */
.mk-meter { .mk-meter {
--mk-w: 136px; --mk-w: 136px;
--meter-fill: var(--success-dot); /* < 2/3 — зелёный */ --meter-fill: var(--primary);
--meter-on: #2a2118; /* цвет числа поверх заливки */ --meter-on: var(--on-primary); /* цвет числа поверх заливки */
position: relative; position: relative;
width: var(--mk-w); height: 20px; width: var(--mk-w); height: 20px;
border-radius: var(--r-pill); border-radius: var(--shape-full);
background: var(--outline-variant); background: var(--outline-variant);
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
@@ -144,7 +184,7 @@ LICENSE file in the root directory of this source tree.
position: absolute; left: 0; top: 0; bottom: 0; position: absolute; left: 0; top: 0; bottom: 0;
background: var(--meter-fill); background: var(--meter-fill);
overflow: hidden; overflow: hidden;
transition: width 0.25s, background 0.25s; transition: width var(--duration-medium1) var(--ease-standard), background var(--duration-medium1) var(--ease-standard);
} }
/* числа по краям внутри полоски: текущее слева, лимит справа */ /* числа по краям внутри полоски: текущее слева, лимит справа */
.mk-meter-text { .mk-meter-text {
@@ -153,90 +193,62 @@ LICENSE file in the root directory of this source tree.
box-sizing: border-box; box-sizing: border-box;
padding: 0 11px; padding: 0 11px;
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
font-size: 12px; font-weight: 500; font-size: var(--label-medium-size); line-height: var(--label-medium-line); font-weight: var(--weight-medium);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--on-surface); color: var(--on-surface);
} }
.mk-meter-text--on { color: var(--meter-on); } .mk-meter-text--on { color: var(--meter-on); }
.mk-meter--warn { --meter-fill: var(--warn-dot); } /* от 2/3 — оранжевый */ .mk-meter--full { --meter-fill: var(--warn-dot); --meter-on: #2a2118; } /* все места заняты */
.mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */
:root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; }
.mk-box .tip-wrap { width: 100%; } .mk-box .tip-wrap { width: 100%; }
/* дети: вертикальная линия слева и «ножка» к каждому узлу */ /* сводка (статус, серверы) и действия ключа */
.mk-children { margin: 8px 0 0 18px; padding-left: 28px; border-left: 2px solid var(--outline); } .mk-master-sum { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; }
.mk-node { position: relative; padding: 4px 0; } .mk-master-sum:empty { display: none; }
.mk-node::before { .mk-master-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
content: ''; .mk-master-actions .btn { flex: 1; }
position: absolute; .mk-tree .mk-more { width: 40px; height: 40px; }
left: -28px; top: 50%; /* на телефоне .tip-wrap по умолчанию во всю ширину — здесь чипы и ID идут в строку */
width: 28px; .mk-tree .tip-wrap { width: auto; }
border-top: 2px solid var(--outline);
}
/* последний узел: линия родителя обрывается на его «ножке» */
.mk-node:last-child::after {
content: '';
position: absolute;
left: -30px; top: calc(50% + 1px); bottom: 0;
width: 2px;
background: var(--surface-bright);
}
.mk-device { /* устройства ключа — строками списка под ним, разделены линией */
display: flex; align-items: center; gap: 12px; flex-wrap: wrap; .mk-devs { margin: 12px -16px -12px; border-top: 1px solid var(--outline-variant); }
background: var(--surface-container); .mk-dev { padding: 10px 16px; }
border-radius: 14px; .mk-dev + .mk-dev { border-top: 1px solid var(--outline-variant); }
padding: 8px 14px; .mk-dev-head { display: flex; align-items: center; gap: 8px; }
.mk-dev-title { flex: 1; min-width: 0; }
.mk-dev-name {
font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.mk-device-main { display: flex; flex-direction: column; min-width: 0; flex: 1 1 160px; } .mk-dev-head .mk-more { margin: -4px -8px -4px 0; }
.mk-device-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; } .mk-dev-meta {
.mk-device-id { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 4px;
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text);
font-size: 11px;
color: var(--neutral-text);
background: var(--surface-bright);
border-radius: var(--r-pill);
padding: 1px 8px;
min-width: 0;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); } .mk-dev-meta .chip { font-size: var(--body-small-size); }
.mk-device-sub { font-size: 12px; color: var(--neutral-text); } .mk-dev-traffic { font-family: var(--mono); white-space: pre; }
.mk-device-ip {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
width: 100%;
color: var(--neutral-text);
}
.mk-device-ip .mk-ip { font-size: 13px; }
.mk-device-ip .tip-wrap { width: auto; }
.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); }
.mk-metric { display: inline-flex; gap: 4px; }
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
.mk-free { /* ── телефон и планшет (design.md, часть IV): карточка на ключ, устройства строками, тач-цели ≥ 44px ── */
border: 1px dashed var(--outline); /* compact и medium (< 840px): карточки вместо таблицы */
border-radius: 14px; @media (max-width: 839px) {
padding: 10px 14px; .mk-warn { flex-wrap: wrap; padding: 12px 12px 12px 16px; }
font-size: 13px; .mk-warn-text { flex-basis: 100%; }
color: var(--neutral-dot); .mk-warn .btn { margin-left: auto; }
}
/* ── мобильный (design.md, часть IV): одна колонка, тач-цели ≥ 44px, rx/tx блоками
50/50, действия рядом с большим пальцем ── */
@media (max-width: 640px) {
.mk-tree { display: flex; } .mk-tree { display: flex; }
}
@media (pointer: coarse) {
.mk-tree .mk-more { width: 44px; height: 44px; }
}
@media (max-width: 599px) {
.mk-limit-input { width: 100% !important; } .mk-limit-input { width: 100% !important; }
.mk-root { padding: 12px; } .mk-root { padding: 12px; }
.mk-devs { margin: 12px -12px -12px; }
.mk-dev { padding: 10px 12px; }
/* родитель: шапка, затем ряд действий на всю ширину */
.mk-master { flex-wrap: wrap; row-gap: 10px; }
.mk-master .actions { width: 100%; gap: 8px; }
.mk-caret { width: 44px; height: 44px; margin: -6px 0 -6px -8px; } .mk-caret { width: 44px; height: 44px; margin: -6px 0 -6px -8px; }
.mk-caret-gap { width: 44px; margin-left: -8px; }
.mk-meter { --mk-w: 112px; height: 24px; } .mk-meter { --mk-w: 112px; height: 24px; }
/* настройки узла: поля и кнопки на всю ширину */ /* настройки узла: поля и кнопки на всю ширину */
@@ -246,38 +258,6 @@ LICENSE file in the root directory of this source tree.
.mk-setting-ctl .btn { flex: 1; height: 44px; } .mk-setting-ctl .btn { flex: 1; height: 44px; }
.mk-setting-ctl .mk-limit-input { width: 100% !important; flex: 1; height: 44px; } .mk-setting-ctl .mk-limit-input { width: 100% !important; flex: 1; height: 44px; }
.mk-tools .btn { width: 100%; height: 44px; justify-content: center; } .mk-tools .btn { width: 100%; height: 44px; justify-content: center; }
/* дерево уже: отступы поменьше, «ножки» короче */
.mk-children { margin-left: 8px; padding-left: 14px; }
.mk-node { padding: 4px 0; }
.mk-node::before { left: -14px; width: 14px; }
.mk-node:last-child::after { left: -16px; }
/* устройство — карточка: заголовок + чип, метрики 50/50, ряд действий */
.mk-device { border-radius: 16px; padding: 12px; gap: 10px; }
.mk-device-main { flex: 1 1 0; }
.mk-device-title { flex-wrap: wrap; row-gap: 2px; }
.mk-device-id { max-width: 100%; }
.mk-device-traffic { width: 100%; gap: 8px; }
.mk-metric {
flex: 1; flex-direction: column; gap: 2px;
background: var(--surface-bright);
border-radius: 16px; padding: 8px 12px;
}
.mk-metric i { font-family: var(--font, inherit); font-size: 12px; color: var(--neutral-text); }
.mk-metric-name::after { content: attr(data-sfx); }
.mk-metric b { font-size: 16px; font-weight: 500; color: var(--on-surface); }
/* кнопки действий: контурные, на всю ширину ряда, 44px */
.mk-device .actions { width: 100%; gap: 8px; }
.mk-master .btn-icon, .mk-device .btn-icon {
flex: 1; width: auto; height: 44px;
border: 1px solid var(--outline);
border-radius: var(--r-pill);
}
.mk-master .btn-icon--danger, .mk-device .btn-icon--danger { border-color: var(--error-dot); }
.mk-free { padding: 12px; font-size: 13px; }
} }
/* «Серверы для всех» (BulkServersModal) */ /* «Серверы для всех» (BulkServersModal) */
@@ -285,27 +265,25 @@ LICENSE file in the root directory of this source tree.
.bs-list { display: flex; flex-direction: column; gap: 8px; } .bs-list { display: flex; flex-direction: column; gap: 8px; }
.bs-row { .bs-row {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
background: var(--surface-container); border-radius: 12px; padding: 8px 8px 8px 14px; background: var(--surface-container); border-radius: var(--shape-medium); padding: 8px 8px 8px 14px;
font-size: 13px; color: var(--on-surface); font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
} }
.bs-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } .bs-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.bs-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--neutral-text); } .bs-sub { display: flex; flex-wrap: wrap; gap: 8px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.bs-sub .server-pick-ip { margin-left: 0; } .bs-sub .server-pick-ip { margin-left: 0; }
.bs-opts { display: flex; gap: 4px; flex-shrink: 0; } .bs-opts { display: flex; gap: 4px; flex-shrink: 0; }
.bs-opt { .bs-opt {
min-height: 36px; padding: 0 12px; min-height: 36px; padding: 0 12px;
font: inherit; font-size: 13px; color: var(--on-surface); font: inherit; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
background: var(--surface-bright); background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--r-pill); border: 1px solid var(--outline); border-radius: var(--shape-full);
cursor: pointer; white-space: nowrap; cursor: pointer; white-space: nowrap;
} }
.bs-opt:hover { background: var(--state-primary); } .bs-opt--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: var(--weight-medium); }
.bs-opt--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; } .bs-check { display: flex; align-items: center; gap: 8px; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); cursor: pointer; }
.bs-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--on-surface); cursor: pointer; }
.bs-check input { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; } .bs-check input { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; }
@media (max-width: 640px) { @media (max-width: 599px) {
.bs-row { flex-wrap: wrap; } .bs-row { flex-wrap: wrap; }
.bs-opts { width: 100%; } .bs-opts { width: 100%; }
.bs-opt { flex: 1; } .bs-opt { flex: 1; }
} }
.bs-modal .btn:disabled { opacity: .5; cursor: default; }
-11
View File
@@ -1,11 +0,0 @@
{
"id": "migrate",
"label": "Перенос",
"icon": "move",
"group": "server",
"order": 1.9,
"chrome": {
"serverBar": false,
"restart": false
}
}
-26
View File
@@ -1,26 +0,0 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Перенос». Общая дизайн-система — в App.css. */
.mv-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
.mv-title { margin: 0; font-size: 14px; font-weight: 500; }
.mv-text { margin: 0; font-size: 14px; line-height: 1.5; }
.mv-muted { font-size: 13px; color: var(--neutral-text); }
.mv-steps { margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.6; }
.mv-row { display: flex; gap: 8px; }
.mv-row .field { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; }
.mv-row--end { justify-content: flex-end; flex-wrap: wrap; }
.mv-self { align-self: flex-start; }
.mv-notice {
font-size: 13px; color: var(--error-text); background: var(--error-bg);
border-radius: 16px; padding: 10px 16px; margin: 0;
}
.mv-box .node-modal-cmd { background: var(--surface-bright); }
@media (max-width: 640px) { .mv-row:not(.mv-row--end) { flex-direction: column; } }
.mv-box .btn:disabled { opacity: .5; cursor: default; }
.mv-checks { display: flex; flex-direction: column; gap: 8px; }
.mv-check { display: flex; align-items: baseline; gap: 10px; font-size: 14px; line-height: 1.5; }
.mv-check .chip { flex-shrink: 0; }
+106 -154
View File
@@ -2,20 +2,16 @@
// 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 { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch, copyText, getServerId, type NodeInfo, type PageProps } from '../../lib/shared'; import { apiFetch, getServerId, openTab, announcePrometheus, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoCopy, IcoTrash } from '../../components/icons'; import { IcoGear } from '../../components/icons';
import type { MonitorStatus as Status } from '../settings/prometheus';
import LineChart from '../../components/LineChart'; import LineChart from '../../components/LineChart';
import ServerSelect, { ALL_SERVERS } from '../../components/ServerSelect';
import './monitor.css'; import './monitor.css';
// Вкладка «Монитор»: встроенный Prometheus и основные графики. // Вкладка «Монитор»: основные графики со встроенного Prometheus. Ставится и удаляется он в «Настройках»,
// Кнопка «Подключить Prometheus» просит awg-ctrl сервера поставить его (`awg-ctrl monitor install`, root; // раздел «Prometheus» (tabs/settings/prometheus); пока его нет, здесь — карточка со ссылкой туда.
// сама панель пакетов не ставит). У панели без VPN он ставится на ноду, и панель читает его через её агента.
// Графики читают Prometheus через фиксированные запросы панели (internal/ui/monitor.go). // Графики читают Prometheus через фиксированные запросы панели (internal/ui/monitor.go).
interface Status {
ready: boolean; can_install: boolean; command: string;
host: { id: number; name: string } | null; // Prometheus на ноде (или будет поставлен туда); null — на этом сервере
job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция
}
type Pts = [number, number][]; type Pts = [number, number][];
interface Overview { interface Overview {
range: string; step: number; from: number; to: number; range: string; step: number; from: number; to: number;
@@ -27,6 +23,24 @@ interface Overview {
total: Series; total: Series;
} }
interface Series { down: Pts; up: Pts; online: Pts; heavy: Pts; drops: Pts; cpu: Pts; mem: Pts } interface Series { down: Pts; up: Pts; online: Pts; heavy: Pts; drops: Pts; cpu: Pts; mem: Pts }
type Row = Overview['servers'][number];
type NumKey = 'down' | 'up' | 'online' | 'heavy' | 'cpu' | 'mem' | 'cores' | 'mem_total' | 'used_down' | 'used_up';
// «Все серверы» — сводка «сейчас» для плиток: скорости, устройства, ядра, память и объём складываем,
// загрузку процессора и памяти — среднее по серверам (как и ряды total в monitor.go).
function sumUp(rows: Row[]): Row | undefined {
if (rows.length <= 1) return rows[0];
const sum = (k: NumKey) => rows.some(r => r[k] != null) ? rows.reduce((n, r) => n + (r[k] ?? 0), 0) : null;
const avg = (k: NumKey) => {
const v = rows.map(r => r[k]).filter((x): x is number => x != null);
return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
};
return {
id: ALL_SERVERS, name: 'Все серверы', ok: rows.every(r => r.ok), congested: rows.some(r => r.congested),
down: sum('down'), up: sum('up'), online: sum('online'), heavy: sum('heavy'), cpu: avg('cpu'), mem: avg('mem'),
cores: sum('cores'), mem_total: sum('mem_total'), used_down: sum('used_down'), used_up: sum('used_up'),
};
}
const RANGES: [string, string][] = [['1h', '1 час'], ['6h', '6 часов'], ['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней']]; const RANGES: [string, string][] = [['1h', '1 час'], ['6h', '6 часов'], ['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней']];
@@ -51,28 +65,6 @@ function Bar({ v, label }: { v: number | null | undefined; label: string }) {
</span> </span>
); );
} }
// Прогресс операции из строк «N/M шаг», которые печатает сам установщик (cli/src/monitor.ts). Шаг N считаем
// выполненным наполовину: так полоска не стоит на нуле, пока идёт долгий apt, и не обещает больше, чем известно.
const STEP = /^(\d+)\/(\d+)\s+(.+)$/;
function JobProgress({ job, starting }: { job: Status['job']; starting: boolean }) {
let step: [number, number, string] | null = null;
for (const l of job?.log.split('\n') ?? []) { const m = STEP.exec(l.trim()); if (m) step = [Number(m[1]), Number(m[2]), m[3]]; }
const failed = !!job && job.exit !== null && job.exit !== 0;
const done = !!job && job.exit === 0;
const pct = done ? 100 : step ? ((step[0] - 0.5) / step[1]) * 100 : 0;
const label = failed ? `Не удалось (код ${job!.exit})`
: done ? (job!.action === 'install' ? 'Готово' : 'Удалено')
: step ? `Шаг ${step[0]} из ${step[1]}: ${step[2]}` : 'Запуск…';
return (
<div className="mon-progress" aria-live="polite">
<span className={`mon-pbar${failed ? ' mon-pbar--bad' : done ? ' mon-pbar--ok' : !step && (starting || job?.running) ? ' mon-pbar--wait' : ''}`}
role="progressbar" aria-label="Ход операции" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}>
<i style={{ width: `${pct}%` }} />
</span>
<span className="mon-progress-label">{label}</span>
</div>
);
}
const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`; const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`;
// Объём трафика — десятичные единицы, как считают провайдеры: 1 ТБ = 10¹² байт // Объём трафика — десятичные единицы, как считают провайдеры: 1 ТБ = 10¹² байт
function vol(b: number): string { function vol(b: number): string {
@@ -81,39 +73,42 @@ function vol(b: number): string {
return `${nf(b / 1e6, 0)} МБ`; return `${nf(b / 1e6, 0)} МБ`;
} }
export default function MonitorPage({ token, showMsg }: PageProps) { export default function MonitorPage({ token }: PageProps) {
const [status, setStatus] = useState<Status | null>(null); const [status, setStatus] = useState<Status | null>(null);
const [range, setRange] = useState('1h'); const [range, setRange] = useState('1h');
const [ov, setOv] = useState<Overview | null>(null); const [ov, setOv] = useState<Overview | null>(null);
const [ovErr, setOvErr] = useState(''); const [ovErr, setOvErr] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена из этой вкладки const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [server, setServer] = useState<NodeInfo | null>(null); // показанный сервер: выбран в другой вкладке, здесь не меняется // Чьи графики: сервер (по умолчанию — выбранный в шапке) или ALL_SERVERS. Выбор свой, шапку не меняет.
const [view, setView] = useState(() => getServerId() ?? 0);
const alive = useRef(true); const alive = useRef(true);
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются // StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const loadStatus = useCallback(async () => { const loadStatus = useCallback(async () => {
try { const s = await apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token) as Status; if (alive.current) setStatus(s); return s; } try { const s = await apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token) as Status; announcePrometheus(s.ready); if (alive.current) setStatus(s); return s; }
catch { return null; } catch { return null; }
}, [token]); }, [token]);
const loadOverview = useCallback(async () => { const loadOverview = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const o = await apiFetch('GET', `/ui/monitor/overview?range=${range}&server=${getServerId() ?? 0}`, token) as Overview; // Без ?server= monitor.go отдаёт все серверы: total — сумма (процессор и память — среднее), servers — каждый
const q = view === ALL_SERVERS ? '' : `&server=${view}`;
const o = await apiFetch('GET', `/ui/monitor/overview?range=${range}${q}`, token) as Overview;
if (alive.current) { setOv(o); setOvErr(''); } if (alive.current) { setOv(o); setOvErr(''); }
} catch (e: any) { if (alive.current) setOvErr(e?.response?.data?.error ?? 'Не удалось получить данные'); } } catch (e: any) { if (alive.current) setOvErr(e?.response?.data?.error ?? 'Не удалось получить данные'); }
finally { if (alive.current) setLoading(false); } finally { if (alive.current) setLoading(false); }
}, [token, range]); }, [token, range, view]);
useEffect(() => { void loadStatus(); }, [loadStatus]); useEffect(() => { void loadStatus(); }, [loadStatus]);
// Название и состояние сервера для заголовка; список серверов на этой вкладке не показываем // Серверы для выбора (и их связь — точки на плашках)
useEffect(() => { useEffect(() => {
const load = () => apiFetch('GET', '/ui/nodes', token) const load = () => apiFetch('GET', '/ui/nodes', token)
.then(r => { if (alive.current) setServer((r.nodes as NodeInfo[]).find(n => n.id === (getServerId() ?? 0)) ?? null); }) .then(r => { if (alive.current) setNodes(r.nodes ?? []); })
.catch(() => { /* заголовок необязателен */ }); .catch(() => { /* выбор необязателен — останется текущий сервер */ });
void load(); void load();
const t = setInterval(load, 15000); const t = setInterval(load, 15000);
return () => clearInterval(t); return () => clearInterval(t);
@@ -127,116 +122,43 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
return () => clearInterval(t); return () => clearInterval(t);
}, [status?.ready, loadOverview]); }, [status?.ready, loadOverview]);
// Пока идёт установка или удаление — следим за ходом: раз в 2 с читаем статус и лог // Пока Prometheus нет — раз в 10 с проверяем: его могли поставить в «Настройках»
useEffect(() => { useEffect(() => {
if (!pending && !status?.job?.running) return; if (!status || status.ready) return;
const t = setInterval(async () => { const t = setInterval(() => { void loadStatus(); }, 10000);
const s = await loadStatus();
if (s && !s.job?.running) setPending(null);
}, 2000);
return () => clearInterval(t); return () => clearInterval(t);
}, [pending, status?.job?.running, loadStatus]); }, [status, loadStatus]);
const run = async (action: 'install' | 'remove') => { // Prometheus нет — графиков не будет: ведём в «Настройки», где он ставится
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return;
setPending(action);
try { await apiFetch('POST', `/ui/monitor/${action}?server=${getServerId() ?? 0}`, token); await loadStatus(); }
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
};
const working = !!pending || !!status?.job?.running;
// Прогресс рядом с кнопкой: для установки — пока Prometheus нет (там же виден итог удаления), для удаления — только удаление
const progressFor = (action: 'install' | 'remove' | 'any') => {
const j = status?.job;
const mine = !!j && (action === 'any' || j.action === action) && (j.running || j.exit !== null);
const starting = !!pending && (action === 'any' || action === pending);
return mine || starting ? <JobProgress job={mine ? j! : null} starting={starting} /> : null;
};
// Полный лог — только при ошибке: оператору нужна причина, а не поток строк apt
const failureLog = () => {
const j = status?.job;
if (!j || j.exit === null || j.exit === 0) return null;
return (
<>
<pre className="mon-log">{j.log.trim()}</pre>
<p className="login-error">
{j.action === 'install' ? 'Установка' : 'Удаление'} не удалась. Если дело в правах или пакетах, выполните вручную: <b>{`awg-ctrl monitor ${j.action}`}</b>
</p>
</>
);
};
// ───────── установка ─────────
const setup = status && !status.ready && ( const setup = status && !status.ready && (
<section className="mon-card" aria-labelledby="mon-setup"> <section className="mon-card" aria-labelledby="mon-setup">
<div className="mon-head"> <div className="mon-head">
<h3 className="mon-title" id="mon-setup">Мониторинг</h3> <h3 className="mon-title" id="mon-setup">Мониторинг</h3>
<span className="chip chip--offline"><span className="chip-dot" />не подключён</span> <span className="chip chip--offline">
<span className="chip-dot" />{status.job?.running && status.job.action === 'install' ? 'ставится' : 'не подключён'}
</span>
</div> </div>
<p className="mon-hint"> <p className="mon-hint">
Панель поставит Prometheus {status.host ? <>на сервер <b>{status.host.name}</b></> : 'на этот сервер'} и сама подключит его к своим метрикам: основные графики — Графики — скорость, устройства онлайн, нагрузка на канал — строит встроенный Prometheus.
скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает Он ставится в «Настройках», на вкладке «Prometheus»; графики появятся здесь через минуту-две после установки.
только 127.0.0.1, наружу он не открывается.
</p> </p>
<div className="mon-actions">
{status.can_install ? ( <button className="btn btn--primary" onClick={() => openTab('settings', 'prom')}>
<div className="mon-actions"> <IcoGear /> Открыть настройки
<button className="btn btn--primary" onClick={() => run('install')} disabled={working}> </button>
{pending === 'install' || (status.job?.running && status.job.action === 'install') ? 'Устанавливаем…' : 'Подключить Prometheus'}
</button>
{progressFor('any')}
</div>
) : (
<>
<p className="mon-hint">
У этой панели нет своего VPN-сервера: выберите ноду в сервер-баре, чтобы поставить Prometheus на неё
кнопкой, или выполните на ней от root:
</p>
<code className="mon-code">{status.command}</code>
<div className="mon-actions">
<button className="btn btn--tonal" onClick={() => copyText(status.command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
</button>
</div>
</>
)}
{failureLog()}
</section>
);
// Удаление: только пока Prometheus есть
const manage = status?.ready && (
<section className="mon-card" aria-labelledby="mon-manage">
<div className="mon-head">
<h3 className="mon-title" id="mon-manage">Prometheus</h3>
<span className="chip chip--online"><span className="chip-dot" />работает</span>
</div> </div>
<p className="mon-hint">
Стоит {status.host ? <>на сервере <b>{status.host.name}</b> (панель читает его через агента ноды)</> : 'на этом сервере'} и
слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет пакет и сотрёт накопленные данные.
</p>
{status.can_install ? (
<div className="mon-actions">
<button className="btn btn--danger" onClick={() => run('remove')} disabled={working}>
<IcoTrash /> {pending === 'remove' || (status.job?.running && status.job.action === 'remove') ? 'Удаляем…' : 'Удалить Prometheus'}
</button>
{progressFor('remove')}
</div>
) : (
<>
<p className="mon-hint">Выполните на сервере с Prometheus от root:</p>
<code className="mon-code">awg-ctrl monitor remove</code>
</>
)}
{status.job?.action === 'remove' && failureLog()}
</section> </section>
); );
// ───────── дашборд ───────── // ───────── дашборд ─────────
// Вкладка показывает один сервер — выбранный в сервер-баре; при смене сервера App монтирует её заново. // Один сервер или сводка по всем (sumUp) — плитки читают одно и то же
const one = ov?.servers[0]; const all = view === ALL_SERVERS;
const one = all ? sumUp(ov?.servers ?? []) : ov?.servers[0];
// Кого нет в сумме: сервер не отдал метрики (ok = false) или на связи, а рядов нет совсем (старый агент)
const silent = !all || !ov ? [] : [
...ov.servers.filter(r => !r.ok).map(r => r.name),
...nodes.filter(n => n.online && !ov.servers.some(r => r.id === n.id)).map(n => n.health?.server || n.name),
];
const data = ov?.total ?? null; const data = ov?.total ?? null;
const cur = data && { const cur = data && {
// Скорость «сейчас» — от сервера: на неделе/месяце последняя точка ряда — среднее за шаг, а не текущее // Скорость «сейчас» — от сервера: на неделе/месяце последняя точка ряда — среднее за шаг, а не текущее
@@ -249,18 +171,30 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
const unit = peak >= 1e9 ? { name: 'Гбит/с', div: 1e9 } : peak >= 1e6 ? { name: 'Мбит/с', div: 1e6 } : peak >= 1e3 ? { name: 'Кбит/с', div: 1e3 } : { name: 'бит/с', div: 1 }; const unit = peak >= 1e9 ? { name: 'Гбит/с', div: 1e9 } : peak >= 1e6 ? { name: 'Мбит/с', div: 1e6 } : peak >= 1e3 ? { name: 'Кбит/с', div: 1e3 } : { name: 'бит/с', div: 1 };
const dashboard = status?.ready && ( const dashboard = status?.ready && (
<> <>
{/* Период — единственный фильтр: он задаёт окно для всего ниже */} {/* Период слева задаёт окно для всего ниже; справа — чьи графики: кнопка и меню, как выбор
<div className="mon-filters" role="group" aria-label="Период"> сервера в шапке, «Все серверы» — сводка по всем */}
{RANGES.map(([id, label]) => ( <div className="mon-toolbar">
<button key={id} type="button" className={`mon-range${range === id ? ' mon-range--on' : ''}`} <div className="mon-filters" role="group" aria-label="Период">
aria-pressed={range === id} onClick={() => setRange(id)}>{label}</button> {RANGES.map(([id, label]) => (
))} <button key={id} type="button" className={`mon-range${range === id ? ' mon-range--on' : ''}`}
aria-pressed={range === id} onClick={() => setRange(id)}>{label}</button>
))}
</div>
{nodes.length > 0 && (
<ServerSelect name="mon-server-menu" nodes={nodes} value={view} onChange={setView} all pickOffline />
)}
</div> </div>
{ovErr && <p className="login-error">{ovErr}</p>} {ovErr && <p className="login-error">{ovErr}</p>}
{ov && !one?.ok && ( {ov && !all && !one?.ok && (
<p className="mon-hint mon-notice">Этот сервер сейчас не отдаёт метрики: графики могут быть неполными или пустыми.</p> <p className="mon-hint mon-notice">Этот сервер сейчас не отдаёт метрики: графики могут быть неполными или пустыми.</p>
)} )}
{ov && all && (ov.servers.length === 0 || silent.length > 0) && (
<p className="mon-hint mon-notice">
{ov.servers.length === 0 ? 'Метрики сейчас не отдаёт ни один сервер.'
: `Не отдают метрики: ${silent.join(', ')} — в сумме их нет.`}
</p>
)}
<div className="mon-tiles"> <div className="mon-tiles">
<div className="mon-tile"> <div className="mon-tile">
@@ -303,14 +237,14 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
<div className="mon-tile-meter"> <div className="mon-tile-meter">
<span className="mon-tile-value mon-tile-rate"> <span className="mon-tile-value mon-tile-rate">
{one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'} {one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'}
<span className="mon-tile-sub">процессор{one?.cores ? ` · ${nf(one.cores, 0)} ядер` : ''}</span> <span className="mon-tile-sub">процессор{all ? ` · в среднем по ${ov?.servers.length ?? 0}` : one?.cores ? ` · ${nf(one.cores, 0)} ядер` : ''}</span>
</span> </span>
<Bar v={one?.cpu} label="Загрузка процессора" /> <Bar v={one?.cpu} label="Загрузка процессора" />
</div> </div>
<div className="mon-tile-meter"> <div className="mon-tile-meter">
<span className="mon-tile-value mon-tile-rate"> <span className="mon-tile-value mon-tile-rate">
{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'} {one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}
<span className="mon-tile-sub">память{one?.mem_total ? ` · из ${gb(one.mem_total)}` : ''}</span> <span className="mon-tile-sub">память{all ? ` · в среднем по ${ov?.servers.length ?? 0}` : one?.mem_total ? ` · из ${gb(one.mem_total)}` : ''}</span>
</span> </span>
<Bar v={one?.mem} label="Занято оперативной памяти" /> <Bar v={one?.mem} label="Занято оперативной памяти" />
</div> </div>
@@ -318,6 +252,33 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
</div> </div>
</div> </div>
{/* Сводка по всем — и разбивка: кто сколько качает и насколько загружен */}
{all && ov && ov.servers.length > 1 && (
<section className="mon-chart mon-per-server" aria-labelledby="mon-per">
<div className="mon-chart-head"><span className="mon-chart-title" id="mon-per">По серверам</span></div>
<div className="mon-table-wrap">
<table className="mon-table">
<thead>
<tr><th>Сервер</th><th>Загрузка</th><th>Отдача</th><th>Онлайн</th><th>Процессор</th><th>Память</th><th>Потрачено</th></tr>
</thead>
<tbody>
{ov.servers.map(r => (
<tr key={r.id}>
<td>{r.name}{!r.ok && <span className="mon-tile-sub"> · нет метрик</span>}</td>
<td>{r.down != null ? rate(r.down) : '—'}</td>
<td>{r.up != null ? rate(r.up) : '—'}</td>
<td>{r.online != null ? nf(r.online, 0) : '—'}</td>
<td>{r.cpu != null ? `${nf(r.cpu, 0)} %` : '—'}</td>
<td>{r.mem != null ? `${nf(r.mem, 0)} %` : '—'}</td>
<td>{r.used_down != null && r.used_up != null ? vol(r.used_down + r.used_up) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{ov && data && ( {ov && data && (
<div className="mon-charts"> <div className="mon-charts">
<LineChart title={`Трафик, ${unit.name}`} from={ov.from} to={ov.to} step={ov.step} dim={loading} <LineChart title={`Трафик, ${unit.name}`} from={ov.from} to={ov.to} step={ov.step} dim={loading}
@@ -348,18 +309,9 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
return ( return (
<> <>
{server && (
<p className="mon-server">
<span className="mon-server-label">Сервер</span>
<b>{server.health?.server || server.name}</b>
<span className={`chip chip--${server.online ? 'online' : 'offline'}`}><span className="chip-dot" />{server.online ? 'на связи' : 'нет связи'}</span>
</p>
)}
{!status && <p className="mon-hint">Загрузка…</p>} {!status && <p className="mon-hint">Загрузка…</p>}
{setup} {setup}
{dashboard} {dashboard}
{manage}
</> </>
); );
} }
+1
View File
@@ -4,6 +4,7 @@
"icon": "pulse", "icon": "pulse",
"group": "server", "group": "server",
"order": 1.7, "order": 1.7,
"requires": "prometheus",
"chrome": { "chrome": {
"serverBar": false, "serverBar": false,
"restart": false "restart": false
+40 -62
View File
@@ -8,25 +8,20 @@ LICENSE file in the root directory of this source tree.
display: flex; flex-direction: column; gap: 12px; display: flex; flex-direction: column; gap: 12px;
background: var(--surface-bright); background: var(--surface-bright);
border: 1px solid var(--outline-variant); border: 1px solid var(--outline-variant);
border-radius: var(--r-card); border-radius: var(--shape-extra-large);
padding: 20px; padding: 20px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.mon-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .mon-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.mon-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); } .mon-title { margin: 0; font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mon-hint { margin: 0; font-size: 12px; color: var(--neutral-text); } .mon-hint { margin: 0; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-actions { display: flex; flex-wrap: wrap; gap: 8px; } .mon-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.mon-code { @media (max-width: 599px) {
display: block; word-break: break-all; user-select: all;
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
background: var(--surface-container);
border-radius: var(--r-table);
padding: 12px;
}
@media (max-width: 640px) {
.mon-actions .btn { flex: 1; } .mon-actions .btn { flex: 1; }
.mon-toolbar .server-select { flex: 1 1 100%; } /* своя строка под периодом */
.mon-toolbar .server-current { width: 100%; }
.mon-toolbar .server-menu { width: 100%; }
} }
/* ───── Графики ───── /* ───── Графики ─────
@@ -35,30 +30,31 @@ LICENSE file in the root directory of this source tree.
:root { --series-1: #6a45b8; --series-2: #c4620a; } :root { --series-1: #6a45b8; --series-2: #c4620a; }
:root[data-theme="dark"] { --series-1: #8f6be0; --series-2: #d9772a; } :root[data-theme="dark"] { --series-1: #8f6be0; --series-2: #d9772a; }
.mon-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } /* Строка над плитками: период слева, выбор сервера (components/ServerSelect) справа; на узком — друг под другом */
.mon-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; margin-bottom: 16px; }
.mon-filters { display: flex; flex-wrap: wrap; gap: 8px; }
.mon-range { .mon-range {
min-height: 44px; padding: 0 18px; min-height: 44px; padding: 0 18px;
font: inherit; font-size: 14px; color: var(--on-surface); font: inherit; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface);
background: var(--surface-bright); background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--r-pill); border: 1px solid var(--outline); border-radius: var(--shape-full);
cursor: pointer; cursor: pointer;
} }
.mon-range:hover { background: var(--state-primary); } .mon-range--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: var(--weight-medium); }
.mon-range--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
.mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin-bottom: 16px; } .mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin-bottom: 16px; }
.mon-tile { .mon-tile {
display: flex; flex-direction: column; gap: 4px; display: flex; flex-direction: column; gap: 4px;
background: var(--surface-bright); background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--r-table); border: 1px solid var(--outline-variant); border-radius: var(--shape-large);
padding: 14px 16px; padding: 14px 16px;
} }
.mon-tile-label { font-size: 12px; color: var(--neutral-text); } .mon-tile-label { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-tile-value { font-size: 20px; font-weight: 500; color: var(--on-surface); } .mon-tile-value { font-size: var(--title-large-size); line-height: var(--title-large-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mon-tile-sub { font-size: 12px; color: var(--neutral-text); } .mon-tile-sub { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-tile-rates { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; } .mon-tile-rates { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
.mon-tile-rate { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; white-space: nowrap; } .mon-tile-rate { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; white-space: nowrap; }
.mon-tile-dir { font-size: 16px; } .mon-tile-dir { font-size: var(--body-large-size); line-height: var(--body-large-line); }
.mon-tile-total { margin-top: 6px; padding-top: 10px; border-top: 1px solid var(--outline-variant); } .mon-tile-total { margin-top: 6px; padding-top: 10px; border-top: 1px solid var(--outline-variant); }
.mon-tile-meter { display: flex; flex-direction: column; gap: 4px; } .mon-tile-meter { display: flex; flex-direction: column; gap: 4px; }
.mon-tile-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; align-self: center; } .mon-tile-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; align-self: center; }
@@ -71,78 +67,60 @@ LICENSE file in the root directory of this source tree.
.mon-chart { .mon-chart {
margin: 0; min-width: 0; margin: 0; min-width: 0;
background: var(--surface-bright); background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--r-card); border: 1px solid var(--outline-variant); border-radius: var(--shape-extra-large);
padding: 16px; padding: 16px;
transition: opacity .15s; transition: opacity var(--duration-short3) var(--ease-standard);
} }
.mon-chart--dim { opacity: .6; } .mon-chart--dim { opacity: .6; }
.mon-chart-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; margin-bottom: 4px; } .mon-chart-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; margin-bottom: 4px; }
.mon-chart-title { font-size: 15px; font-weight: 500; color: var(--on-surface); } .mon-chart-title { font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.mon-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--neutral-text); } .mon-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mon-legend-item { display: inline-flex; align-items: center; gap: 6px; } .mon-legend-item { display: inline-flex; align-items: center; gap: 6px; }
.mon-key { display: inline-block; width: 14px; height: 3px; border-radius: 2px; flex: none; } .mon-key { display: inline-block; width: 14px; height: 3px; border-radius: var(--shape-extra-small); flex: none; }
.mon-chart-toggle { .mon-chart-toggle {
margin-left: auto; min-height: 44px; padding: 0 12px; margin-left: auto; min-height: 44px; padding: 0 12px;
font: inherit; font-size: 13px; color: var(--on-primary-container); font: inherit; font-size: var(--label-large-size); line-height: var(--label-large-line); color: var(--on-primary-container);
background: none; border: 0; border-radius: var(--r-pill); cursor: pointer; background: none; border: 0; border-radius: var(--shape-full); cursor: pointer;
} }
.mon-chart-toggle:hover { background: var(--state-primary); }
.mon-plot { position: relative; } .mon-plot { position: relative; }
.mon-plot svg { display: block; max-width: 100%; } .mon-plot svg { display: block; max-width: 100%; }
.mon-plot svg:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 8px; } .mon-plot svg:focus-visible { border-radius: var(--shape-small); }
.mon-grid { stroke: var(--outline-variant); stroke-width: 1; } .mon-grid { stroke: var(--outline-variant); stroke-width: 1; }
.mon-tick { fill: var(--neutral-text); font-size: 12px; font-family: var(--font); } .mon-tick { fill: var(--neutral-text); font-size: var(--body-small-size); font-family: var(--font); }
.mon-cross { stroke: var(--outline); stroke-width: 1; } .mon-cross { stroke: var(--outline); stroke-width: 1; }
.mon-end { stroke: var(--surface-bright); stroke-width: 2px; } .mon-end { stroke: var(--surface-bright); stroke-width: 2px; }
.mon-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 0 24px; text-align: center; font-size: 13px; color: var(--neutral-text); } .mon-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 0 24px; text-align: center; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); }
.mon-tip { .mon-tip {
position: absolute; pointer-events: none; z-index: 2; position: absolute; pointer-events: none; z-index: 2;
display: flex; flex-direction: column; gap: 4px; display: flex; flex-direction: column; gap: 4px;
background: var(--surface-container); background: var(--surface-container);
border: 1px solid var(--outline-variant); border-radius: 12px; border: 1px solid var(--outline-variant); border-radius: var(--shape-medium);
padding: 8px 10px; font-size: 12px; color: var(--neutral-text); padding: 8px 10px; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text);
} }
.mon-tip-time { color: var(--neutral-text); } .mon-tip-time { color: var(--neutral-text); }
.mon-tip-row { display: flex; align-items: center; gap: 8px; } .mon-tip-row { display: flex; align-items: center; gap: 8px; }
.mon-tip-row b { font-weight: 500; font-size: 13px; color: var(--on-surface); } .mon-tip-row b { font-weight: var(--weight-medium); font-size: var(--label-large-size); line-height: var(--label-large-line); color: var(--on-surface); }
.mon-table-wrap { overflow-x: auto; } .mon-table-wrap { overflow-x: auto; }
.mon-table { width: 100%; border-collapse: collapse; font-size: 13px; } .mon-table { width: 100%; border-collapse: collapse; font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
.mon-table th { text-align: left; font-weight: 500; color: var(--label); padding: 8px 10px; white-space: nowrap; } .mon-table th { text-align: left; font-weight: var(--weight-medium); color: var(--on-surface-variant); padding: 8px 10px; white-space: nowrap; }
.mon-table td { padding: 8px 10px; color: var(--on-surface); border-top: .5px solid var(--outline-variant); font-variant-numeric: tabular-nums; white-space: nowrap; } .mon-table td { padding: 8px 10px; color: var(--on-surface); border-top: .5px solid var(--outline-variant); font-variant-numeric: tabular-nums; white-space: nowrap; }
.mon-log {
margin: 0; max-height: 240px; overflow: auto;
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
background: var(--surface-container); border-radius: var(--r-table);
padding: 12px; white-space: pre-wrap; word-break: break-word;
}
.mon-notice { margin-bottom: 12px; } .mon-notice { margin-bottom: 12px; }
/* Полоски процессора и памяти в плитках */ /* Полоски процессора и памяти в плитках */
.mon-bar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); margin: 2px 0; } .mon-bar { display: block; width: 100%; height: 8px; border-radius: var(--shape-extra-small); overflow: hidden; background: var(--primary-container); margin: 2px 0; }
.mon-bar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .3s; } .mon-bar i { display: block; height: 100%; border-radius: var(--shape-extra-small); background: var(--series-1); transition: width var(--duration-medium2) var(--ease-standard); }
.mon-bar i.mon-bar--warn { background: var(--warn-dot); } .mon-bar i.mon-bar--warn { background: var(--warn-dot); }
.mon-bar i.mon-bar--bad { background: var(--error-dot); } .mon-bar i.mon-bar--bad { background: var(--error-dot); }
/* Какой сервер показан (выбирается в других вкладках) */
.mon-server { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 0 0 12px; font-size: 16px; color: var(--on-surface); }
.mon-server b { font-weight: 500; }
.mon-server-label { font-size: 12px; color: var(--neutral-text); }
/* Прогресс операции рядом с кнопкой */
/* Разбивка «По серверам» в режиме «Все серверы» */
.mon-per-server { margin-bottom: 16px; }
/* Кнопка рядом с подписью — по центру строки */
.mon-actions { align-items: center; } .mon-actions { align-items: center; }
.mon-progress { flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 6px; }
.mon-progress-label { font-size: 12px; color: var(--neutral-text); }
.mon-pbar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); }
.mon-pbar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .4s ease; }
.mon-pbar--ok i { background: var(--success-dot); }
.mon-pbar--bad i { background: var(--error-dot); }
/* Шага ещё нет — ход неизвестен: бегущий отрезок вместо ложных процентов */
.mon-pbar--wait i { width: 35% !important; animation: mon-wait 1.2s ease-in-out infinite; }
@keyframes mon-wait { 0% { transform: translateX(-100%); } 100% { transform: translateX(290%); } }
@media (prefers-reduced-motion: reduce) { .mon-pbar--wait i { animation: none; } .mon-pbar i { transition: none; } }
@@ -8,11 +8,11 @@ LICENSE file in the root directory of this source tree.
/* Открытый API-ключ (показывается один раз при создании) */ /* Открытый API-ключ (показывается один раз при создании) */
.apikey-value { .apikey-value {
font-family: var(--mono); font-family: var(--mono);
font-size: 13px; font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--on-surface); color: var(--on-surface);
background: var(--surface-container); background: var(--surface-container);
border: 1px solid var(--outline-variant); border: 1px solid var(--outline-variant);
border-radius: 12px; border-radius: var(--shape-medium);
padding: 12px 14px; padding: 12px 14px;
width: 100%; width: 100%;
word-break: break-all; word-break: break-all;
@@ -21,9 +21,9 @@ LICENSE file in the root directory of this source tree.
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; } .apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
.apikey-count { font-size: 12px; color: var(--neutral-text); } .apikey-count { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-empty { .apikey-empty {
font-size: 14px; font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--neutral-text); color: var(--neutral-text);
text-align: center; text-align: center;
padding: 48px 0; padding: 48px 0;
@@ -31,7 +31,7 @@ LICENSE file in the root directory of this source tree.
.apikey-card { .apikey-card {
background: var(--surface-bright); background: var(--surface-bright);
border: 1px solid var(--outline-variant); border: 1px solid var(--outline-variant);
border-radius: var(--r-card); border-radius: var(--shape-extra-large);
padding: 16px; padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -49,10 +49,10 @@ LICENSE file in the root directory of this source tree.
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
} }
.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; } .apikey-card-num { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); flex-shrink: 0; }
.apikey-card-label { .apikey-card-label {
font-size: 16px; font-size: var(--title-medium-size); line-height: var(--title-medium-line);
font-weight: 500; font-weight: var(--weight-medium);
color: var(--on-surface); color: var(--on-surface);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -60,7 +60,7 @@ LICENSE file in the root directory of this source tree.
} }
.apikey-card-prefix { .apikey-card-prefix {
font-family: var(--mono); font-family: var(--mono);
font-size: 13px; font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--neutral-text); color: var(--neutral-text);
word-break: break-all; word-break: break-all;
} }
@@ -68,12 +68,15 @@ LICENSE file in the root directory of this source tree.
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
font-size: 12px; font-size: var(--body-small-size); line-height: var(--body-small-line);
color: var(--neutral-text); color: var(--neutral-text);
} }
@media (max-width: 640px) { /* compact и medium (< 840px): карточки вместо таблицы, на планшете — сеткой */
.apikey-cards-list { display: flex; } @media (max-width: 839px) {
.apikey-cards-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
/* счётчик и «пусто» — на всю строку сетки */
:is(.apikey-count, .apikey-empty) { grid-column: 1 / -1; }
} }
/* Настройки ключа: какие серверы он видит */ /* Настройки ключа: какие серверы он видит */
@@ -82,13 +85,13 @@ LICENSE file in the root directory of this source tree.
display: flex; flex-direction: column; gap: 10px; display: flex; flex-direction: column; gap: 10px;
background: var(--surface-bright); background: var(--surface-bright);
border: 1px solid var(--outline-variant); border: 1px solid var(--outline-variant);
border-radius: 16px; border-radius: var(--shape-large);
padding: 12px 16px 14px; padding: 12px 16px 14px;
margin: 6px 0 8px; margin: 6px 0 8px;
} }
.apikey-tools-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); } .apikey-tools-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); color: var(--on-surface-variant); }
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); } .apikey-tools-hint { margin: 0; font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; } .apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); } .apikey-card-servers { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.apikey-new-servers { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 0 0 16px; } .apikey-new-servers { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 0 0 16px; }
@@ -2,15 +2,15 @@
// 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 { Fragment, useState, useEffect, useCallback } from 'react'; import { Fragment, useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../lib/shared'; import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../components/icons'; import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker'; import ServerPicker, { serverName } from '../../../components/ServerPicker';
import './apikeys.css'; import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается // Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App. // один раз при создании (в БД хранится только хэш). Раздел вкладки «Настройки».
// Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах // Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
// управляет мастер-ключами. Создаётся на выбранном в сервер-баре сервере, набор // управляет мастер-ключами. По умолчанию новый ключ видит выбранный в панели сервер, набор
// правится в настройках ключа. // правится в настройках ключа.
export default function ApiKeysPage({ token, showMsg }: PageProps) { export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]); const [keys, setKeys] = useState<ApiKey[]>([]);
@@ -18,7 +18,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null); const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
const [picked, setPicked] = useState<number[] | null>(null); // серверы нового ключа; null — только выбранный в сервер-баре const [picked, setPicked] = useState<number[] | null>(null); // серверы нового ключа; null — только выбранный в панели
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -77,7 +77,6 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
return ( return (
<> <>
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
<div className="toolbar"> <div className="toolbar">
<input <input
className="field" className="field"
@@ -194,11 +193,11 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
</p> </p>
<code className="apikey-value">{created.key}</code> <code className="apikey-value">{created.key}</code>
<div className="qr-actions"> <div className="qr-actions">
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
<button className="btn btn--primary" onClick={async () => { <button className="btn btn--primary" onClick={async () => {
await copyText(created.key); await copyText(created.key);
showMsg('Ключ скопирован'); showMsg('Ключ скопирован');
}}><IcoCopy /> Скопировать</button> }}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
</div> </div>
</div> </div>
</div> </div>
@@ -7,33 +7,33 @@ LICENSE file in the root directory of this source tree.
.au-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; } .au-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.au-run { margin-left: auto; } .au-run { margin-left: auto; }
.au-hint { font-size: 13px; color: var(--neutral-text); } .au-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.au-notice { .au-notice {
font-size: 13px; color: var(--error-text); background: var(--error-bg); font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-error-container); background: var(--error-container);
border-radius: 16px; padding: 10px 16px; margin: 0; border-radius: var(--shape-large); padding: 10px 16px; margin: 0;
} }
.au-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; } .au-empty { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); text-align: center; padding: 48px 0; }
.au-pub { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; font-family: var(--mono); font-size: 12px; } .au-pub { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
.au-table td, .au-table th { white-space: nowrap; } .au-table td, .au-table th { white-space: nowrap; }
/* мобильный: таблица скрыта (.table-card), то же — карточками */ /* мобильный: таблица скрыта (.table-card), то же — карточками */
.au-cards { list-style: none; margin: 0; padding: 0; display: none; flex-direction: column; gap: 8px; } .au-cards { list-style: none; margin: 0; padding: 0; display: none; flex-direction: column; gap: 8px; }
.au-card { display: flex; flex-direction: column; gap: 4px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; } .au-card { display: flex; flex-direction: column; gap: 4px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.au-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .au-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.au-card .au-pub { max-width: 100%; } .au-card .au-pub { max-width: 100%; }
@media (max-width: 640px) { .au-cards { display: flex; } } @media (max-width: 839px) { .au-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } }
.au-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; } .au-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
.au-run { margin-left: 0; } .au-run { margin-left: 0; }
.au-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; } .au-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.au-box-title { margin: 0; font-size: 14px; font-weight: 500; } .au-box-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); }
.au-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .au-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.au-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .au-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.au-setting-name { font-size: 14px; } .au-setting-name { font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
.au-setting-ctl { display: inline-flex; align-items: center; gap: 8px; } .au-setting-ctl { display: inline-flex; align-items: center; gap: 8px; }
.au-max { width: 88px !important; } .au-max { width: 88px !important; }
.au-select { width: auto !important; } .au-select { width: auto !important; }
.au-log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } .au-log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.au-log li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 13px; } .au-log li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
.au-log .au-pub { max-width: 240px; } .au-log .au-pub { max-width: 240px; }
@@ -2,12 +2,12 @@
// 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 { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared'; import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../../lib/shared';
import { IcoRefresh, IcoTrash } from '../../components/icons'; import { IcoRefresh, IcoTrash } from '../../../components/icons';
import { serverName } from '../../components/ServerPicker'; import { serverName } from '../../../components/ServerPicker';
import './audit.css'; import './audit.css';
// Вкладка «Аудит»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере, // Раздел «Аудит» вкладки «Настройки»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
// хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых // хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых
// (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой. // (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой.
// Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*). // Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*).
@@ -96,7 +96,7 @@ export default function AuditPage({ token, showMsg }: PageProps) {
</section> </section>
<section className="au-box"> <section className="au-box">
<h4 className="au-box-title">Настройки</h4> <h4 className="au-box-title">Параметры аудита</h4>
<label className="au-setting"> <label className="au-setting">
<span className="au-setting-text"> <span className="au-setting-text">
<span className="au-setting-name">Снимать мёртвых автоматически</span> <span className="au-setting-name">Снимать мёртвых автоматически</span>
+285
View File
@@ -0,0 +1,285 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import { apiFetch, copyText, timeAgo, BRAND_FALLBACK, SETTINGS_SECTION_KEY, type Brand, type NodeInfo, type PageProps } from '../../lib/shared';
import { useTheme, setThemeMode, type ThemeMode } from '../../lib/theme';
import { IcoCheck, IcoCopy, IcoRefresh, IcoTrash, IcoPlus } from '../../components/icons';
import ApiKeysPage from './apikeys';
import AuditPage from './audit';
import MigratePage from './migrate';
import PrometheusInstall from './prometheus';
import ServerSettings from './server';
import './settings.css';
// Вкладка «Настройки»: настройки самой панели, не конкретного сервера (те — в меню серверов).
// Разделы — по вкладкам MD3 (primary tabs) сверху, виден один:
// Оформление — тема (хранится в браузере, lib/theme).
// Сервер — деление канала и блокировка торрентов сервера из шапки (./server; туда ведёт и шестерёнка
// в меню серверов).
// API-ключи, Аудит, Перенос — бывшие отдельные вкладки меню; живут в подпапках и
// рендерятся здесь целиком (реестр tabs/index.ts их не видит — он ищет на уровень выше).
// Prometheus — установка встроенного Prometheus (./prometheus) и токен «только метрики» для GET /metrics (/ui/metrics-token): выпуск,
// перевыпуск, отзыв. Значение показывается один раз, в БД — только хэш.
// О панели — имя, канал, версия и серверы.
interface MetricsToken {
exists: boolean;
prefix?: string;
created_at?: number;
last_used?: number | null;
per_peer: boolean; // пер-пировые серии (METRICS_PEERS=on в .env)
}
const SECTIONS = [
{ id: 'look', label: 'Оформление' },
{ id: 'server', label: 'Сервер' },
{ id: 'apikeys', label: 'API-ключи' },
{ id: 'prom', label: 'Prometheus' },
{ id: 'audit', label: 'Аудит' },
{ id: 'migrate', label: 'Перенос' },
{ id: 'about', label: 'О панели' },
] as const;
type SectionId = (typeof SECTIONS)[number]['id'];
// Открытый раздел помнится в браузере — чтобы «Аудит» или «Перенос» не искать заново
// (туда же пишет openTab из lib/shared, когда другая вкладка ведёт в нужный раздел)
const indexOf = (id: SectionId) => SECTIONS.findIndex(t => t.id === id);
function savedSection(): SectionId {
try {
const v = localStorage.getItem(SETTINGS_SECTION_KEY);
if (SECTIONS.some(t => t.id === v)) return v as SectionId;
} catch { /* не критично */ }
return 'look';
}
const THEMES: { id: ThemeMode; label: string }[] = [
{ id: 'light', label: 'Светлая' },
{ id: 'dark', label: 'Тёмная' },
{ id: 'system', label: 'Как в системе' },
];
export default function SettingsPage({ token, showMsg }: PageProps) {
const { mode } = useTheme();
const [metrics, setMetrics] = useState<MetricsToken | null>(null);
const [created, setCreated] = useState<string | null>(null); // открытый токен — один раз после выпуска
const [busy, setBusy] = useState(false);
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
const [nodes, setNodes] = useState<NodeInfo[]>([]);
// dir — откуда въезжает раздел: 1 — справа (вкладка правее прежней), -1 — слева, 0 — первый показ
const [view, setView] = useState<{ section: SectionId; dir: number }>(() => ({ section: savedSection(), dir: 0 }));
const section = view.section;
const go = (id: SectionId) => setView(v => v.section === id ? v : { section: id, dir: Math.sign(indexOf(id) - indexOf(v.section)) });
const tabRefs = useRef<Partial<Record<SectionId, HTMLButtonElement | null>>>({});
const indicator = useRef<HTMLSpanElement>(null);
const centered = useRef(false); // первый показ — без анимации прокрутки вкладок
// Индикатор — одна полоса под активной вкладкой: переезжает к ней, а не появляется заново
useLayoutEffect(() => {
const place = () => {
const b = tabRefs.current[section], el = indicator.current;
if (b && el) { el.style.left = `${b.offsetLeft}px`; el.style.width = `${b.offsetWidth}px`; }
};
place();
// На узком экране вкладки прокручиваются — выбранную ставим по центру полосы (у краёв — насколько
// позволяет прокрутка). Двигаем саму полосу, а не scrollIntoView: тот прокрутил бы и страницу.
const b = tabRefs.current[section], bar = b?.parentElement;
if (b && bar && bar.scrollWidth > bar.clientWidth) {
const still = !centered.current || matchMedia('(prefers-reduced-motion: reduce)').matches;
bar.scrollTo({ left: b.offsetLeft - (bar.clientWidth - b.offsetWidth) / 2, behavior: still ? 'auto' : 'smooth' });
}
centered.current = true;
try { localStorage.setItem(SETTINGS_SECTION_KEY, section); } catch { /* не критично */ }
window.addEventListener('resize', place);
return () => window.removeEventListener('resize', place);
}, [section]);
// openTab('settings', раздел) при уже открытых настройках: раздел записан в localStorage — переключаемся на него
useEffect(() => {
const h = (e: Event) => { if ((e as CustomEvent).detail === 'settings') go(savedSection()); };
window.addEventListener('awg:open-tab', h);
return () => window.removeEventListener('awg:open-tab', h);
}, []);
// Стрелки, Home/End — по вкладкам (MD3 / WAI-ARIA tabs)
const onTabKey = (e: React.KeyboardEvent) => {
const i = indexOf(section);
const j = e.key === 'ArrowRight' ? (i + 1) % SECTIONS.length
: e.key === 'ArrowLeft' ? (i + SECTIONS.length - 1) % SECTIONS.length
: e.key === 'Home' ? 0 : e.key === 'End' ? SECTIONS.length - 1 : -1;
if (j < 0) return;
e.preventDefault();
go(SECTIONS[j].id);
tabRefs.current[SECTIONS[j].id]?.focus();
};
// Адрес /metrics рядом с панелью — с учётом префикса, под которым она открыта
const metricsUrl = new URL('metrics', location.href.split(/[?#]/)[0]).href;
const load = useCallback(async () => {
try {
setMetrics(await apiFetch('GET', '/ui/metrics-token', token));
} catch {
showMsg('Ошибка загрузки настроек');
}
axios.get('/ui/brand').then(({ data }) => setBrand({
brand: data.brand || BRAND_FALLBACK, channel: data.channel || '', version: data.version || '',
})).catch(() => {});
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
}, [token, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
const issue = useCallback(async () => {
if (metrics?.exists && !confirm('Перевыпустить токен? Старый сразу перестанет работать — Prometheus нужно будет перенастроить.')) return;
setBusy(true);
try {
const r = await apiFetch('POST', '/ui/metrics-token', token);
setCreated(r.token);
setMetrics(r);
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
} finally {
setBusy(false);
}
}, [metrics, token, showMsg]);
const revoke = useCallback(async () => {
if (!confirm('Отозвать токен? Prometheus перестанет получать метрики панели.')) return;
setBusy(true);
try {
setMetrics(await apiFetch('DELETE', '/ui/metrics-token', token));
showMsg('Токен отозван');
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Ошибка запроса');
} finally {
setBusy(false);
}
}, [token, showMsg]);
const online = nodes.filter(n => n.online).length;
return (
<>
<div className="st-tabs" role="tablist" aria-label="Разделы настроек" onKeyDown={onTabKey}>
{SECTIONS.map(t => (
<button key={t.id} type="button" role="tab" id={`st-tab-${t.id}`} className="st-tab"
aria-selected={section === t.id} aria-controls={`st-panel-${t.id}`}
tabIndex={section === t.id ? 0 : -1}
ref={el => { tabRefs.current[t.id] = el; }} onClick={() => go(t.id)}>
{t.label}
</button>
))}
<span className="st-tabs-indicator" ref={indicator} aria-hidden="true" />
</div>
{/* Раздел пересоздаётся при смене (key) и въезжает со стороны выбранной вкладки */}
<div key={section} className="st-view" role="tabpanel" id={`st-panel-${section}`} aria-labelledby={`st-tab-${section}`}
style={{ '--st-dir': view.dir } as React.CSSProperties}>
{section === 'look' && (
<section className="st-section">
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Тема</span>
<span className="st-hint">Запоминается в этом браузере</span>
</span>
<div className="segmented" role="radiogroup" aria-label="Тема">
{THEMES.map(t => (
<button key={t.id} type="button" role="radio" aria-checked={mode === t.id}
className="segmented-item" onClick={() => setThemeMode(t.id)}>
{mode === t.id && <IcoCheck />}{t.label}
</button>
))}
</div>
</div>
</section>
)}
{section === 'prom' && <PrometheusInstall token={token} showMsg={showMsg} />}
{section === 'prom' && (
<section className="st-section">
<p className="st-hint st-lead">
Метрики панели и всех серверов отдаются по адресу <code className="st-code">{metricsUrl}</code> только
с токеном: Prometheus передаёт его заголовком <code className="st-code">Authorization: Bearer</code>.
Токен открывает только метрики — ни панель, ни API.
</p>
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Токен метрик</span>
{metrics?.exists ? (
<span className="st-hint">
<code className="st-code">{metrics.prefix}</code> · выпущен {new Date((metrics.created_at ?? 0) * 1000).toLocaleDateString()}
{' · '}{metrics.last_used ? `скрейп ${timeAgo(metrics.last_used)} назад` : 'ещё не использовался'}
</span>
) : (
<span className="st-hint">{metrics ? 'Не выпущен — /metrics закрыт' : 'Загрузка…'}</span>
)}
</span>
{metrics && (
<span className="st-row-ctl">
{metrics.exists && (
<button className="btn btn--danger" disabled={busy} onClick={revoke}><IcoTrash /> Отозвать</button>
)}
<button className={`btn ${metrics.exists ? 'btn--tonal' : 'btn--primary'}`} disabled={busy} onClick={issue}>
{metrics.exists ? <><IcoRefresh /> Перевыпустить</> : <><IcoPlus /> Выпустить</>}
</button>
</span>
)}
</div>
{metrics && (
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Метрики по пользователям</span>
<span className="st-hint">
{metrics.per_peer
? 'Включены: серии по каждому пиру с внутренним IP и именем'
: 'Выключены. Включаются строкой METRICS_PEERS=on в .env панели'}
</span>
</span>
<span className={`chip chip--${metrics.per_peer ? 'online' : 'offline'}`}>
<span className="chip-dot" />{metrics.per_peer ? 'Вкл' : 'Выкл'}
</span>
</div>
)}
</section>
)}
{section === 'server' && <ServerSettings token={token} showMsg={showMsg} />}
{section === 'apikeys' && <ApiKeysPage token={token} showMsg={showMsg} />}
{section === 'audit' && <AuditPage token={token} showMsg={showMsg} />}
{section === 'migrate' && <MigratePage token={token} showMsg={showMsg} />}
{section === 'about' && (
<section className="st-section">
<dl className="st-facts">
<div><dt>Панель</dt><dd>{brand.brand}</dd></div>
<div><dt>Версия</dt><dd>{[brand.channel, brand.version].filter(Boolean).join(' ') || '—'}</dd></div>
<div><dt>Серверы</dt><dd>{nodes.length ? `${nodes.length}, на связи ${online}` : '—'}</dd></div>
</dl>
</section>
)}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<div className="qr-card" role="dialog" aria-modal="true" aria-labelledby="st-new-title" onClick={e => e.stopPropagation()}>
<p className="qr-name" id="st-new-title">Токен метрик выпущен</p>
<p className="qr-hint">
Скопируй его сейчас — он показывается один раз. В prometheus.yml он идёт
в <code className="st-code">authorization.credentials</code>.
</p>
<code className="st-secret">{created}</code>
<div className="qr-actions">
<button className="btn btn--text" onClick={() => setCreated(null)}>Закрыть</button>
<button className="btn btn--primary" onClick={async () => {
await copyText(created);
showMsg('Токен скопирован');
}}><IcoCopy /> Скопировать</button>
</div>
</div>
</div>
)}
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "settings",
"label": "Настройки",
"icon": "settings",
"group": "server",
"order": 9,
"chrome": {
"restart": false
}
}
@@ -2,11 +2,11 @@
// 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 { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, type PageProps } from '../../lib/shared'; import { apiFetch, copyText, type PageProps } from '../../../lib/shared';
import { IcoCopy } from '../../components/icons'; import { IcoCopy } from '../../../components/icons';
import './migrate.css'; import './migrate.css';
// Вкладка «Перенос»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а // Раздел «Перенос» вкладки «Настройки»: панель уезжает с сервера «панель + VPN» на отдельную панель без VPN, а
// старый сервер остаётся её нодой #0. Новая панель выдаёт одноразовый код, старая по нему сама // старый сервер остаётся её нодой #0. Новая панель выдаёт одноразовый код, старая по нему сама
// отправляет туда базу и ключи подписки — у пользователей не меняется ничего. Логика — в // отправляет туда базу и ключи подписки — у пользователей не меняется ничего. Логика — в
// internal/ui/migrate.go (/ui/migrate*), порядок — docs/node-protocol.md, «Перенос панели». // internal/ui/migrate.go (/ui/migrate*), порядок — docs/node-protocol.md, «Перенос панели».
@@ -160,7 +160,7 @@ export default function MigratePage({ token, showMsg }: PageProps) {
</p> </p>
<ol className="mv-steps"> <ol className="mv-steps">
<li>На новом сервере установите Forgetting с ролью «только панель».</li> <li>На новом сервере установите Forgetting с ролью «только панель».</li>
<li>Там на вкладке «Перенос» нажмите «Получить код».</li> <li>Там в «Настройках», на вкладке «Перенос», нажмите «Получить код».</li>
<li>Вставьте код сюда и нажмите «Перенести».</li> <li>Вставьте код сюда и нажмите «Перенести».</li>
</ol> </ol>
{info.state === 'uncertain' ? ( {info.state === 'uncertain' ? (
@@ -190,7 +190,7 @@ export default function MigratePage({ token, showMsg }: PageProps) {
info.code ? ( info.code ? (
<> <>
<p className="mv-text"> <p className="mv-text">
Вставьте код на вкладке «Перенос» старой панели. Он одноразовый и действует Вставьте код в «Настройках» старой панели, на вкладке «Перенос». Он одноразовый и действует
ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода. ещё {Math.max(1, Math.round(info.code.expires_in / 60))} мин. Архив придёт зашифрованным ключом из кода.
</p> </p>
<code className="node-modal-cmd">{info.code.code}</code> <code className="node-modal-cmd">{info.code.code}</code>
@@ -0,0 +1,25 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Перенос». Общая дизайн-система — в App.css. */
.mv-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: var(--shape-large); padding: 12px 16px; }
.mv-title { margin: 0; font-size: var(--title-small-size); line-height: var(--title-small-line); font-weight: var(--weight-medium); }
.mv-text { margin: 0; font-size: var(--body-medium-size); line-height: 1.5; }
.mv-muted { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.mv-steps { margin: 0; padding-left: 20px; font-size: var(--body-medium-size); line-height: 1.6; }
.mv-row { display: flex; gap: 8px; }
.mv-row .field { flex: 1; min-width: 0; font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); }
.mv-row--end { justify-content: flex-end; flex-wrap: wrap; }
.mv-self { align-self: flex-start; }
.mv-notice {
font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-error-container); background: var(--error-container);
border-radius: var(--shape-large); padding: 10px 16px; margin: 0;
}
.mv-box .node-modal-cmd { background: var(--surface-bright); }
@media (max-width: 599px) { .mv-row:not(.mv-row--end) { flex-direction: column; } }
.mv-checks { display: flex; flex-direction: column; gap: 8px; }
.mv-check { display: flex; align-items: baseline; gap: 10px; font-size: var(--body-medium-size); line-height: 1.5; }
.mv-check .chip { flex-shrink: 0; }
@@ -0,0 +1,141 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch, copyText, getServerId, announcePrometheus, type PageProps } from '../../../lib/shared';
import { IcoCopy, IcoPlus, IcoTrash } from '../../../components/icons';
import './prometheus.css';
// Установка встроенного Prometheus (раздел «Prometheus» вкладки «Настройки»; графики — во вкладке «Монитор»).
// Кнопка просит awg-ctrl сервера поставить его (`awg-ctrl monitor install`, root; сама панель пакетов
// не ставит). У панели без VPN он ставится на ноду, и панель читает его через её агента — на сервер,
// выбранный в шапке (при смене сервера вкладка перемонтируется). У панели со своим VPN цель всегда
// «этот сервер».
export interface MonitorStatus {
ready: boolean; can_install: boolean; command: string;
host: { id: number; name: string } | null; // Prometheus на ноде (или будет поставлен туда); null — на этом сервере
job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция
}
// Прогресс операции из строк «N/M шаг», которые печатает сам установщик (cli/src/monitor.ts). Шаг N считаем
// выполненным наполовину: так полоска не стоит на нуле, пока идёт долгий apt, и не обещает больше, чем известно.
const STEP = /^(\d+)\/(\d+)\s+(.+)$/;
function JobProgress({ job, starting }: { job: MonitorStatus['job']; starting: boolean }) {
let step: [number, number, string] | null = null;
for (const l of job?.log.split('\n') ?? []) { const m = STEP.exec(l.trim()); if (m) step = [Number(m[1]), Number(m[2]), m[3]]; }
const failed = !!job && job.exit !== null && job.exit !== 0;
const done = !!job && job.exit === 0;
const pct = done ? 100 : step ? ((step[0] - 0.5) / step[1]) * 100 : 0;
const label = failed ? `Не удалось (код ${job!.exit})`
: done ? (job!.action === 'install' ? 'Готово' : 'Удалено')
: step ? `Шаг ${step[0]} из ${step[1]}: ${step[2]}` : 'Запуск…';
return (
<div className="prom-progress" aria-live="polite">
<span className={`prom-pbar${failed ? ' prom-pbar--bad' : done ? ' prom-pbar--ok' : !step && (starting || job?.running) ? ' prom-pbar--wait' : ''}`}
role="progressbar" aria-label="Ход операции" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}>
<i style={{ width: `${pct}%` }} />
</span>
<span className="st-hint">{label}</span>
</div>
);
}
export default function PrometheusInstall({ token, showMsg }: PageProps) {
const target = getServerId() ?? 0; // куда ставить (панель без VPN) — сервер из шапки
const [status, setStatus] = useState<MonitorStatus | null>(null);
const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена отсюда
const alive = useRef(true);
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const loadStatus = useCallback(async () => {
try { const s = await apiFetch('GET', `/ui/monitor?server=${target}`, token) as MonitorStatus; announcePrometheus(s.ready); if (alive.current) setStatus(s); return s; }
catch { return null; }
}, [token, target]);
useEffect(() => { void loadStatus(); }, [loadStatus]);
// Пока идёт установка или удаление — следим за ходом: раз в 2 с читаем статус и лог
useEffect(() => {
if (!pending && !status?.job?.running) return;
const t = setInterval(async () => {
const s = await loadStatus();
if (s && !s.job?.running) setPending(null);
}, 2000);
return () => clearInterval(t);
}, [pending, status?.job?.running, loadStatus]);
const run = async (action: 'install' | 'remove') => {
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return;
setPending(action);
try { await apiFetch('POST', `/ui/monitor/${action}?server=${target}`, token); await loadStatus(); }
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
};
if (!status) return <section className="st-section"><p className="st-hint">Загрузка…</p></section>;
const job = status.job;
const working = !!pending || !!job?.running;
const doing = pending ?? (job?.running ? job.action : null);
const showJob = !!pending || (!!job && (job.running || job.exit !== null));
const failed = !!job && job.exit !== null && job.exit !== 0;
// «Этот сервер» путал: в шапке может быть выбрана нода, а Prometheus у панели один и стоит не там
const where = status.host ? <>на сервере <b>{status.host.name}</b></> : 'на сервере панели';
const command = status.ready ? 'awg-ctrl monitor remove' : status.command;
return (
<section className="st-section">
<div className="st-row">
<span className="st-row-text">
<span className="st-row-name">Встроенный Prometheus</span>
<span className="st-hint">
{status.ready
? <>Один на всю панель, собирает метрики всех серверов. Стоит {where}{status.host && ' (панель читает его через агента ноды)'}, слушает только 127.0.0.1:9090, графики хранит 31 день.</>
: <>Один на всю панель, соберёт метрики всех серверов. Панель поставит его {where} и сама подключит к своим метрикам: графики «Монитора» появятся через минуту-две. Наружу он не открывается.</>}
</span>
</span>
<span className="st-row-ctl">
<span className={`chip chip--${status.ready ? 'online' : 'offline'}`}>
<span className="chip-dot" />{doing === 'install' ? 'ставится' : doing === 'remove' ? 'удаляется' : status.ready ? 'работает' : 'не подключён'}
</span>
{status.can_install && (status.ready ? (
<button className="btn btn--danger" onClick={() => run('remove')} disabled={working}>
<IcoTrash /> {doing === 'remove' ? 'Удаляем…' : 'Удалить'}
</button>
) : (
<button className="btn btn--primary" onClick={() => run('install')} disabled={working}>
<IcoPlus /> {doing === 'install' ? 'Устанавливаем…' : 'Установить'}
</button>
))}
</span>
</div>
{showJob && <JobProgress job={job} starting={!!pending} />}
{!status.can_install && (
<div className="st-row">
<span className="st-row-text">
<span className="st-hint">
{status.ready ? 'Удаляется на сервере с Prometheus от root:'
: 'На выбранном сервере установка кнопкой недоступна (нет связи или старый awg-ctrl): выберите другой сервер в шапке или выполните на этом от root:'}
</span>
<code className="prom-code">{command}</code>
</span>
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
</button>
</div>
)}
{/* Полный лог — только при ошибке: оператору нужна причина, а не поток строк apt */}
{failed && (
<>
<pre className="prom-log">{job!.log.trim()}</pre>
<p className="login-error">
{job!.action === 'install' ? 'Установка' : 'Удаление'} не удалась. Если дело в правах или пакетах, выполните вручную: <b>{`awg-ctrl monitor ${job!.action}`}</b>
</p>
</>
)}
</section>
);
}
@@ -0,0 +1,31 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Установка Prometheus (раздел «Prometheus» вкладки «Настройки»). Строки — из settings.css. */
/* Ход установки или удаления: полоска + подпись шага */
.prom-progress { display: flex; flex-direction: column; gap: 6px; padding: 4px 0 12px; }
.prom-pbar { display: block; width: 100%; height: 8px; border-radius: var(--shape-extra-small); overflow: hidden; background: var(--primary-container); }
.prom-pbar i { display: block; height: 100%; border-radius: var(--shape-extra-small); background: var(--primary); transition: width var(--duration-medium4) var(--ease-standard); }
.prom-pbar--ok i { background: var(--success-dot); }
.prom-pbar--bad i { background: var(--error-dot); }
/* Шага ещё нет — ход неизвестен: бегущий отрезок вместо ложных процентов */
.prom-pbar--wait i { width: 35% !important; animation: prom-wait 1.2s ease-in-out infinite; }
@keyframes prom-wait { 0% { transform: translateX(-100%); } 100% { transform: translateX(290%); } }
@media (prefers-reduced-motion: reduce) { .prom-pbar--wait i { animation: none; } .prom-pbar i { transition: none; } }
.prom-code {
display: block; margin-top: 6px; word-break: break-all; user-select: all;
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface);
background: var(--surface-container-lowest);
border-radius: var(--shape-medium);
padding: 10px 12px;
}
.prom-log {
margin: 0 0 8px; max-height: 240px; overflow: auto;
font-family: var(--mono); font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--on-surface);
background: var(--surface-container-lowest); border-radius: var(--shape-medium);
padding: 12px; white-space: pre-wrap; word-break: break-word;
}
@@ -3,17 +3,13 @@
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import axios from 'axios'; import axios from 'axios';
import { apiFetch, type NodeInfo, type ShapingStatus, type TorrentStatus } from '../lib/shared'; import { apiFetch, type PageProps, type ShapingStatus, type TorrentStatus } from '../../../lib/shared';
import './server.css';
// Настройки сервера: деление канала между устройствами (CAKE) и блокировка BitTorrent. Сами // Раздел «Сервер» вкладки «Настройки»: деление канала между устройствами (CAKE) и блокировка BitTorrent
// настройки живут в awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер // для сервера из шапки (при его смене вкладка перемонтируется). Сами настройки живут в awg-ctrl этого
// до открытия модалки), так что ядро и ноды настраиваются одинаково. // сервера — запрос идёт обычным прокси с X-Server-Id, так что ядро и ноды настраиваются одинаково.
export default function ServerSettingsModal({ token, node, onClose, showMsg }: { export default function ServerSettings({ token, showMsg }: PageProps) {
token: string;
node: NodeInfo;
onClose: () => void;
showMsg: (text: string) => void;
}) {
const [st, setSt] = useState<ShapingStatus | null>(null); const [st, setSt] = useState<ShapingStatus | null>(null);
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [auto, setAuto] = useState(true); const [auto, setAuto] = useState(true);
@@ -63,12 +59,6 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
return () => { alive = false; }; return () => { alive = false; };
}, [token]); }, [token]);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
const downN = Number(down), upN = Number(up); const downN = Number(down), upN = Number(up);
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1); const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
@@ -99,18 +89,17 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
}; };
return ( return (
<div className="node-scrim" onClick={onClose}> <>
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Настройки сервера" onClick={e => e.stopPropagation()}> <section className="st-section ss-card">
<h3 className="node-modal-title">Настройки сервера: {node.name}</h3> <h3 className="ss-section">Деление канала</h3>
<h4 className="ss-section">Деление канала</h4>
{unsupported ? ( {unsupported ? (
<p className="node-modal-hint"> <p className="st-hint">
Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»). Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»).
</p> </p>
) : ( ) : (
<> <>
<p className="node-modal-hint"> <p className="st-hint">
Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё
нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками. нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками.
</p> </p>
@@ -134,7 +123,7 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} /> value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} />
</label> </label>
</div> </div>
<p className="node-modal-hint"> <p className="st-hint">
Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера
и деление перестаёт работать. и деление перестаёт работать.
</p> </p>
@@ -142,7 +131,7 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
<label className="ss-setting"> <label className="ss-setting">
<span className="ss-setting-text"> <span className="ss-setting-text">
<span className="ss-setting-name">Снижать приоритет тяжёлых</span> <span className="ss-setting-name">Снижать приоритет тяжёлых</span>
<span className="node-modal-hint"> <span className="st-hint">
Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно
уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают. уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают.
</span> </span>
@@ -167,10 +156,12 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
</> </>
)} )}
<hr className="ss-divider" /> </section>
<h4 className="ss-section">Торренты</h4>
<section className="st-section ss-card">
<h3 className="ss-section">Торренты</h3>
{tUnsupported ? ( {tUnsupported ? (
<p className="node-modal-hint"> <p className="st-hint">
Этот сервер не умеет блокировать торренты: обновите его (install.sh → «Обновить»). Этот сервер не умеет блокировать торренты: обновите его (install.sh → «Обновить»).
</p> </p>
) : ( ) : (
@@ -178,12 +169,12 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
<label className="ss-setting"> <label className="ss-setting">
<span className="ss-setting-text"> <span className="ss-setting-text">
<span className="ss-setting-name">Блокировать BitTorrent</span> <span className="ss-setting-name">Блокировать BitTorrent</span>
<span className="node-modal-hint"> <span className="st-hint">
Режет поиск пиров (DHT), трекеры и начало торрент-соединений — как блокировка в 3x-ui. Режет поиск пиров (DHT), трекеры и начало торрент-соединений — как блокировка в 3x-ui.
Клиент с шифрованием и HTTPS-трекерами может частично её обойти, но без DHT и открытых Клиент с шифрованием и HTTPS-трекерами может частично её обойти, но без DHT и открытых
трекеров большинство торрентов не стартуют. Остальной трафик не режется. трекеров большинство торрентов не стартуют. Остальной трафик не режется.
</span> </span>
<span className="node-modal-hint"> <span className="st-hint">
{enabled {enabled
? 'Кто пытается качать, на 10 минут после последней попытки уходит в фоновый приоритет: уступает остальным, когда канал занят.' ? 'Кто пытается качать, на 10 минут после последней попытки уходит в фоновый приоритет: уступает остальным, когда канал занят.'
: 'Включите деление канала — тогда кто пытается качать, на 10 минут уйдёт ещё и в фоновый приоритет.'} : 'Включите деление канала — тогда кто пытается качать, на 10 минут уйдёт ещё и в фоновый приоритет.'}
@@ -199,14 +190,14 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
</span> </span>
<span className="ss-mono">заблокировано попыток: {tst.hits.toLocaleString('ru-RU')}</span> <span className="ss-mono">заблокировано попыток: {tst.hits.toLocaleString('ru-RU')}</span>
{tst.active && !tst.tcp && ( {tst.active && !tst.tcp && (
<span className="node-modal-hint"> <span className="st-hint">
TCP не проверяется: на сервере старый nftables. DHT, трекеры и uTP блокируются. TCP не проверяется: на сервере старый nftables. DHT, трекеры и uTP блокируются.
</span> </span>
)} )}
{tst.error && <span className="node-modal-hint">{tst.error}</span>} {tst.error && <span className="st-hint">{tst.error}</span>}
{tst.seen.length > 0 && ( {tst.seen.length > 0 && (
<> <>
<span className="node-modal-hint">Пытались качать за последние 10 минут:</span> <span className="st-hint">Пытались качать за последние 10 минут:</span>
<span className="ss-seen"> <span className="ss-seen">
{tst.seen.map(p => ( {tst.seen.map(p => (
<span key={p.ip} className="chip chip--error"> <span key={p.ip} className="chip chip--error">
@@ -221,17 +212,17 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
</> </>
)} )}
{error && <p className="login-error">{error}</p>} </section>
<div className="node-modal-actions"> {error && <p className="login-error">{error}</p>}
{!(unsupported && tUnsupported) && (
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}> {!(unsupported && tUnsupported) && (
Сохранить <div className="ss-actions">
</button> <button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
)} Сохранить
<button className="btn btn--outline" onClick={onClose}>Закрыть</button> </button>
</div> </div>
</div> )}
</div> </>
); );
} }
@@ -0,0 +1,11 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Раздел «Сервер» вкладки «Настройки»: бывший диалог «Настройки сервера», строки .ss-* — из App.css */
.ss-card { gap: 12px; padding: 16px; }
.ss-card .ss-section { margin: 0; }
.ss-card .ss-status { background: var(--surface-container-lowest); }
.ss-actions { display: flex; justify-content: flex-end; }
.ss-card .ss-grid { max-width: 480px; }
+119
View File
@@ -0,0 +1,119 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Настройки». Общая дизайн-система — в App.css (там же .segmented). */
/* Вкладки разделов (MD3 primary tabs, прокручиваемые): 48px, подпись title-small, прижаты влево;
активная — цветом primary и полосой 3px снизу, которая переезжает между вкладками (left/width
ставит компонент). Под вкладками — линия outline-variant во всю ширину. */
.st-tabs {
position: relative;
display: flex;
box-shadow: inset 0 -1px var(--outline-variant); /* линия — тенью: рамка ушла бы в прокрутку */
/* MD3 scrollable tabs: не влезли — прокручиваются вбок, без полосы прокрутки */
overflow-x: auto;
scrollbar-width: none;
}
.st-tabs::-webkit-scrollbar { display: none; }
.st-tab {
flex: 0 0 auto;
height: 48px;
padding: 0 20px;
border: none;
background: transparent;
color: var(--on-surface-variant);
font-family: var(--font);
font-size: var(--title-small-size); line-height: var(--title-small-line);
font-weight: var(--weight-medium);
white-space: nowrap;
cursor: pointer;
transition: color var(--duration-short3) var(--ease-standard);
}
.st-tab[aria-selected="true"] { color: var(--primary); }
.st-tab:focus-visible { outline-offset: -3px; }
.st-tabs-indicator {
position: absolute;
bottom: 0;
height: 3px;
border-radius: 3px 3px 0 0;
background: var(--primary);
transition: left var(--duration-medium2) var(--ease-standard), width var(--duration-medium2) var(--ease-standard);
}
@media (prefers-reduced-motion: reduce) { .st-tabs-indicator { transition: none; } }
/* Содержимое раздела — с тем же шагом, что у страницы. При смене вкладки въезжает на 32px со стороны
выбранной (--st-dir: 1 — справа, -1 — слева, 0 — только проявляется), как MD3 shared axis X.
Только backwards: после анимации transform не остаётся — иначе fixed-диалоги внутри (QR, ключи)
отсчитывались бы от раздела, а не от окна. */
.st-view {
display: flex; flex-direction: column; gap: 20px; min-width: 0;
animation: st-view-in var(--duration-medium2) var(--ease-emphasized-decelerate) backwards;
}
@keyframes st-view-in {
from { opacity: 0; transform: translateX(calc(var(--st-dir, 0) * 32px)); }
}
@media (prefers-reduced-motion: reduce) { .st-view { animation: none; } }
/* въезжающий раздел на миг шире страницы — без горизонтальной прокрутки */
.main:has(.st-view) { overflow-x: hidden; }
/* Раздел — блок на surface-container; строки внутри разделены линией outline-variant */
.st-section {
display: flex; flex-direction: column;
background: var(--surface-container);
border-radius: var(--shape-large);
padding: 12px 16px;
}
.st-lead { margin: 4px 0; }
.st-row {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
gap: 8px 16px;
padding: 12px 0;
}
.st-row + .st-row, .st-lead + .st-row { border-top: 1px solid var(--outline-variant); }
.st-row:first-child { padding-top: 4px; }
.st-row:last-child { padding-bottom: 4px; }
.st-row-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 240px; }
.st-row-name { font-size: var(--body-large-size); line-height: var(--body-large-line); color: var(--on-surface); }
.st-row-ctl { display: inline-flex; flex-wrap: wrap; gap: 8px; }
.st-hint { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); overflow-wrap: anywhere; }
.st-code {
font-family: var(--mono);
font-size: 0.95em;
background: var(--surface-container-highest);
border-radius: var(--shape-extra-small);
padding: 0 4px;
overflow-wrap: anywhere;
}
/* О панели: пары «название — значение» */
.st-facts { display: flex; flex-direction: column; margin: 0; }
.st-facts > div { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; }
.st-facts > div + div { border-top: 1px solid var(--outline-variant); }
.st-facts > div:first-child { padding-top: 4px; }
.st-facts > div:last-child { padding-bottom: 4px; }
.st-facts dt { font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--neutral-text); }
.st-facts dd { margin: 0; font-size: var(--body-medium-size); line-height: var(--body-medium-line); color: var(--on-surface); text-align: right; }
/* Открытый токен (один раз после выпуска) — как ключ API */
.st-secret {
font-family: var(--mono);
font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--on-surface);
background: var(--surface-container-lowest);
border-radius: var(--shape-medium);
padding: 12px 14px;
width: 100%;
word-break: break-all;
user-select: all;
}
/* compact: переключатель темы и кнопки — на всю ширину строки */
@media (max-width: 599px) {
.st-row > .segmented { width: 100%; }
.st-row > .segmented .segmented-item { flex: 1; padding: 0 8px; }
.st-row-ctl { width: 100%; }
.st-row-ctl .btn { flex: 1; }
}
+3 -8
View File
@@ -2,11 +2,11 @@
// 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 { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import QRCode from 'qrcode';
import { import {
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel, apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
type User, type PageProps, type AwgGen, type User, type PageProps, type AwgGen,
} from '../../lib/shared'; } from '../../lib/shared';
import { qrDataUrl } from '../../lib/qr';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons'; import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
import PriorityChip from '../../components/PriorityChip'; import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip'; import TorrentChip from '../../components/TorrentChip';
@@ -109,12 +109,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const vpnKey = u.vpn_key ?? ''; const vpnKey = u.vpn_key ?? '';
// errorCorrectionLevel 'L' (а не 'H'): ключ vpn:// длинный (~1.3 КБ), // errorCorrectionLevel 'L' (а не 'H'): ключ vpn:// длинный (~1.3 КБ),
// при 'H' ёмкость QR падает до ~1273 байт и кодирование падает. // при 'H' ёмкость QR падает до ~1273 байт и кодирование падает.
const dataUrl = await QRCode.toDataURL(vpnKey, { setQrModal({ name, dataUrl: qrDataUrl(vpnKey, 'L'), vpnKey });
width: 560,
margin: 2,
errorCorrectionLevel: 'L',
});
setQrModal({ name, dataUrl, vpnKey });
} catch { } catch {
showMsg('Не удалось сгенерировать QR-код'); showMsg('Не удалось сгенерировать QR-код');
} }
@@ -297,6 +292,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
/> />
<p className="qr-hint">Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать</p> <p className="qr-hint">Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать</p>
<div className="qr-actions"> <div className="qr-actions">
<button className="btn btn--text" onClick={() => setQrModal(null)}>Закрыть</button>
<button className="btn btn--tonal" onClick={async () => { <button className="btn btn--tonal" onClick={async () => {
try { try {
const conf = await vpnKeyToConf(qrModal.vpnKey); const conf = await vpnKeyToConf(qrModal.vpnKey);
@@ -305,7 +301,6 @@ export default function UsersPage({ token, showMsg }: PageProps) {
showMsg('Конфиг скачан'); showMsg('Конфиг скачан');
} catch { showMsg('Не удалось декодировать ключ'); } } catch { showMsg('Не удалось декодировать ключ'); }
}}>Скачать .conf</button> }}>Скачать .conf</button>
<button className="btn btn--tonal" onClick={() => setQrModal(null)}>Закрыть</button>
</div> </div>
</div> </div>
</div> </div>
+21 -13
View File
@@ -8,9 +8,9 @@ LICENSE file in the root directory of this source tree.
.user-cards-list { display: none; flex-direction: column; gap: 12px; } .user-cards-list { display: none; flex-direction: column; gap: 12px; }
.users-count { font-size: 12px; color: var(--neutral-text); } .users-count { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.empty-cards { .empty-cards {
font-size: 14px; font-size: var(--body-medium-size); line-height: var(--body-medium-line);
color: var(--neutral-text); color: var(--neutral-text);
text-align: center; text-align: center;
padding: 48px 0; padding: 48px 0;
@@ -18,7 +18,7 @@ LICENSE file in the root directory of this source tree.
.user-card { .user-card {
background: var(--surface-bright); background: var(--surface-bright);
border: 1px solid var(--outline-variant); border: 1px solid var(--outline-variant);
border-radius: var(--r-card); border-radius: var(--shape-extra-large);
padding: 16px; padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -36,10 +36,10 @@ LICENSE file in the root directory of this source tree.
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
} }
.user-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; } .user-card-num { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-dot); flex-shrink: 0; }
.user-card-name { .user-card-name {
font-size: 16px; font-size: var(--title-medium-size); line-height: var(--title-medium-line);
font-weight: 500; font-weight: var(--weight-medium);
color: var(--on-surface); color: var(--on-surface);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -52,20 +52,23 @@ LICENSE file in the root directory of this source tree.
gap: 8px; gap: 8px;
color: var(--neutral-text); color: var(--neutral-text);
} }
.user-card-ip-text { font-family: var(--mono); font-size: 13px; } .user-card-ip-text { font-family: var(--mono); font-size: var(--body-medium-size); line-height: var(--body-medium-line); }
/* чипы приоритета и торрентов — рядом с IP, как в карточке устройства мастер-ключа (на телефоне
.tip-wrap по умолчанию во всю ширину и уводил чип на отдельную строку) */
.user-card-ip .tip-wrap { width: auto; }
.user-card-metrics { display: flex; gap: 8px; } .user-card-metrics { display: flex; gap: 8px; }
.metric-block { .metric-block {
flex: 1; flex: 1;
background: var(--surface-container); background: var(--surface-container);
border-radius: var(--r-table); border-radius: var(--shape-large);
padding: 10px 14px; padding: 10px 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.metric-label { font-size: 12px; color: var(--neutral-text); } .metric-label { font-size: var(--body-small-size); line-height: var(--body-small-line); color: var(--neutral-text); }
.metric-value { font-family: var(--mono); font-size: 18px; font-weight: 500; color: var(--on-surface); } .metric-value { font-family: var(--mono); font-size: var(--title-medium-size); line-height: var(--title-medium-line); font-weight: var(--weight-medium); color: var(--on-surface); }
.user-card-actions { display: flex; gap: 8px; } .user-card-actions { display: flex; gap: 8px; }
.user-card-actions .btn--outline { flex: 1; } .user-card-actions .btn--outline { flex: 1; }
@@ -73,9 +76,14 @@ LICENSE file in the root directory of this source tree.
/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */ /* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */
.user-gen { margin-left: 8px; vertical-align: middle; } .user-gen { margin-left: 8px; vertical-align: middle; }
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */ /* compact и medium (< 840px): таблица скрывается (.table-card в App.css), показываются карточки —
@media (max-width: 640px) { в одну колонку на телефоне, сеткой на планшете. */
.user-cards-list { display: flex; } @media (max-width: 839px) {
.user-cards-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
/* счётчик и «пусто» — на всю строку сетки */
:is(.users-count, .empty-cards) { grid-column: 1 / -1; }
}
@media (max-width: 599px) {
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */ /* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
.user-gen { width: auto; } .user-gen { width: auto; }
} }
+164
View File
@@ -0,0 +1,164 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Токены дизайн-системы Material 3 (design.md, часть I). Компоненты берут значения только
отсюда — без литеральных цветов, размеров шрифта, радиусов и длительностей.
Цвета — тёплая палитра Forgetting, разложенная по ролям MD3: роли, которых раньше не было
(secondary, tertiary, лестница surface-container, inverse), достроены в тех же оттенках. */
:root {
/* ── Цвет: роли MD3 ── */
--primary: #6a45b8;
--on-primary: #ffffff;
--primary-container: #ebddff;
--on-primary-container: #5a2da8;
--inverse-primary: #cbb3ff;
--secondary: #645a73;
--on-secondary: #ffffff;
--secondary-container: #e8dff5;
--on-secondary-container: #4c425a;
--tertiary: #8b5a1f;
--on-tertiary: #ffffff;
--tertiary-container: #ffdcad;
--on-tertiary-container: #7a4a14;
--error: #ba1a1a;
--on-error: #ffffff;
--error-container: #ffdad6;
--on-error-container: #410002;
--surface: #faf6f0;
--surface-dim: #e2d9cc;
--surface-bright: #fdfaf6; /* рабочая зона: карточки, поля, диалоги */
--surface-container-lowest: #ffffff;
--surface-container-low: #f5efe6;
--surface-container: #f0e8dd;
--surface-container-high: #ece2d3;
--surface-container-highest: #e4dac9;
--on-surface: #3a2e22;
--on-surface-variant: #5a4a3a;
--inverse-surface: #3a2e22;
--inverse-on-surface: #fdfaf6;
--outline: #d8cdbe;
--outline-variant: #ece2d3;
/* ── Цвет: статусы (custom colors MD3), тёплый подтон ── */
--success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922;
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
--error-dot: #ba1a1a;
--warn-dot: #e08a2c;
/* ── State layers: непрозрачность слоя MD3 (цвет слоя — цвет содержимого, App.css) ── */
--state-hover: .08;
--state-focus: .10;
--state-pressed: .10;
--state-dragged: .16;
/* недоступные элементы: содержимое и контейнер (on-surface 38% / 12%) */
--disabled-content: color-mix(in srgb, var(--on-surface) 38%, transparent);
--disabled-container: color-mix(in srgb, var(--on-surface) 12%, transparent);
--state-row: rgba(106, 69, 184, .035);
--scrim: rgba(58, 46, 34, .45);
--shadow: rgba(58, 46, 34, .15);
/* ── Типографика: шкала MD3 (размер / интерлиньяж); веса — только 400 и 500.
label-small (11px) не заводим: минимальный размер шрифта — 12px (design.md, 6) ── */
--font: system-ui, -apple-system, 'Inter', sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
--weight-regular: 400;
--weight-medium: 500;
--headline-small-size: 24px; --headline-small-line: 32px;
--title-large-size: 22px; --title-large-line: 28px; /* emphasized: вес 500 */
--title-medium-size: 16px; --title-medium-line: 24px;
--title-small-size: 14px; --title-small-line: 20px;
--body-large-size: 16px; --body-large-line: 24px;
--body-medium-size: 14px; --body-medium-line: 20px;
--body-small-size: 12px; --body-small-line: 16px;
--label-large-size: 14px; --label-large-line: 20px;
--label-medium-size: 12px; --label-medium-line: 16px;
/* ── Форма: шкала скруглений MD3 ── */
--shape-extra-small: 4px;
--shape-small: 8px;
--shape-medium: 12px;
--shape-large: 16px;
--shape-extra-large: 28px;
--shape-full: 9999px;
/* ── Движение: кривые и длительности MD3 ── */
--ease-standard: cubic-bezier(.2, 0, 0, 1);
--ease-standard-decelerate: cubic-bezier(0, 0, 0, 1);
--ease-standard-accelerate: cubic-bezier(.3, 0, 1, 1);
--ease-emphasized-decelerate: cubic-bezier(.05, .7, .1, 1);
--ease-emphasized-accelerate: cubic-bezier(.3, 0, .8, .15);
--duration-short1: 50ms;
--duration-short2: 100ms;
--duration-short3: 150ms;
--duration-short4: 200ms;
--duration-medium1: 250ms;
--duration-medium2: 300ms;
--duration-medium3: 350ms;
--duration-medium4: 400ms;
/* ── Элевация MD3: уровни 1–3 (тени цвета --shadow) ── */
--elevation-1: 0 1px 2px var(--shadow), 0 1px 3px 1px var(--shadow);
--elevation-2: 0 1px 2px var(--shadow), 0 2px 6px 2px var(--shadow);
--elevation-3: 0 1px 3px var(--shadow), 0 4px 8px 3px var(--shadow);
}
:root[data-theme="dark"] {
--primary: #cbb3ff;
--on-primary: #381b6c;
--primary-container: #52389e;
--on-primary-container: #ebddff;
--inverse-primary: #6a45b8;
--secondary: #cec1de;
--on-secondary: #352c43;
--secondary-container: #483a55;
--on-secondary-container: #ebddff;
--tertiary: #f2bd7c;
--on-tertiary: #4a2800;
--tertiary-container: #4a3218;
--on-tertiary-container: #ffdcad;
--error: #ffb4ab;
--on-error: #690005;
--error-container: #5c1414;
--on-error-container: #ffb4ab;
/* surface-bright светлее канона MD3 (тон 15 вместо 24): рабочая зона в тёмной теме
чуть приподнята над фоном, а не выбелена */
--surface: #1a1611;
--surface-dim: #1a1611;
--surface-bright: #2b251d;
--surface-container-lowest: #14100b;
--surface-container-low: #201a14;
--surface-container: #251f18;
--surface-container-high: #302820;
--surface-container-highest: #3a332a;
--on-surface: #ede2d4;
--on-surface-variant: #b6a994;
--inverse-surface: #ede2d4;
--inverse-on-surface: #2b251d;
--outline: #4d4334;
--outline-variant: #342c20;
--success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f;
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
--error-dot: #ff5449;
--warn-dot: #ffb76b;
--state-row: rgba(203, 179, 255, .05);
--scrim: rgba(0, 0, 0, .55);
--shadow: rgba(0, 0, 0, .45);
}
+7 -5
View File
@@ -4,6 +4,8 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
const api = `http://localhost:${process.env.MOCK_PORT || 8080}`;
export default defineConfig({ export default defineConfig({
// Ресурсы — от адреса страницы, а не от корня домена: собранная панель работает и в корне, и в // Ресурсы — от адреса страницы, а не от корня домена: собранная панель работает и в корне, и в
// подпапке за reverse-proxy (example.com/forgetting/) без пересборки. Запросы к API — так же // подпапке за reverse-proxy (example.com/forgetting/) без пересборки. Запросы к API — так же
@@ -12,11 +14,11 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:8080', '/api': api,
'/ui': 'http://localhost:8080', '/ui': api,
'/health': 'http://localhost:8080', '/health': api,
'/login': 'http://localhost:8080', '/login': api,
'/logout': 'http://localhost:8080', '/logout': api,
}, },
}, },
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-cli", "name": "awg-cli",
"version": "0.4.7", "version": "0.5.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"tsx": "^4.7.0", "tsx": "^4.7.0",
+176 -65
View File
@@ -15,37 +15,90 @@ else
GRN=""; YLW=""; RED=""; ACC=""; BLD=""; DIM=""; NC="" GRN=""; YLW=""; RED=""; ACC=""; BLD=""; DIM=""; NC=""
fi fi
# Терминал ли на выводе — запоминаем до перенаправления в лог (после него stdout — уже труба).
# Ширина — чтобы строка спиннера не переносилась.
TTY_OUT=""
[[ -t 1 ]] && TTY_OUT="y"
COLS=$(stty size 2>/dev/null </dev/tty | cut -d' ' -f2 || true)
[[ "$COLS" =~ ^[0-9]+$ && "$COLS" -ge 40 ]] || COLS=80
ok() { echo -e " ${GRN}✓${NC} $*"; } ok() { echo -e " ${GRN}✓${NC} $*"; }
warn() { echo -e " ${YLW}⚠${NC} $*"; } warn() { echo -e " ${YLW}⚠${NC} $*"; }
fail() { trap - ERR; echo -e "\n ${RED}✗${NC} $*" >&2; exit 1; } fail() { trap - ERR; echo -e "\n ${RED}✗${NC} $*" >&2; exit 1; }
info() { echo -e " ${DIM}→ $*${NC}"; } info() { echo -e " ${DIM}→ $*${NC}"; }
skip() { echo -e " ${DIM}– $*${NC}"; } skip() { echo -e " ${DIM}– $*${NC}"; }
rule() { echo -e " ${DIM}────────────────────────────────────────────────────────${NC}"; } # Заголовок шага — полоса прогресса фиксированной ширины, затем название (так названия стоят
# Заголовок шага: step "1/6 AmneziaWG" → «▍ AmneziaWG 1 из 6»; без номера — просто заголовок. # в один столбец): step "4/6 Запуск AWG" → «━━━━━━━━──── Запуск AWG 4/6». Шаг без номера
# (проверки, вопросы) — с пустой полосой той же ширины.
STEP_W=12
step() { step() {
local n=0 t=1 title="$*" full="" left="" bar i f
if [[ "$1" =~ ^([0-9]+)/([0-9]+)\ +(.*)$ ]]; then if [[ "$1" =~ ^([0-9]+)/([0-9]+)\ +(.*)$ ]]; then
echo -e "\n${ACC}▍${NC}${BLD} ${BASH_REMATCH[3]}${NC} ${DIM}${BASH_REMATCH[1]} из ${BASH_REMATCH[2]}${NC}" n="${BASH_REMATCH[1]}"; t="${BASH_REMATCH[2]}"; title="${BASH_REMATCH[3]}"
fi
# Две половины собираем отдельно: срез ${bar:0:f} в C-локали режет байты, а «━» — три байта.
f=$(( n * STEP_W / t ))
for (( i = 0; i < STEP_W; i++ )); do
if (( i < f )); then full+="━"; else left+="─"; fi
done
bar="${ACC}${full}${NC}${DIM}${left}${NC}"
if (( n > 0 )); then
echo -e "\n ${bar} ${BLD}${title}${NC} ${DIM}${n}/${t}${NC}"
else else
echo -e "\n${ACC}▍${NC}${BLD} $*${NC}" echo -e "\n ${bar} ${BLD}${title}${NC}"
fi fi
} }
# Карточка — строки с цветной полосой слева (правой рамки нет: ширину кириллицы в C-локали
# bash не посчитает, и рамка бы поехала). card_line ЦВЕТ "текст".
card_line() { echo -e " ${1}┃${NC} ${2:-}"; }
# Приглашение к вводу — для read -p. # Приглашение к вводу — для read -p.
Q=" ${ACC}›${NC} " Q=" ${ACC}›${NC} "
# Вывод команд — полностью, на экран и в лог, приглушённым блоком «│» под шагом. # Вывод команд. В лог — всегда полностью, приглушённым блоком «│». На экране (терминал) — одна
# run cmd … — выполнить, показать всё, что она пишет (stdout+stderr); код возврата — её. # строка: спиннер, последняя строка вывода и время; упала команда — её последние 30 строк.
# Без терминала или с VERBOSE=1 — весь вывод и на экран, как в логе.
# run cmd … — выполнить; код возврата — её.
# … | logblock — то же оформление для готового текста (хвост make.log, dmesg). # … | logblock — то же оформление для готового текста (хвост make.log, dmesg).
# Переменные перед вызовом функции bash передаёт и в неё, и в её команды: # Переменные перед вызовом функции bash передаёт и в неё, и в её команды:
# DEBIAN_FRONTEND=noninteractive run apt-get install -y … # DEBIAN_FRONTEND=noninteractive run apt-get install -y …
# Дескрипторы (открываются ниже, вместе с логом): 3 — только в лог, 4 — только на экран.
# Командам 3 закрываем: демон, унаследовавший его, держал бы лог открытым после выхода.
logblock() { logblock() {
local line local line
while IFS= read -r line || [[ -n "$line" ]]; do while IFS= read -r line || [[ -n "$line" ]]; do
printf ' %s│ %s%s\n' "$DIM" "$line" "$NC" printf ' %s│ %s%s\n' "$DIM" "$line" "$NC"
done done
} }
SPIN=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
run() { run() {
"$@" 2>&1 | logblock if [[ -z "$TTY_OUT" || -n "${VERBOSE:-}" ]] || ! { true >&3; } 2>/dev/null; then
return "${PIPESTATUS[0]}" "$@" 2>&1 3>&- | logblock
return "${PIPESTATUS[0]}"
fi
local out pid rc=0 i=0 t0=$SECONDS last el w name="$1"
[[ "$name" == */* ]] && name="${name##*/}"
[[ -n "${2:-}" && "$2" != -* ]] && name+=" $2"
w=$(( COLS - ${#name} - 20 )); (( w < 10 )) && w=10
out=$(mktemp)
printf ' %s$ %s%s\n' "$DIM" "$*" "$NC" >&3
"$@" > "$out" 2>&1 3>&- &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
el=$(( SECONDS - t0 ))
last=$(tail -n 1 "$out" 2>/dev/null | tr -d '\r\033\t' | cut -c1-"$w" || true)
printf '\r\033[K %s%s%s %s %s%s %d:%02d%s' "$ACC" "${SPIN[i++ % 10]}" "$NC" "$name" "$DIM" "$last" $(( el / 60 )) $(( el % 60 )) "$NC" >&4
sleep 0.12
done
wait "$pid" || rc=$?
printf '\r\033[K' >&4
logblock < "$out" >&3
(( rc != 0 )) && printf ' ✗ код %d\n' "$rc" >&3
if (( rc != 0 )); then
printf ' %s✗%s %s — код %d, последние строки (весь вывод — в логе):\n' "$RED" "$NC" "$name" "$rc" >&4
tail -n 30 "$out" | logblock >&4
fi
rm -f "$out"
return "$rc"
} }
# Случайная строка из [a-zA-Z0-9]. Вход конечный: при `tr < /dev/urandom | head` tr получает # Случайная строка из [a-zA-Z0-9]. Вход конечный: при `tr < /dev/urandom | head` tr получает
@@ -92,15 +145,22 @@ LOGFILE="/var/log/awg-install-$(date +%Y%m%d-%H%M%S).log"
# LC_ALL=C: sed режет байты, а не символы — на битом UTF-8 из чужого вывода (make.log, dmesg) он # LC_ALL=C: sed режет байты, а не символы — на битом UTF-8 из чужого вывода (make.log, dmesg) он
# не спотыкается. tee -p: если запись лога вдруг умрёт, tee не падает от SIGPIPE и продолжает # не спотыкается. tee -p: если запись лога вдруг умрёт, tee не падает от SIGPIPE и продолжает
# писать на экран — иначе вслед за ним молча умер бы и сам установщик. # писать на экран — иначе вслед за ним молча умер бы и сам установщик.
exec > >(tee -p >(LC_ALL=C sed -u -e $'s/\033\\[[0-9;]*[A-Za-z]//g' -e $'s/.*\r//' >> "$LOGFILE")) 2>&1 # Три потока: 4 — сам терминал (спиннер run и хвост упавшей команды — только на экран);
# 3 — только лог (полный вывод команд под спиннером); stdout/stderr — и туда, и туда (tee).
exec 4>&1
exec 3> >(LC_ALL=C sed -u -e $'s/\033\\[[0-9;]*[A-Za-z]//g' -e $'s/.*\r//' >> "$LOGFILE")
LOG_SED_PID="${!:-}"
exec > >(tee -p /dev/fd/3) 2>&1
LOG_PID="${!:-}" LOG_PID="${!:-}"
# Дождаться, пока tee допишет хвост, — иначе последние строки вылезут уже после приглашения шелла. # Дождаться, пока tee и sed допишут хвост, — иначе последние строки вылезут уже после приглашения
trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT # шелла. sed ждём не дольше 2 с: если какой-то процесс всё же унаследовал fd 3, выход не повиснет.
trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true; exec 3>&-
for _ in {1..20}; do kill -0 "$LOG_SED_PID" 2>/dev/null || break; sleep 0.1; done' EXIT
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает # ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может. # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
# При бампе версии правь оба места. # При бампе версии правь оба места.
VERSION="0.4.7" VERSION="0.5.0"
CHANNEL="${CHANNEL:-Beta}" CHANNEL="${CHANNEL:-Beta}"
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник # Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
@@ -158,25 +218,57 @@ MTU="1376"
BIN="$PROJECT/awg-control" BIN="$PROJECT/awg-control"
echo echo
echo -e " ${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}" card_line "$ACC"
echo -e " ${DIM}Панель управления VPN на AmneziaWG — установка${NC}" card_line "$ACC" "${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}"
echo -e " ${DIM}Лог: $LOGFILE${NC}" card_line "$ACC" "${DIM}Панель управления VPN на AmneziaWG — установка${NC}"
rule card_line "$ACC"
card_line "$ACC" "${DIM}лог $LOGFILE${NC}"
card_line "$ACC"
# Поверх существующей установки: обновить (настройки, пользователи и роль остаются) # Поверх существующей установки: обновить (настройки, пользователи и роль остаются)
# или переустановить с нуля. Спрашиваем первым — от ответа зависит, спрашивать ли роль. # или переустановить с нуля. Спрашиваем первым — от ответа зависит, спрашивать ли роль.
#
# Установку старше MIN_UPGRADE_VERSION не обновить и не перенести: с ней несовместимы и данные,
# и выданные ключи. Для неё — только полная переустановка со сбросом всех данных (WIPE=y):
# пользователи, мастер-ключи, ключи API, ключи сервера и ноды — всё заново. Старые базы уходят
# в бэкап (*.bak-…), как при обычной переустановке, но перенести их нельзя. Версия — из .env
# прежней установки (он есть с 0.2.0); нет его — версия неизвестна и тоже считается устаревшей.
MIN_UPGRADE_VERSION="0.5.0"
version_lt() { [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$1" ]]; }
INSTALL_MODE="fresh" INSTALL_MODE="fresh"
WIPE="n"
PREV_VERSION=""
if [[ -d "$PROJECT" ]]; then if [[ -d "$PROJECT" ]]; then
PREV_VERSION=$(sed -n 's/^VERSION=//p' "$PROJECT/.env" 2>/dev/null | head -1 | tr -d "\"' " || true)
echo echo
warn "Уже установлено в $PROJECT" if [[ -z "$PREV_VERSION" ]] || version_lt "${PREV_VERSION%%-*}" "$MIN_UPGRADE_VERSION"; then
echo -e " ${ACC}${BLD}1${NC} Обновить ${DIM}до ${VERSION}; настройки, пользователи и роль сохранятся${NC}" card_line "$RED"
echo -e " ${ACC}${BLD}2${NC} Переустановить ${DIM}с нуля; данные можно будет сохранить${NC}" card_line "$RED" "${RED}${BLD}✗ Установлена устаревшая версия ${PREV_VERSION:-(неизвестна)}${NC}"
ask INST_CHOICE "Выбери [1/2, Enter — 1]: " card_line "$RED"
case "${INST_CHOICE:-1}" in card_line "$RED" "Обновить её до ${VERSION} нельзя: версии старше ${MIN_UPGRADE_VERSION} нужна полная переустановка."
1) INSTALL_MODE="update" ;; card_line "$RED" "Будут удалены все ключи и данные:"
2) INSTALL_MODE="fresh" ;; card_line "$RED" " ${DIM}·${NC} пользователи и их vpn:// ключи"
*) fail "Неверный выбор: введи 1 или 2" ;; card_line "$RED" " ${DIM}·${NC} мастер-ключи и ссылки sen://, ключи API"
esac card_line "$RED" " ${DIM}·${NC} ключи сервера и подключение к панели (ноду придётся добавить заново)"
card_line "$RED"
card_line "$RED" "Выданные ключи перестанут работать — клиентам нужно будет выдать новые."
card_line "$RED" "${DIM}Старые базы останутся в бэкапе (*.bak-…), но перенести их в ${VERSION} нельзя.${NC}"
card_line "$RED"
echo
ask WIPE_YN "Переустановить с удалением всех ключей? [y/N]: "
[[ "${WIPE_YN:-n}" =~ ^[Yy]$ ]] || { echo " Отменено — ничего не изменено."; exit 0; }
WIPE="y"
else
warn "Уже установлено в $PROJECT ${DIM}(версия ${PREV_VERSION})${NC}"
echo -e " ${ACC}${BLD}1${NC} Обновить ${DIM}до ${VERSION}; настройки, пользователи и роль сохранятся${NC}"
echo -e " ${ACC}${BLD}2${NC} Переустановить ${DIM}с нуля; данные можно будет сохранить${NC}"
ask INST_CHOICE "Выбери [1/2, Enter — 1]: "
case "${INST_CHOICE:-1}" in
1) INSTALL_MODE="update" ;;
2) INSTALL_MODE="fresh" ;;
*) fail "Неверный выбор: введи 1 или 2" ;;
esac
fi
echo echo
fi fi
@@ -371,7 +463,9 @@ DB_EXISTS="n"
# - S4 добавляется к каждому пакету данных — держим маленьким, как прежние 14 # - S4 добавляется к каждому пакету данных — держим маленьким, как прежние 14
# - H1–H4: непересекающиеся диапазоны в [10^8, 2^31−1] — далеко от 1–4 (типы сообщений # - H1–H4: непересекающиеся диапазоны в [10^8, 2^31−1] — далеко от 1–4 (типы сообщений
# WireGuard) и не выше прежних значений (некоторые клиенты разбирают их как int32) # WireGuard) и не выше прежних значений (некоторые клиенты разбирают их как int32)
# - I1–I5 НЕ трогать: в vpn:// ключ уходят пустыми плейсхолдерами # - I1 здесь не задаётся и в awg1.conf не пишется: он нужен только клиентам, его выбирает
# awg-ctrl (свой у каждого сервера, хранится в users.db — KEEP_DATA его сохраняет;
# internal/ctrl/i1.go). I2–I5 в vpn:// ключ уходят пустыми плейсхолдерами
# Прежние постоянные значения: ими собраны ключи установок до рандомизации — нужны, если # Прежние постоянные значения: ими собраны ключи установок до рандомизации — нужны, если
# при KEEP_DATA=y awg1.conf потерялся. # при KEEP_DATA=y awg1.conf потерялся.
LEGACY_AWG_PORT="47619" LEGACY_AWG_PORT="47619"
@@ -632,9 +726,10 @@ if [[ "$INSTALL_MODE" == "update" ]]; then
install_command install_command
start_and_status start_and_status
rule echo
echo -e " ${GRN}${BLD}✓ Обновлено до ${VERSION}${NC} ${DIM}${ROLE_LABEL}${NC}" card_line "$GRN"
rule card_line "$GRN" "${GRN}${BLD}✓ Обновлено до ${VERSION}${NC} ${DIM}${BRAND} · ${ROLE_LABEL}${NC}"
card_line "$GRN"
echo echo
exit 0 exit 0
fi fi
@@ -683,7 +778,9 @@ fi
# vpn:// ключи пользователей привязаны к ключу сервера. ui.db следует за ними. # vpn:// ключи пользователей привязаны к ключу сервера. ui.db следует за ними.
# Панель без VPN (core): её данные — ui.db (серверы, ключи API, мастер-ключи). # Панель без VPN (core): её данные — ui.db (серверы, ключи API, мастер-ключи).
KEEP_DATA="n" KEEP_DATA="n"
if [[ "$ROLE" == "core" ]]; then if [[ "$WIPE" == "y" ]]; then
: # устаревшая установка: сохранять нечего, сброс уже подтверждён
elif [[ "$ROLE" == "core" ]]; then
if [[ -f "$UI_DB_FILE" ]]; then if [[ -f "$UI_DB_FILE" ]]; then
echo echo
warn "Найдены данные панели: $UI_DB_FILE" warn "Найдены данные панели: $UI_DB_FILE"
@@ -793,7 +890,7 @@ if [[ "$ROLE" == "node" ]]; then
JOIN="${JOIN:-}" JOIN="${JOIN:-}"
# Переустановка ноды: ключ ноды на месте, панель её уже знает — можно не выпускать # Переустановка ноды: ключ ноды на месте, панель её уже знает — можно не выпускать
# новую строку, а оставить прежнее подключение. # новую строку, а оставить прежнее подключение.
if [[ -z "$JOIN" && "$PREV_ROLE" == "node" && -n "$(prev_val CORE_HOST)" && -f "$NODE_KEY_FILE" ]]; then if [[ -z "$JOIN" && "$WIPE" != "y" && "$PREV_ROLE" == "node" && -n "$(prev_val CORE_HOST)" && -f "$NODE_KEY_FILE" ]]; then
ask KJ "Оставить подключение к панели $(prev_val CORE_HOST):$(prev_val CORE_PORT) (нода #$(prev_val NODE_ID))? [Y/n]: " ask KJ "Оставить подключение к панели $(prev_val CORE_HOST):$(prev_val CORE_PORT) (нода #$(prev_val NODE_ID))? [Y/n]: "
if [[ "${KJ:-y}" =~ ^[Yy]$ ]]; then if [[ "${KJ:-y}" =~ ^[Yy]$ ]]; then
CORE_HOST="$(prev_val CORE_HOST)"; CORE_PORT="$(prev_val CORE_PORT)" CORE_HOST="$(prev_val CORE_HOST)"; CORE_PORT="$(prev_val CORE_PORT)"
@@ -836,36 +933,39 @@ fi
# Сводка. Метки выровнены пробелами прямо в строках: printf в bash считает ширину в байтах, # Сводка. Метки выровнены пробелами прямо в строках: printf в bash считает ширину в байтах,
# и кириллица поехала бы. # и кириллица поехала бы.
echo echo
echo -e " ${BLD}Будет установлено${NC}" card_line "$ACC" "${BLD}Будет установлено${NC}"
rule card_line "$ACC"
echo -e " ${DIM}Роль ${NC} ${BLD}$ROLE_LABEL${NC}" card_line "$ACC" "${DIM}Роль ${NC} ${BLD}$ROLE_LABEL${NC}"
echo -e " ${DIM}Адрес ${NC} $SERVER_IP ${DIM}· интерфейс $NET_IFACE${NC}" card_line "$ACC" "${DIM}Адрес ${NC} $SERVER_IP ${DIM}· интерфейс $NET_IFACE${NC}"
if [[ "$ROLE" != "core" ]]; then if [[ "$ROLE" != "core" ]]; then
echo -e " ${DIM}Имя сервера ${NC} $SERVER_NAME" card_line "$ACC" "${DIM}Имя сервера ${NC} $SERVER_NAME"
echo -e " ${DIM}VPN ${NC} ${AWG_PORT}/udp" card_line "$ACC" "${DIM}VPN ${NC} ${AWG_PORT}/udp"
fi fi
if [[ "$ROLE" != "node" ]]; then if [[ "$ROLE" != "node" ]]; then
echo -e " ${DIM}Панель ${NC} ${UI_PORT}/tcp ${DIM}· логин $UI_USER${NC}" card_line "$ACC" "${DIM}Панель ${NC} ${UI_PORT}/tcp ${DIM}· логин $UI_USER${NC}"
echo -e " ${DIM}Подписка sen://${NC} ${SUB_PORT}/tcp ${DIM}· $([[ "$SUB_TLS" == "on" ]] && echo "HTTPS" || echo "HTTP, ответы подписаны")${NC}" card_line "$ACC" "${DIM}Подписка sen://${NC} ${SUB_PORT}/tcp ${DIM}· $([[ "$SUB_TLS" == "on" ]] && echo "HTTPS" || echo "HTTP, ответы подписаны")${NC}"
if [[ -n "$NODE_PORT" ]]; then if [[ -n "$NODE_PORT" ]]; then
echo -e " ${DIM}Приём нод ${NC} ${NODE_PORT}/tcp" card_line "$ACC" "${DIM}Приём нод ${NC} ${NODE_PORT}/tcp"
else else
echo -e " ${DIM}Приём нод ${NC} ${DIM}выключен${NC}" card_line "$ACC" "${DIM}Приём нод ${NC} ${DIM}выключен${NC}"
fi fi
else else
echo -e " ${DIM}Панель ${NC} $CORE_HOST:$CORE_PORT ${DIM}· нода #$NODE_ID${NC}" card_line "$ACC" "${DIM}Панель ${NC} $CORE_HOST:$CORE_PORT ${DIM}· нода #$NODE_ID${NC}"
fi fi
if [[ "$KEEP_DATA" == "y" ]]; then if [[ "$KEEP_DATA" == "y" ]]; then
if [[ "$ROLE" == "core" ]]; then if [[ "$ROLE" == "core" ]]; then
echo -e " ${DIM}Данные ${NC} ${GRN}сохраняются: серверы и ключи панели${NC}" card_line "$ACC" "${DIM}Данные ${NC} ${GRN}сохраняются: серверы и ключи панели${NC}"
else else
echo -e " ${DIM}Данные ${NC} ${GRN}сохраняются: пользователи и ключ сервера${NC}" card_line "$ACC" "${DIM}Данные ${NC} ${GRN}сохраняются: пользователи и ключ сервера${NC}"
fi fi
elif [[ "$WIPE" == "y" ]]; then
card_line "$ACC" "${DIM}Данные ${NC} ${RED}все ключи и данные удаляются${NC} ${DIM}(устаревшая версия ${PREV_VERSION:-?}; базы — в бэкап)${NC}"
elif [[ "$ROLE" != "core" && -f "$DB_FILE" ]] || [[ "$ROLE" == "core" && -f "$UI_DB_FILE" ]]; then elif [[ "$ROLE" != "core" && -f "$DB_FILE" ]] || [[ "$ROLE" == "core" && -f "$UI_DB_FILE" ]]; then
echo -e " ${DIM}Данные ${NC} ${YLW}с нуля, старая база уйдёт в бэкап${NC}" card_line "$ACC" "${DIM}Данные ${NC} ${YLW}с нуля, старая база уйдёт в бэкап${NC}"
fi fi
echo -e " ${DIM}Каталог ${NC} ${DIM}$PROJECT${NC}" card_line "$ACC" "${DIM}Каталог ${NC} ${DIM}$PROJECT${NC}"
rule card_line "$ACC"
echo
ask YN "Продолжить? [Y/n]: " ask YN "Продолжить? [Y/n]: "
[[ "${YN:-y}" =~ ^[Nn]$ ]] && { echo " Отменено."; exit 0; } [[ "${YN:-y}" =~ ^[Nn]$ ]] && { echo " Отменено."; exit 0; }
@@ -1110,6 +1210,13 @@ step "3/6 Ключи и конфиг AWG"
mkdir -p "$AWG_DIR" "$AMNEZIA_DIR" mkdir -p "$AWG_DIR" "$AMNEZIA_DIR"
# Сброс устаревшей установки: ключ ноды — тоже в бэкап, агент создаст новый (ноду добавляют в
# панель заново). Остальное (базы, ключи сервера и подписи) сбрасывается как при KEEP_DATA=n ниже.
if [[ "$WIPE" == "y" && -f "$NODE_KEY_FILE" ]]; then
mv "$NODE_KEY_FILE" "${NODE_KEY_FILE}.bak-$(date +%Y%m%d-%H%M%S)"
warn "Старый ключ ноды убран в бэкап — ноду нужно заново добавить в панели"
fi
# ui.db (серверы, API-ключи, мастер-ключи awg-ui) — в бэкап, чтобы awg-ui создал чистую БД. # ui.db (серверы, API-ключи, мастер-ключи awg-ui) — в бэкап, чтобы awg-ui создал чистую БД.
if [[ "$KEEP_DATA" != "y" && -f "$UI_DB_FILE" ]]; then if [[ "$KEEP_DATA" != "y" && -f "$UI_DB_FILE" ]]; then
UI_DB_BAK="${UI_DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)" UI_DB_BAK="${UI_DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)"
@@ -1175,9 +1282,10 @@ if [[ "$ROLE" != "node" ]]; then
if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then
ok "Сертификат подписки sen:// сохранён" ok "Сертификат подписки sen:// сохранён"
else else
# CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI.
( umask 077 ( umask 077
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 -subj "/CN=sen" 2>/dev/null ) -keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 -subj "/CN=$(openssl rand -hex 6)" 2>/dev/null )
chmod 600 "$SUB_TLS_KEY" "$SUB_TLS_CRT" chmod 600 "$SUB_TLS_KEY" "$SUB_TLS_CRT"
ok "Самоподписанный сертификат подписки sen:// создан (10 лет)" ok "Самоподписанный сертификат подписки sen:// создан (10 лет)"
fi fi
@@ -1515,29 +1623,32 @@ if [[ "$ROLE" == "node" ]]; then
fi fi
fi fi
rule echo
echo -e " ${GRN}${BLD}✓ Готово${NC} ${DIM}${BRAND} ${VERSION} · ${ROLE_LABEL}${NC}" card_line "$GRN"
rule card_line "$GRN" "${GRN}${BLD}✓ Готово${NC} ${DIM}${BRAND} ${VERSION} · ${ROLE_LABEL}${NC}"
card_line "$GRN"
if [[ "$ROLE" != "node" ]]; then if [[ "$ROLE" != "node" ]]; then
echo -e " ${DIM}Панель ${NC} ${BLD}http://${SERVER_IP}:${UI_PORT}${NC}" card_line "$GRN" "${DIM}Панель ${NC} ${BLD}http://${SERVER_IP}:${UI_PORT}${NC}"
echo -e " ${DIM}Логин ${NC} ${UI_USER}" card_line "$GRN" "${DIM}Логин ${NC} ${UI_USER}"
echo -e " ${DIM}Пароль ${NC} ${YLW}${UI_PASS}${NC} ${DIM}сменить: awg-ctrl credentials${NC}" card_line "$GRN" "${DIM}Пароль ${NC} ${YLW}${UI_PASS}${NC} ${DIM}сменить: awg-ctrl credentials${NC}"
echo card_line "$GRN"
echo -e " ${DIM}sen:// ${NC} $([[ "$SUB_TLS" == "on" ]] && echo https || echo http)://${SERVER_IP}:${SUB_PORT} ${DIM}подписка SenAWG${NC}" card_line "$GRN" "${DIM}sen:// ${NC} $([[ "$SUB_TLS" == "on" ]] && echo https || echo http)://${SERVER_IP}:${SUB_PORT} ${DIM}подписка SenAWG${NC}"
[[ -n "$NODE_PORT" ]] && echo -e " ${DIM}Ноды ${NC} wss://${SERVER_IP}:${NODE_PORT} ${DIM}«Добавить сервер» в панели${NC}" [[ -n "$NODE_PORT" ]] && card_line "$GRN" "${DIM}Ноды ${NC} wss://${SERVER_IP}:${NODE_PORT} ${DIM}«Добавить сервер» в панели${NC}"
fi fi
if [[ "$ROLE" != "core" ]]; then if [[ "$ROLE" != "core" ]]; then
echo -e " ${DIM}VPN ${NC} ${SERVER_IP}:${AWG_PORT}/udp ${DIM}· awg-ctrl на 127.0.0.1:${AWGCTRL_PORT}${NC}" card_line "$GRN" "${DIM}VPN ${NC} ${SERVER_IP}:${AWG_PORT}/udp ${DIM}· awg-ctrl на 127.0.0.1:${AWGCTRL_PORT}${NC}"
fi fi
if [[ "$ROLE" == "node" ]]; then if [[ "$ROLE" == "node" ]]; then
echo -e " ${DIM}Панель ${NC} wss://${CORE_HOST}:${CORE_PORT} ${DIM}исходящее подключение${NC}" card_line "$GRN" "${DIM}Панель ${NC} wss://${CORE_HOST}:${CORE_PORT} ${DIM}исходящее подключение${NC}"
echo card_line "$GRN"
echo -e " Сервер «${SERVER_NAME}» появится в списке серверов панели — управляй им оттуда." card_line "$GRN" "Сервер «${SERVER_NAME}» появится в списке серверов панели — управляй им оттуда."
fi fi
rule card_line "$GRN"
echo -e " ${YLW}⚠${NC} Если у хостера свой фаервол (панель провайдера, security group) — открой там:" echo
echo -e " ${BLD}${OPEN_PORTS[*]}${NC} ${DIM}иначе клиенты не подключатся, а на сервере всё будет выглядеть исправным${NC}" card_line "$YLW" "${YLW}⚠${NC} Если у хостера свой фаервол (панель провайдера, security group) — открой там:"
rule card_line "$YLW" " ${BLD}${OPEN_PORTS[*]}${NC}"
card_line "$YLW" " ${DIM}иначе клиенты не подключатся, а на сервере всё будет выглядеть исправным${NC}"
echo
echo -e " ${DIM}awg-ctrl ${NC} меню: запуск, статус, логин и пароль, join/unjoin" echo -e " ${DIM}awg-ctrl ${NC} меню: запуск, статус, логин и пароль, join/unjoin"
echo -e " ${DIM}systemctl restart awg-control${NC} перезапуск ${DIM}(автозапуск на загрузке включён)${NC}" echo -e " ${DIM}systemctl restart awg-control${NC} перезапуск ${DIM}(автозапуск на загрузке включён)${NC}"
echo echo
+3
View File
@@ -511,6 +511,9 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo
if code == closeAuthFailed { if code == closeAuthFailed {
a.c.Errorf("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)") a.c.Errorf("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)")
} }
if code == 4026 {
a.c.Errorf("agent: панель требует обновить ноду (install.sh → «Обновить»): %v", err)
}
return code == closeAuthFailed return code == closeAuthFailed
} }
v, err := jsjson.Decode(raw) v, err := jsjson.Decode(raw)
+1
View File
@@ -344,6 +344,7 @@ func (s *Server) syncPeers() error {
// vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1. // vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1.
func (s *Server) reloadAwgParams() string { func (s *Server) reloadAwgParams() string {
p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) }) p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
p.I1 = s.getCfg(cfgI1, p.I1)
s.cfgMu.Lock() s.cfgMu.Lock()
s.c.Params = p s.c.Params = p
s.c.Gen = p.Gen() s.c.Gen = p.Gen()
+219
View File
@@ -0,0 +1,219 @@
package ctrl
import (
"encoding/binary"
"encoding/hex"
"math/rand/v2"
"regexp"
"strconv"
"strings"
)
// I1 — «сигнатурный» пакет, который клиент шлёт перед рукопожатием, чтобы начало сессии
// выглядело как другой протокол. Сервер I-пакеты отбрасывает (они не совпадают ни с одним
// H-заголовком), поэтому совпадать у сервера и клиента I1 не обязан: выданные vpn:// ключи со
// старым I1 продолжают подключаться, а смена касается только новых конфигов.
//
// Поэтому I1 живёт не в awg1.conf, а в БД awg-ctrl (config.i1): в ядро он не уходит — нет
// риска, что модуль не примет шаблон и интерфейс не поднимется, — и переживает KEEP_DATA вместе
// с базой. Если I1 вписан в awg1.conf руками, на первом старте он и берётся.
//
// Шаблон — один из нескольких правдоподобных (DNS-запрос, начало QUIC Initial, STUN binding
// request) со случайными полями: у каждого сервера свой, общий для всех I1 был бы сигнатурой.
// Только теги <b 0x…> и <r N>: их понимают все клиенты с I1–I5. Идентификаторы транзакций и
// соединений — <r N>, они случайны в каждом пакете, как у настоящих протоколов.
const cfgI1 = "i1"
// Популярные домены, запросы к которым в любой сети выглядят обычно. Заблокированных в РФ
// нет: запрос к ним сам по себе привлекал бы внимание.
var i1Domains = []string{
"google.com", "www.google.com", "youtube.com", "gstatic.com", "googleapis.com",
"apple.com", "icloud.com", "microsoft.com", "bing.com", "live.com", "cloudflare.com",
"amazon.com", "wikipedia.org", "github.com", "yandex.ru", "ya.ru", "vk.com", "mail.ru",
"ok.ru", "avito.ru", "ozon.ru", "wildberries.ru", "gosuslugi.ru", "dzen.ru", "2gis.ru",
}
// GenI1 — случайный I1 из одного из шаблонов.
func GenI1() string {
switch rand.IntN(3) {
case 0:
return i1DNS()
case 1:
return i1QUIC()
default:
return i1STUN()
}
}
func pick[T any](v ...T) T { return v[rand.IntN(len(v))] }
// DNS-запрос: ID — <r 2>, флаги RD, один вопрос A/AAAA/HTTPS, иногда EDNS0 (как у systemd-resolved,
// браузеров и роутеров).
func i1DNS() string {
var b []byte
edns := rand.IntN(2) == 0
ar := uint16(0)
if edns {
ar = 1
}
b = binary.BigEndian.AppendUint16(b, 0x0100) // флаги: стандартный запрос, RD
b = append(b, 0, 1, 0, 0, 0, 0) // QDCOUNT 1, ANCOUNT 0, NSCOUNT 0
b = binary.BigEndian.AppendUint16(b, ar) // ARCOUNT
for _, l := range strings.Split(pick(i1Domains...), ".") { // QNAME
b = append(b, byte(len(l)))
b = append(b, l...)
}
b = append(b, 0)
b = binary.BigEndian.AppendUint16(b, pick[uint16](1, 1, 28, 65)) // QTYPE: A чаще, AAAA, HTTPS
b = append(b, 0, 1) // QCLASS IN
if edns {
b = append(b, 0, 0, 41) // OPT, имя — корень
b = binary.BigEndian.AppendUint16(b, pick[uint16](1232, 4096, 1400))
b = append(b, 0, 0) // расширенный RCODE, версия
b = binary.BigEndian.AppendUint16(b, pick[uint16](0, 0x8000)) // флаг DO
b = append(b, 0, 0) // RDLEN
}
return "<r 2><b 0x" + hex.EncodeToString(b) + ">"
}
// Начало QUIC v1 Initial: длинный заголовок (младшие биты первого байта под header protection —
// случайны), DCID 8 байт, SCID 0 или 8, без токена; остаток до 1200/1250 байт — шифрованная
// полезная нагрузка, её изображает <r N>.
func i1QUIC() string {
scid := pick(0, 8)
total := pick(1200, 1250, 1252)
head := 1 + 4 + 1 + 8 + 1 + scid + 1 + 2
rest := total - head
s := "<b 0x" + hex.EncodeToString([]byte{byte(0xc0 | rand.IntN(16)), 0, 0, 0, 1, 8}) + "><r 8>"
if scid > 0 {
s += "<b 0x08><r 8>"
} else {
s += "<b 0x00>"
}
return s + "<b 0x00" + hex.EncodeToString(binary.BigEndian.AppendUint16(nil, 0x4000|uint16(rest))) + "><r " + strconv.Itoa(rest) + ">"
}
// STUN binding request (RFC 5389): magic cookie, ID транзакции — <r 12>; иногда с
// CHANGE-REQUEST (проверка NAT, RFC 5780).
func i1STUN() string {
attrs := pick([]byte{}, []byte{0, 3, 0, 4, 0, 0, 0, 0})
b := []byte{0, 1}
b = binary.BigEndian.AppendUint16(b, uint16(len(attrs)))
b = append(b, 0x21, 0x12, 0xa4, 0x42)
s := "<b 0x" + hex.EncodeToString(b) + "><r 12>"
if len(attrs) > 0 {
s += "<b 0x" + hex.EncodeToString(attrs) + ">"
}
return s
}
var reI1Tag = regexp.MustCompile(`<(b|r) ([^>]*)>`)
// i1Bytes раскрывает шаблон в байты (<r N> — нули); ok=false для тегов, кроме <b>/<r>.
func i1Bytes(s string) ([]byte, bool) {
var out []byte
rest := strings.TrimSpace(s)
for rest != "" {
m := reI1Tag.FindStringSubmatchIndex(rest)
if m == nil || m[0] != 0 {
return nil, false
}
kind, val := rest[m[2]:m[3]], rest[m[4]:m[5]]
if kind == "b" {
h, err := hex.DecodeString(strings.TrimPrefix(val, "0x"))
if err != nil {
return nil, false
}
out = append(out, h...)
} else {
n, err := strconv.Atoi(val)
if err != nil || n < 0 || n > 65535 {
return nil, false
}
out = append(out, make([]byte, n)...)
}
rest = rest[m[1]:]
}
return out, len(out) > 0
}
// dnsName — имя первого вопроса DNS-пакета.
func dnsName(b []byte) string {
var parts []string
for i := 12; i < len(b) && b[i] != 0; {
n := int(b[i])
if n > 63 || i+1+n > len(b) {
return ""
}
parts = append(parts, string(b[i+1:i+1+n]))
i += 1 + n
}
return strings.Join(parts, ".")
}
// DescribeI1 — под что маскируется I1, для панели: вид (dns, quic, stun, custom, none) и подпись.
func DescribeI1(s string) (kind, label string) {
if strings.TrimSpace(s) == "" {
return "none", "без маскировки"
}
b, ok := i1Bytes(s)
switch {
case !ok:
case len(b) >= 20 && binary.BigEndian.Uint32(b[4:8]) == 0x2112a442 && b[0] == 0 && b[1] == 1:
return "stun", "STUN binding request"
case len(b) >= 6 && b[0]&0xc0 == 0xc0 && binary.BigEndian.Uint32(b[1:5]) == 1:
return "quic", "QUIC Initial, " + strconv.Itoa(len(b)) + " байт"
case len(b) > 12 && b[2]&0x80 == 0 && binary.BigEndian.Uint16(b[4:6]) == 1:
if n := dnsName(b); n != "" {
return "dns", "DNS-запрос к " + n
}
case len(b) > 12 && b[2]&0x80 != 0:
if n := dnsName(b); n != "" {
return "dns", "DNS-ответ от " + n
}
}
return "custom", "свой шаблон"
}
// i1FromConf — I1, вписанный в [Interface] awg1.conf руками (ReadParams без него отдаёт
// значение по умолчанию).
func i1FromConf(confFile string) string {
p := ReadParams(confFile, nil)
if p.I1 == DefaultParams().I1 {
return ""
}
return p.I1
}
// ensureI1 — на старте: I1 этого сервера из БД; нет — из awg1.conf, нет и там — новый случайный.
func (s *Server) ensureI1() string {
if v := s.getCfg(cfgI1, ""); v != "" {
return v
}
v := i1FromConf(s.confPath())
if v == "" {
v = GenI1()
}
s.setCfg(cfgI1, v)
kind, label := DescribeI1(v)
s.log.Info("I1 assigned", "kind", kind, "label", label)
return v
}
// regenI1 — новый случайный I1. Клиенты по sen:// получают его со следующим обновлением
// профиля; vpn:// ключи несут прежний и продолжают работать, пока их не перевыпустят.
func (s *Server) regenI1() string {
v := GenI1()
s.setCfg(cfgI1, v)
s.cfgMu.Lock()
s.c.Params.I1 = v
s.cfgMu.Unlock()
kind, label := DescribeI1(v)
s.log.Info("I1 regenerated", "kind", kind, "label", label)
return v
}
func maskingInfo(i1 string) ob {
kind, label := DescribeI1(i1)
return ob{{"i1", i1}, {"kind", kind}, {"label", label}}
}
+58
View File
@@ -0,0 +1,58 @@
package ctrl
import (
"strings"
"testing"
)
func TestGenI1Templates(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 500; i++ {
v := GenI1()
b, ok := i1Bytes(v)
if !ok {
t.Fatalf("шаблон не разбирается: %q", v)
}
kind, label := DescribeI1(v)
seen[kind] = true
switch kind {
case "dns":
if !strings.HasPrefix(v, "<r 2>") || !strings.HasPrefix(label, "DNS-запрос к ") {
t.Fatalf("DNS: %q → %q", v, label)
}
case "quic":
if n := len(b); n != 1200 && n != 1250 && n != 1252 {
t.Fatalf("QUIC длиной %d: %q", n, v)
}
case "stun":
if len(b) != 20 && len(b) != 28 {
t.Fatalf("STUN длиной %d: %q", len(b), v)
}
default:
t.Fatalf("неожиданный вид %q: %q", kind, v)
}
// Только <b>/<r>, без пробелов вне тегов — так I1 пишут и клиенты AmneziaVPN.
if strings.Contains(strings.NewReplacer("<b ", "", "<r ", "").Replace(v), " ") {
t.Fatalf("лишние пробелы: %q", v)
}
}
for _, k := range []string{"dns", "quic", "stun"} {
if !seen[k] {
t.Fatalf("шаблон %s ни разу не выпал", k)
}
}
}
func TestDescribeI1(t *testing.T) {
cases := map[string]string{
DefaultParams().I1: "DNS-ответ от icloud.com",
"": "без маскировки",
"<c><t>": "свой шаблон",
"<b 0xdeadbeef>": "свой шаблон",
}
for in, want := range cases {
if _, got := DescribeI1(in); got != want {
t.Errorf("DescribeI1(%q) = %q, want %q", in, got, want)
}
}
}
+6
View File
@@ -300,6 +300,12 @@ func (s *Server) routes() []route {
{"keepalive", cfg.Keepalive}, {"mtu", cfg.MTU}, {"awg", profileAwg(cfg.Params)}}) {"keepalive", cfg.Keepalive}, {"mtu", cfg.MTU}, {"awg", profileAwg(cfg.Params)}})
}), }),
// Маскировка первого пакета (I1): посмотреть и сменить. Путь под /api/ — его пропускает
// awg-agent нод. Интерфейс не трогается: I1 нужен только клиентам (см. i1.go).
R("GET", "/api/masking", false, a, func(c *rctx) (result, error) { return ok(maskingInfo(s.cfg().Params.I1)) }),
R("POST", "/api/masking", true, a, func(c *rctx) (result, error) { return ok(maskingInfo(s.regenI1())) }),
R("POST", "/awg/restart", true, a, func(c *rctx) (result, error) { R("POST", "/awg/restart", true, a, func(c *rctx) (result, error) {
if err := s.restartAwg(); err != nil { if err := s.restartAwg(); err != nil {
return result{}, err return result{}, err
+1
View File
@@ -146,6 +146,7 @@ func New() (*Server, error) {
sp, _ := strconv.Atoi(portStr) sp, _ := strconv.Atoi(portStr)
params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) }) params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
params.I1 = s.ensureI1() // I1 — из БД, не из conf (см. i1.go)
gen := params.Gen() gen := params.Gen()
module, tools := readVersions() module, tools := readVersions()
s.c = Config{ s.c = Config{
+1 -1
View File
@@ -91,7 +91,7 @@ func TestPrometheusOnNodeZero(t *testing.T) {
ag, err := agent.New(agent.Config{ ag, err := agent.New(agent.Config{
CoreHost: "127.0.0.1", CorePort: hubPort, CorePin: b64url.EncodeToString(a.nodes.pin), NodeID: 0, CoreHost: "127.0.0.1", CorePort: hubPort, CorePin: b64url.EncodeToString(a.nodes.pin), NodeID: 0,
Ctrl: ctrl.URL, NodeKeyFile: keyFile, InternalKey: filepath.Join(dir, "none"), Ctrl: ctrl.URL, NodeKeyFile: keyFile, InternalKey: filepath.Join(dir, "none"),
PromURL: prom.URL, MetricsPort: metricsPort, MonitorHash: hashFile, PromURL: prom.URL, MetricsPort: metricsPort, MonitorHash: hashFile, Version: MinNodeVersion,
Logf: t.Logf, Errorf: t.Logf, Logf: t.Logf, Errorf: t.Logf,
}) })
if err != nil { if err != nil {
+39 -3
View File
@@ -159,8 +159,11 @@ func newNodes(a *App) (*Nodes, error) {
keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt") keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt")
if err := func() error { if err := func() error {
if !exists(keyF) && !exists(crtF) { if !exists(keyF) && !exists(crtF) {
// CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI.
cn := make([]byte, 6)
rand.Read(cn)
cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node") "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", fmt.Sprintf("/CN=%x", cn))
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return err return err
} }
@@ -473,6 +476,30 @@ func (n *Nodes) dropConn(id int64, c *nodeConn) {
n.mu.Unlock() n.mu.Unlock()
} }
// MinNodeVersion — самая старая версия ноды, с которой работает панель (в UI — MIN_NODE_VERSION).
const MinNodeVersion = "0.5.0"
// closeOutdated — код закрытия для ноды старше MinNodeVersion.
const closeOutdated = 4026
// cmpVersion сравнивает версии вида 0.5.0 (хвост после «-» не учитывается): <0 — a старше b.
func cmpVersion(a, b string) int {
pa, pb := strings.Split(strings.SplitN(a, "-", 2)[0], "."), strings.Split(strings.SplitN(b, "-", 2)[0], ".")
for i := 0; i < max(len(pa), len(pb)); i++ {
var x, y int
if i < len(pa) {
x, _ = strconv.Atoi(pa[i])
}
if i < len(pb) {
y, _ = strconv.Atoi(pb[i])
}
if x != y {
return x - y
}
}
return 0
}
func closeWS(ws *websocket.Conn, code int, reason string) { func closeWS(ws *websocket.Conn, code int, reason string) {
go ws.Close(websocket.StatusCode(code), reason) go ws.Close(websocket.StatusCode(code), reason)
} }
@@ -540,6 +567,13 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
version = v version = v
} }
n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID) n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID)
// Нода старше MinNodeVersion (или без версии — агент старше 0.3.4) с этой панелью не работает:
// соединение не принимаем, для панели она «нет связи», пока её не обновят (агент переподключится сам).
if v, _ := version.(string); v == "" || cmpVersion(v, MinNodeVersion) < 0 {
logf("nodes: «%s» (#%d) — версия %q, нужна не ниже %s: соединение отклонено", row.Name, row.ID, v, MinNodeVersion)
closeWS(ws, closeOutdated, "outdated "+MinNodeVersion)
return
}
conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}} conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}}
nodeID = row.ID nodeID = row.ID
@@ -775,7 +809,8 @@ func (n *Nodes) List() []serverInfo {
} }
for _, r := range n.all() { for _, r := range n.all() {
out = append(out, serverInfo{ID: int(r.ID), Name: r.Name, Online: online[r.ID], Pending: !r.Pub.Valid, out = append(out, serverInfo{ID: int(r.ID), Name: r.Name, Online: online[r.ID], Pending: !r.Pub.Valid,
LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID]}) LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID],
Outdated: r.Pub.Valid && (!r.Version.Valid || cmpVersion(r.Version.String, MinNodeVersion) < 0)})
} }
return out return out
} }
@@ -789,11 +824,12 @@ type serverInfo struct {
LastSeen any LastSeen any
Version any Version any
Health *health Health *health
Outdated bool // старше MinNodeVersion: хаб её не подключает, пока не обновят
} }
func (s serverInfo) json() jsjson.Object { func (s serverInfo) json() jsjson.Object {
return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}, {Key: "local", Val: s.Local}, {Key: "online", Val: s.Online}, return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}, {Key: "local", Val: s.Local}, {Key: "online", Val: s.Online},
{Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}} {Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}, {Key: "outdated", Val: s.Outdated}}
} }
func (n *Nodes) idOf(c *Ctx) (int64, bool) { func (n *Nodes) idOf(c *Ctx) (int64, bool) {
+88
View File
@@ -0,0 +1,88 @@
package ui
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Нода старше MinNodeVersion (или без версии) с панелью не работает: хаб отклоняет её с 4026 и
// не считает подключённой, но версию запоминает — панель показывает, до чего обновлять.
func TestOutdatedNodeRejected(t *testing.T) {
p := newMovePanel(t, "off", "40500")
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', ?, 1)", b64url.EncodeToString(pub)); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS))
defer srv.Close()
connect := func(version string) (jsjson.Object, websocket.StatusCode) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ws, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer ws.CloseNow()
read := func() (jsjson.Object, error) {
_, raw, err := ws.Read(ctx)
if err != nil {
return nil, err
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
return o, nil
}
hello, _ := read()
auth := jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(hello.Get2("nonce").(string), 3)))}}
if version != "" {
auth = append(auth, jsjson.KV{Key: "version", Val: version})
}
ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(auth))
o, err := read()
return o, websocket.CloseStatus(err)
}
for _, v := range []string{"0.4.9", ""} {
if o, code := connect(v); code != closeOutdated {
t.Fatalf("версия %q: ждали закрытия %d, получили %v / %v", v, closeOutdated, o, code)
}
p.a.nodes.mu.Lock()
_, on := p.a.nodes.conns[3]
p.a.nodes.mu.Unlock()
if on {
t.Fatalf("версия %q: нода считается подключённой", v)
}
}
var stored string
p.a.db.QueryRow("SELECT COALESCE(version, '') FROM nodes WHERE id = 3").Scan(&stored)
if stored != "" { // последняя попытка — без версии
t.Fatalf("версия в БД: %q", stored)
}
if o, code := connect(MinNodeVersion); o.Get2("t") != "ready" {
t.Fatalf("актуальная версия: %v / %v", o, code)
}
}
func TestCmpVersion(t *testing.T) {
for _, c := range []struct {
a, b string
sign int
}{{"0.5.0", "0.5.0", 0}, {"0.4.9", "0.5.0", -1}, {"0.10.0", "0.9.9", 1}, {"0.5", "0.5.0", 0}, {"0.5.1-beta", "0.5.1", 0}} {
got := cmpVersion(c.a, c.b)
if (got > 0) != (c.sign > 0) || (got < 0) != (c.sign < 0) {
t.Errorf("cmpVersion(%q, %q) = %d", c.a, c.b, got)
}
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ func TestRetireReadiness(t *testing.T) {
write := func(o jsjson.Object) { ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(o)) } write := func(o jsjson.Object) { ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(o)) }
nonce := read().Get2("nonce").(string) nonce := read().Get2("nonce").(string)
write(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3}, write(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}}) {Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}, {Key: "version", Val: MinNodeVersion}})
if got := string(jsjson.MustMarshal(read())); got != `{"t":"ready","core":"198.51.100.20:40500"}` { if got := string(jsjson.MustMarshal(read())); got != `{"t":"ready","core":"198.51.100.20:40500"}` {
t.Fatalf("ready: %s", got) t.Fatalf("ready: %s", got)
} }