feat: добавлены новые компоненты для управления правилами и серверами
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s

- Создан компонент RuleEditorDrawer для редактирования правил с поддержкой JSON.
- Добавлен компонент ServersPage для отображения и управления серверами.
- Реализован компонент SettingsPage для управления подписками и конфигурациями.
- Создан компонент Sidebar для навигации по приложению.
- Добавлен компонент StatusPane для отображения статуса сервера.
- Реализован компонент Toasts для отображения уведомлений.
- Создан компонент Topbar для отображения информации о текущем состоянии.
- Добавлен модуль country.js для определения страны по тегу сервера.

Refs: None
This commit is contained in:
2026-05-08 19:31:49 +03:00
parent a8f2c6f3f9
commit 8476ab16e5
27 changed files with 3014 additions and 1139 deletions

View File

@@ -1,34 +1,66 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import { api } from './api.js';
import { SubscriptionPanel } from './components/SubscriptionPanel.jsx';
import { ServerList } from './components/ServerList.jsx';
import { RuntimePanel } from './components/RuntimePanel.jsx';
import { RulesPanel } from './components/RulesPanel.jsx';
import { LogsPanel } from './components/LogsPanel.jsx';
import { Topbar } from './components/Topbar.jsx';
import { Sidebar } from './components/Sidebar.jsx';
import { StatusPane } from './components/StatusPane.jsx';
import { OverviewPage } from './components/OverviewPage.jsx';
import { ServersPage } from './components/ServersPage.jsx';
import { RoutingPage } from './components/RoutingPage.jsx';
import { LogsPage } from './components/LogsPage.jsx';
import { SettingsPage } from './components/SettingsPage.jsx';
import { ConfigViewer } from './components/ConfigViewer.jsx';
import { Toasts } from './components/Toasts.jsx';
const ROLLBACK_WINDOW_MS = 12_000;
function getInitialPage() {
const hash = window.location.hash.replace('#/', '').replace('#', '');
const valid = ['overview', 'servers', 'routing', 'logs', 'settings'];
return valid.includes(hash) ? hash : 'overview';
}
function App() {
const [page, setPage] = useState(getInitialPage());
const [state, setState] = useState(null);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [editingSubscription, setEditingSubscription] = useState(false);
const [servers, setServers] = useState([]);
const [customRules, setCustomRules] = useState([]);
const [selectedTag, setSelectedTag] = useState('');
const [pendingTag, setPendingTag] = useState('');
const [busy, setBusy] = useState(false);
const [log, setLog] = useState([]);
const [error, setError] = useState('');
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
const [configOpen, setConfigOpen] = useState(false);
const [pings, setPings] = useState({});
const [toasts, setToasts] = useState([]);
const [applyStatus, setApplyStatus] = useState('idle'); // idle | applying | error
const [rollbackOffer, setRollbackOffer] = useState(null);
const rulesDirtyRef = useRef(false);
const rulesSaveTimerRef = useRef(null);
const rulesRevisionRef = useRef(0);
const rollbackTimerRef = useRef(null);
function addLog(message) {
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
setLog((items) => [{ time, message }, ...items].slice(0, 8));
function pushToast(toast) {
const id = `t-${Date.now()}-${Math.random()}`;
setToasts((prev) => [...prev, { id, ...toast }]);
}
function dismissToast(id) {
setToasts((prev) => prev.filter((t) => t.id !== id));
}
function navigate(p) {
setPage(p);
window.location.hash = `#/${p}`;
}
useEffect(() => {
function onHash() { setPage(getInitialPage()); }
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
async function loadState() {
const data = await api.state();
@@ -36,86 +68,125 @@ function App() {
setServers(data.servers || []);
if (!rulesDirtyRef.current) setCustomRules(data.customRules || []);
setSelectedTag((prev) => prev || data.selectedTag || '');
setPendingTag((prev) => prev || data.selectedTag || '');
}
useEffect(() => {
loadState().catch(() => {});
loadState().catch((err) => setError(err.message));
const timer = setInterval(() => loadState().catch(() => {}), 5000);
return () => clearInterval(timer);
}, []);
useEffect(() => () => {
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
}, []);
async function withBusy(label, fn) {
async function withBusy(label, fn, { quiet = false } = {}) {
setBusy(true);
setError('');
if (label) addLog(label);
try {
await fn();
const result = await fn();
if (!quiet && label) pushToast({ kind: 'success', title: label });
return result;
} catch (err) {
setError(err.message);
addLog(`ОШИБКА: ${err.message}`);
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message, duration: 6000 });
throw err;
} finally {
setBusy(false);
}
}
async function fetchServers() {
await withBusy('Загрузка подписки', async () => {
const data = await api.subscription.fetch(subscriptionUrl);
// === Subscription ===
async function fetchSubscription() {
return withBusy('Подписка обновлена', async () => {
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
setServers(data.servers || []);
setSelectedTag(data.servers?.[0]?.tag || '');
addLog(`Найдено серверов: ${data.servers.length}`);
if (!selectedTag && data.servers?.length) {
setSelectedTag(data.servers[0].tag);
setPendingTag(data.servers[0].tag);
}
await loadState();
});
}
async function forgetSubscription() {
if (!confirm('Удалить подписку и остановить sing-box?')) return;
await withBusy('Удаление подписки', async () => {
return withBusy('Подписка удалена', async () => {
await api.subscription.forget();
setSubscriptionUrl('');
setServers([]);
setSelectedTag('');
setEditingSubscription(true);
setPendingTag('');
await loadState();
});
}
async function applyServer() {
await withBusy(`Применяем ${selectedTag}`, async () => {
const data = await api.apply(selectedTag);
addLog(`sing-box: ${data.singboxRunning ? 'работает' : 'не запущен'}`);
// === Apply with rollback offer ===
async function applyServer(tag) {
const target = tag || selectedTag;
if (!target) return;
const previous = state?.selectedTag;
setApplyStatus('applying');
try {
await withBusy('Сервер применён', async () => {
await api.apply(target);
await loadState();
});
setApplyStatus('idle');
if (previous && previous !== target) {
setRollbackOffer({ from: target, to: previous, expiresAt: Date.now() + ROLLBACK_WINDOW_MS });
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
rollbackTimerRef.current = setTimeout(() => setRollbackOffer(null), ROLLBACK_WINDOW_MS);
}
} catch {
setApplyStatus('error');
}
}
async function rollback() {
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
setRollbackOffer(null);
return withBusy('Откат выполнен', async () => {
const data = await api.rollback();
setSelectedTag(data.selectedTag);
setPendingTag(data.selectedTag);
await loadState();
});
}
// === sing-box control ===
async function stopSingbox() {
if (!confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
await withBusy('Остановка sing-box', async () => {
await api.singbox.stop();
await loadState();
});
return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); });
}
async function restartSingbox() {
await withBusy('Перезапуск sing-box', async () => {
await api.singbox.restart();
await loadState();
});
return withBusy('Перезапущено', async () => { await api.singbox.restart(); await loadState(); });
}
async function clearConfig() {
if (!confirm('Сбросить config sing-box и остановить процесс?')) return;
await withBusy('Сброс конфига', async () => {
return withBusy('Config сброшен', async () => {
await api.singbox.clear();
setSelectedTag('');
setPendingTag('');
await loadState();
});
}
// === Rules CRUD ===
function emptyRule() {
return {
id: `rule-${Date.now()}`,
name: 'Новое правило',
enabled: true,
outbound: 'direct',
domains: [], domainSuffixes: [], domainKeywords: [],
ipCidrs: [], ports: [], networks: [],
};
}
function queueRulesSave(nextRules) {
rulesDirtyRef.current = true;
const revision = rulesRevisionRef.current + 1;
@@ -123,35 +194,28 @@ function App() {
setRulesSaveStatus('pending');
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
rulesSaveTimerRef.current = setTimeout(() => {
saveRules(nextRules, { silent: true, revision });
}, 700);
rulesSaveTimerRef.current = setTimeout(() => saveRules(nextRules, { silent: true, revision }), 700);
}
async function saveRules(nextRules = customRules, options = {}) {
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
if (!silent) setBusy(true);
setError('');
if (!silent) addLog('Сохранение правил');
setRulesSaveStatus('saving');
try {
const data = await api.rules.save(nextRules);
if (rulesRevisionRef.current === revision) {
rulesDirtyRef.current = false;
setCustomRules(data.rules || []);
setRulesSaveStatus('saved');
addLog(`Правил сохранено: ${data.rules.length}`);
await loadState();
if (!silent) pushToast({ kind: 'success', title: 'Правила сохранены' });
} else {
setRulesSaveStatus('pending');
}
} catch (err) {
setError(err.message);
setRulesSaveStatus('error');
addLog(`ОШИБКА: ${err.message}`);
} finally {
if (!silent) setBusy(false);
pushToast({ kind: 'danger', title: 'Не удалось сохранить', message: err.message });
}
}
@@ -163,127 +227,202 @@ function App() {
saveRules(customRules, { silent: false, revision });
}
function emptyRule() {
return {
id: `rule-${Date.now()}`,
name: 'Новый список',
enabled: true,
outbound: 'direct',
domains: [],
domainSuffixes: [],
domainKeywords: [],
ipCidrs: [],
ports: [],
networks: [],
};
}
function updateRule(id, patch) {
setCustomRules((rules) => {
const nextRules = rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule));
queueRulesSave(nextRules);
return nextRules;
const next = rules.map((r) => (r.id === id ? { ...r, ...patch } : r));
queueRulesSave(next);
return next;
});
}
function addRule() {
setCustomRules((rules) => {
const nextRules = [emptyRule(), ...rules];
queueRulesSave(nextRules);
return nextRules;
const next = [emptyRule(), ...rules];
queueRulesSave(next);
return next;
});
}
function addRuleFromTemplate(template) {
function addRuleFromTemplate(tpl) {
setCustomRules((rules) => {
const nextRules = [template, ...rules];
queueRulesSave(nextRules);
return nextRules;
const next = [tpl, ...rules];
queueRulesSave(next);
return next;
});
}
function removeRule(id) {
setCustomRules((rules) => {
const nextRules = rules.filter((rule) => rule.id !== id);
queueRulesSave(nextRules);
return nextRules;
const next = rules.filter((r) => r.id !== id);
queueRulesSave(next);
return next;
});
}
function reorderRules(nextRules) {
setCustomRules(nextRules);
queueRulesSave(nextRules);
function reorderRules(next) {
setCustomRules(next);
queueRulesSave(next);
}
// === Computed ===
const status = useMemo(() => {
if (applyStatus === 'applying') return 'applying';
if (applyStatus === 'error') return 'error';
if (state?.singboxRunning) return 'running';
if (state?.configExists) return 'stopped';
return 'no_config';
}, [state, applyStatus]);
const activeServer = useMemo(
() => servers.find((s) => s.tag === state?.selectedTag) || null,
[servers, state?.selectedTag],
);
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
const dirtyServer = pendingTag && pendingTag !== state?.selectedTag;
const dirty = dirtyRules || dirtyServer;
const sidebarBadges = {
routing: dirtyRules ? { kind: 'warn', text: '●' } : null,
servers: dirtyServer ? { kind: 'warn', text: '●' } : null,
settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null,
};
// === Render ===
return (
<main className="shell">
<section className="hero panel">
<div>
<p className="eyebrow">VPN Proxy / Gateway</p>
<h1>Прозрачный VPN-шлюз для всей сети</h1>
<p className="lead">
Загрузи подписку, выбери сервер контейнер сгенерирует gateway-конфиг для sing-box: TProxy для роутера и mixed proxy для ручных клиентов.
</p>
</div>
<div className="status-card">
<span className={state?.singboxRunning ? 'dot on' : 'dot'} />
<div>
<strong>{state?.singboxRunning ? 'sing-box работает' : 'sing-box остановлен'}</strong>
<small>{state?.selectedTag || 'сервер не выбран'}</small>
</div>
</div>
</section>
<div className="app">
<Topbar
state={state}
status={status}
activeServer={activeServer}
dirty={dirty}
onRestart={restartSingbox}
onTryApply={rollback}
/>
<section className="grid">
<div className="panel primary-flow">
<SubscriptionPanel
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
hasSubscription={Boolean(state?.hasSubscription)}
subscriptionHost={state?.subscriptionHost}
busy={busy}
onFetch={fetchServers}
onForget={forgetSubscription}
editing={editingSubscription || !state?.hasSubscription}
setEditing={setEditingSubscription}
/>
<ServerList
servers={servers}
selectedTag={selectedTag}
setSelectedTag={setSelectedTag}
busy={busy}
onApply={applyServer}
/>
{error && <div className="error">{error}</div>}
</div>
<div className="app-body">
<Sidebar active={page} onChange={navigate} badges={sidebarBadges} />
<RuntimePanel
<main className="app-main">
{page === 'overview' && (
<OverviewPage
state={state}
status={status}
busy={busy}
onRestart={restartSingbox}
onStop={stopSingbox}
onShowConfig={() => setConfigOpen(true)}
onNav={navigate}
/>
)}
{page === 'servers' && (
<ServersPage
state={state}
servers={servers}
selectedTag={selectedTag}
setSelectedTag={setSelectedTag}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
busy={busy}
onApply={applyServer}
onRollback={rollback}
pings={pings}
setPings={setPings}
pushToast={pushToast}
/>
)}
{page === 'routing' && (
<RoutingPage
rules={customRules}
saveStatus={rulesSaveStatus}
busy={busy}
onAdd={addRule}
onAddTemplate={addRuleFromTemplate}
onUpdate={updateRule}
onRemove={removeRule}
onSaveNow={saveRulesNow}
onReorder={reorderRules}
/>
)}
{page === 'logs' && <LogsPage />}
{page === 'settings' && (
<SettingsPage
state={state}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
busy={busy}
onFetchSubscription={fetchSubscription}
onForgetSubscription={forgetSubscription}
onShowConfig={() => setConfigOpen(true)}
onClearConfig={clearConfig}
pushToast={pushToast}
/>
)}
{/* Sticky bar — для routing/servers */}
{(page === 'routing' && rulesSaveStatus !== 'saved') && (
<div className="sticky-bar">
<div className="flex">
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
<strong>
{rulesSaveStatus === 'saving' && 'Сохраняем…'}
{rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'}
{rulesSaveStatus === 'error' && 'Ошибка сохранения'}
</strong>
<small className="muted">Изменения сохранены, но конфиг не пересобран. Применить на странице «Серверы».</small>
</div>
<div className="btn-group">
<button className="btn btn-secondary sm" onClick={saveRulesNow}>Сохранить сейчас</button>
{state?.selectedTag && (
<button className="btn btn-primary sm" onClick={() => applyServer(state.selectedTag)} disabled={busy}>
Применить config
</button>
)}
</div>
</div>
)}
{(page === 'servers' && dirtyServer) && (
<div className="sticky-bar">
<div className="flex">
<span className="dot warning" />
<strong>Сервер не применён</strong>
<small className="muted">Выбран: {pendingTag}</small>
</div>
<div className="btn-group">
<button className="btn btn-ghost sm" onClick={() => setPendingTag(state?.selectedTag || '')}>Отменить</button>
<button className="btn btn-primary sm" onClick={() => applyServer(pendingTag)} disabled={busy}>
Применить
</button>
</div>
</div>
)}
</main>
<StatusPane
state={state}
log={log}
busy={busy}
onStop={stopSingbox}
onRestart={restartSingbox}
onClear={clearConfig}
onShowConfig={() => setConfigOpen(true)}
/>
</section>
<RulesPanel
rules={customRules}
saveStatus={rulesSaveStatus}
busy={busy}
onAdd={addRule}
onAddTemplate={addRuleFromTemplate}
onUpdate={updateRule}
onRemove={removeRule}
onSaveNow={saveRulesNow}
onReorder={reorderRules}
/>
<LogsPanel />
</div>
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />
</main>
<Toasts items={toasts} onDismiss={dismissToast} />
{rollbackOffer && (
<div className="toasts">
<div className="toast warning">
<span className="dot warning" style={{ marginTop: 4 }} />
<div className="body">
<strong>Сервер применён</strong>
<small>Можно откатиться к «{rollbackOffer.to}»</small>
<button className="btn btn-link" onClick={rollback} style={{ padding: 0, marginTop: 4 }}>
Откатить
</button>
</div>
<button onClick={() => setRollbackOffer(null)}>×</button>
</div>
</div>
)}
</div>
);
}