import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import './styles.css'; import { api } from './api.js'; 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 [servers, setServers] = useState([]); const [customRules, setCustomRules] = useState([]); const [selectedTag, setSelectedTag] = useState(''); const [pendingTag, setPendingTag] = useState(''); const [busy, setBusy] = useState(false); 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 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(); setState(data); setServers(data.servers || []); if (!rulesDirtyRef.current) setCustomRules(data.customRules || []); setSelectedTag((prev) => prev || data.selectedTag || ''); setPendingTag((prev) => prev || data.selectedTag || ''); } useEffect(() => { 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, { quiet = false } = {}) { setBusy(true); setError(''); try { const result = await fn(); if (!quiet && label) pushToast({ kind: 'success', title: label }); return result; } catch (err) { setError(err.message); pushToast({ kind: 'danger', title: 'Ошибка', message: err.message, duration: 6000 }); throw err; } finally { setBusy(false); } } // === Subscription === async function fetchSubscription() { return withBusy('Подписка обновлена', async () => { const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || ''); setServers(data.servers || []); 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; return withBusy('Подписка удалена', async () => { await api.subscription.forget(); setSubscriptionUrl(''); setServers([]); setSelectedTag(''); setPendingTag(''); await loadState(); }); } // === 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; return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); }); } async function restartSingbox() { return withBusy('Перезапущено', async () => { await api.singbox.restart(); await loadState(); }); } async function clearConfig() { if (!confirm('Сбросить config sing-box и остановить процесс?')) return; 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; rulesRevisionRef.current = revision; setRulesSaveStatus('pending'); if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current); rulesSaveTimerRef.current = setTimeout(() => saveRules(nextRules, { silent: true, revision }), 700); } async function saveRules(nextRules = customRules, options = {}) { const { silent = false, revision = rulesRevisionRef.current + 1 } = options; setError(''); setRulesSaveStatus('saving'); try { const data = await api.rules.save(nextRules); if (rulesRevisionRef.current === revision) { rulesDirtyRef.current = false; setCustomRules(data.rules || []); setRulesSaveStatus('saved'); await loadState(); if (!silent) pushToast({ kind: 'success', title: 'Правила сохранены' }); } else { setRulesSaveStatus('pending'); } } catch (err) { setError(err.message); setRulesSaveStatus('error'); pushToast({ kind: 'danger', title: 'Не удалось сохранить', message: err.message }); } } function saveRulesNow() { if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current); rulesDirtyRef.current = true; const revision = rulesRevisionRef.current + 1; rulesRevisionRef.current = revision; saveRules(customRules, { silent: false, revision }); } function updateRule(id, patch) { setCustomRules((rules) => { const next = rules.map((r) => (r.id === id ? { ...r, ...patch } : r)); queueRulesSave(next); return next; }); } function addRule() { setCustomRules((rules) => { const next = [emptyRule(), ...rules]; queueRulesSave(next); return next; }); } function addRuleFromTemplate(tpl) { setCustomRules((rules) => { const next = [tpl, ...rules]; queueRulesSave(next); return next; }); } function removeRule(id) { setCustomRules((rules) => { const next = rules.filter((r) => r.id !== id); queueRulesSave(next); return next; }); } 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 (