The product name, the channel word "Alpha" and the version were scattered across install.sh, the CLI banner, two spots in App.tsx and three package.json files, so a rename or a bump meant editing strings in code. They now live in one committed .env at the project root (BRAND, CHANNEL, VERSION), shipped inside the release archive and read at runtime: the CLI loads it after cli.env — so real env and machine settings still win — and injects the three values into awg-ui, which serves them from GET /ui/brand for the wordmark. awg-ui also parses the same file directly, so `npm run server` in dev shows what production shows. install.sh keeps its own VERSION and CHANNEL and says why: it prints the banner and builds the release URL before the archive holding .env has been downloaded. It writes BRAND into cli.env only when the operator passed one explicitly, so the file stays the single source otherwise. Version bumped to Beta 0.2.0 across .env, install.sh and the three package.json files.
101 lines
4.3 KiB
TypeScript
101 lines
4.3 KiB
TypeScript
// 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 axios from 'axios';
|
|
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
|
|
export type AwgGen = '2' | '3.1';
|
|
export interface User {
|
|
name: string;
|
|
ip: string;
|
|
pub_key: string;
|
|
vpn_key: string;
|
|
key_gen: AwgGen;
|
|
online: boolean;
|
|
lastHandshake: number;
|
|
rx?: number;
|
|
tx?: number;
|
|
}
|
|
export interface ServerInfo {
|
|
name: string;
|
|
ip: string;
|
|
peers: number;
|
|
gen: AwgGen;
|
|
mod: string; // версия загруженного модуля amneziawg, из /health awg.module
|
|
tools: string; // версия awg-tools, из /health awg.tools
|
|
}
|
|
// На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'.
|
|
export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0');
|
|
|
|
export interface PageProps {
|
|
token: string;
|
|
showMsg: (text: string) => void;
|
|
}
|
|
// Имя панели, канал и версия приходят с сервера (GET /ui/brand; значения — из
|
|
// .env в корне проекта) — переименование и бамп версии живут в одном файле.
|
|
// Эта константа — фолбэк на время загрузки и при недоступности роута.
|
|
export const BRAND_FALLBACK = 'Forgetting';
|
|
export interface Brand { brand: string; channel: string; version: string }
|
|
|
|
export const TOKEN_KEY = 'awg_token';
|
|
export const THEME_KEY = 'awg_theme';
|
|
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
|
|
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body });
|
|
return r.data;
|
|
}
|
|
|
|
export async function vpnKeyToConf(vpnKey: string): Promise<string> {
|
|
const b64 = vpnKey.replace('vpn://', '').replace(/-/g, '+').replace(/_/g, '/');
|
|
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
const stream = new Blob([raw.slice(4)]).stream().pipeThrough(new DecompressionStream('deflate'));
|
|
const json = await new Response(stream).text();
|
|
const data = JSON.parse(json);
|
|
const awg = data.containers[0].awg;
|
|
try {
|
|
const lc = JSON.parse(awg.last_config || '{}');
|
|
return (lc.config || '')
|
|
.replace('$PRIMARY_DNS', data.dns1 || '1.1.1.1')
|
|
.replace('$SECONDARY_DNS', data.dns2 || '1.0.0.1');
|
|
} catch {
|
|
return (awg.last_config || '')
|
|
.replace('$PRIMARY_DNS', data.dns1 || '1.1.1.1')
|
|
.replace('$SECONDARY_DNS', data.dns2 || '1.0.0.1');
|
|
}
|
|
}
|
|
export function downloadFile(filename: string, text: string) {
|
|
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
export async function copyText(text: string) {
|
|
if (navigator.clipboard) {
|
|
await navigator.clipboard.writeText(text);
|
|
} else {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.cssText = 'position:fixed;opacity:0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
}
|
|
}
|
|
export function bytes(n: number | undefined): string {
|
|
if (!n) return '—';
|
|
if (n < 1024) return n + ' B';
|
|
if (n < 1_048_576) return (n / 1024).toFixed(1) + ' KB';
|
|
if (n < 1_073_741_824) return (n / 1_048_576).toFixed(1) + ' MB';
|
|
return (n / 1_073_741_824).toFixed(2) + ' GB';
|
|
}
|
|
export function timeAgo(ts: number | undefined): string {
|
|
if (!ts) return '—';
|
|
const sec = Math.floor(Date.now() / 1000) - ts;
|
|
if (sec < 60) return sec + ' сек';
|
|
if (sec < 3600) return Math.floor(sec / 60) + ' мин';
|
|
return Math.floor(sec / 3600) + ' ч';
|
|
} |