Remove legacy vpn proxy code and simplify the client
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s

This commit is contained in:
2026-07-11 11:18:03 +03:00
parent e19d33adb9
commit 9d4f312595
53 changed files with 471 additions and 11511 deletions

View File

@@ -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>
);
}

View File

@@ -2,113 +2,36 @@ async function request(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"content-type": "application/json",
'content-type': 'application/json',
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok || (data && data.success === false)) {
throw new Error(
data?.error || `Запрос ${url} завершился ошибкой ${response.status}`,
);
if (!response.ok || data?.success === false) {
throw new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`);
}
return data;
}
export const api = {
state: () => request("/api/state"),
config: () => request("/api/config"),
rules: {
get: () => request("/api/rules"),
save: (rules) =>
request("/api/rules", { method: "PUT", body: JSON.stringify({ rules }) }),
conflicts: () => request("/api/rules/conflicts"),
},
deviceRules: {
get: () => request("/api/device-rules"),
save: (deviceRules) =>
request("/api/device-rules", {
method: "PUT",
body: JSON.stringify({ deviceRules }),
}),
},
devices: {
get: () => request("/api/devices"),
save: (devicesConfig) =>
request("/api/devices", {
method: "PUT",
body: JSON.stringify(devicesConfig),
}),
},
ruleSets: {
get: () => request("/api/rule-sets"),
save: (ruleSets) =>
request("/api/rule-sets", {
method: "PUT",
body: JSON.stringify({ ruleSets }),
}),
lookup: (tag, url) =>
request("/api/rule-sets/lookup", {
method: "POST",
body: JSON.stringify({ tag, url }),
}),
sagernetCatalog: () => request("/api/rule-sets/sagernet-catalog"),
},
state: () => request('/api/state'),
subscription: {
fetch: (url) =>
request("/api/subscription/fetch", {
method: "POST",
body: JSON.stringify({ url }),
}),
refreshInfo: () => request("/api/subscription/refresh-info", { method: "POST" }),
forget: () => request("/api/subscription", { method: "DELETE" }),
},
apply: (selectedTag) =>
request("/api/apply", {
method: "POST",
body: JSON.stringify({ selectedTag }),
fetch: (url) => request('/api/subscription/fetch', {
method: 'POST',
body: JSON.stringify({ url }),
}),
rollback: () => request("/api/apply/rollback", { method: "POST" }),
refreshInfo: () => request('/api/subscription/refresh-info', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }),
},
apply: (selectedTag) => request('/api/apply', {
method: 'POST',
body: JSON.stringify({ selectedTag }),
}),
singbox: {
stop: () => request("/api/singbox/stop", { method: "POST" }),
restart: () => request("/api/singbox/restart", { method: "POST" }),
clear: () => request("/api/singbox/clear", { method: "POST" }),
stop: () => request('/api/singbox/stop', { method: 'POST' }),
restart: () => request('/api/singbox/restart', { method: 'POST' }),
},
servers: {
ping: (host, port) =>
request("/api/servers/ping", {
method: "POST",
body: JSON.stringify({ host, port }),
}),
pingAll: () => request("/api/servers/ping-all", { method: "POST" }),
pingAll: () => request('/api/servers/ping-all', { method: 'POST' }),
},
bypass: (enabled) =>
request("/api/bypass", {
method: "POST",
body: JSON.stringify({ enabled }),
}),
directCache: {
get: () => request("/api/direct-cache"),
flush: () => request("/api/direct-cache", { method: "DELETE" }),
},
route: {
check: ({ host, ip, port, network, sourceIp, inbound }) =>
request("/api/route/check", {
method: "POST",
body: JSON.stringify({ host, ip, port, network, sourceIp, inbound }),
}),
},
configValidate: () => request("/api/config/validate", { method: "POST" }),
};

View File

@@ -1,61 +0,0 @@
import React, { useState } from 'react';
/**
* Chip input. Items separated by Enter, comma, или space (для CIDR/портов).
* Невалидные элементы помечаются красным.
*/
export function ChipsInput({ value = [], onChange, placeholder = '', validate, splitter = /[\s,]/ }) {
const [draft, setDraft] = useState('');
function commit(text) {
const parts = String(text).split(splitter).map((p) => p.trim()).filter(Boolean);
if (!parts.length) return;
const next = Array.from(new Set([...value, ...parts]));
onChange(next);
setDraft('');
}
function remove(item) {
onChange(value.filter((v) => v !== item));
}
function onKeyDown(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (draft.trim()) commit(draft);
} else if (e.key === 'Backspace' && !draft && value.length) {
onChange(value.slice(0, -1));
}
}
function onPaste(e) {
const text = e.clipboardData.getData('text');
if (text && splitter.test(text)) {
e.preventDefault();
commit(text);
}
}
return (
<div className="chips" onClick={(e) => e.currentTarget.querySelector('input')?.focus()}>
{value.map((item) => {
const invalid = validate ? !validate(item) : false;
return (
<span key={item} className={`chip ${invalid ? 'error' : ''}`}>
{item}
<button type="button" onClick={() => remove(item)} title="Убрать">×</button>
</span>
);
})}
<input
className="chip-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
onBlur={() => draft.trim() && commit(draft)}
placeholder={value.length ? '' : placeholder}
/>
</div>
);
}

View File

@@ -13,6 +13,7 @@ import { formatBytes } from '../utils/format.js';
export function ClientOverviewPage({
state,
busy,
error,
subscriptionUrl,
setSubscriptionUrl,
servers,
@@ -25,6 +26,7 @@ export function ClientOverviewPage({
onRestart,
onStop,
}) {
const isGateway = state?.mode === 'gateway';
const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription);
const selectedTag = pendingTag || state?.selectedTag || '';
@@ -41,7 +43,8 @@ export function ClientOverviewPage({
const subscriptionInputRef = useRef(null);
const subscriptionRef = useRef(null);
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
const proxyUrls = localProxyUrls(state?.proxyPort);
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo);
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
const hasUsage = Boolean(
@@ -152,7 +155,7 @@ export function ClientOverviewPage({
async function copyProxy(kind) {
try {
await navigator.clipboard.writeText(proxyUrls[kind]);
await navigator.clipboard.writeText(kind === 'gateway' ? gatewayAddress : proxyUrls[kind]);
setCopiedProxy(kind);
setTimeout(() => setCopiedProxy(''), 800);
} catch {
@@ -191,7 +194,7 @@ export function ClientOverviewPage({
type="button"
role="switch"
aria-checked={connected}
aria-label={connected ? 'Выключить VPN' : 'Включить VPN'}
aria-label={connected ? `Выключить ${isGateway ? 'Gateway' : 'VPN'}` : `Включить ${isGateway ? 'Gateway' : 'VPN'}`}
disabled={busy || (!connected && !canStart)}
onClick={toggleConnection}
>
@@ -201,7 +204,9 @@ export function ClientOverviewPage({
</button>
<div className="client-state-copy" aria-live="polite">
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
{connected ? 'VPN включён' : 'VPN выключен'}
{isGateway
? connected ? 'Gateway включён' : 'Gateway выключен'
: connected ? 'VPN включён' : 'VPN выключен'}
</h2>
<div className="client-state-detail">
{connected ? (
@@ -216,12 +221,28 @@ export function ClientOverviewPage({
</div>
</div>
<section className="client-proxies" aria-label="Локальный прокси">
<span className="client-proxy-label">Адрес</span>
<strong className="client-proxy-address">
{proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>
<div>
<section className="client-proxies" aria-label={isGateway ? 'Адреса Gateway и Proxy' : 'Локальный прокси'}>
{isGateway && (
<div className="client-access-point">
<span className="client-proxy-label">Gateway</span>
<strong className="client-proxy-address">{gatewayAddress}</strong>
<button
className={copiedProxy === 'gateway' ? 'is-copied' : ''}
type="button"
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
onClick={() => copyProxy('gateway')}
>
<span className="client-copy-label">КОПИРОВАТЬ</span>
{copiedProxy === 'gateway' && <span className="client-copy-feedback">Copied</span>}
</button>
</div>
)}
<div className="client-access-point">
<span className="client-proxy-label">{isGateway ? 'Proxy' : 'Адрес'}</span>
<strong className="client-proxy-address">
{proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>
<div className="client-proxy-actions">
{[
['socks5', 'SOCKS5'],
['http', 'HTTP'],
@@ -237,11 +258,14 @@ export function ClientOverviewPage({
{copiedProxy === kind && <span className="client-copy-feedback">Copied</span>}
</button>
))}
</div>
</div>
</section>
</section>
)}
{error && <p className="client-error" role="alert">{error}</p>}
<div className="client-form">
<div
ref={subscriptionRef}

View File

@@ -1,85 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
export function ConfigViewer({ open, onClose }) {
const [config, setConfig] = useState(null);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
useEffect(() => {
if (!open) return;
let cancelled = false;
setConfig(null);
setError('');
api.config()
.then((data) => { if (!cancelled) setConfig(data.config); })
.catch((err) => { if (!cancelled) setError(err.message); });
return () => { cancelled = true; };
}, [open]);
const text = useMemo(() => (config ? JSON.stringify(config, null, 2) : ''), [config]);
const highlighted = useMemo(() => {
if (!search || !text) return text;
try {
const re = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
return text.split(re);
} catch {
return text;
}
}, [text, search]);
if (!open) return null;
function copy() { navigator.clipboard?.writeText(text).catch(() => {}); }
function download() {
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sing-box-config.json';
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div>
<h3>sing-box config</h3>
<small className="muted">Автогенерируемый, перезаписывается при apply</small>
</div>
<div className="btn-group">
<input
className="input"
placeholder="Поиск…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 160 }}
/>
<button className="btn btn-ghost sm" disabled={!config} onClick={copy}>Копировать</button>
<button className="btn btn-ghost sm" disabled={!config} onClick={download}>Скачать</button>
<button className="btn btn-secondary sm" onClick={onClose}>Закрыть</button>
</div>
</div>
<div className="modal-body">
{error && <div className="conflict-banner danger">{error}</div>}
{!error && !config && <p className="muted">Конфиг ещё не сгенерирован.</p>}
{config && (
<pre className="config-view">
{Array.isArray(highlighted)
? highlighted.map((part, i) => (
<React.Fragment key={i}>
{part}
{i < highlighted.length - 1 && <mark style={{ background: 'var(--warning-dim)', color: 'var(--warning)' }}>{search}</mark>}
</React.Fragment>
))
: text}
</pre>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,337 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { formatTime } from '../utils/format.js';
const MAX_ENTRIES = 800;
const MAX_TRAFFIC = 500;
const GROUP_WINDOW_MS = 30_000;
function normalizeLine(line) {
return String(line || '').replace(/\x1b\[\d+m/g, '').trim();
}
function groupEntries(entries) {
const out = [];
for (const e of entries) {
const key = `${e.level}|${normalizeLine(e.line)}`;
const last = out[out.length - 1];
const ts = new Date(e.ts).getTime();
if (last && last._key === key && ts - last._lastTs < GROUP_WINDOW_MS) {
last.count += 1;
last._lastTs = ts;
last.lastTs = e.ts;
} else {
out.push({ ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
}
}
return out;
}
const CATEGORY_BADGE = {
direct: { cls: 'success', label: 'direct' },
vpn: { cls: 'info', label: 'VPN' },
block: { cls: 'danger', label: 'block' },
other: { cls: '', label: 'other' },
};
function getDeviceName(sourceIp, devices) {
if (!sourceIp || !devices?.length) return null;
for (const d of devices) {
if (d.enabled === false) continue;
const ip = d.ip || d.sourceIp || (d.sourceIps || [])[0];
const plain = ip?.endsWith('/32') ? ip.slice(0, -3) : ip;
if (plain === sourceIp) return d.name;
}
return null;
}
function groupTraffic(list, sortBy = 'time') {
const map = new Map();
for (const e of list) {
const key = `${e.sourceIp || ''}|${e.category}|${e.host}|${e.port}|${e.matchedRule || ''}`;
const ts = new Date(e.ts).getTime();
if (map.has(key)) {
const g = map.get(key);
g.count++;
g._lastTs = ts;
g.lastTs = e.ts;
} else {
map.set(key, { ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
}
}
const arr = Array.from(map.values());
if (sortBy === 'count') return arr.sort((a, b) => b.count - a.count || b._lastTs - a._lastTs);
return arr.sort((a, b) => b._lastTs - a._lastTs);
}
function TrafficTab({ devices = [] }) {
const [traffic, setTraffic] = useState([]);
const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('all'); // all | direct | vpn | block
const [search, setSearch] = useState('');
const [grouped, setGrouped] = useState(true);
const [sortBy, setSortBy] = useState('count'); // time | count
const [autoscroll, setAutoscroll] = useState(true);
const containerRef = useRef(null);
const pausedRef = useRef(false);
useEffect(() => { pausedRef.current = paused; }, [paused]);
useEffect(() => {
const source = new EventSource('/api/traffic/stream');
source.onmessage = (ev) => {
if (pausedRef.current) return;
try {
const entry = JSON.parse(ev.data);
setTraffic((prev) => {
const next = [...prev, entry];
if (next.length > MAX_TRAFFIC) next.splice(0, next.length - MAX_TRAFFIC);
return next;
});
} catch {}
};
return () => source.close();
}, []);
const filtered = useMemo(() => {
let list = traffic;
if (filter !== 'all') list = list.filter((e) => e.category === filter);
if (search) {
const s = search.toLowerCase();
list = list.filter((e) =>
e.host?.toLowerCase().includes(s) ||
String(e.port || '').includes(s) ||
e.outbound?.toLowerCase().includes(s) ||
e.matchedRule?.toLowerCase().includes(s) ||
e.sourceIp?.toLowerCase().includes(s) ||
getDeviceName(e.sourceIp, devices)?.toLowerCase().includes(s),
);
}
return grouped ? groupTraffic(list, sortBy) : list;
}, [traffic, filter, search, grouped, sortBy, devices]);
useEffect(() => {
if (!autoscroll || !containerRef.current) return;
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}, [filtered, autoscroll]);
const counts = useMemo(() => {
const c = { direct: 0, vpn: 0, block: 0 };
for (const e of traffic) if (e.category in c) c[e.category]++;
return c;
}, [traffic]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
<div className="filter-bar" style={{ marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
<input
className="input"
placeholder="Поиск: host, порт, правило…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: 1, minWidth: 180 }}
/>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все ({traffic.length})</option>
<option value="direct">direct ({counts.direct})</option>
<option value="vpn">VPN ({counts.vpn})</option>
<option value="block">block ({counts.block})</option>
</select>
<label className="checkbox">
<input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} />
Группировать
</label>
{grouped && (
<select className="select" value={sortBy} onChange={(e) => setSortBy(e.target.value)} style={{ width: 'auto' }}>
<option value="count">По частоте</option>
<option value="time">По времени</option>
</select>
)}
<label className="checkbox">
<input type="checkbox" checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} />
Автоскролл
</label>
<button className="btn btn-ghost sm" onClick={() => setPaused((p) => !p)}>
{paused ? '▶ Продолжить' : '⏸ Пауза'}
</button>
<button
className="btn btn-ghost sm"
onClick={() => { setTraffic([]); fetch('/api/traffic', { method: 'DELETE' }).catch(() => {}); }}
>
Очистить
</button>
</div>
{traffic.length === 0 ? (
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>
Ожидаем трафик Убедитесь что sing-box запущен и уровень логов не выше INFO.
</div>
) : (
<div ref={containerRef} style={{ flex: 1, overflow: 'auto' }}>
<table className="table" style={{ fontSize: 12 }}>
<thead>
<tr>
<th style={{ width: 70 }}>Время</th>
<th style={{ width: 70 }}>Туннель</th>
<th style={{ width: 110 }}>Устройство</th>
<th>Хост / IP</th>
<th style={{ width: 55 }}>Порт</th>
<th>Правило</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{filtered.map((e, i) => {
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
const deviceName = getDeviceName(e.sourceIp, devices);
return (
<tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}>
<td className="muted text-mono" style={{ whiteSpace: 'nowrap' }}>{formatTime(e.ts)}</td>
<td>
<span className={`badge ${badge.cls}`} style={{ fontSize: 11 }}>{badge.label}</span>
</td>
<td className="text-mono" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 110 }}>
{deviceName
? <span style={{ fontSize: 11 }}>{deviceName}</span>
: e.sourceIp
? <span className="muted" style={{ fontSize: 10 }}>{e.sourceIp}</span>
: <span className="muted" style={{ fontSize: 11 }}></span>}
</td>
<td className="text-mono" style={{ wordBreak: 'break-all' }}>{e.host || '—'}</td>
<td className="muted text-mono">{e.port || '—'}</td>
<td>
{e.matchedRule
? <span className="badge info" style={{ fontSize: 11 }}>{e.matchedRule}</span>
: <span className="muted" style={{ fontSize: 11 }}></span>}
</td>
<td className="muted text-mono" style={{ textAlign: 'right', fontSize: 11 }}>
{e.count > 1 && <span className="repeat">×{e.count}</span>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
export function LogsPage({ devices = [] }) {
const [tab, setTab] = useState('traffic'); // traffic | logs
const [entries, setEntries] = useState([]);
const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('all');
const [search, setSearch] = useState('');
const [autoscroll, setAutoscroll] = useState(true);
const [grouped, setGrouped] = useState(true);
const containerRef = useRef(null);
const pausedRef = useRef(false);
useEffect(() => { pausedRef.current = paused; }, [paused]);
useEffect(() => {
const source = new EventSource('/api/logs/stream');
source.onmessage = (event) => {
if (pausedRef.current) return;
try {
const entry = JSON.parse(event.data);
setEntries((prev) => {
const next = [...prev, entry];
if (next.length > MAX_ENTRIES) next.splice(0, next.length - MAX_ENTRIES);
return next;
});
} catch {}
};
return () => source.close();
}, []);
const filtered = useMemo(() => {
let list = entries;
if (filter !== 'all') list = list.filter((e) => e.level === filter);
if (search) {
const s = search.toLowerCase();
list = list.filter((e) => normalizeLine(e.line).toLowerCase().includes(s));
}
return grouped ? groupEntries(list) : list;
}, [entries, filter, search, grouped]);
useEffect(() => {
if (!autoscroll || !containerRef.current) return;
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}, [filtered, autoscroll]);
function copy(text) {
navigator.clipboard?.writeText(text).catch(() => {});
}
return (
<div className="card" style={{ display: 'flex', flexDirection: 'column', minHeight: 'calc(100vh - 160px)' }}>
<div className="card-header">
<h2>Логи sing-box</h2>
<div className="tabs" style={{ marginLeft: 'auto', marginBottom: 0 }}>
<button className={`tab ${tab === 'traffic' ? 'active' : ''}`} onClick={() => setTab('traffic')}>Трафик</button>
<button className={`tab ${tab === 'logs' ? 'active' : ''}`} onClick={() => setTab('logs')}>Системные логи</button>
</div>
</div>
{tab === 'traffic' && <TrafficTab devices={devices} />}
{tab === 'logs' && (
<>
<div className="filter-bar" style={{ marginBottom: 12 }}>
<input
className="input"
placeholder="Поиск по тексту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все уровни</option>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="error">error</option>
<option value="debug">debug</option>
</select>
<label className="checkbox"><input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} /> Группировать</label>
<label className="checkbox"><input type="checkbox" checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} /> Автоскролл</label>
<button className="btn btn-ghost sm" onClick={() => setPaused((p) => !p)}>{paused ? '▶ Продолжить' : '⏸ Пауза'}</button>
<button className="btn btn-ghost sm" onClick={() => setEntries([])}>Очистить</button>
</div>
<div ref={containerRef} className="logs-stream">
{filtered.length === 0 && <p className="muted">Логов пока нет.</p>}
{filtered.map((entry, index) => {
const text = normalizeLine(entry.line);
if (grouped && entry.count > 1) {
return (
<div key={`${entry.ts}-${index}`} className="log-group">
<span className="log-time mono">{formatTime(entry.ts)}</span>
<span className={`log-level text-${entry.level === 'error' ? 'danger' : entry.level === 'warning' ? 'warning' : 'info'}`}>
{entry.level}
</span>
<span className="log-text">{text}</span>
<span className="repeat">×{entry.count}</span>
</div>
);
}
return (
<div
key={`${entry.ts}-${index}`}
className={`log-line ${entry.level}`}
onDoubleClick={() => copy(`${formatTime(entry.ts)} ${entry.level} ${text}`)}
title="Двойной клик — скопировать"
>
<span className="log-time">{formatTime(entry.ts)}</span>
<span className="log-level">{entry.level}</span>
<span className="log-text">{text}</span>
</div>
);
})}
</div>
</>
)}
</div>
);
}

View File

@@ -1,192 +0,0 @@
import React, { useEffect, useState } from 'react';
import { formatRelative, formatBytes } from '../utils/format.js';
import { flagFor } from '../utils/country.js';
import { api } from '../api.js';
function StatusHero({ state, status }) {
const text = {
running: { title: '🟢 VPN-шлюз работает', kind: 'success' },
applying: { title: '🟠 Применяем изменения…', kind: 'warning' },
error: { title: '🔴 Ошибка', kind: 'danger' },
stopped: { title: '⚫ Шлюз остановлен', kind: 'neutral' },
no_config: { title: '⚪ Шлюз не настроен', kind: 'neutral' },
}[status];
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : 'без лимита'}`
: 'нет данных';
return (
<div className="card">
<div className="flex-between">
<div>
<h2 style={{ marginBottom: 4 }}>{text.title}</h2>
<small className="muted">
{state?.appliedAt ? `Последнее применение: ${formatRelative(state.appliedAt)}` : 'Конфиг ещё не применялся'}
</small>
</div>
<span className={`badge ${text.kind}`}>{state?.singboxRunning ? 'sing-box online' : 'sing-box offline'}</span>
</div>
<div className="divider" />
<div className="grid-3">
<div>
<small className="muted">Активный сервер</small>
<div style={{ marginTop: 4 }}>
{state?.selectedTag ? (
<>
<strong>{flagFor({ tag: state.selectedTag })} {state.selectedTag}</strong>
</>
) : <span className="muted">Не выбран</span>}
</div>
</div>
<div>
<small className="muted">Трафик</small>
<div style={{ marginTop: 4 }}><strong>{traffic}</strong></div>
</div>
<div>
<small className="muted">Правил маршрутизации</small>
<div style={{ marginTop: 4 }}><strong>{(state?.customRules || []).filter(r => r.enabled).length} активных</strong></div>
</div>
</div>
</div>
);
}
function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle }) {
return (
<div className="card">
<div className="card-header">
<h3>Быстрые действия</h3>
</div>
<div className="btn-group">
<button className="btn btn-primary" disabled={busy} onClick={() => onNav('servers')}>
Сменить сервер
</button>
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>
Перезапустить
</button>
<button className="btn btn-secondary" disabled={busy || !state?.singboxRunning} onClick={onStop}>
Остановить
</button>
<button className="btn btn-ghost" disabled={!state?.configExists} onClick={onShowConfig}>
Показать config
</button>
<button
className={`btn ${state?.bypassMode ? 'btn-warning' : 'btn-ghost'}`}
disabled={busy || !state?.singboxRunning}
onClick={onBypassToggle}
title="Весь трафик напрямую — для диагностики"
>
{state?.bypassMode ? '⚠ Обход правил ВКЛЮЧЁН' : '↗ Весь трафик напрямую'}
</button>
</div>
</div>
);
}
function RecentEvents({ onNav }) {
const [entries, setEntries] = useState([]);
useEffect(() => {
let cancelled = false;
fetch('/api/logs')
.then((r) => r.json())
.then((data) => {
if (cancelled) return;
const list = (data.logs || []).slice(-15).reverse();
setEntries(list);
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
return (
<div className="card">
<div className="card-header">
<h3>Последние события</h3>
<button className="btn btn-link" onClick={() => onNav('logs')}>Открыть логи </button>
</div>
{entries.length === 0 ? (
<small className="muted">Пока ничего нет.</small>
) : (
<div className="events-list">
{entries.slice(0, 8).map((e, i) => {
const dot = e.level === 'error' ? 'danger'
: e.level === 'warning' ? 'warning'
: 'success';
const time = new Date(e.ts).toLocaleTimeString('ru-RU', { hour12: false });
return (
<div key={`${e.ts}-${i}`} className="event-row">
<span className={`dot ${dot}`} />
<span className="event-time">{time}</span>
<span className="text-truncate" title={e.line}>{e.line}</span>
</div>
);
})}
</div>
)}
</div>
);
}
function RoutingSummary({ state, onNav, onFlushDirectCache }) {
const rules = state?.customRules || [];
const enabled = rules.filter((r) => r.enabled).length;
const cacheCount = state?.directBypassCount || 0;
const cacheAvailable = state?.directBypassAvailable && state?.directBypassEnabled;
const transparentDefault = state?.devicesConfig?.defaultTransparentMode || 'vpn';
const proxyDefault = state?.devicesConfig?.proxyDefaultMode || 'vpn';
return (
<div className="card">
<div className="card-header">
<h3>Маршрутизация</h3>
<button className="btn btn-link" onClick={() => onNav('routing')}>Открыть правила </button>
</div>
<div className="kv-list">
<div className="row"><span className="key">Private IP</span><span className="val text-success"> direct</span></div>
{state?.routingRuDirect && (
<div className="row"><span className="key">RU (geoip/geosite)</span><span className="val text-success"> direct</span></div>
)}
<div className="row"><span className="key">Global custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
<div className="row"><span className="key">Transparent fallback</span><span className="val"> {transparentDefault}</span></div>
<div className="row"><span className="key">Proxy fallback</span><span className="val text-warning"> {proxyDefault}</span></div>
{cacheAvailable && (
<div className="row">
<span className="key">Direct bypass cache</span>
<span className="val" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="text-success">{cacheCount} IP</span>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={onFlushDirectCache} title="Сбросить — все IP снова пройдут через sing-box один раз">
сбросить
</button>
</span>
</div>
)}
</div>
</div>
);
}
export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle, onFlushDirectCache }) {
return (
<div className="section-stack">
{state?.bypassMode && (
<div className="alert alert-warning" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<strong> Режим обхода правил активен</strong>
<span className="muted"> весь трафик идёт напрямую, VPN-правила не применяются.</span>
<button className="btn btn-sm btn-warning" style={{ marginLeft: 'auto' }} onClick={onBypassToggle}>
Отключить
</button>
</div>
)}
<StatusHero state={state} status={status} />
<div className="grid-2">
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} onBypassToggle={onBypassToggle} />
<RoutingSummary state={state} onNav={onNav} onFlushDirectCache={onFlushDirectCache} />
</div>
<RecentEvents onNav={onNav} />
</div>
);
}

View File

@@ -1,93 +0,0 @@
import React, { useState } from 'react';
import { api } from '../api.js';
export function RouteChecker() {
const [host, setHost] = useState('');
const [port, setPort] = useState('443');
const [network, setNetwork] = useState('tcp');
const [sourceIp, setSourceIp] = useState('');
const [inbound, setInbound] = useState('tproxy-in');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState('');
async function check() {
setBusy(true);
setError('');
setResult(null);
try {
const data = await api.route.check({
host,
port: port || undefined,
network,
sourceIp: sourceIp || undefined,
inbound,
});
setResult(data);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
const r = result?.result;
const kind = r?.outbound?.startsWith('direct') ? 'success'
: r?.outbound === 'block' ? 'danger'
: r?.outbound?.includes('VPN') || r?.outbound?.includes('vpn') ? 'info'
: 'warning';
return (
<div className="card flat compact">
<div className="card-header no-margin"><h3>Проверить маршрут</h3></div>
<div className="filter-bar" style={{ marginTop: 12 }}>
<input
className="input"
placeholder="домен или IP (riotgames.com)"
value={host}
onChange={(e) => setHost(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && check()}
style={{ minWidth: 220, flex: 1 }}
/>
<input
className="input"
placeholder="port"
value={port}
onChange={(e) => setPort(e.target.value)}
style={{ width: 90 }}
/>
<select className="select" value={network} onChange={(e) => setNetwork(e.target.value)} style={{ width: 90 }}>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<input
className="input"
placeholder="source IP"
value={sourceIp}
onChange={(e) => setSourceIp(e.target.value)}
style={{ width: 145 }}
/>
<select className="select" value={inbound} onChange={(e) => setInbound(e.target.value)} style={{ width: 130 }}>
<option value="tproxy-in">tproxy-in</option>
<option value="mixed-in">mixed-in</option>
</select>
<button className="btn btn-primary" onClick={check} disabled={busy || !host}>Проверить</button>
</div>
{error && <div className="field-error" style={{ marginTop: 10 }}>{error}</div>}
{r && (
<div className="route-result" style={{ marginTop: 12 }}>
<div className="flex-between">
<strong>{r.ruleIndex >= 0 ? `Правило #${r.ruleIndex + 1}: ${r.ruleName}` : r.ruleName}</strong>
<span className={`badge ${kind}`}> {r.outbound}</span>
</div>
{result.resolvedIp && result.resolvedFrom && (
<small className="muted text-mono">DNS: {result.resolvedFrom} {result.resolvedIp}</small>
)}
<small className="muted">{r.reason}</small>
</div>
)}
</div>
);
}

View File

@@ -1,384 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
} from '@dnd-kit/core';
import {
arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ruleTemplates } from '../templates/ruleTemplates.js';
import { ruleErrors, hasErrors } from '../utils/validation.js';
import { RuleEditorDrawer } from './RuleEditorDrawer.jsx';
import { RouteChecker } from './RouteChecker.jsx';
import { api } from '../api.js';
const OUTBOUND_KIND = {
direct: { kind: 'success', label: 'direct' },
vpn: { kind: 'info', label: 'VPN' },
block: { kind: 'danger', label: 'block' },
};
const DEVICE_MODES = {
bypass: { kind: 'warning', label: 'bypass TProxy', hint: 'мимо sing-box; ручной proxy отдельно' },
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
block: { kind: 'danger', label: 'block', hint: 'fallback после global rules' },
};
function DeviceModeSelect({ value, onChange }) {
return (
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
<option value="bypass">bypass TProxy</option>
<option value="direct">direct</option>
<option value="vpn">VPN</option>
<option value="rules">default</option>
<option value="block">block</option>
</select>
);
}
function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemove }) {
const devices = devicesConfig?.devices || [];
const defaultTransparentMode = devicesConfig?.defaultTransparentMode || devicesConfig?.defaultMode || 'vpn';
const proxyDefaultMode = devicesConfig?.proxyDefaultMode || 'vpn';
return (
<div className="card">
<div className="card-header">
<div>
<h2>Устройства</h2>
<small className="muted">bypass TProxy применяется до sing-box. Остальные режимы fallback после global rules.</small>
</div>
<div className="btn-group">
<label className="field" style={{ minWidth: 180, margin: 0 }}>
<span className="field-label">Transparent default</span>
<select
className="select sm"
value={defaultTransparentMode}
onChange={(e) => onDefaultsChange({ defaultTransparentMode: e.target.value })}
>
<option value="direct">direct</option>
<option value="vpn">VPN</option>
<option value="block">block</option>
</select>
</label>
<label className="field" style={{ minWidth: 160, margin: 0 }}>
<span className="field-label">Proxy default</span>
<select
className="select sm"
value={proxyDefaultMode}
onChange={(e) => onDefaultsChange({ proxyDefaultMode: e.target.value })}
>
<option value="vpn">VPN</option>
<option value="direct">direct</option>
<option value="block">block</option>
</select>
</label>
<button className="btn btn-primary sm" onClick={onAdd}>
+ Добавить устройство
</button>
</div>
</div>
{devices.length === 0 ? (
<div className="empty-state" style={{ padding: '16px 0' }}>
<p style={{ margin: 0 }}>Нет профилей устройств. Неизвестные transparent-устройства используют transparent default.</p>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Название</th>
<th style={{ width: 170 }}>IP</th>
<th style={{ width: 150 }}>MAC</th>
<th style={{ width: 150 }}>Mode</th>
<th>Поведение</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{devices.map((dev) => {
const mode = DEVICE_MODES[dev.mode] || DEVICE_MODES.rules;
return (
<tr key={dev.id} className={dev.enabled !== false ? '' : 'disabled'}>
<td>
<input
type="checkbox"
checked={dev.enabled !== false}
onChange={(e) => onUpdate(dev.id, { enabled: e.target.checked })}
style={{ accentColor: 'var(--accent)' }}
/>
</td>
<td>
<input
className="input sm"
value={dev.name || ''}
onChange={(e) => onUpdate(dev.id, { name: e.target.value })}
placeholder="Название устройства"
style={{ width: '100%', minWidth: 120 }}
/>
</td>
<td>
<input
className="input sm"
value={dev.ip || ''}
onChange={(e) => onUpdate(dev.id, { ip: e.target.value })}
placeholder="192.168.1.50"
style={{ width: '100%', minWidth: 140 }}
/>
</td>
<td>
<input
className="input sm"
value={dev.mac || ''}
onChange={(e) => onUpdate(dev.id, { mac: e.target.value })}
placeholder="опционально"
style={{ width: '100%', minWidth: 120 }}
/>
</td>
<td>
<DeviceModeSelect value={dev.mode} onChange={(mode) => onUpdate(dev.id, { mode })} />
</td>
<td>
<span className={`badge ${mode.kind}`}>{mode.label}</span>
<small className="muted" style={{ marginLeft: 8 }}>{mode.hint}</small>
</td>
<td>
<button
className="btn btn-ghost sm"
onClick={() => {
if (confirm('Удалить устройство?')) onRemove(dev.id);
}}
>×</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
function summary(rule) {
const parts = [];
const totalDomains = (rule.domains?.length || 0) + (rule.domainSuffixes?.length || 0) + (rule.domainKeywords?.length || 0);
if (totalDomains) parts.push(`${totalDomains} дом.`);
if (rule.ipCidrs?.length) parts.push(`${rule.ipCidrs.length} CIDR`);
if (rule.ports?.length) parts.push(`${rule.ports.length} портов`);
if (rule.networks?.length) parts.push(rule.networks.join('/'));
return parts.join(' · ') || '—';
}
function SortableRuleRow({ rule, index, total, onEdit, onUpdate, onRemove, conflict }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rule.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
const errors = ruleErrors(rule);
const invalid = hasErrors(errors);
const ob = OUTBOUND_KIND[rule.outbound] || OUTBOUND_KIND.direct;
return (
<tr ref={setNodeRef} style={style} className={`rule-row ${rule.enabled ? '' : 'disabled'} ${invalid ? 'invalid' : ''}`}>
<td style={{ width: 30 }}>
<span className="drag-handle" {...attributes} {...listeners} title="Перетащить"></span>
</td>
<td style={{ width: 36 }} className="muted text-mono">#{index + 1}</td>
<td>
<div className="flex" style={{ alignItems: 'center' }}>
<input
type="checkbox"
checked={rule.enabled !== false}
onChange={(e) => onUpdate(rule.id, { enabled: e.target.checked })}
style={{ accentColor: 'var(--accent)' }}
/>
<button className="btn btn-link" style={{ padding: 0, fontWeight: 600 }} onClick={() => onEdit(rule.id)}>
{rule.name || '(без названия)'}
</button>
{invalid && <span className="badge danger">ошибки</span>}
{conflict && <span className={`badge ${conflict.severity === 'warning' ? 'warning' : 'info'}`} title={`Перекрывается с #${conflict.conflictWithIndex + 1}`}>конфликт</span>}
</div>
</td>
<td><span className={`badge ${ob.kind}`}>{ob.label}</span></td>
<td className="muted" style={{ fontSize: 12 }}>{summary(rule)}</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className="btn btn-ghost sm" onClick={() => onEdit(rule.id)}>Редактировать</button>
<button className="btn btn-ghost sm" onClick={() => { if (confirm('Удалить правило?')) onRemove(rule.id); }}>×</button>
</div>
</td>
</tr>
);
}
function TemplatesModal({ open, onClose, onAdd }) {
if (!open) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h3>Шаблоны маршрутизации</h3>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
<div className="modal-body">
<div className="template-grid">
{ruleTemplates.map((tpl) => (
<div key={tpl.key} className="template-card">
<h4>{tpl.label}</h4>
<small>{tpl.description}</small>
<button className="btn btn-secondary sm" onClick={() => { onAdd(tpl.build()); onClose(); }}>
+ Добавить
</button>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export function RoutingPage({
rules, saveStatus, busy,
onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder,
devicesConfig, onUpdateDeviceDefaults, onAddDevice, onUpdateDevice, onRemoveDevice,
}) {
const [editingId, setEditingId] = useState(null);
const [showTemplates, setShowTemplates] = useState(false);
const [conflicts, setConflicts] = useState([]);
const [availableRuleSets, setAvailableRuleSets] = useState([]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
api.ruleSets.get().then((data) => setAvailableRuleSets(data.ruleSets || [])).catch(() => {});
}, []);
useEffect(() => {
let cancelled = false;
const t = setTimeout(() => {
api.rules.conflicts().then((data) => { if (!cancelled) setConflicts(data.conflicts || []); }).catch(() => {});
}, 600);
return () => { cancelled = true; clearTimeout(t); };
}, [rules]);
const conflictsByRuleId = useMemo(() => {
const map = {};
for (const c of conflicts) map[c.ruleId] = c;
return map;
}, [conflicts]);
function handleDragEnd(event) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = rules.findIndex((r) => r.id === active.id);
const newIndex = rules.findIndex((r) => r.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
onReorder(arrayMove(rules, oldIndex, newIndex));
}
const editing = rules.find((r) => r.id === editingId) || null;
return (
<div className="section-stack">
<RouteChecker />
<DevicesCard
devicesConfig={devicesConfig}
onDefaultsChange={onUpdateDeviceDefaults}
onAdd={onAddDevice}
onUpdate={onUpdateDevice}
onRemove={onRemoveDevice}
/>
<div className="card">
<div className="card-header">
<h2>Правила маршрутизации</h2>
<div className="btn-group">
<button className="btn btn-secondary sm" onClick={() => setShowTemplates(true)}>Шаблоны</button>
<button className="btn btn-primary sm" onClick={() => { const newId = `rule-${Date.now()}`; onAdd(); setTimeout(() => setEditingId(newId), 50); }}>
+ Добавить
</button>
</div>
</div>
{conflicts.length > 0 && (
<div className="conflict-banner" style={{ marginBottom: 12 }}>
<span></span>
<div>
<strong>{conflicts.length} конфликт(ов) обнаружено</strong>
<div style={{ marginTop: 4 }}>
{conflicts.slice(0, 3).map((c, i) => (
<div key={i} style={{ fontSize: 12 }}>
#{c.ruleIndex + 1} «{c.ruleName}» перекрывается правилом #{c.conflictWithIndex + 1} «{c.conflictWithName}»
</div>
))}
</div>
</div>
</div>
)}
<small className="muted" style={{ display: 'block', marginBottom: 8 }}>
Применяются <strong>сверху вниз</strong>. Перетаскивай чтобы менять порядок.
</small>
{rules.length === 0 ? (
<div className="empty-state">
<h3>Правил пока нет</h3>
<p>Добавь шаблон (например «League of Legends direct») или создай пустое правило.</p>
<button className="btn btn-primary" onClick={() => setShowTemplates(true)} style={{ marginTop: 12 }}>
Открыть шаблоны
</button>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th></th>
<th>#</th>
<th>Правило</th>
<th>Outbound</th>
<th>Условия</th>
<th></th>
</tr>
</thead>
<tbody>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={rules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
{rules.map((rule, i) => (
<SortableRuleRow
key={rule.id}
rule={rule}
index={i}
total={rules.length}
onEdit={setEditingId}
onUpdate={onUpdate}
onRemove={onRemove}
conflict={conflictsByRuleId[rule.id]}
/>
))}
</SortableContext>
</DndContext>
</tbody>
</table>
</div>
)}
</div>
<RuleEditorDrawer
rule={editing}
onUpdate={onUpdate}
onClose={() => setEditingId(null)}
onRemove={onRemove}
availableRuleSets={availableRuleSets}
/>
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
</div>
);
}

View File

@@ -1,453 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ChipsInput } from './ChipsInput.jsx';
import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js';
import { api } from '../api.js';
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
const RULE_SET_TAG = /^[a-z0-9][a-z0-9_.@!-]*$/i;
const validDomain = (v) => DOMAIN.test(String(v).trim());
const validRuleSetTag = (v) => RULE_SET_TAG.test(String(v).trim());
const RS_PAGE_SIZE = 100;
const RS_TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключ', cidr: 'CIDR', regex: 'regex' };
function RuleSetBrowseModal({ tag, url, rule, onPatch, onClose }) {
const [status, setStatus] = useState('loading');
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
const [page, setPage] = useState(0);
const inputRef = useRef(null);
useEffect(() => {
api.ruleSets.lookup(tag, url)
.then((d) => { setData(d); setStatus('done'); })
.catch((err) => { setError(err.message); setStatus('error'); });
}, [tag, url]);
useEffect(() => {
if (status === 'done') setTimeout(() => inputRef.current?.focus(), 50);
}, [status]);
const filtered = useMemo(() => {
if (!data?.entries) return [];
const q = search.trim().toLowerCase();
return data.entries.filter((e) => {
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
if (!q) return true;
return e.value.toLowerCase().includes(q);
});
}, [data, search, typeFilter]);
function onSearchChange(v) { setSearch(v); setPage(0); }
function onTypeChange(v) { setTypeFilter(v); setPage(0); }
function addEntry(entry) {
const val = entry.value;
switch (entry.type) {
case 'domain': {
const cur = new Set(rule.domains || []);
if (!cur.has(val)) onPatch({ domains: [...(rule.domains || []), val] });
break;
}
case 'suffix': {
const cur = new Set(rule.domainSuffixes || []);
if (!cur.has(val)) onPatch({ domainSuffixes: [...(rule.domainSuffixes || []), val] });
break;
}
case 'keyword': {
const cur = new Set(rule.domainKeywords || []);
if (!cur.has(val)) onPatch({ domainKeywords: [...(rule.domainKeywords || []), val] });
break;
}
case 'cidr': {
const cur = new Set(rule.ipCidrs || []);
if (!cur.has(val)) onPatch({ ipCidrs: [...(rule.ipCidrs || []), val] });
break;
}
default: break;
}
}
const totalPages = Math.ceil(filtered.length / RS_PAGE_SIZE);
const pageItems = filtered.slice(page * RS_PAGE_SIZE, (page + 1) * RS_PAGE_SIZE);
const addedValues = useMemo(() => new Set([
...(rule.domains || []),
...(rule.domainSuffixes || []),
...(rule.domainKeywords || []),
...(rule.ipCidrs || []),
]), [rule]);
return (
<div className="modal-backdrop" style={{ zIndex: 1100 }} onClick={onClose}>
<div
className="modal lg"
style={{ maxWidth: 680, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-head">
<div>
<h3 style={{ margin: 0 }}>Содержимое: <code style={{ fontSize: 14 }}>{tag}</code></h3>
<small className="muted">Кликните запись чтобы добавить в правило</small>
</div>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
{status === 'loading' && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
Скачивание и декомпиляция<br />
<small>Может занять 1030 секунд</small>
</div>
)}
{status === 'error' && (
<div style={{ padding: 24 }}>
<div className="conflict-banner danger"><span></span><div>{error}</div></div>
</div>
)}
{status === 'done' && data && (
<>
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="badge info">всего: {data.stats.total.toLocaleString()}</span>
{data.stats.domain > 0 && <span className="badge">доменов: {data.stats.domain.toLocaleString()}</span>}
{data.stats.suffix > 0 && <span className="badge">суффиксов: {data.stats.suffix.toLocaleString()}</span>}
{data.stats.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
</div>
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8 }}>
<input
ref={inputRef}
className="input"
style={{ flex: 1 }}
placeholder="Поиск: youtube, 149.154, .ru…"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
<select className="select" style={{ width: 130 }} value={typeFilter} onChange={(e) => onTypeChange(e.target.value)}>
<option value="all">Все типы</option>
{Object.entries(RS_TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px' }}>
{filtered.length === 0 ? (
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>Ничего не найдено</div>
) : (
<>
<div style={{ fontSize: 12, color: 'var(--text-muted)', padding: '6px 0' }}>
{filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
{totalPages > 1 && ` · стр. ${page + 1}/${totalPages}`}
<span className="muted" style={{ marginLeft: 12 }}> нажмите строку чтобы добавить в правило</span>
</div>
<table className="table" style={{ fontSize: 12 }}>
<thead>
<tr><th style={{ width: 70 }}>Тип</th><th>Значение</th><th style={{ width: 30 }}></th></tr>
</thead>
<tbody>
{pageItems.map((e, i) => {
const already = addedValues.has(e.value);
return (
<tr
key={i}
style={{ cursor: already ? 'default' : 'pointer', opacity: already ? 0.5 : 1 }}
onClick={() => !already && addEntry(e)}
title={already ? 'Уже добавлено' : `Добавить в ${e.type === 'cidr' ? 'IP/CIDR' : e.type === 'suffix' ? 'суффиксы' : e.type === 'keyword' ? 'ключевые слова' : 'домены'}`}
>
<td><span className="badge" style={{ fontSize: 10 }}>{RS_TYPE_LABELS[e.type] || e.type}</span></td>
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
<td style={{ color: 'var(--text-muted)', fontSize: 14 }}>{already ? '✓' : '+'}</td>
</tr>
);
})}
</tbody>
</table>
{totalPages > 1 && (
<div className="flex" style={{ gap: 8, padding: '10px 0', justifyContent: 'center' }}>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage(0)}>«</button>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}></button>
<span className="muted" style={{ lineHeight: '28px', fontSize: 12 }}>{page + 1} / {totalPages}</span>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}></button>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>»</button>
</div>
)}
</>
)}
</div>
</>
)}
</div>
</div>
);
}
export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder', availableRuleSets = [] }) {
const [view, setView] = useState(mode); // builder | json
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
const [jsonError, setJsonError] = useState('');
const [browseTag, setBrowseTag] = useState(null); // { tag, url } | null
const errors = ruleErrors(rule);
// Индекс URL по тегу из доступных rule-sets
const ruleSetUrlMap = useMemo(() => {
const map = {};
for (const rs of availableRuleSets) map[rs.tag] = rs.url;
return map;
}, [availableRuleSets]);
function patch(p) {
onUpdate(rule.id, p);
}
function applyJson() {
try {
const parsed = JSON.parse(jsonDraft);
onUpdate(rule.id, { ...parsed, id: rule.id });
setJsonError('');
} catch (err) {
setJsonError(err.message);
}
}
return (
<div className="drawer-body">
<div className="tabs">
<button className={`tab ${view === 'builder' ? 'active' : ''}`} onClick={() => setView('builder')}>Конструктор</button>
<button className={`tab ${view === 'json' ? 'active' : ''}`} onClick={() => { setJsonDraft(JSON.stringify(rule, null, 2)); setView('json'); }}>Raw JSON</button>
</div>
{view === 'builder' ? (
<>
<div className="field">
<span className="field-label">Название</span>
<input className="input" value={rule.name} onChange={(e) => patch({ name: e.target.value })} />
</div>
<div className="field-row">
<div className="field">
<span className="field-label">Outbound</span>
<select className="select" value={rule.outbound} onChange={(e) => patch({ outbound: e.target.value })}>
<option value="direct">direct (напрямую)</option>
<option value="vpn">vpn (через выбранный сервер)</option>
<option value="block">block (заблокировать)</option>
</select>
</div>
<div className="field">
<span className="field-label">Состояние</span>
<label className="checkbox">
<input
type="checkbox"
checked={rule.enabled !== false}
onChange={(e) => patch({ enabled: e.target.checked })}
/>
Правило включено
</label>
</div>
</div>
<div className="field">
<span className="field-label">Rule-sets (geo-базы)</span>
<ChipsInput
value={rule.ruleSets || []}
onChange={(v) => patch({ ruleSets: v })}
placeholder="geosite-runet"
validate={validRuleSetTag}
/>
{/* Кнопки просмотра содержимого для выбранных rule-sets */}
{(rule.ruleSets || []).length > 0 && (
<div className="field-hint" style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
{(rule.ruleSets || []).map((tag) => {
const url = ruleSetUrlMap[tag];
return url ? (
<button
key={tag}
className="btn btn-ghost sm"
style={{ padding: '0 6px', fontSize: 11 }}
onClick={() => setBrowseTag({ tag, url })}
title={`Просмотреть содержимое ${tag}`}
>
🔍 {tag}
</button>
) : null;
})}
</div>
)}
{availableRuleSets.length > 0 && (
<div className="field-hint">
Доступны:{' '}
{availableRuleSets.map((rs) => (
<span key={rs.tag} style={{ display: 'inline-flex', alignItems: 'center', marginRight: 4 }}>
<button
className="btn btn-ghost sm"
style={{ padding: '0 6px', marginRight: 2 }}
onClick={() => {
const current = new Set(rule.ruleSets || []);
if (!current.has(rs.tag)) {
patch({ ruleSets: [...(rule.ruleSets || []), rs.tag] });
}
}}
>
+ {rs.tag}
</button>
<button
className="btn btn-ghost sm"
style={{ padding: '0 4px', fontSize: 12 }}
onClick={() => setBrowseTag({ tag: rs.tag, url: rs.url })}
title="Просмотреть содержимое"
>
🔍
</button>
</span>
))}
</div>
)}
{availableRuleSets.length === 0 && (
<span className="field-hint">
Настройте rule-sets в Настройках, затем вводите их теги здесь
</span>
)}
</div>
<div className="field">
<span className="field-label">Домены (точное совпадение)</span>
<ChipsInput
value={rule.domains || []}
onChange={(v) => patch({ domains: v })}
placeholder="riotgames.com"
validate={validDomain}
/>
{errors.domains.length > 0 && <span className="field-error">Невалидно: {errors.domains.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Суффиксы доменов</span>
<ChipsInput
value={rule.domainSuffixes || []}
onChange={(v) => patch({ domainSuffixes: v })}
placeholder="riotcdn.net"
validate={validDomain}
/>
{errors.domainSuffixes.length > 0 && <span className="field-error">Невалидно: {errors.domainSuffixes.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">IP / CIDR</span>
<ChipsInput
value={rule.ipCidrs || []}
onChange={(v) => patch({ ipCidrs: v })}
placeholder="104.160.128.0/19"
validate={isValidCidr}
/>
{errors.ipCidrs.length > 0 && <span className="field-error">Невалидно: {errors.ipCidrs.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Порты (число или диапазон 5000-6000)</span>
<ChipsInput
value={rule.ports || []}
onChange={(v) => patch({ ports: v })}
placeholder="443"
validate={(p) => {
const s = String(p);
if (s.includes('-')) {
const [a, b] = s.split('-');
return isValidPort(a) && isValidPort(b);
}
return isValidPort(p);
}}
/>
{errors.ports.length > 0 && <span className="field-error">Невалидно: {errors.ports.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Протоколы</span>
<div className="flex">
<label className="checkbox">
<input
type="checkbox"
checked={(rule.networks || []).includes('tcp')}
onChange={(e) => {
const set = new Set(rule.networks || []);
e.target.checked ? set.add('tcp') : set.delete('tcp');
patch({ networks: Array.from(set) });
}}
/>
TCP
</label>
<label className="checkbox">
<input
type="checkbox"
checked={(rule.networks || []).includes('udp')}
onChange={(e) => {
const set = new Set(rule.networks || []);
e.target.checked ? set.add('udp') : set.delete('udp');
patch({ networks: Array.from(set) });
}}
/>
UDP
</label>
<span className="field-hint">Если ничего оба</span>
</div>
</div>
</>
) : (
<>
<div className="field">
<span className="field-label">Сырой JSON правила</span>
<textarea
className="textarea"
style={{ minHeight: 320 }}
value={jsonDraft}
onChange={(e) => setJsonDraft(e.target.value)}
/>
{jsonError && <span className="field-error">{jsonError}</span>}
</div>
<div className="btn-group">
<button className="btn btn-primary" onClick={applyJson}>Применить JSON</button>
<button className="btn btn-ghost" onClick={() => setJsonDraft(JSON.stringify(rule, null, 2))}>Сбросить</button>
</div>
</>
)}
{browseTag && (
<RuleSetBrowseModal
tag={browseTag.tag}
url={browseTag.url}
rule={rule}
onPatch={(p) => patch(p)}
onClose={() => setBrowseTag(null)}
/>
)}
</div>
);
}
export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove, availableRuleSets = [] }) {
if (!rule) return null;
const errors = ruleErrors(rule);
const invalid = hasErrors(errors);
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<aside className="drawer">
<div className="drawer-head">
<div>
<h3>Редактирование правила</h3>
<small className="muted">{rule.name || '(без названия)'}</small>
</div>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
<RuleEditor rule={rule} onUpdate={onUpdate} onClose={onClose} onRemove={onRemove} availableRuleSets={availableRuleSets} />
<div className="drawer-foot">
<button className="btn btn-danger" onClick={() => { if (confirm('Удалить правило?')) { onRemove(rule.id); onClose(); } }}>Удалить</button>
<div className="btn-group">
{invalid && <span className="badge danger">Есть ошибки</span>}
<button className="btn btn-primary" onClick={onClose}>Готово</button>
</div>
</div>
</aside>
</>
);
}

View File

@@ -1,210 +0,0 @@
import React, { useMemo, useState } from 'react';
import { api } from '../api.js';
import { flagFor } from '../utils/country.js';
import { formatRelative } from '../utils/format.js';
function PingCell({ ping }) {
if (!ping) return <span className="muted"></span>;
if (ping.checking) return <span className="badge neutral pulse">проверяем</span>;
if (!ping.ok) return <span className="badge danger" title={ping.error}>offline</span>;
const ms = ping.latency;
const kind = ms < 80 ? 'success' : ms < 200 ? 'warning' : 'danger';
return <span className={`badge ${kind}`}>{ms} ms</span>;
}
function StatusCell({ ping }) {
if (!ping) return <span className="badge neutral">unknown</span>;
if (ping.checking) return <span className="badge neutral pulse"></span>;
return ping.ok
? <span className="badge success"> online</span>
: <span className="badge danger"> offline</span>;
}
export function ServersPage({
state,
servers,
selectedTag,
setSelectedTag,
pendingTag,
setPendingTag,
busy,
onApply,
onRollback,
pings,
setPings,
pushToast,
}) {
const [filter, setFilter] = useState('all'); // all | online
const [search, setSearch] = useState('');
async function pingOne(server) {
setPings((prev) => ({ ...prev, [server.tag]: { checking: true } }));
try {
const res = await api.servers.ping(server.server, server.server_port);
setPings((prev) => ({
...prev,
[server.tag]: { ok: res.ok, latency: res.latency, error: res.error, checkedAt: new Date().toISOString() },
}));
} catch (err) {
setPings((prev) => ({ ...prev, [server.tag]: { ok: false, error: err.message } }));
}
}
async function pingAll() {
setPings((prev) => {
const next = { ...prev };
for (const s of servers) next[s.tag] = { checking: true };
return next;
});
try {
const res = await api.servers.pingAll();
const map = {};
for (const r of res.results || []) {
map[r.tag] = { ok: r.ok, latency: r.latency, error: r.error, checkedAt: r.checkedAt };
}
setPings((prev) => ({ ...prev, ...map }));
pushToast({ kind: 'success', title: 'Пинг завершён' });
} catch (err) {
pushToast({ kind: 'danger', title: 'Ошибка пинга', message: err.message });
}
}
const filtered = useMemo(() => {
return servers.filter((s) => {
if (search && !s.tag.toLowerCase().includes(search.toLowerCase()) && !s.server.toLowerCase().includes(search.toLowerCase())) {
return false;
}
if (filter === 'online' && !pings[s.tag]?.ok) return false;
return true;
});
}, [servers, search, filter, pings]);
const pendingDifferent = pendingTag && pendingTag !== state?.selectedTag;
const activeServer = servers.find((s) => s.tag === state?.selectedTag);
const pendingServer = servers.find((s) => s.tag === pendingTag);
if (!servers.length) {
return (
<div className="card">
<div className="empty-state">
<h3>Серверы ещё не загружены</h3>
<p>Загрузите подписку в разделе «Настройки», чтобы получить список серверов.</p>
</div>
</div>
);
}
return (
<div className="section-stack">
{pendingDifferent && (
<div className="card" style={{ borderColor: 'var(--warning)' }}>
<div className="flex-between">
<div>
<strong>Выбран: {flagFor(pendingServer)} {pendingServer?.tag}</strong>
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>
Текущий: {state?.selectedTag ? `${flagFor(activeServer)} ${state.selectedTag}` : 'нет'}
</div>
</div>
<div className="btn-group">
<button className="btn btn-ghost" onClick={() => setPendingTag(state?.selectedTag || '')} disabled={busy}>
Отменить
</button>
<button className="btn btn-primary" onClick={() => onApply(pendingTag)} disabled={busy}>
Применить изменения
</button>
</div>
</div>
</div>
)}
<div className="card">
<div className="card-header">
<h2>Серверы ({servers.length})</h2>
<div className="btn-group">
<button className="btn btn-secondary sm" onClick={pingAll} disabled={busy}>
Проверить все
</button>
{state?.previousTag && (
<button className="btn btn-ghost sm" onClick={onRollback} disabled={busy}>
Откатить ({state.previousTag})
</button>
)}
</div>
</div>
<div className="filter-bar" style={{ marginBottom: 12 }}>
<input
className="input"
placeholder="Поиск по тегу или хосту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все</option>
<option value="online">Только online</option>
</select>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th style={{ width: 16 }}></th>
<th>Сервер</th>
<th>Хост</th>
<th>Тип</th>
<th>Ping</th>
<th>Статус</th>
<th style={{ textAlign: 'right' }}>Действие</th>
</tr>
</thead>
<tbody>
{filtered.map((server) => {
const isActive = server.tag === state?.selectedTag;
const isPending = server.tag === pendingTag && !isActive;
const ping = pings[server.tag];
return (
<tr key={server.tag} className={isActive ? 'active' : ''}>
<td>{flagFor(server)}</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<strong>{server.tag}</strong>
{isActive && <span className="badge success">ACTIVE</span>}
{isPending && <span className="badge warning">pending</span>}
</div>
</td>
<td className="text-mono muted">{server.server}:{server.server_port}</td>
<td><span className="badge neutral">{server.type}</span></td>
<td><PingCell ping={ping} /></td>
<td><StatusCell ping={ping} /></td>
<td>
<div className="row-actions">
<button className="btn btn-ghost sm" onClick={() => pingOne(server)} disabled={busy}>
Ping
</button>
{isActive ? (
<button className="btn btn-secondary sm" disabled>Активен</button>
) : (
<button
className="btn btn-primary sm"
onClick={() => { setSelectedTag(server.tag); setPendingTag(server.tag); }}
disabled={busy}
>
Выбрать
</button>
)}
</div>
</td>
</tr>
);
})}
{!filtered.length && (
<tr><td colSpan={7} className="muted" style={{ padding: 24, textAlign: 'center' }}>Ничего не найдено</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,784 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import { formatRelative } from '../utils/format.js';
const TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключевое слово', cidr: 'CIDR', regex: 'regex' };
const PAGE_SIZE = 100;
function RuleSetLookupModal({ tag, url, onClose }) {
const [state, setState] = useState('idle'); // idle | loading | done | error
const [error, setError] = useState('');
const [data, setData] = useState(null); // { entries, stats, cachedAt }
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState('all');
const [page, setPage] = useState(0);
const inputRef = useRef(null);
useEffect(() => {
setState('loading');
api.ruleSets.lookup(tag, url)
.then((res) => { setData(res); setState('done'); })
.catch((err) => { setError(err.message); setState('error'); });
}, [tag, url]);
useEffect(() => {
if (state === 'done') setTimeout(() => inputRef.current?.focus(), 50);
}, [state]);
const filtered = useMemo(() => {
if (!data?.entries) return [];
const q = search.trim().toLowerCase();
return data.entries.filter((e) => {
if (filterType !== 'all' && e.type !== filterType) return false;
if (!q) return true;
return e.value.toLowerCase().includes(q);
});
}, [data, search, filterType]);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const pageItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
function onSearchChange(v) { setSearch(v); setPage(0); }
function onTypeChange(v) { setFilterType(v); setPage(0); }
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal lg" style={{ maxWidth: 720, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div>
<h3 style={{ margin: 0 }}>Содержимое: <code style={{ fontSize: 14 }}>{tag}</code></h3>
<small className="muted">{url}</small>
</div>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
{state === 'loading' && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
Скачивание и декомпиляция<br />
<small>Может занять 1030 секунд</small>
</div>
)}
{state === 'error' && (
<div style={{ padding: 24 }}>
<div className="conflict-banner danger"><span></span><div>{error}</div></div>
</div>
)}
{state === 'done' && data && (
<>
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)' }}>
<div className="flex" style={{ gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="badge info">всего: {data.stats.total.toLocaleString()}</span>
{data.stats.domain > 0 && <span className="badge">доменов: {data.stats.domain.toLocaleString()}</span>}
{data.stats.suffix > 0 && <span className="badge">суффиксов: {data.stats.suffix.toLocaleString()}</span>}
{data.stats.keyword > 0 && <span className="badge">ключ. слов: {data.stats.keyword.toLocaleString()}</span>}
{data.stats.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
{data.stats.regex > 0 && <span className="badge">regex: {data.stats.regex.toLocaleString()}</span>}
<span className="muted" style={{ fontSize: 12, marginLeft: 'auto' }}>
кеш: {formatRelative(data.cachedAt)}
</span>
</div>
</div>
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8 }}>
<input
ref={inputRef}
className="input"
style={{ flex: 1 }}
placeholder="Поиск: youtube, 149.154, .ru…"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
<select className="select" style={{ width: 140 }} value={filterType} onChange={(e) => onTypeChange(e.target.value)}>
<option value="all">Все типы</option>
{Object.entries(TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px' }}>
{filtered.length === 0 ? (
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>Ничего не найдено</div>
) : (
<>
<div style={{ fontSize: 12, color: 'var(--text-muted)', padding: '8px 0' }}>
Найдено: {filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
{totalPages > 1 && ` · страница ${page + 1} из ${totalPages}`}
</div>
<table className="table" style={{ fontSize: 13 }}>
<thead>
<tr><th style={{ width: 80 }}>Тип</th><th>Значение</th></tr>
</thead>
<tbody>
{pageItems.map((e, i) => (
<tr key={i}>
<td><span className="badge">{TYPE_LABELS[e.type] || e.type}</span></td>
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<div className="flex" style={{ gap: 8, padding: '12px 0', justifyContent: 'center' }}>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage(0)}>«</button>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}></button>
<span className="muted" style={{ lineHeight: '28px', fontSize: 13 }}>{page + 1} / {totalPages}</span>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}></button>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>»</button>
</div>
)}
</>
)}
</div>
</>
)}
</div>
</div>
);
}
// Каталог готовых rule-set источников для sing-box (.srs формат)
// Источники: SagerNet (официальные, используются как встроенные), runetfreedom (RKN-реестр)
const RULE_SET_CATALOG = [
{
tag: 'geosite-runet',
url: 'https://github.com/runetfreedom/russia-blocked-geosite/releases/latest/download/rule-set/ru.srs',
source: 'runetfreedom',
category: 'RU',
description: 'Заблокированные в РФ домены по реестру РКН. Обновляется автоматически из официальных источников.',
examples: ['rutracker.org', 'youtube.com', 'instagram.com', 'facebook.com', 'twitter.com'],
use: 'vpn — маршрутизировать заблокированные домены через VPN.',
builtIn: false,
},
{
tag: 'geoip-ru',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs',
source: 'SagerNet/sing-geoip',
category: 'RU',
description: 'IP-диапазоны, зарегистрированные в России (RIPE NCC). Покрывает российские хостинги, банки, госсайты.',
examples: ['77.88.0.0/18 (Яндекс)', '95.173.128.0/19 (МТС)', '213.180.192.0/19 (Яндекс)'],
use: 'direct — российские сервисы без VPN.',
builtIn: true,
},
{
tag: 'geosite-category-ru',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs',
source: 'SagerNet/sing-geosite',
category: 'RU',
description: 'Домены российских сервисов: Яндекс, VK, Mail.ru, Сбербанк, банки, госуслуги. Не заблокированные, а просто российские.',
examples: ['yandex.ru', 'vk.com', 'mail.ru', 'sberbank.ru', 'gosuslugi.ru', 'ozon.ru'],
use: 'direct — чтобы российские сайты открывались с российским IP (нужно для оплаты и т.п.).',
builtIn: true,
},
{
tag: 'geosite-google',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-google.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'Все домены Google: поиск, Gmail, YouTube, Drive, Maps, Google API, reCAPTCHA и пр.',
examples: ['google.com', 'googleapis.com', 'googlevideo.com', 'gstatic.com', 'ggpht.com'],
use: 'vpn — если Google заблокирован или нужна стабильная работа сервисов.',
builtIn: false,
},
{
tag: 'geosite-youtube',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-youtube.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'Только домены YouTube и связанных CDN. Меньше чем полный Google.',
examples: ['youtube.com', 'youtu.be', 'ytimg.com', 'googlevideo.com'],
use: 'vpn — для разблокировки YouTube.',
builtIn: false,
},
{
tag: 'geosite-telegram',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-telegram.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'Домены и IP Telegram. Включает CDN, API и голосовые серверы.',
examples: ['telegram.org', 't.me', 'telegra.ph', '149.154.160.0/20'],
use: 'vpn — разблокировка в РФ. direct — если хочешь избежать задержек.',
builtIn: false,
},
{
tag: 'geosite-openai',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-openai.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'ChatGPT, OpenAI API, Dall-E и другие сервисы OpenAI.',
examples: ['openai.com', 'chatgpt.com', 'oaistatic.com', 'oaiusercontent.com'],
use: 'vpn — OpenAI заблокирован в РФ и ряде других стран.',
builtIn: false,
},
{
tag: 'geosite-apple',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-apple.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'App Store, iCloud, Apple CDN, push-уведомления (APNs), iMessage.',
examples: ['apple.com', 'icloud.com', 'mzstatic.com', 'apple-cloudkit.com'],
use: 'direct — Apple обычно работает без VPN. vpn — если нужен другой регион App Store.',
builtIn: false,
},
{
tag: 'geosite-github',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-github.srs',
source: 'SagerNet/sing-geosite',
category: 'Разработка',
description: 'GitHub, GitHub Actions, GitHub Pages, raw.githubusercontent.com.',
examples: ['github.com', 'githubusercontent.com', 'github.io', 'githubassets.com'],
use: 'vpn — если GitHub замедлен или заблокирован.',
builtIn: false,
},
{
tag: 'geosite-category-ads-all',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ads-all.srs',
source: 'SagerNet/sing-geosite',
category: 'Блокировка',
description: 'Рекламные сети, трекеры, аналитика. Тысячи доменов.',
examples: ['doubleclick.net', 'googlesyndication.com', 'amazon-adsystem.com'],
use: 'block — блокировка рекламы и трекеров на уровне DNS.',
builtIn: false,
},
];
function SubscriptionCard({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetch, onForget, pushToast }) {
const [editing, setEditing] = useState(!state?.hasSubscription);
useEffect(() => { if (!state?.hasSubscription) setEditing(true); }, [state?.hasSubscription]);
const masked = state?.hasSubscription && !editing;
return (
<div className="card">
<div className="card-header">
<h2>Подписка</h2>
{state?.hasSubscription && (
<span className="badge success"> активна</span>
)}
</div>
{masked ? (
<div className="kv-list">
<div className="row">
<span className="key">URL</span>
<span className="val text-mono">{state.subscriptionHost}</span>
</div>
<div className="row">
<span className="key">Серверов</span>
<span className="val">{state.servers?.length || 0}</span>
</div>
<div className="row">
<span className="key">Загружено</span>
<span className="val">{state.fetchedAt ? formatRelative(state.fetchedAt) : '—'}</span>
</div>
</div>
) : (
<div className="field">
<span className="field-label">Subscription URL</span>
<div className="subscription-input">
<input
className="input"
value={subscriptionUrl}
onChange={(e) => setSubscriptionUrl(e.target.value)}
placeholder="https://provider.example/sub/..."
/>
</div>
</div>
)}
<div className="btn-group" style={{ marginTop: 16 }}>
{masked ? (
<>
<button className="btn btn-secondary" onClick={() => setEditing(true)} disabled={busy}>Изменить URL</button>
<button className="btn btn-secondary" onClick={onFetch} disabled={busy}> Обновить серверы</button>
<button className="btn btn-danger" onClick={onForget} disabled={busy}>Удалить подписку</button>
</>
) : (
<>
<button
className="btn btn-primary"
onClick={async () => { await onFetch(); setEditing(false); }}
disabled={busy || !subscriptionUrl}
>
{busy ? 'Загрузка…' : 'Загрузить серверы'}
</button>
{state?.hasSubscription && (
<button className="btn btn-ghost" onClick={() => setEditing(false)}>Отмена</button>
)}
</>
)}
</div>
</div>
);
}
function ConfigCard({ state, busy, onShowConfig, onClearConfig, pushToast }) {
const [validation, setValidation] = useState(null);
const [validating, setValidating] = useState(false);
async function validate() {
setValidating(true);
try {
const data = await api.configValidate();
setValidation(data);
pushToast({
kind: data.valid ? 'success' : 'danger',
title: data.valid ? 'Config валиден' : 'Config невалиден',
message: data.error || data.note,
});
} catch (err) {
pushToast({ kind: 'danger', title: 'Ошибка проверки', message: err.message });
} finally {
setValidating(false);
}
}
return (
<div className="card">
<div className="card-header">
<h2>sing-box config</h2>
{validation && (
<span className={`badge ${validation.valid ? 'success' : 'danger'}`}>
{validation.valid ? '✓ валиден' : '✗ ошибка'}
</span>
)}
</div>
<div className="kv-list">
<div className="row"><span className="key">Файл</span><span className="val">{state?.configExists ? 'есть' : 'нет'}</span></div>
<div className="row"><span className="key">Применено</span><span className="val">{state?.appliedAt ? formatRelative(state.appliedAt) : '—'}</span></div>
</div>
<div className="btn-group" style={{ marginTop: 16 }}>
<button className="btn btn-secondary" disabled={!state?.configExists} onClick={onShowConfig}>Показать config</button>
<button className="btn btn-secondary" disabled={validating || !state?.configExists} onClick={validate}>
{validating ? 'Проверяем…' : '✓ Валидировать'}
</button>
<button className="btn btn-danger" disabled={busy || !state?.configExists} onClick={onClearConfig}>
Сбросить config
</button>
</div>
{validation && !validation.valid && validation.error && (
<div className="conflict-banner danger" style={{ marginTop: 12 }}>
<span></span><div>{validation.error}</div>
</div>
)}
</div>
);
}
const CATALOG_CATEGORIES = ['Все', ...Array.from(new Set(RULE_SET_CATALOG.map((r) => r.category)))];
function CatalogEntry({ entry, added, busy, onAdd, onLookup }) {
const [open, setOpen] = useState(false);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '10px 14px', marginBottom: 8 }}>
<div className="flex" style={{ alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1 }}>
<div className="flex" style={{ alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<strong className="text-mono" style={{ fontSize: 13 }}>{entry.tag}</strong>
<span className="badge info" style={{ fontSize: 11 }}>{entry.category}</span>
<span className="muted" style={{ fontSize: 12 }}>{entry.source}</span>
{entry.builtIn && (
<span className="badge success" style={{ fontSize: 11 }} title="Загружается автоматически при включённом RU direct">встроен</span>
)}
</div>
<div style={{ fontSize: 13, marginTop: 2, color: 'var(--text)' }}>{entry.description}</div>
</div>
<div className="flex" style={{ gap: 6, flexShrink: 0 }}>
<button
className="btn btn-ghost sm"
onClick={() => setOpen((o) => !o)}
title="Примеры и подсказка"
>
{open ? '▲' : '▼'}
</button>
<button
className="btn btn-ghost sm"
onClick={() => onLookup(entry)}
title="Просмотреть содержимое и искать внутри"
>
🔍
</button>
<button
className="btn btn-secondary sm"
disabled={busy || added}
onClick={() => onAdd(entry)}
>
{added ? '✓ добавлен' : '+ Добавить'}
</button>
</div>
</div>
{open && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 12, marginBottom: 6 }}>
<span className="muted">Примеры содержимого: </span>
{entry.examples.map((ex, i) => (
<span key={i}>
<code style={{ background: 'var(--bg-muted)', borderRadius: 3, padding: '1px 5px', fontSize: 11 }}>{ex}</code>
{i < entry.examples.length - 1 ? ' ' : ''}
</span>
))}
</div>
<div style={{ fontSize: 12 }}>
<span className="muted">Рекомендуемый outbound: </span>
<span>{entry.use}</span>
</div>
</div>
)}
</div>
);
}
function SagerNetSearchCard({ ruleSets, onAdd, busy }) {
const [open, setOpen] = useState(false);
const [status, setStatus] = useState('idle'); // idle | loading | done | error
const [catalog, setCatalog] = useState(null); // { geosite, geoip, cachedAt }
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [repoFilter, setRepoFilter] = useState('all'); // all | geosite | geoip
function load() {
if (status !== 'idle') return;
setStatus('loading');
api.ruleSets.sagernetCatalog()
.then((d) => { setCatalog(d); setStatus('done'); })
.catch((err) => { setError(err.message); setStatus('error'); });
}
function toggle() {
if (!open && status === 'idle') load();
setOpen((o) => !o);
}
const results = useMemo(() => {
if (!catalog) return [];
const q = query.trim().toLowerCase();
const toItem = (repo) => (name) => ({ name, repo, url: `https://cdn.jsdelivr.net/gh/SagerNet/sing-${repo}@rule-set/${name}.srs` });
const gs = repoFilter !== 'geoip' ? (catalog.geosite || []).map(toItem('geosite')) : [];
const gi = repoFilter !== 'geosite' ? (catalog.geoip || []).map(toItem('geoip')) : [];
const all = [...gs, ...gi];
if (!q) return all;
return all.filter((item) => item.name.includes(q));
}, [catalog, query, repoFilter]);
const addedTags = new Set(ruleSets.map((rs) => rs.tag));
return (
<div className="card">
<div className="card-header" style={{ cursor: 'pointer' }} onClick={toggle}>
<h2>Поиск в каталоге SagerNet</h2>
<div className="flex" style={{ gap: 8, alignItems: 'center' }}>
{status === 'done' && catalog && (
<span className="badge info" style={{ fontSize: 11 }}>
{(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} rule-sets
</span>
)}
<span className="muted" style={{ fontSize: 13 }}>{open ? '▲' : '▼'}</span>
</div>
</div>
{open && (
<>
{status === 'loading' && (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>
Загрузка списка из GitHub
</div>
)}
{status === 'error' && (
<div className="conflict-banner danger" style={{ marginTop: 8 }}>
<span></span><div>{error}</div>
</div>
)}
{status === 'done' && (
<>
<small className="muted" style={{ display: 'block', marginBottom: 12 }}>
Полный список rule-sets из репозиториев <strong>SagerNet/sing-geosite</strong> и <strong>SagerNet/sing-geoip</strong>.
Ищите по имени: <code>steam</code>, <code>gaming</code>, <code>netflix</code>, <code>apple</code> и т.д.
Кеш обновляется раз в 24 ч.
</small>
{catalog.fallback && (
<div className="conflict-banner warning" style={{ marginBottom: 12 }}>
<span>!</span><div>{catalog.warning || 'Показан встроенный fallback-каталог.'}</div>
</div>
)}
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
className="input"
style={{ flex: 1, minWidth: 180 }}
placeholder="steam, gaming, netflix, cloudflare…"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
<select className="select" style={{ width: 130 }} value={repoFilter} onChange={(e) => setRepoFilter(e.target.value)}>
<option value="all">geosite + geoip</option>
<option value="geosite">только geosite</option>
<option value="geoip">только geoip</option>
</select>
</div>
{query.trim() === '' ? (
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>
Введите запрос покажем совпадения ({(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} доступно)
</div>
) : results.length === 0 ? (
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>Ничего не найдено</div>
) : (
<table className="table" style={{ fontSize: 13 }}>
<thead>
<tr>
<th style={{ width: 60 }}>Тип</th>
<th>Тег</th>
<th style={{ width: 120 }}></th>
</tr>
</thead>
<tbody>
{results.slice(0, 100).map((item) => (
<tr key={item.name}>
<td><span className={`badge ${item.repo === 'geosite' ? 'info' : ''}`} style={{ fontSize: 11 }}>{item.repo}</span></td>
<td className="text-mono">{item.name}</td>
<td style={{ textAlign: 'right' }}>
{addedTags.has(item.name) ? (
<span className="badge success" style={{ fontSize: 11 }}> добавлен</span>
) : (
<button
className="btn btn-secondary sm"
disabled={busy}
onClick={() => onAdd({ tag: item.name, url: item.url })}
>
+ Добавить
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{results.length > 100 && (
<div className="muted" style={{ fontSize: 12, marginTop: 8 }}>
Показано 100 из {results.length} уточните запрос
</div>
)}
<div className="muted" style={{ fontSize: 11, marginTop: 12 }}>
кеш: {catalog.cachedAt ? formatRelative(catalog.cachedAt) : '—'}
</div>
</>
)}
</>
)}
</div>
);
}
function RuleSetsCard({ pushToast }) {
const [ruleSets, setRuleSets] = useState([]);
const [newTag, setNewTag] = useState('');
const [newUrl, setNewUrl] = useState('');
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('Все');
const [lookup, setLookup] = useState(null); // { tag, url }
useEffect(() => {
api.ruleSets.get().then((d) => setRuleSets(d.ruleSets || [])).catch(() => {});
}, []);
async function save(next) {
setBusy(true);
try {
const data = await api.ruleSets.save(next);
setRuleSets(data.ruleSets || []);
pushToast({ kind: 'success', title: 'Rule-sets сохранены' });
} catch (err) {
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message });
} finally {
setBusy(false);
}
}
function addNew() {
const tag = newTag.trim();
const url = newUrl.trim();
if (!tag || !url) return;
if (!/^[a-z0-9][a-z0-9_.@!-]*$/i.test(tag)) {
pushToast({ kind: 'danger', title: 'Невалидный тег', message: 'Буквы, цифры и символы - _ . @ !' });
return;
}
if (ruleSets.some((rs) => rs.tag === tag)) {
pushToast({ kind: 'danger', title: 'Тег уже существует' });
return;
}
const next = [...ruleSets, { tag, url }];
setNewTag('');
setNewUrl('');
save(next);
}
function remove(tag) {
save(ruleSets.filter((rs) => rs.tag !== tag));
}
function addFromCatalog(entry) {
if (ruleSets.some((rs) => rs.tag === entry.tag)) {
pushToast({ kind: 'info', title: `${entry.tag} уже добавлен` });
return;
}
save([...ruleSets, { tag: entry.tag, url: entry.url }]);
}
const q = search.trim().toLowerCase();
const filtered = RULE_SET_CATALOG.filter((entry) => {
if (category !== 'Все' && entry.category !== category) return false;
if (!q) return true;
return (
entry.tag.includes(q) ||
entry.description.toLowerCase().includes(q) ||
entry.source.toLowerCase().includes(q) ||
entry.examples.some((ex) => ex.toLowerCase().includes(q))
);
});
return (
<>
<div className="card">
<div className="card-header">
<h2>Источники (rule-sets)</h2>
</div>
<small className="muted" style={{ display: 'block', marginBottom: 16 }}>
Geo-базы в формате <strong>.srs</strong> (sing-box). Sing-box скачает их автоматически при применении.
<strong> .dat файлы (v2ray) не поддерживаются</strong>.
</small>
{ruleSets.length > 0 && (
<>
<div className="field-label" style={{ marginBottom: 6 }}>Подключённые</div>
<table className="table" style={{ marginBottom: 20 }}>
<thead>
<tr>
<th>Тег</th>
<th>URL</th>
<th></th>
</tr>
</thead>
<tbody>
{ruleSets.map((rs) => (
<tr key={rs.tag}>
<td className="text-mono" style={{ whiteSpace: 'nowrap' }}>{rs.tag}</td>
<td className="muted" style={{ fontSize: 12, wordBreak: 'break-all' }}>{rs.url}</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn btn-ghost sm" style={{ marginRight: 4 }} onClick={() => setLookup(rs)} title="Просмотреть содержимое">🔍</button>
<button className="btn btn-ghost sm" disabled={busy} onClick={() => remove(rs.tag)}>×</button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
<div className="field-label" style={{ marginBottom: 8 }}>Каталог</div>
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
className="input"
style={{ flex: 1, minWidth: 180 }}
placeholder="Поиск: telegram, реклама, youtube…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select className="select" style={{ width: 140 }} value={category} onChange={(e) => setCategory(e.target.value)}>
{CATALOG_CATEGORIES.map((c) => <option key={c}>{c}</option>)}
</select>
</div>
{filtered.length === 0 && (
<div className="muted" style={{ fontSize: 13, marginBottom: 12 }}>Ничего не найдено</div>
)}
{filtered.map((entry) => (
<CatalogEntry
key={entry.tag}
entry={entry}
added={ruleSets.some((rs) => rs.tag === entry.tag)}
busy={busy}
onAdd={addFromCatalog}
onLookup={(e) => setLookup(e)}
/>
))}
<div className="field" style={{ marginTop: 16 }}>
<span className="field-label">Добавить свой .srs</span>
<div className="flex" style={{ gap: 8, flexWrap: 'wrap' }}>
<input
className="input"
style={{ width: 200 }}
placeholder="тег (напр. geosite-custom)"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
/>
<input
className="input"
style={{ flex: 1, minWidth: 200 }}
placeholder="https://…/rule-set.srs"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
/>
<button className="btn btn-primary" disabled={busy || !newTag || !newUrl} onClick={addNew}>
Добавить
</button>
</div>
</div>
</div>
<SagerNetSearchCard ruleSets={ruleSets} onAdd={addFromCatalog} busy={busy} />
{lookup && (
<RuleSetLookupModal
tag={lookup.tag}
url={lookup.url}
onClose={() => setLookup(null)}
/>
)}
</>
);
}
function PortsCard({ state }) {
const isClient = state?.mode === 'client';
return (
<div className="card">
<div className="card-header"><h2>{isClient ? 'Локальные порты' : 'Порты и маршруты'}</h2></div>
<div className="kv-list">
<div className="row"><span className="key">UI</span><span className="val text-mono">:{state?.port || 3456}</span></div>
<div className="row"><span className="key">HTTP/SOCKS proxy</span><span className="val text-mono">{isClient ? '127.0.0.1' : state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}</span></div>
{!isClient && <div className="row"><span className="key">TProxy</span><span className="val text-mono">:{state?.tproxyPort || 7895}</span></div>}
<div className="row"><span className="key">RU direct (geoip-ru)</span><span className="val">{state?.routingRuDirect ? 'включено' : 'выключено'}</span></div>
</div>
</div>
);
}
export function SettingsPage({
state, subscriptionUrl, setSubscriptionUrl, busy,
onFetchSubscription, onForgetSubscription, onShowConfig, onClearConfig, pushToast,
}) {
return (
<div className="section-stack">
<SubscriptionCard
state={state}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
busy={busy}
onFetch={onFetchSubscription}
onForget={onForgetSubscription}
pushToast={pushToast}
/>
<ConfigCard
state={state}
busy={busy}
onShowConfig={onShowConfig}
onClearConfig={onClearConfig}
pushToast={pushToast}
/>
<RuleSetsCard pushToast={pushToast} />
<PortsCard state={state} />
</div>
);
}

View File

@@ -1,37 +0,0 @@
import React from 'react';
const NAV = [
{ id: 'overview', label: 'Обзор', ico: '◉' },
{ id: 'servers', label: 'Серверы', ico: '⋆' },
{ id: 'routing', label: 'Маршрутизация', ico: '⇅' },
{ id: 'logs', label: 'Логи', ico: '≡' },
{ id: 'settings', label: 'Настройки', ico: '⚙' },
];
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
const items = mode === 'client'
? NAV.filter((item) => item.id !== 'routing')
: NAV;
return (
<nav className="sidebar">
{items.map((item) => {
const badge = badges[item.id];
return (
<button
key={item.id}
type="button"
className={`sidebar-item${active === item.id ? ' active' : ''}`}
onClick={() => onChange(item.id)}
>
<span className="ico">{item.ico}</span>
{item.label}
{badge && (
<span className={`badge ${badge.kind || ''}`}>{badge.text}</span>
)}
</button>
);
})}
</nav>
);
}

View File

@@ -1,91 +0,0 @@
import React from 'react';
import { formatBytes, formatRelative } from '../utils/format.js';
function StatusRow({ label, value, kind }) {
return (
<div className="row">
<span className="key">{label}</span>
<span className={`val ${kind ? 'text-' + kind : ''}`}>{value}</span>
</div>
);
}
export function StatusPane({ state, busy, onStop, onRestart, onShowConfig }) {
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : '∞'}`
: '—';
let singboxStatus = 'Остановлен';
let singboxKind = 'muted';
if (state?.singboxRunning) {
singboxStatus = `работает · ${formatRelative(state.singboxStartedAt)}`;
singboxKind = 'success';
} else if (state?.configExists) {
singboxStatus = 'остановлен (конфиг есть)';
singboxKind = 'warning';
}
return (
<aside className="status-pane">
<div className="card compact flat">
<div className="card-header no-margin">
<h3>sing-box</h3>
<span className={`badge ${state?.singboxRunning ? 'success' : 'neutral'}`}>
{state?.singboxRunning ? '● online' : '○ offline'}
</span>
</div>
<div className="kv-list" style={{ marginTop: 12 }}>
<StatusRow label="Статус" value={singboxStatus} kind={singboxKind} />
<StatusRow label="UI порт" value={`:${state?.port || 3456}`} />
<StatusRow label="Mixed proxy" value={`${state?.proxyBindIp || '0.0.0.0'}:${state?.proxyPort || 8080}`} />
<StatusRow label="TProxy" value={`:${state?.tproxyPort || 7895}`} />
<StatusRow label="RU direct" value={state?.routingRuDirect ? 'включено' : 'выключено'} />
<StatusRow label="Трафик" value={traffic} />
<StatusRow
label="Применено"
value={state?.appliedAt ? formatRelative(state.appliedAt) : 'не применено'}
/>
</div>
<div className="btn-group" style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
<button
className="btn btn-secondary sm block"
disabled={busy || !state?.configExists}
onClick={onRestart}
>
Перезапустить
</button>
<button
className="btn btn-ghost sm block"
disabled={busy || !state?.singboxRunning}
onClick={onStop}
>
Остановить
</button>
<button
className="btn btn-ghost sm block"
disabled={!state?.configExists}
onClick={onShowConfig}
>
Показать config
</button>
</div>
</div>
{state?.appliedHistory?.length > 0 && (
<div className="card compact flat">
<h4 style={{ marginBottom: 8 }}>История применений</h4>
<div className="events-list">
{state.appliedHistory.slice(0, 5).map((h) => (
<div key={h.at} className="event-row" style={{ gridTemplateColumns: '1fr auto' }}>
<span className="text-truncate">{h.tag}</span>
<span className="event-time">{formatRelative(h.at)}</span>
</div>
))}
</div>
</div>
)}
</aside>
);
}

View File

@@ -1,32 +0,0 @@
import React, { useEffect } from 'react';
export function Toasts({ items, onDismiss }) {
useEffect(() => {
const timers = items.map((t) =>
t.sticky ? null : setTimeout(() => onDismiss(t.id), t.duration || 4000),
);
return () => timers.forEach((t) => t && clearTimeout(t));
}, [items, onDismiss]);
if (!items.length) return null;
return (
<div className="toasts">
{items.map((t) => (
<div key={t.id} className={`toast ${t.kind || ''}`}>
<span className={`dot ${t.kind || ''}`} style={{ marginTop: 4 }} />
<div className="body">
<strong>{t.title}</strong>
{t.message && <small>{t.message}</small>}
{t.action && (
<button className="btn btn-link sm" onClick={t.action.onClick} style={{ marginTop: 4, padding: 0 }}>
{t.action.label}
</button>
)}
</div>
<button onClick={() => onDismiss(t.id)} title="Закрыть">×</button>
</div>
))}
</div>
);
}

View File

@@ -1,76 +0,0 @@
import React from 'react';
import { formatBytes, formatRelative } from '../utils/format.js';
import { flagFor } from '../utils/country.js';
function StatusBadge({ status }) {
const map = {
running: { dot: 'success', text: 'Работает', cls: '' },
applying: { dot: 'warning pulse', text: 'Применяем…', cls: '' },
error: { dot: 'danger', text: 'Ошибка', cls: '' },
stopped: { dot: '', text: 'Остановлен', cls: '' },
no_config: { dot: '', text: 'Не настроен', cls: '' },
};
const cfg = map[status] || map.stopped;
return (
<span className="flex">
<span className={`dot ${cfg.dot}`} />
<strong>{cfg.text}</strong>
</span>
);
}
export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApply }) {
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
: null;
const isClient = state?.mode === 'client';
return (
<header className="topbar">
<div className="topbar-brand">
<span className="logo-dot" />
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
</div>
<div className="topbar-status">
<StatusBadge status={status} />
{activeServer && (
<div className="status-text">
<strong>
{flagFor(activeServer)} {activeServer.tag}
</strong>
<small>
{activeServer.server}:{activeServer.server_port}
{state?.appliedAt ? ` · применено ${formatRelative(state.appliedAt)}` : ''}
</small>
</div>
)}
{!activeServer && (
<small className="muted">Сервер не выбран</small>
)}
{traffic && <span className="badge neutral">{traffic}</span>}
</div>
<div className="topbar-actions">
{!isClient && dirty && (
<span className="badge warning"> Несохранённые изменения</span>
)}
{!isClient && state?.previousTag && (
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
Откат
</button>
)}
<button
className="btn btn-secondary sm"
onClick={onRestart}
disabled={!state?.configExists}
title="Перезапустить sing-box"
>
Перезапуск
</button>
</div>
</header>
);
}

View File

@@ -1,843 +1,54 @@
/* Системные шрифты — без загрузки из интернета */
:root {
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, sans-serif;
--font-head: 'Segoe UI', system-ui, -apple-system, Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Cascadia Code', Consolas, 'Liberation Mono', Menlo, monospace;
color-scheme: dark;
/* Surfaces */
--bg: #06110d;
--surface: #0b1b14;
--surface-2: #10251b;
--surface-3: #163224;
--border: #214734;
--border-strong: #2c5d44;
/* Text */
--text: #effff5;
--muted: #9fbaaa;
--subtle: #6f8c7c;
/* Roles */
--success: #6dff9d;
--success-dim: rgba(109, 255, 157, 0.14);
--warning: #ffd166;
--warning-dim: rgba(255, 209, 102, 0.14);
--danger: #ff5c5c;
--danger-dim: rgba(255, 92, 92, 0.14);
--accent: #b7ff63;
--accent-dim: rgba(183, 255, 99, 0.14);
--info: #8ed4ff;
--info-dim: rgba(142, 212, 255, 0.14);
/* Radii */
--radius-card: 20px;
--radius-input: 12px;
--radius-pill: 999px;
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
/* Shadow */
--shadow-card: 0 12px 36px rgba(0, 0, 0, 0.32);
--shadow-modal: 0 32px 80px rgba(0, 0, 0, 0.6);
/* Layout */
--topbar-h: 64px;
--sidebar-w: 220px;
--status-w: 280px;
color-scheme: light dark;
}
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
}
button,
input {
font: inherit;
color: inherit;
}
h2,
p {
margin: 0;
}
.app {
min-height: 100vh;
display: grid;
}
.app-body {
min-height: 0;
}
.app-main {
min-width: 0;
}
.app-loading {
min-height: 100%;
min-height: 100vh;
display: grid;
place-items: center;
font: 700 16px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
color: light-dark(oklch(0.42 0.01 145), oklch(0.76 0.01 145));
background: light-dark(oklch(0.965 0.006 145), oklch(0.18 0.012 145));
color-scheme: light dark;
}
body {
margin: 0;
font-family: var(--font-ui);
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3, h4 {
font-family: var(--font-head);
margin: 0;
font-weight: 600;
letter-spacing: -0.01em;
}
h1 { font-size: 22px; }
h2 { font-size: 18px; }
h3 { font-size: 15px; color: var(--text); }
h4 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
p { margin: 0; }
small { font-size: 12px; color: var(--muted); }
code, .mono {
font-family: var(--font-mono);
font-size: 12px;
}
/* ============ Layout ============ */
.app {
min-height: 100vh;
display: grid;
grid-template-rows: var(--topbar-h) 1fr;
background:
radial-gradient(circle at 8% 0%, rgba(109, 255, 157, 0.05), transparent 32rem),
radial-gradient(circle at 92% 100%, rgba(142, 212, 255, 0.04), transparent 28rem),
var(--bg);
}
.app-body {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr var(--status-w);
min-height: 0;
}
.app-body.client-mode {
grid-template-columns: 1fr;
background:
radial-gradient(circle at 10% 0%, rgba(142, 212, 255, 0.08), transparent 28rem),
linear-gradient(180deg, #07110f 0%, #070d11 60%, #06090d 100%);
}
.app-main {
padding: var(--space-6);
overflow-y: auto;
min-width: 0;
}
@media (max-width: 1100px) {
.app-body { grid-template-columns: var(--sidebar-w) 1fr; }
.status-pane { display: none; }
}
@media (max-width: 768px) {
.app-body { grid-template-columns: 1fr; }
.app-body.client-mode { grid-template-columns: 1fr; }
.sidebar { display: none; }
.app-main { padding: var(--space-4); }
}
/* ============ Topbar ============ */
.topbar {
display: flex;
align-items: center;
gap: var(--space-4);
padding: 0 var(--space-6);
background: var(--surface);
border-bottom: 1px solid var(--border);
height: var(--topbar-h);
}
.topbar-brand {
font-family: var(--font-head);
font-weight: 700;
font-size: 16px;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: var(--space-2);
}
.topbar-brand .logo-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px var(--accent);
}
.topbar-status {
display: flex;
align-items: center;
gap: var(--space-3);
flex: 1;
min-width: 0;
}
.topbar-status .status-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.topbar-status .status-text strong { font-weight: 600; font-size: 13px; }
.topbar-status .status-text small { font-size: 11px; color: var(--subtle); }
.topbar-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* ============ Sidebar ============ */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
padding: var(--space-4) var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-1);
overflow-y: auto;
}
.sidebar-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 10px 12px;
border-radius: var(--radius-input);
color: var(--muted);
cursor: pointer;
border: 1px solid transparent;
background: transparent;
text-align: left;
font-weight: 500;
font-size: 14px;
transition: background 0.12s, color 0.12s;
}
.sidebar-item:hover {
background: var(--surface-2);
color: var(--text);
}
.sidebar-item.active {
background: var(--surface-2);
color: var(--text);
border-color: var(--border);
}
.sidebar-item .ico { width: 18px; opacity: 0.85; flex: 0 0 18px; }
.sidebar-item .badge {
margin-left: auto;
font-size: 11px;
padding: 2px 8px;
border-radius: var(--radius-pill);
background: var(--surface-3);
color: var(--muted);
}
.sidebar-item .badge.warn { background: var(--warning-dim); color: var(--warning); }
.sidebar-item .badge.danger { background: var(--danger-dim); color: var(--danger); }
/* ============ Status pane ============ */
.status-pane {
background: var(--surface);
border-left: 1px solid var(--border);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
overflow-y: auto;
}
/* ============ Cards & panels ============ */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-card);
padding: var(--space-5);
box-shadow: var(--shadow-card);
}
.card.compact { padding: var(--space-4); }
.card.flat { box-shadow: none; background: var(--surface-2); }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.card-header.no-margin { margin-bottom: 0; }
.section-stack {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
@media (max-width: 900px) {
.grid-2, .grid-3 { grid-template-columns: 1fr; }
}
/* ============ Buttons ============ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-input);
padding: 8px 14px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
background: transparent;
color: var(--text);
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
white-space: nowrap;
line-height: 1.2;
}
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn.lg { padding: 12px 18px; font-size: 14px; }
.btn.sm { padding: 6px 10px; font-size: 12px; }
.btn.block { width: 100%; }
.btn-primary {
background: var(--accent);
color: #061608;
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.05); }
.btn-secondary {
background: var(--surface-2);
border-color: var(--border);
color: var(--text);
}
.btn-secondary:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-strong); }
.btn-ghost {
background: transparent;
border-color: var(--border);
color: var(--muted);
}
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: var(--border-strong); }
.btn-danger {
background: transparent;
border-color: var(--danger);
color: var(--danger);
}
.btn-danger:hover:not(:disabled) { background: var(--danger-dim); }
.btn-link {
background: transparent;
border: none;
color: var(--accent);
padding: 4px 6px;
}
.btn-link:hover:not(:disabled) { text-decoration: underline; }
.btn-group {
display: inline-flex;
gap: var(--space-2);
flex-wrap: wrap;
}
/* ============ Inputs ============ */
.input, .select, .textarea {
display: block;
width: 100%;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: 9px 12px;
color: var(--text);
outline: none;
transition: border-color 0.12s, box-shadow 0.12s;
}
.input:focus, .select:focus, .textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.textarea { min-height: 80px; resize: vertical; font-family: var(--font-mono); font-size: 12px; }
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 12px;
color: var(--muted);
font-weight: 500;
}
.field-error { color: var(--danger); font-size: 11px; }
.field-hint { color: var(--subtle); font-size: 11px; }
.checkbox {
display: inline-flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
user-select: none;
font-size: 13px;
color: var(--muted);
}
.checkbox input { width: 14px; height: 14px; accent-color: var(--accent); }
/* ============ Badges & dots ============ */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 600;
background: var(--surface-3);
color: var(--muted);
border: 1px solid var(--border);
line-height: 1.2;
white-space: nowrap;
}
.badge.success { background: var(--success-dim); color: var(--success); border-color: rgba(109, 255, 157, 0.3); }
.badge.warning { background: var(--warning-dim); color: var(--warning); border-color: rgba(255, 209, 102, 0.3); }
.badge.danger { background: var(--danger-dim); color: var(--danger); border-color: rgba(255, 92, 92, 0.3); }
.badge.info { background: var(--info-dim); color: var(--info); border-color: rgba(142, 212, 255, 0.3); }
.badge.neutral { background: var(--surface-3); color: var(--muted); }
.dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--subtle);
flex: 0 0 8px;
}
.dot.success { background: var(--success); box-shadow: 0 0 8px var(--success); }
.dot.warning { background: var(--warning); box-shadow: 0 0 8px var(--warning); }
.dot.danger { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
.dot.info { background: var(--info); box-shadow: 0 0 8px var(--info); }
.pulse {
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ============ Lists & tables ============ */
.kv-list {
display: flex;
flex-direction: column;
}
.kv-list .row {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.kv-list .row:last-child { border-bottom: 0; }
.kv-list .key { color: var(--muted); }
.kv-list .val { color: var(--text); font-weight: 500; }
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th, .table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.table th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-weight: 600;
}
.table tbody tr { transition: background 0.1s; }
.table tbody tr:hover { background: var(--surface-2); }
.table tbody tr.active { background: var(--accent-dim); }
.table .row-actions { display: flex; gap: var(--space-2); justify-content: flex-end; }
/* ============ Tag input (chips) ============ */
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
min-height: 40px;
align-items: center;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--surface-3);
border: 1px solid var(--border);
border-radius: 8px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text);
}
.chip.error { border-color: var(--danger); color: var(--danger); }
.chip button {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
}
.chip button:hover { color: var(--danger); }
.chips .chip-input {
flex: 1;
min-width: 100px;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
padding: 4px 6px;
}
/* ============ Sticky action bar ============ */
.sticky-bar {
position: sticky;
bottom: 0;
margin: var(--space-6) calc(-1 * var(--space-6)) calc(-1 * var(--space-6));
padding: var(--space-3) var(--space-6);
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
z-index: 5;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.3);
}
/* ============ Drawer & modal ============ */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(2, 8, 5, 0.65);
backdrop-filter: blur(4px);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-modal);
width: min(720px, 100%);
max-height: 86vh;
display: flex;
flex-direction: column;
}
.modal.lg { width: min(960px, 100%); }
.modal-head, .modal-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.modal-foot { border-top: 1px solid var(--border); border-bottom: 0; }
.modal-body { padding: var(--space-5); overflow-y: auto; flex: 1; }
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(2, 8, 5, 0.55);
backdrop-filter: blur(3px);
z-index: 90;
}
.drawer {
position: fixed;
top: 0; right: 0; bottom: 0;
width: min(540px, 100%);
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: var(--shadow-modal);
z-index: 91;
display: flex;
flex-direction: column;
}
.drawer-head, .drawer-foot {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.drawer-foot { border-top: 1px solid var(--border); border-bottom: 0; }
.drawer-body { padding: var(--space-5); overflow-y: auto; flex: 1; display: flex; flex-direction: column; gap: var(--space-4); }
/* ============ Toasts ============ */
.toasts {
position: fixed;
top: calc(var(--topbar-h) + 12px);
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 200;
max-width: 360px;
}
.toast {
background: var(--surface-3);
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: 12px 14px;
display: flex;
gap: 10px;
align-items: flex-start;
box-shadow: var(--shadow-card);
font-size: 13px;
}
.toast.success { border-color: rgba(109, 255, 157, 0.3); }
.toast.danger { border-color: rgba(255, 92, 92, 0.3); }
.toast.warning { border-color: rgba(255, 209, 102, 0.3); }
.toast .body { flex: 1; }
.toast .body small { display: block; color: var(--muted); margin-top: 2px; }
.toast button { background: none; border: none; color: var(--muted); cursor: pointer; }
/* ============ Logs ============ */
.logs-stream {
background: #02080a;
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: var(--space-3);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.55;
overflow-y: auto;
flex: 1;
min-height: 320px;
}
.log-line {
display: grid;
grid-template-columns: 70px 60px 1fr;
gap: 10px;
padding: 2px 4px;
border-radius: 4px;
word-break: break-word;
}
.log-line:hover { background: var(--surface-2); }
.log-line .log-time { color: var(--subtle); }
.log-line .log-level { font-size: 10px; text-transform: uppercase; padding-top: 2px; font-weight: 600; }
.log-line.info .log-level { color: var(--info); }
.log-line.warning .log-level { color: var(--warning); }
.log-line.error .log-level { color: var(--danger); }
.log-line.debug .log-level { color: var(--subtle); }
.log-line .log-text { color: var(--text); }
.log-group {
display: grid;
grid-template-columns: 70px 60px 1fr auto;
gap: 10px;
padding: 4px;
border-radius: 4px;
background: var(--surface-2);
margin-bottom: 4px;
align-items: center;
}
.log-group .repeat { color: var(--warning); font-size: 11px; }
/* ============ Misc ============ */
.empty-state {
text-align: center;
padding: var(--space-8) var(--space-4);
color: var(--muted);
}
.empty-state h3 { margin-bottom: var(--space-2); color: var(--text); }
.flex { display: flex; gap: var(--space-3); align-items: center; }
.flex-col { display: flex; flex-direction: column; gap: var(--space-3); }
.flex-between { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1; min-width: 0; }
.spacer { flex: 1; }
.muted { color: var(--muted); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-danger { color: var(--danger); }
.text-mono { font-family: var(--font-mono); }
.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.divider {
height: 1px;
background: var(--border);
margin: var(--space-3) 0;
}
.config-view {
background: #02080a;
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: var(--space-3);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
margin: 0;
color: var(--text);
overflow: auto;
max-height: 60vh;
white-space: pre;
}
/* drag handle for rules sortable */
.drag-handle {
cursor: grab;
user-select: none;
padding: 6px 8px;
border-radius: 6px;
color: var(--subtle);
font-family: var(--font-mono);
font-size: 14px;
}
.drag-handle:hover { color: var(--text); background: var(--surface-2); }
.drag-handle:active { cursor: grabbing; }
.rule-row.disabled { opacity: 0.5; }
.rule-row.invalid td:first-child { box-shadow: inset 3px 0 0 var(--danger); }
.conflict-banner {
background: var(--warning-dim);
border: 1px solid rgba(255, 209, 102, 0.3);
color: var(--warning);
padding: 10px 14px;
border-radius: var(--radius-input);
font-size: 12px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.conflict-banner.danger { background: var(--danger-dim); border-color: rgba(255, 92, 92, 0.3); color: var(--danger); }
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-3);
}
.template-card {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: 6px;
}
.template-card h4 { color: var(--text); text-transform: none; letter-spacing: 0; font-size: 13px; }
.template-card small { color: var(--subtle); font-size: 11px; line-height: 1.4; }
.events-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.event-row {
display: grid;
grid-template-columns: 16px 60px 1fr;
gap: 10px;
align-items: center;
padding: 6px 8px;
border-radius: 8px;
font-size: 12px;
}
.event-row:hover { background: var(--surface-2); }
.event-row .event-time { color: var(--subtle); font-family: var(--font-mono); font-size: 11px; }
.route-result {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-4);
}
.tab {
padding: 8px 14px;
background: transparent;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 13px;
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.tab:hover { color: var(--text); }
.filter-bar {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
align-items: center;
}
.filter-bar .input, .filter-bar .select { width: auto; min-width: 140px; }
.subscription-input {
display: flex;
gap: var(--space-2);
align-items: stretch;
}
.subscription-input .input { flex: 1; }
/* ============ Client overview ============ */
.app.client-app {
@@ -1037,6 +248,7 @@ code, .mono {
.client-form {
position: absolute;
left: calc(50% + 130px);
min-width: 0;
width: min(360px, calc(50% - 154px));
display: grid;
gap: 36px;
@@ -1046,6 +258,7 @@ code, .mono {
.client-subscription {
position: relative;
min-width: 0;
min-height: 88px;
display: grid;
align-items: center;
@@ -1246,6 +459,9 @@ code, .mono {
}
.client-subscription-domain-button {
width: 100%;
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
@@ -1255,6 +471,8 @@ code, .mono {
}
.client-subscription-summary {
min-width: 0;
max-width: 100%;
min-height: 88px;
display: flex;
flex-direction: column;
@@ -1265,6 +483,7 @@ code, .mono {
color: var(--client-muted);
text-align: center;
cursor: pointer;
overflow: hidden;
}
.client-subscription.is-editing .client-subscription-summary {
@@ -1281,6 +500,8 @@ code, .mono {
}
.client-subscription-domain-button strong {
display: block;
overflow: hidden;
color: var(--client-text);
font-size: 28px;
font-weight: 600;
@@ -1288,6 +509,8 @@ code, .mono {
opacity: 0.88;
text-shadow: 0 0 20px oklch(0.62 0.05 151 / 0.24);
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
text-overflow: ellipsis;
white-space: nowrap;
}
.client-subscription-domain-button:hover strong {
@@ -1520,12 +743,33 @@ code, .mono {
text-shadow: 0 0 12px oklch(0.62 0.05 151 / 0.12);
}
.client-proxies > div {
.client-access-point {
display: grid;
justify-items: center;
gap: 5px;
}
.client-access-point + .client-access-point {
margin-top: 12px;
}
.client-proxy-actions {
display: flex;
gap: 8px;
margin-top: 7px;
}
.client-error {
position: absolute;
bottom: 0;
left: 50%;
width: min(420px, 90vw);
color: oklch(0.62 0.2 28);
font: 600 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-align: center;
transform: translateX(-50%);
}
.client-proxies button {
position: relative;
width: 86px;
@@ -1648,11 +892,3 @@ code, .mono {
animation: none;
}
}
/* For drawer rule editor */
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
@media (max-width: 600px) { .field-row { grid-template-columns: 1fr; } }

View File

@@ -1,127 +0,0 @@
// Готовые шаблоны правил роутинга. domains/suffixes/cidr/ports собраны из публичных
// reference-конфигов sing-box. Это пресеты «на старт», а не исчерпывающие списки.
let counter = 0;
function id(prefix) {
counter += 1;
return `${prefix}-${Date.now()}-${counter}`;
}
function template(name, outbound, fields) {
return {
id: id("tpl"),
name,
enabled: true,
outbound,
domains: [],
domainSuffixes: [],
domainKeywords: [],
ipCidrs: [],
ports: [],
networks: [],
...fields,
};
}
export const ruleTemplates = [
{
key: "lol-direct",
label: "League of Legends → direct",
description: "Riot/LoL домены и порты — играть напрямую без VPN.",
build: () =>
template("League of Legends", "direct", {
domainSuffixes: [
"leagueoflegends.com",
"riotgames.com",
"riotcdn.net",
"dyn.riotcdn.net",
],
ports: ["5000", "5223", "5222", "8088"],
}),
},
{
key: "discord-direct",
label: "Discord/Vesktop → direct",
description: "Discord voice/video и WebSocket напрямую.",
build: () =>
template("Discord", "direct", {
domainSuffixes: [
"discord.com",
"discord.gg",
"discord.media",
"discordapp.com",
"discordapp.net",
],
ports: ["50000-65535"],
networks: ["udp"],
}),
},
{
key: "telegram-vpn",
label: "Telegram → VPN",
description: "Telegram через выбранный VPN outbound.",
build: () =>
template("Telegram", "vpn", {
domainSuffixes: [
"telegram.org",
"t.me",
"telegram.me",
"telegra.ph",
"tdesktop.com",
],
ipCidrs: [
"149.154.160.0/20",
"91.108.4.0/22",
"91.108.8.0/22",
"91.108.12.0/22",
"91.108.16.0/22",
"91.108.56.0/22",
],
}),
},
{
key: "youtube-vpn",
label: "YouTube → VPN",
description: "YouTube/Google Video через VPN.",
build: () =>
template("YouTube", "vpn", {
domainSuffixes: [
"youtube.com",
"youtu.be",
"ytimg.com",
"googlevideo.com",
"youtube-nocookie.com",
],
}),
},
{
key: "steam-direct",
label: "Steam → direct",
description: "Загрузка/обновления Steam напрямую.",
build: () =>
template("Steam", "direct", {
domainSuffixes: [
"steampowered.com",
"steamcontent.com",
"steamcommunity.com",
"steamserver.net",
"cm.steampowered.com",
],
}),
},
{
key: "ads-block",
label: "Реклама → block",
description: "Базовый набор рекламных доменов — заблокировать.",
build: () =>
template("Реклама (block)", "block", {
domainSuffixes: [
"doubleclick.net",
"googlesyndication.com",
"googleadservices.com",
"adservice.google.com",
"adnxs.com",
],
}),
},
];

View File

@@ -28,10 +28,11 @@ export function subscriptionDomain(subscriptionHost) {
}
}
export function localProxyUrls(port = 8082) {
export function localProxyUrls(port = 8082, host = '127.0.0.1') {
const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
return {
socks5: `socks5://127.0.0.1:${port}`,
http: `http://127.0.0.1:${port}`,
socks5: `socks5://${urlHost}:${port}`,
http: `http://${urlHost}:${port}`,
};
}

View File

@@ -1,37 +0,0 @@
// Грубое определение страны по тегу сервера и/или хосту.
// Это эвристика — мы не делаем GeoIP-lookup.
const COUNTRIES = [
{ re: /\b(ru|россия|russia|moscow|spb)\b/i, code: "RU", flag: "🇷🇺" },
{ re: /\b(de|germany|frankfurt|berlin|deu)\b/i, code: "DE", flag: "🇩🇪" },
{ re: /\b(nl|netherlands|amsterdam|holland)\b/i, code: "NL", flag: "🇳🇱" },
{
re: /\b(us|usa|america|new[-_ ]?york|chicago|miami)\b/i,
code: "US",
flag: "🇺🇸",
},
{ re: /\b(uk|britain|london|england)\b/i, code: "GB", flag: "🇬🇧" },
{ re: /\b(fr|france|paris)\b/i, code: "FR", flag: "🇫🇷" },
{ re: /\b(jp|japan|tokyo)\b/i, code: "JP", flag: "🇯🇵" },
{ re: /\b(sg|singapore)\b/i, code: "SG", flag: "🇸🇬" },
{ re: /\b(hk|hongkong|hong[-_ ]?kong)\b/i, code: "HK", flag: "🇭🇰" },
{ re: /\b(fi|finland|helsinki)\b/i, code: "FI", flag: "🇫🇮" },
{ re: /\b(se|sweden|stockholm)\b/i, code: "SE", flag: "🇸🇪" },
{ re: /\b(pl|poland|warsaw)\b/i, code: "PL", flag: "🇵🇱" },
{ re: /\b(tr|turkey|istanbul)\b/i, code: "TR", flag: "🇹🇷" },
{ re: /\b(ua|ukraine|kiev|kyiv)\b/i, code: "UA", flag: "🇺🇦" },
];
export function detectCountry(...inputs) {
const text = inputs.filter(Boolean).join(" ").toLowerCase();
for (const c of COUNTRIES) {
if (c.re.test(text)) return c;
}
return null;
}
export function flagFor(server) {
if (!server) return "";
const detected = detectCountry(server.tag, server.server);
return detected?.flag || "🌐";
}

View File

@@ -1,56 +0,0 @@
// Простые валидаторы для полей правил роутинга. Возвращают массив ошибочных строк.
const IPV4 =
/^((25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(25[0-5]|2[0-4]\d|[01]?\d?\d)$/;
const IPV6 = /^[0-9a-f:]+$/i;
const DOMAIN =
/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
export function invalidCidrs(values) {
return (values || []).filter((value) => !isValidCidr(value));
}
export function isValidCidr(value) {
const trimmed = String(value || "").trim();
if (!trimmed) return false;
const [addr, mask] = trimmed.split("/");
if (!addr) return false;
if (IPV4.test(addr)) {
if (mask === undefined) return true;
const m = Number(mask);
return Number.isInteger(m) && m >= 0 && m <= 32;
}
if (IPV6.test(addr) && addr.includes(":")) {
if (mask === undefined) return true;
const m = Number(mask);
return Number.isInteger(m) && m >= 0 && m <= 128;
}
return false;
}
export function invalidPorts(values) {
return (values || []).filter((value) => !isValidPort(value));
}
export function isValidPort(value) {
const n = Number.parseInt(String(value).trim(), 10);
return Number.isInteger(n) && n > 0 && n <= 65535;
}
export function invalidDomains(values) {
return (values || []).filter((value) => !DOMAIN.test(String(value).trim()));
}
export function ruleErrors(rule) {
return {
domains: invalidDomains(rule.domains),
domainSuffixes: invalidDomains(rule.domainSuffixes),
ipCidrs: invalidCidrs(rule.ipCidrs),
ports: invalidPorts(rule.ports),
};
}
export function hasErrors(errors) {
return Object.values(errors).some((arr) => arr.length > 0);
}