Manage several servers from one panel: core + nodes
A node is a VPN server without a panel. awg-agent on it dials the panel (core) over an outbound WSS connection and runs the panel's requests on its own awg-ctrl, which stays on 127.0.0.1 and is unchanged. No inbound port on the node, works behind NAT. - awg-ui/nodes.ts: node registry in ui.db, WSS hub on NODE_PORT with a self-signed cert whose SPKI pin goes into the join string, RPC with timeouts, health polling, /ui/nodes routes. - Nodes enroll with a one-time awgjoin:// string (secret stored hashed, 1 hour) and then authenticate by their own Ed25519 key. The agent checks the pin before sending anything and only forwards /health, /api/*, /awg/*. - server_id 0 is the panel's own awg-ctrl; LOCAL_NODE=off makes the panel run without a VPN of its own. The panel picks a server with X-Server-Id, API keys use their server_id. sen:// still serves one server. - UI: a card per server in the server bar, switching remounts the tab, «Добавить сервер» gives the join command. - CLI: agent service, roles from cli.env, join / unjoin commands. - Release archives include awg-agent.
This commit is contained in:
@@ -375,6 +375,7 @@ body {
|
||||
.server-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.server-card {
|
||||
@@ -389,6 +390,45 @@ body {
|
||||
user-select: none;
|
||||
}
|
||||
.server-card--active { border-color: var(--primary-container); }
|
||||
.server-card--inactive { cursor: pointer; }
|
||||
.server-card--inactive:hover { background: var(--state-primary); }
|
||||
.server-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
|
||||
/* «Добавить сервер»: призрачная кнопка с пунктиром (design.md, 4.2) */
|
||||
.server-add {
|
||||
background: transparent;
|
||||
border: 1.5px dashed var(--outline);
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
.server-add:disabled { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
/* Модалка добавления ноды */
|
||||
.node-scrim {
|
||||
position: fixed; inset: 0; z-index: 60;
|
||||
background: var(--scrim);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.node-modal {
|
||||
width: 100%; max-width: 520px;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--r-card);
|
||||
padding: 20px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.node-modal-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); }
|
||||
.node-modal-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
|
||||
.node-modal-cmd {
|
||||
display: block;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
background: var(--surface-container);
|
||||
border-radius: var(--r-table);
|
||||
padding: 12px;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.server-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -656,6 +696,9 @@ tbody td:last-child { padding-right: 20px; }
|
||||
border-color: var(--primary-container);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.server-card--inactive { border-color: var(--outline-variant); }
|
||||
.server-add { width: 100%; justify-content: center; }
|
||||
.node-modal-actions .btn { flex: 1; }
|
||||
.server-toggle { display: flex; }
|
||||
.server-online-chip { display: none; }
|
||||
|
||||
|
||||
+84
-19
@@ -4,20 +4,27 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import './App.css';
|
||||
import { TOKEN_KEY, THEME_KEY, BRAND_FALLBACK, apiFetch, type ServerInfo, type Brand } from './lib/shared';
|
||||
import {
|
||||
TOKEN_KEY, THEME_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId,
|
||||
type NodeInfo, type Brand,
|
||||
} from './lib/shared';
|
||||
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
|
||||
import ServerBar from './components/ServerBar';
|
||||
import AddServerModal from './components/AddServerModal';
|
||||
import { TABS } from './tabs';
|
||||
|
||||
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
|
||||
// serverInfo, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
|
||||
// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
|
||||
// из реестра TABS — App про конкретные вкладки ничего не знает.
|
||||
export default function App() {
|
||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
|
||||
const [loginUser, setLoginUser] = useState('');
|
||||
const [loginPass, setLoginPass] = useState('');
|
||||
const [statusText, setStatusText] = useState('');
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [hubEnabled, setHubEnabled] = useState(false);
|
||||
const [serverId, setServerIdState] = useState<number | null>(null);
|
||||
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(TABS[0].id);
|
||||
@@ -33,17 +40,34 @@ export default function App() {
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
}, []);
|
||||
|
||||
// Проверяет токен и подтягивает данные сервера (/health). Данные конкретных
|
||||
// вкладок (юзеры, ключи) грузят сами вкладки.
|
||||
const startSession = useCallback(async (tok: string) => {
|
||||
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
|
||||
setServerInfo({
|
||||
name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2',
|
||||
mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '',
|
||||
});
|
||||
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
|
||||
// Выбрать сервер: id уходит заголовком X-Server-Id во все запросы вкладок
|
||||
// (setServerId — до смены state, чтобы вкладка с новым key читала уже с ним).
|
||||
const pickServer = useCallback((id: number | null) => {
|
||||
setServerId(id);
|
||||
setServerIdState(id);
|
||||
try { if (id !== null) localStorage.setItem(SERVER_KEY, String(id)); } catch { /* не критично */ }
|
||||
}, []);
|
||||
|
||||
// Список серверов (core + ноды) с их состоянием. Если выбранного больше нет —
|
||||
// берём сохранённый, иначе первый.
|
||||
const loadNodes = useCallback(async (tok: string) => {
|
||||
const { data } = await axios.get('/ui/nodes', { headers: { Authorization: `Bearer ${tok}` } });
|
||||
const list: NodeInfo[] = data.nodes ?? [];
|
||||
setNodes(list);
|
||||
setHubEnabled(!!data.hub);
|
||||
const cur = getServerId();
|
||||
if (cur !== null && list.some(n => n.id === cur)) return;
|
||||
let saved: number | null = null;
|
||||
try { const raw = localStorage.getItem(SERVER_KEY); saved = raw === null ? null : Number(raw); } catch { /* не критично */ }
|
||||
pickServer(list.find(n => n.id === saved)?.id ?? list[0]?.id ?? null);
|
||||
}, [pickServer]);
|
||||
|
||||
// Проверяет токен и подтягивает список серверов. Данные конкретных вкладок
|
||||
// (юзеры, ключи) грузят сами вкладки.
|
||||
const startSession = useCallback(async (tok: string) => {
|
||||
await loadNodes(tok);
|
||||
}, [loadNodes]);
|
||||
|
||||
const login = useCallback(async () => {
|
||||
try {
|
||||
const { data: auth } = await axios.post('/login', { user: loginUser, pass: loginPass });
|
||||
@@ -69,10 +93,23 @@ export default function App() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
setToken('');
|
||||
setStatusText('');
|
||||
setServerInfo(null);
|
||||
setNodes([]);
|
||||
setServerId(null);
|
||||
setServerIdState(null);
|
||||
setDrawerOpen(false);
|
||||
}, [token]);
|
||||
|
||||
const deleteNode = useCallback(async (n: NodeInfo) => {
|
||||
if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return;
|
||||
try {
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
|
||||
showMsg('Сервер удалён');
|
||||
await loadNodes(token);
|
||||
} catch (e) {
|
||||
showMsg(axios.isAxiosError(e) ? e.response?.data?.error ?? 'Не удалось удалить' : 'Не удалось удалить');
|
||||
}
|
||||
}, [token, showMsg, loadNodes]);
|
||||
|
||||
// Перезапуск AWG-интерфейса (awg-quick down/up + ресинк пиров в awg-ctrl).
|
||||
// Соединения клиентов кратковременно прерываются — поэтому подтверждение.
|
||||
const restartAwg = useCallback(async () => {
|
||||
@@ -111,6 +148,13 @@ export default function App() {
|
||||
}
|
||||
}, [startSession]);
|
||||
|
||||
// Состояние серверов (онлайн, пиры) обновляем раз в 15 с, пока открыта панель.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const t = setInterval(() => { loadNodes(token).catch(() => { /* следующий тик */ }); }, 15_000);
|
||||
return () => clearInterval(t);
|
||||
}, [token, loadNodes]);
|
||||
|
||||
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
|
||||
// Если не ответил, остаётся фолбэк из констант.
|
||||
useEffect(() => {
|
||||
@@ -143,8 +187,7 @@ export default function App() {
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [drawerOpen]);
|
||||
|
||||
const isOnline = statusText.startsWith('online:');
|
||||
const statusLabel = statusText.replace(/^(online|offline):/, '');
|
||||
const statusLabel = statusText.replace(/^(online|offline):/, '');
|
||||
const tab = TABS.find(t => t.id === activeTab) ?? TABS[0];
|
||||
// Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён.
|
||||
const chrome = tab.chrome ?? {};
|
||||
@@ -161,7 +204,7 @@ export default function App() {
|
||||
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
|
||||
</button>
|
||||
|
||||
{token && showRestart && (
|
||||
{token && showRestart && serverId !== null && (
|
||||
<button
|
||||
className={`awg-restart${restarting ? ' spinning' : ''}`}
|
||||
aria-label="Перезапустить AWG"
|
||||
@@ -257,19 +300,41 @@ export default function App() {
|
||||
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
|
||||
{showServerBar && (
|
||||
<ServerBar
|
||||
serverInfo={serverInfo}
|
||||
serverOnline={isOnline}
|
||||
nodes={nodes}
|
||||
serverId={serverId}
|
||||
hubEnabled={hubEnabled}
|
||||
onSelect={pickServer}
|
||||
onAdd={() => setModal({})}
|
||||
onJoin={n => setModal({ node: n })}
|
||||
onDelete={deleteNode}
|
||||
onRestartAwg={restartAwg}
|
||||
restarting={restarting}
|
||||
showRestart={showRestart}
|
||||
/>
|
||||
)}
|
||||
<tab.Page token={token} showMsg={showMsg} />
|
||||
{serverId === null && showServerBar ? (
|
||||
<p className="login-sub">
|
||||
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
|
||||
</p>
|
||||
) : (
|
||||
// key: при смене сервера вкладка монтируется заново и читает данные уже с ним
|
||||
<tab.Page key={`${tab.id}:${serverId}`} token={token} showMsg={showMsg} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<AddServerModal
|
||||
token={token}
|
||||
node={modal.node}
|
||||
onClose={() => setModal(null)}
|
||||
onChanged={() => { loadNodes(token).catch(() => { /* следующий тик */ }); }}
|
||||
showMsg={showMsg}
|
||||
/>
|
||||
)}
|
||||
|
||||
{msg && <div className="snack">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// 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 { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { apiFetch, copyText, type NodeInfo } from '../lib/shared';
|
||||
import { IcoCopy } from './icons';
|
||||
|
||||
interface Joined { id: number; name: string; join: string; expires_in: number }
|
||||
|
||||
// Добавление ноды: имя → одноразовая join-строка, которую выполняют на самой ноде
|
||||
// (`awg-ctrl join …`). С `node` открывается сразу за новой строкой для уже
|
||||
// созданной, но ещё не подключавшейся ноды.
|
||||
export default function AddServerModal({ token, node, onClose, onChanged, showMsg }: {
|
||||
token: string;
|
||||
node?: NodeInfo;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
showMsg: (text: string) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<Joined | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const call = async (fn: () => Promise<Joined>) => {
|
||||
setBusy(true); setError('');
|
||||
try { setResult(await fn()); onChanged(); }
|
||||
catch (e) { setError(axios.isAxiosError(e) ? e.response?.data?.error ?? 'Не удалось' : 'Не удалось'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const create = () => call(() =>
|
||||
apiFetch('POST', '/ui/nodes', token, { name: name.trim(), host: window.location.hostname }));
|
||||
|
||||
useEffect(() => {
|
||||
if (node) void call(() => apiFetch('POST', `/ui/nodes/${node.id}/join`, token, { host: window.location.hostname }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', h);
|
||||
return () => document.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
const command = result ? `awg-ctrl join '${result.join}'` : '';
|
||||
|
||||
return (
|
||||
<div className="node-scrim" onClick={onClose}>
|
||||
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Добавить сервер" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="node-modal-title">{node ? `Подключение: ${node.name}` : 'Добавить сервер'}</h3>
|
||||
|
||||
{!node && !result && (
|
||||
<>
|
||||
<p className="node-modal-hint">
|
||||
Нода — обычный сервер с установленным Forgetting. Она сама подключится к этой панели,
|
||||
открывать на ней порты не нужно.
|
||||
</p>
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Название сервера"
|
||||
value={name}
|
||||
maxLength={40}
|
||||
autoFocus
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && name.trim() && !busy && create()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<p className="node-modal-hint">
|
||||
Выполните на сервере «{result.name}» от root. Ссылка одноразовая и действует
|
||||
{' '}{Math.round(result.expires_in / 60)} мин.
|
||||
</p>
|
||||
<code className="node-modal-cmd">{command}</code>
|
||||
<p className="node-modal-hint">
|
||||
Канал шифруется TLS с проверкой отпечатка сертификата этой панели: подменить ноду
|
||||
или панель по дороге не выйдет.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
|
||||
<div className="node-modal-actions">
|
||||
{result ? (
|
||||
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
|
||||
<IcoCopy /> Скопировать
|
||||
</button>
|
||||
) : !node && (
|
||||
<button className="btn btn--primary" onClick={create} disabled={busy || !name.trim()}>
|
||||
Создать
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn--outline" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +1,104 @@
|
||||
// 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 { type ServerInfo, genLabel } from '../lib/shared';
|
||||
import { IcoRefresh } from './icons';
|
||||
import { type NodeInfo, genLabel } from '../lib/shared';
|
||||
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
|
||||
|
||||
// Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под
|
||||
// ней. Общий для вкладок — показывает сервер, к которому относится содержимое
|
||||
// вкладки (пиры / абоненты).
|
||||
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
||||
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
||||
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
|
||||
export default function ServerBar({
|
||||
serverInfo, serverOnline, onRestartAwg, restarting,
|
||||
showRestart = true,
|
||||
nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete,
|
||||
onRestartAwg, restarting, showRestart = true,
|
||||
}: {
|
||||
serverInfo: ServerInfo | null;
|
||||
serverOnline: boolean;
|
||||
nodes: NodeInfo[];
|
||||
serverId: number | null;
|
||||
hubEnabled: boolean;
|
||||
onSelect: (id: number) => void;
|
||||
onAdd: () => void;
|
||||
onJoin: (n: NodeInfo) => void;
|
||||
onDelete: (n: NodeInfo) => void;
|
||||
onRestartAwg: () => void;
|
||||
restarting: boolean;
|
||||
showRestart?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="server-bar">
|
||||
{serverInfo && (
|
||||
<div className="server-card server-card--active">
|
||||
{/* toggle — виден на мобильном вместо чипа */}
|
||||
{nodes.map(n => {
|
||||
const h = n.health;
|
||||
const active = n.id === serverId;
|
||||
return (
|
||||
<div
|
||||
className={`server-toggle${serverOnline ? ' server-toggle--on' : ''}`}
|
||||
aria-hidden="true"
|
||||
key={n.id}
|
||||
className={`server-card server-card--${active ? 'active' : 'inactive'}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={active}
|
||||
onClick={() => onSelect(n.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }}
|
||||
>
|
||||
<div className="server-toggle-thumb" />
|
||||
</div>
|
||||
{/* чип онлайн/офлайн — виден на десктопе */}
|
||||
<span className={`chip chip--${serverOnline ? 'online' : 'offline'} server-online-chip`}>
|
||||
<span className="chip-dot" />
|
||||
</span>
|
||||
<div className="server-card-info">
|
||||
<span className="server-card-name">{serverInfo.name}</span>
|
||||
<span className="server-card-ip">{serverInfo.ip}</span>
|
||||
{(serverInfo.mod || serverInfo.tools) ? (
|
||||
<span
|
||||
className="tip-wrap server-gen-wrap"
|
||||
data-tip={`модуль ${serverInfo.mod || '?'} · tools ${serverInfo.tools || '?'}`}
|
||||
{/* toggle — виден на мобильном вместо чипа */}
|
||||
<div className={`server-toggle${n.online ? ' server-toggle--on' : ''}`} aria-hidden="true">
|
||||
<div className="server-toggle-thumb" />
|
||||
</div>
|
||||
{/* чип онлайн/офлайн — виден на десктопе */}
|
||||
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-online-chip`}>
|
||||
<span className="chip-dot" />
|
||||
</span>
|
||||
<div className="server-card-info">
|
||||
<span className="server-card-name">{h?.server || n.name}</span>
|
||||
{n.pending ? (
|
||||
<span className="server-card-ip">ждёт подключения</span>
|
||||
) : !n.online ? (
|
||||
<span className="server-card-ip">нет связи</span>
|
||||
) : h && (
|
||||
<>
|
||||
<span className="server-card-ip">{h.ip}</span>
|
||||
{(h.module || h.tools) ? (
|
||||
<span
|
||||
className="tip-wrap server-gen-wrap"
|
||||
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
|
||||
>
|
||||
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
|
||||
{n.pending && (
|
||||
<button
|
||||
className="btn-icon btn-icon--neutral"
|
||||
aria-label="Новая ссылка подключения"
|
||||
onClick={e => { e.stopPropagation(); onJoin(n); }}
|
||||
>
|
||||
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
|
||||
<IcoCopy />
|
||||
</button>
|
||||
)}
|
||||
{!n.local && (
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
aria-label={`Удалить сервер ${n.name}`}
|
||||
onClick={e => { e.stopPropagation(); onDelete(n); }}
|
||||
>
|
||||
<IcoTrash />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="server-card-peers">{serverInfo.peers} peers</span>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
<span
|
||||
className="tip-wrap"
|
||||
data-tip={hubEnabled ? '' : 'Приём нод выключен: задайте NODE_PORT'}
|
||||
>
|
||||
<button className="btn server-add" onClick={onAdd} disabled={!hubEnabled}>
|
||||
<IcoPlus /> Добавить сервер
|
||||
</button>
|
||||
</span>
|
||||
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
|
||||
{showRestart && (
|
||||
{showRestart && serverId !== null && (
|
||||
<button
|
||||
className={`btn btn--tonal awg-restart-mobile${restarting ? ' spinning' : ''}`}
|
||||
onClick={onRestartAwg}
|
||||
@@ -61,4 +109,4 @@ export default function ServerBar({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,32 @@ export interface PageProps {
|
||||
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 const TOKEN_KEY = 'awg_token';
|
||||
export const THEME_KEY = 'awg_theme';
|
||||
export const SERVER_KEY = 'awg_server';
|
||||
|
||||
// Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes.
|
||||
export interface NodeInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
local: boolean; // id 0 — локальный awg-ctrl этой же машины
|
||||
online: boolean;
|
||||
pending: boolean; // нода создана, но ещё ни разу не подключалась
|
||||
last_seen: number | null;
|
||||
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
|
||||
}
|
||||
|
||||
// Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id
|
||||
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
|
||||
// перечитывает данные уже с новым сервером.
|
||||
let currentServerId: number | null = null;
|
||||
export const setServerId = (id: number | null) => { currentServerId = id; };
|
||||
export const getServerId = () => currentServerId;
|
||||
|
||||
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 });
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
|
||||
if (currentServerId !== null) headers['X-Server-Id'] = String(currentServerId);
|
||||
const r = await axios({ method, url: path, headers, data: body });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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 } from 'react';
|
||||
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
|
||||
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type PageProps } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
|
||||
import './apikeys.css';
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
|
||||
const createKey = useCallback(async () => {
|
||||
if (!label.trim()) return;
|
||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
|
||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
|
||||
if (r.error) { showMsg(r.error); return; }
|
||||
setLabel('');
|
||||
setCreated({ label: r.label, key: r.key });
|
||||
|
||||
Reference in New Issue
Block a user