// 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, useCallback } from 'react'; import axios from 'axios'; import './App.css'; 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, // список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит // из реестра 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 [nodes, setNodes] = useState([]); const [hubEnabled, setHubEnabled] = useState(false); const [serverId, setServerIdState] = useState(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); const [restarting, setRestarting] = useState(false); const [brand, setBrand] = useState({ brand: BRAND_FALLBACK, channel: '', version: '' }); const [theme, setTheme] = useState<'light' | 'dark'>(() => localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'); const touchStartX = useRef(0); const touchCurrentX = useRef(0); const showMsg = useCallback((text: string) => { setMsg(text); setTimeout(() => setMsg(''), 3000); }, []); // Выбрать сервер: 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 }); const tok = auth.token as string; await startSession(tok); localStorage.setItem(TOKEN_KEY, tok); setToken(tok); } catch (e) { if (axios.isAxiosError(e) && e.response?.status === 401) { showMsg('Неверный логин или пароль'); } else if (axios.isAxiosError(e) && e.response?.status === 429) { showMsg(e.response.data?.error ?? 'Слишком много попыток'); } else { setStatusText('offline:Недоступен'); } } }, [loginUser, loginPass, startSession, showMsg]); const logout = useCallback(async () => { try { await axios.post('/logout', null, { headers: { Authorization: `Bearer ${token}` } }); } catch { /* токен уже мог истечь */ } localStorage.removeItem(TOKEN_KEY); setToken(''); setStatusText(''); setNodes([]); setServerId(null); setServerIdState(null); setDrawerOpen(false); }, [token]); const deleteNode = useCallback(async (n: NodeInfo) => { if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return; try { try { await apiFetch('DELETE', `/ui/nodes/${n.id}`, token); } catch (e) { // Сервер не подтвердил, что снял пиров удалённых мастер-ключей (не в сети) — // панель объясняет, что останется, и удаляет только с согласия. const data = axios.isAxiosError(e) ? e.response?.data : undefined; if (!data?.can_force || !confirm(`${data.error}\n\nВсё равно удалить «${n.name}»?`)) throw e; await apiFetch('DELETE', `/ui/nodes/${n.id}?force=1`, 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 () => { if (restarting) return; if (!confirm('Перезапустить AWG? Соединения клиентов кратковременно прервутся.')) return; setRestarting(true); try { await apiFetch('POST', '/awg/restart', token); showMsg('AWG перезапущен'); } catch { showMsg('Не удалось перезапустить AWG'); } finally { setRestarting(false); } }, [restarting, token, showMsg]); const handleTouchStart = useCallback((e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; touchCurrentX.current = e.touches[0].clientX; }, []); const handleTouchMove = useCallback((e: React.TouchEvent) => { touchCurrentX.current = e.touches[0].clientX; }, []); const handleTouchEnd = useCallback(() => { if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false); }, []); // Восстановить сессию из сохранённого токена useEffect(() => { const saved = localStorage.getItem(TOKEN_KEY); if (saved) { startSession(saved).catch(() => { localStorage.removeItem(TOKEN_KEY); setToken(''); }); } }, [startSession]); // Состояние серверов (онлайн, пиры) обновляем раз в 15 с, пока открыта панель. useEffect(() => { if (!token) return; const t = setInterval(() => { loadNodes(token).catch(() => { /* следующий тик */ }); }, 15_000); return () => clearInterval(t); }, [token, loadNodes]); // Имя продукта и версия — с сервера, до логина (роут без авторизации). // Если не ответил, остаётся фолбэк из констант. useEffect(() => { axios.get('/ui/brand') .then(({ data }) => setBrand({ brand: data.brand || BRAND_FALLBACK, channel: data.channel || '', version: data.version || '', })) .catch(() => { /* остаётся фолбэк */ }); }, []); // Применять и сохранять тему useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem(THEME_KEY, theme); }, [theme]); // Блокировать прокрутку фона когда drawer открыт useEffect(() => { document.body.style.overflow = drawerOpen ? 'hidden' : ''; return () => { document.body.style.overflow = ''; }; }, [drawerOpen]); // Закрывать drawer по Escape useEffect(() => { if (!drawerOpen) return; const h = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false); }; document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [drawerOpen]); const statusLabel = statusText.replace(/^(online|offline):/, ''); const tab = TABS.find(t => t.id === activeTab) ?? TABS[0]; // Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён. const chrome = tab.chrome ?? {}; const showServerBar = chrome.serverBar !== false; const showRestart = chrome.restart !== false; return (
{token && showRestart && serverId !== null && ( )} {!token ? (
{brand.brand}

Войдите, чтобы продолжить

setLoginUser(e.target.value)} autoFocus /> setLoginPass(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} /> {statusText.startsWith('offline:') && (

{statusLabel}

)}
) : ( <> {/* Top bar — видим только на мобильном */}
{tab.label}
{/* Затемнение под drawer */}
setDrawerOpen(false)} />

{tab.label}

{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */} {showServerBar && ( setModal({})} onJoin={n => setModal({ node: n })} onDelete={deleteNode} onRestartAwg={restartAwg} restarting={restarting} showRestart={showRestart} /> )} {serverId === null && showServerBar ? (

Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.

) : ( // key: при смене сервера вкладка монтируется заново и читает данные уже с ним )}
)} {modal && ( setModal(null)} onChanged={() => { loadNodes(token).catch(() => { /* следующий тик */ }); }} showMsg={showMsg} /> )} {msg &&
{msg}
}
); }