Panel redesign, per-server I1 masking, node minimum version 0.5.0, installer output
ci / check (push) Successful in 23m54s
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:
@@ -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);
|
||||
}
|
||||
@@ -56,6 +56,19 @@ export interface Brand { brand: string; channel: string; version: string }
|
||||
export const TOKEN_KEY = 'awg_token';
|
||||
export const THEME_KEY = 'awg_theme';
|
||||
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.
|
||||
export interface NodeInfo {
|
||||
@@ -66,6 +79,7 @@ export interface NodeInfo {
|
||||
pending: boolean; // нода создана, но ещё ни разу не подключалась
|
||||
last_seen: number | null;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -90,6 +104,18 @@ export interface TorrentStatus {
|
||||
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.
|
||||
export function cmpVersion(a: string, b: string): number {
|
||||
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user