Remove legacy vpn proxy code and simplify the client
This commit is contained in:
549
src/web/App.jsx
549
src/web/App.jsx
@@ -1,85 +1,22 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, 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 { ClientOverviewPage } from './components/ClientOverviewPage.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 [devicesConfig, setDevicesConfig] = useState({
|
||||
defaultTransparentMode: 'vpn',
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: [],
|
||||
});
|
||||
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 || []);
|
||||
setDevicesConfig(data.devicesConfig || {
|
||||
defaultTransparentMode: 'vpn',
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||
setPendingTag((current) => current || data.selectedTag || '');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,496 +25,72 @@ function App() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.mode === 'client' && page !== 'overview') {
|
||||
navigate('overview');
|
||||
}
|
||||
}, [state?.mode, page]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
|
||||
}, []);
|
||||
|
||||
async function withBusy(label, fn, { quiet = false } = {}) {
|
||||
async function run(action) {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await fn();
|
||||
if (!quiet && label && state?.mode !== 'client') {
|
||||
pushToast({ kind: 'success', title: label });
|
||||
}
|
||||
return result;
|
||||
await action();
|
||||
await loadState();
|
||||
} 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 || '');
|
||||
return run(async () => {
|
||||
const data = await api.subscription.fetch(subscriptionUrl);
|
||||
setServers(data.servers || []);
|
||||
if (data.servers?.length) {
|
||||
if (state?.mode === 'client') {
|
||||
setSelectedTag('');
|
||||
setPendingTag('');
|
||||
} else {
|
||||
const nextTag = data.servers.some((server) => server.tag === selectedTag)
|
||||
? selectedTag
|
||||
: data.servers[0].tag;
|
||||
setSelectedTag(nextTag);
|
||||
setPendingTag(nextTag);
|
||||
}
|
||||
}
|
||||
await loadState();
|
||||
setPendingTag('');
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshSubscriptionInfo() {
|
||||
const data = await api.subscription.refreshInfo();
|
||||
setState((prev) => prev ? { ...prev, userInfo: data.userInfo, fetchedAt: data.fetchedAt } : prev);
|
||||
setState((current) => current ? {
|
||||
...current,
|
||||
userInfo: data.userInfo,
|
||||
fetchedAt: data.fetchedAt,
|
||||
} : current);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function forgetSubscription() {
|
||||
if (!confirm('Удалить подписку и остановить sing-box?')) return;
|
||||
return withBusy('Подписка удалена', async () => {
|
||||
if (!confirm('Удалить подписку и остановить VPN?')) return;
|
||||
return run(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 (state?.mode !== 'client' && 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(confirmFirst = true) {
|
||||
if (confirmFirst && !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();
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleBypass() {
|
||||
const next = !state?.bypassMode;
|
||||
return withBusy(
|
||||
next ? 'Обход правил включён — весь трафик напрямую' : 'Обход правил отключён',
|
||||
async () => {
|
||||
await api.bypass(next);
|
||||
await loadState();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function flushDirectCache() {
|
||||
return withBusy('Bypass-кэш сброшен', async () => {
|
||||
await api.directCache.flush();
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
// === Devices ===
|
||||
async function saveDevicesConfig(nextConfig) {
|
||||
try {
|
||||
const data = await api.devices.save(nextConfig);
|
||||
setDevicesConfig({
|
||||
defaultTransparentMode: data.defaultTransparentMode || data.defaultMode || 'vpn',
|
||||
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setState((prev) => prev ? {
|
||||
...prev,
|
||||
devicesUpdatedAt: data.devicesUpdatedAt,
|
||||
sourceBypassCidrs: data.sourceBypassCidrs,
|
||||
} : prev);
|
||||
if (data.sourceBypassResult && data.sourceBypassResult.success === false) {
|
||||
pushToast({
|
||||
kind: 'warning',
|
||||
title: 'Bypass сохранён, но не применён',
|
||||
message: data.sourceBypassResult.error,
|
||||
duration: 7000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function addDevice() {
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: [
|
||||
...devicesConfig.devices,
|
||||
{ id: `dev-${Date.now()}`, name: 'Новое устройство', enabled: true, ip: '', mac: '', mode: 'direct', lastSeen: null },
|
||||
],
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function updateDevice(id, patch) {
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: devicesConfig.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function removeDevice(id) {
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: devicesConfig.devices.filter((d) => d.id !== id),
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function updateDeviceDefaults(patch) {
|
||||
const nextConfig = { ...devicesConfig, ...patch };
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
// === 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 isClientMode = state?.mode === 'client';
|
||||
|
||||
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
|
||||
const dirtyDevices = Boolean(
|
||||
state?.devicesUpdatedAt &&
|
||||
(!state?.rulesAppliedAt || state.devicesUpdatedAt > state.rulesAppliedAt),
|
||||
);
|
||||
const dirtyServer = pendingTag && pendingTag !== state?.selectedTag;
|
||||
const dirtyRouting = dirtyRules || dirtyDevices;
|
||||
const dirty = dirtyRouting || dirtyServer;
|
||||
|
||||
const sidebarBadges = {
|
||||
routing: dirtyRouting ? { kind: 'warn', text: '●' } : null,
|
||||
servers: dirtyServer ? { kind: 'warn', text: '●' } : null,
|
||||
settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null,
|
||||
};
|
||||
|
||||
// === Render ===
|
||||
if (!state) return <div className="app-loading">VPN</div>;
|
||||
|
||||
return (
|
||||
<div className={`app${isClientMode ? ' client-app' : ''}`}>
|
||||
{!isClientMode && (
|
||||
<Topbar
|
||||
state={state}
|
||||
status={status}
|
||||
activeServer={activeServer}
|
||||
dirty={dirty}
|
||||
onRestart={restartSingbox}
|
||||
onTryApply={rollback}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
|
||||
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
|
||||
|
||||
<div className="app client-app">
|
||||
<div className="app-body client-mode">
|
||||
<main className="app-main">
|
||||
{(page === 'overview' || isClientMode) && (
|
||||
isClientMode ? (
|
||||
<ClientOverviewPage
|
||||
state={state}
|
||||
status={status}
|
||||
activeServer={activeServer}
|
||||
busy={busy}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
servers={servers}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
|
||||
onForgetSubscription={forgetSubscription}
|
||||
onApply={applyServer}
|
||||
onRestart={restartSingbox}
|
||||
onStop={() => stopSingbox(false)}
|
||||
/>
|
||||
) : (
|
||||
<OverviewPage
|
||||
state={state}
|
||||
status={status}
|
||||
busy={busy}
|
||||
onRestart={restartSingbox}
|
||||
onStop={stopSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
onNav={navigate}
|
||||
onBypassToggle={toggleBypass}
|
||||
onFlushDirectCache={flushDirectCache}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{page === 'servers' && !isClientMode && (
|
||||
<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' && !isClientMode && (
|
||||
<RoutingPage
|
||||
rules={customRules}
|
||||
saveStatus={rulesSaveStatus}
|
||||
busy={busy}
|
||||
onAdd={addRule}
|
||||
onAddTemplate={addRuleFromTemplate}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
onSaveNow={saveRulesNow}
|
||||
onReorder={reorderRules}
|
||||
devicesConfig={devicesConfig}
|
||||
onUpdateDeviceDefaults={updateDeviceDefaults}
|
||||
onAddDevice={addDevice}
|
||||
onUpdateDevice={updateDevice}
|
||||
onRemoveDevice={removeDevice}
|
||||
/>
|
||||
)}
|
||||
{page === 'logs' && !isClientMode && <LogsPage devices={devicesConfig.devices} />}
|
||||
{page === 'settings' && !isClientMode && (
|
||||
<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' && dirtyRouting) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
|
||||
<strong>
|
||||
{rulesSaveStatus === 'saving' && 'Сохраняем…'}
|
||||
{rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'}
|
||||
{rulesSaveStatus === 'saved' && dirtyDevices && 'Изменения устройств сохранены'}
|
||||
{rulesSaveStatus === 'error' && 'Ошибка сохранения'}
|
||||
</strong>
|
||||
<small className="muted">Конфиг sing-box нужно пересобрать и применить.</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
{rulesSaveStatus !== 'saved' && (
|
||||
<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>
|
||||
|
||||
{!isClientMode && (
|
||||
<StatusPane
|
||||
<ClientOverviewPage
|
||||
state={state}
|
||||
busy={busy}
|
||||
onStop={stopSingbox}
|
||||
onRestart={restartSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
error={error}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
servers={servers}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
|
||||
onForgetSubscription={forgetSubscription}
|
||||
onApply={(tag) => run(() => api.apply(tag))}
|
||||
onRestart={() => run(api.singbox.restart)}
|
||||
onStop={() => run(api.singbox.stop)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user