From 8476ab16e50b11e268e4ede8c8db95bb589b4c5f Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 8 May 2026 19:31:49 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Создан компонент RuleEditorDrawer для редактирования правил с поддержкой JSON. - Добавлен компонент ServersPage для отображения и управления серверами. - Реализован компонент SettingsPage для управления подписками и конфигурациями. - Создан компонент Sidebar для навигации по приложению. - Добавлен компонент StatusPane для отображения статуса сервера. - Реализован компонент Toasts для отображения уведомлений. - Создан компонент Topbar для отображения информации о текущем состоянии. - Добавлен модуль country.js для определения страны по тегу сервера. Refs: None --- src/server/config.js | 3 +- src/server/index.js | 127 ++- src/server/ping.js | 44 + src/server/routeMatcher.js | 222 ++++ src/web/App.jsx | 421 +++++--- src/web/api.js | 25 + src/web/components/ChipsInput.jsx | 61 ++ src/web/components/ConfigViewer.jsx | 86 +- src/web/components/LogsPage.jsx | 136 +++ src/web/components/LogsPanel.jsx | 75 -- src/web/components/OverviewPage.jsx | 159 +++ src/web/components/RouteChecker.jsx | 74 ++ src/web/components/RoutingPage.jsx | 223 ++++ src/web/components/RuleCard.jsx | 127 --- src/web/components/RuleEditorDrawer.jsx | 195 ++++ src/web/components/RulesPanel.jsx | 112 -- src/web/components/RuntimePanel.jsx | 86 -- src/web/components/ServerList.jsx | 32 - src/web/components/ServersPage.jsx | 210 ++++ src/web/components/SettingsPage.jsx | 171 +++ src/web/components/Sidebar.jsx | 33 + src/web/components/StatusPane.jsx | 91 ++ src/web/components/SubscriptionPanel.jsx | 58 - src/web/components/Toasts.jsx | 32 + src/web/components/Topbar.jsx | 74 ++ src/web/styles.css | 1243 ++++++++++++++-------- src/web/utils/country.js | 33 + 27 files changed, 3014 insertions(+), 1139 deletions(-) create mode 100644 src/server/ping.js create mode 100644 src/server/routeMatcher.js create mode 100644 src/web/components/ChipsInput.jsx create mode 100644 src/web/components/LogsPage.jsx delete mode 100644 src/web/components/LogsPanel.jsx create mode 100644 src/web/components/OverviewPage.jsx create mode 100644 src/web/components/RouteChecker.jsx create mode 100644 src/web/components/RoutingPage.jsx delete mode 100644 src/web/components/RuleCard.jsx create mode 100644 src/web/components/RuleEditorDrawer.jsx delete mode 100644 src/web/components/RulesPanel.jsx delete mode 100644 src/web/components/RuntimePanel.jsx delete mode 100644 src/web/components/ServerList.jsx create mode 100644 src/web/components/ServersPage.jsx create mode 100644 src/web/components/SettingsPage.jsx create mode 100644 src/web/components/Sidebar.jsx create mode 100644 src/web/components/StatusPane.jsx delete mode 100644 src/web/components/SubscriptionPanel.jsx create mode 100644 src/web/components/Toasts.jsx create mode 100644 src/web/components/Topbar.jsx create mode 100644 src/web/utils/country.js diff --git a/src/server/config.js b/src/server/config.js index f5bd90e..3a2387c 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -9,7 +9,8 @@ export const settings = { bindIp: process.env.PROXY_BIND_IP || "127.0.0.1", dataDir, distDir: process.env.DIST_DIR || "/app/dist", - configPath: process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"), + configPath: + process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"), cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db", statePath: path.join(dataDir, "state.json"), customRulesPath: path.join(dataDir, "custom-rules.json"), diff --git a/src/server/index.js b/src/server/index.js index 848e74a..4b4cb68 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -10,6 +10,10 @@ import { readSingboxConfig, removeSingboxConfig, } from "./singbox.js"; +import { matchRoute, detectRuleConflicts } from "./routeMatcher.js"; +import { tcpPing, resolveHost } from "./ping.js"; + +const APPLY_HISTORY_LIMIT = 10; fs.mkdirSync(settings.dataDir, { recursive: true }); @@ -32,7 +36,8 @@ function pushLog(level, line) { // Sing-box пишет все логи в stderr, поэтому парсим уровень из содержимого строки. // Формат: ESC[m LEVEL ESC[0m, где ESC = \x1b -const SINGBOX_LEVEL_RE = /\x1b\[\d+m(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\x1b\[0m/i; +const SINGBOX_LEVEL_RE = + /\x1b\[\d+m(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\x1b\[0m/i; function parseSingboxLevel(line, fallback) { const m = line.match(SINGBOX_LEVEL_RE); if (!m) return fallback; @@ -192,6 +197,9 @@ function publicState() { subscriptionHost: maskSubscriptionUrl(subscriptionUrl), hasSubscription: Boolean(subscriptionUrl), customRules, + appliedHistory: state.appliedHistory || [], + rulesUpdatedAt: state.rulesUpdatedAt || null, + rulesAppliedAt: state.rulesAppliedAt || null, ...rest, }; } @@ -241,10 +249,18 @@ async function applySelectedServer(selectedTag) { await startSingbox(); const prevState = readJson(settings.statePath, {}); + const now = new Date().toISOString(); + const previousTag = prevState.selectedTag && prevState.selectedTag !== selectedTag ? prevState.selectedTag : prevState.previousTag || null; + const history = Array.isArray(prevState.appliedHistory) ? prevState.appliedHistory : []; + const nextHistory = [{ tag: selectedTag, at: now }, ...history.filter((h) => h.tag !== selectedTag)].slice(0, APPLY_HISTORY_LIMIT); + writeJson(settings.statePath, { ...prevState, selectedTag, - appliedAt: new Date().toISOString(), + previousTag, + appliedAt: now, + rulesAppliedAt: now, + appliedHistory: nextHistory, }); } @@ -306,9 +322,116 @@ async function handleApi(req, res) { const body = await readBody(req); const rules = normalizeCustomRules(body.rules); writeJson(settings.customRulesPath, rules); + const prevState = readJson(settings.statePath, {}); + writeJson(settings.statePath, { + ...prevState, + rulesUpdatedAt: new Date().toISOString(), + }); return sendJson(res, 200, { success: true, rules }); } + if (req.method === "GET" && req.url === "/api/rules/conflicts") { + const rules = readJson(settings.customRulesPath, []); + return sendJson(res, 200, { success: true, conflicts: detectRuleConflicts(rules) }); + } + + if (req.method === "POST" && req.url === "/api/route/check") { + const body = await readBody(req); + const host = String(body.host || "").trim(); + let ip = String(body.ip || "").trim(); + const port = body.port !== undefined && body.port !== "" ? Number(body.port) : undefined; + const network = String(body.network || "").trim() || undefined; + + if (!host && !ip) { + return sendJson(res, 400, { success: false, error: "Укажите домен или IP" }); + } + + let resolvedFrom = null; + if (!ip && host) { + const resolved = await resolveHost(host); + if (resolved) { + ip = resolved; + resolvedFrom = host; + } + } + + const rules = readJson(settings.customRulesPath, []); + const cached = readJson(settings.subscriptionCachePath, null); + const state = readJson(settings.statePath, {}); + const vpnTag = state.selectedTag || "vpn-out"; + const result = matchRoute( + { host, ip, port, network }, + rules, + { routingRuDirect: settings.routingRuDirect, vpnTag }, + ); + + return sendJson(res, 200, { success: true, result, resolvedIp: ip || null, resolvedFrom }); + } + + if (req.method === "POST" && req.url === "/api/servers/ping") { + const body = await readBody(req); + const host = String(body.host || "").trim(); + const port = Number(body.port); + if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) { + return sendJson(res, 400, { success: false, error: "Требуются host и port" }); + } + const result = await tcpPing(host, port, Number(body.timeout) || 3000); + return sendJson(res, 200, { success: true, ...result }); + } + + if (req.method === "POST" && req.url === "/api/servers/ping-all") { + const cached = readJson(settings.subscriptionCachePath, null); + const state = readJson(settings.statePath, {}); + const servers = state.servers || cached?.servers || []; + const results = await Promise.all( + servers.map(async (server) => { + const ping = await tcpPing(server.server, server.server_port, 3000); + return { tag: server.tag, ...ping, checkedAt: new Date().toISOString() }; + }), + ); + return sendJson(res, 200, { success: true, results }); + } + + if (req.method === "POST" && req.url === "/api/config/validate") { + const cached = readJson(settings.subscriptionCachePath, null); + const customRules = readJson(settings.customRulesPath, []); + const stateData = readJson(settings.statePath, {}); + const tag = stateData.selectedTag; + + if (!cached?.config) { + return sendJson(res, 200, { success: true, valid: false, error: "Подписка не загружена" }); + } + if (!tag) { + return sendJson(res, 200, { success: true, valid: false, error: "Сервер не выбран" }); + } + + try { + buildGatewayConfig({ ...cached.config, customRules }, tag); + } catch (err) { + return sendJson(res, 200, { success: true, valid: false, error: err.message }); + } + + if (fs.existsSync(settings.configPath)) { + try { + checkSingboxConfig(); + return sendJson(res, 200, { success: true, valid: true }); + } catch (err) { + return sendJson(res, 200, { success: true, valid: false, error: err.message }); + } + } + return sendJson(res, 200, { success: true, valid: true, note: "Конфиг собирается без ошибок (sing-box check не выполнен — нет файла)" }); + } + + if (req.method === "POST" && req.url === "/api/apply/rollback") { + const stateData = readJson(settings.statePath, {}); + const target = stateData.previousTag; + if (!target) { + return sendJson(res, 400, { success: false, error: "Нет предыдущего сервера для отката" }); + } + await applySelectedServer(target); + return sendJson(res, 200, { success: true, selectedTag: target }); + } + if (req.method === "POST" && req.url === "/api/subscription/fetch") { const body = await readBody(req); const url = String(body.url || "").trim(); diff --git a/src/server/ping.js b/src/server/ping.js new file mode 100644 index 0000000..2705f7f --- /dev/null +++ b/src/server/ping.js @@ -0,0 +1,44 @@ +// TCP-пинг: меряем время до открытия TCP-соединения с хостом:портом. +// Это не ICMP-ping, но для VPN-серверов точнее (проверяем именно тот порт, куда подключается клиент). + +import net from "node:net"; +import dns from "node:dns/promises"; + +const DEFAULT_TIMEOUT = 3000; + +export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) { + const start = Date.now(); + return new Promise((resolve) => { + const socket = new net.Socket(); + let done = false; + + const finish = (result) => { + if (done) return; + done = true; + socket.removeAllListeners(); + socket.destroy(); + resolve(result); + }; + + socket.setTimeout(timeout); + socket.once("connect", () => finish({ ok: true, latency: Date.now() - start })); + socket.once("timeout", () => finish({ ok: false, latency: null, error: "timeout" })); + socket.once("error", (err) => finish({ ok: false, latency: null, error: err.code || err.message })); + + try { + socket.connect(port, host); + } catch (err) { + finish({ ok: false, latency: null, error: err.message }); + } + }); +} + +export async function resolveHost(host) { + if (net.isIP(host)) return host; + try { + const result = await dns.lookup(host); + return result.address; + } catch { + return null; + } +} diff --git a/src/server/routeMatcher.js b/src/server/routeMatcher.js new file mode 100644 index 0000000..5a64826 --- /dev/null +++ b/src/server/routeMatcher.js @@ -0,0 +1,222 @@ +// Простой симулятор роутинга sing-box. +// Берём список customRules + safety/RU-direct и определяем, какое правило сработает. +// Для geoip-ru / geosite-category-ru возвращаем "может сработать" — без скачанного ruleset +// мы не можем точно сказать, попадает ли IP/домен в RU. + +import net from "node:net"; + +function ipv4ToInt(ip) { + const parts = ip.split(".").map((x) => Number.parseInt(x, 10)); + if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null; + return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; +} + +function ipInCidr(ip, cidr) { + if (!net.isIP(ip)) return false; + const [addr, maskStr] = String(cidr).split("/"); + if (!addr) return false; + + if (net.isIPv4(ip) && net.isIPv4(addr)) { + const mask = maskStr === undefined ? 32 : Number.parseInt(maskStr, 10); + if (!Number.isInteger(mask) || mask < 0 || mask > 32) return false; + const ipInt = ipv4ToInt(ip); + const cidrInt = ipv4ToInt(addr); + if (ipInt === null || cidrInt === null) return false; + if (mask === 0) return true; + const m = (~0 << (32 - mask)) >>> 0; + return (ipInt & m) === (cidrInt & m); + } + // IPv6 — упрощённо: точное сравнение строк (без полноценной обработки) + return false; +} + +const PRIVATE_CIDRS = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16"]; + +function isPrivateIp(ip) { + if (!ip) return false; + return PRIVATE_CIDRS.some((cidr) => ipInCidr(ip, cidr)); +} + +function hostMatchesDomain(host, domain) { + if (!host || !domain) return false; + return host.toLowerCase() === domain.toLowerCase(); +} + +function hostMatchesSuffix(host, suffix) { + if (!host || !suffix) return false; + const h = host.toLowerCase(); + const s = suffix.toLowerCase(); + return h === s || h.endsWith("." + s) || h.endsWith(s); +} + +function hostMatchesKeyword(host, keyword) { + if (!host || !keyword) return false; + return host.toLowerCase().includes(keyword.toLowerCase()); +} + +function ruleMatches(rule, target) { + const { host = "", ip = "", port, network } = target; + + if (!rule?.enabled) return false; + + const checks = []; + + if (rule.domains?.length) { + checks.push(rule.domains.some((d) => hostMatchesDomain(host, d))); + } + if (rule.domainSuffixes?.length) { + checks.push(rule.domainSuffixes.some((d) => hostMatchesSuffix(host, d))); + } + if (rule.domainKeywords?.length) { + checks.push(rule.domainKeywords.some((d) => hostMatchesKeyword(host, d))); + } + if (rule.ipCidrs?.length) { + if (!ip) return false; + checks.push(rule.ipCidrs.some((cidr) => ipInCidr(ip, cidr))); + } + if (rule.ports?.length) { + if (port === undefined || port === null || port === "") return false; + const p = Number(port); + checks.push( + rule.ports.some((portStr) => { + const s = String(portStr).trim(); + if (s.includes("-")) { + const [from, to] = s.split("-").map((x) => Number(x)); + return p >= from && p <= to; + } + return p === Number(s); + }), + ); + } + if (rule.networks?.length) { + if (!network) return false; + checks.push(rule.networks.includes(network)); + } + + if (!checks.length) return false; + return checks.every(Boolean); +} + +/** + * Симулирует роутинг и возвращает результат. + * @param {object} target { host, ip, port, network } + * @param {Array} customRules + * @param {object} options { routingRuDirect, vpnTag } + */ +export function matchRoute(target, customRules, options = {}) { + const { routingRuDirect = true, vpnTag = "vpn-out" } = options; + const rules = Array.isArray(customRules) ? customRules : []; + + // 1. private IP → direct + if (target.ip && isPrivateIp(target.ip)) { + return { + matched: "system", + ruleIndex: -1, + ruleName: "private IP → direct", + outbound: "direct", + reason: `IP ${target.ip} приватный`, + }; + } + + // 2. custom rules (first match wins) + for (let i = 0; i < rules.length; i += 1) { + const rule = rules[i]; + if (ruleMatches(rule, target)) { + const outbound = rule.outbound === "vpn" ? `${vpnTag} (VPN)` : rule.outbound; + return { + matched: "custom", + ruleIndex: i, + ruleId: rule.id, + ruleName: rule.name, + outbound, + reason: "Совпадение по custom-правилу", + }; + } + } + + // 3. RU direct (geoip/geosite) — мы не знаем точно, скажем "может сработать" + if (routingRuDirect) { + return { + matched: "fallback-ru-or-vpn", + ruleIndex: -2, + ruleName: "geoip-ru / geosite-category-ru → direct, иначе VPN", + outbound: `direct или ${vpnTag}`, + reason: + "Если домен/IP попадает в geoip-ru или geosite-category-ru — direct; иначе — VPN. Без локальной базы точно не определить.", + }; + } + + // 4. final → VPN + return { + matched: "final", + ruleIndex: -3, + ruleName: "final", + outbound: vpnTag, + reason: "Не сработало ни одно правило — пойдёт через VPN", + }; +} + +/** + * Детектор конфликтов: ищет правила, перекрытые предыдущими. + * Простая эвристика: если правило-кандидат полностью перекрывается ранее идущим + * по доменам/суффиксам/CIDR — отмечаем конфликт. + */ +export function detectRuleConflicts(rules) { + const list = Array.isArray(rules) ? rules : []; + const conflicts = []; + + for (let i = 1; i < list.length; i += 1) { + const cur = list[i]; + if (!cur?.enabled) continue; + + for (let j = 0; j < i; j += 1) { + const prev = list[j]; + if (!prev?.enabled) continue; + + // Если outbound одинаковый — это не "конфликт", это дубликат + const sameOutbound = prev.outbound === cur.outbound; + + // Проверка перекрытия доменов + const overlaps = []; + + // Точные домены покрываются prev.suffix + for (const d of cur.domains || []) { + if ((prev.domainSuffixes || []).some((s) => hostMatchesSuffix(d, s))) { + overlaps.push({ kind: "domain", value: d, by: `суффикс ${(prev.domainSuffixes || []).find((s) => hostMatchesSuffix(d, s))}` }); + } + if ((prev.domains || []).includes(d)) { + overlaps.push({ kind: "domain", value: d, by: "точный домен" }); + } + } + + // Суффиксы покрываются более общим суффиксом prev + for (const s of cur.domainSuffixes || []) { + if ((prev.domainSuffixes || []).some((ps) => hostMatchesSuffix(s, ps) && ps !== s)) { + overlaps.push({ kind: "suffix", value: s, by: "более общий суффикс" }); + } + } + + // CIDR + for (const c of cur.ipCidrs || []) { + if ((prev.ipCidrs || []).includes(c)) { + overlaps.push({ kind: "cidr", value: c, by: "тот же CIDR" }); + } + } + + if (overlaps.length) { + conflicts.push({ + ruleId: cur.id, + ruleIndex: i, + ruleName: cur.name, + conflictWithId: prev.id, + conflictWithIndex: j, + conflictWithName: prev.name, + severity: sameOutbound ? "info" : "warning", + overlaps, + }); + } + } + } + + return conflicts; +} diff --git a/src/web/App.jsx b/src/web/App.jsx index ae83ed2..60f3010 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -1,34 +1,66 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import './styles.css'; import { api } from './api.js'; -import { SubscriptionPanel } from './components/SubscriptionPanel.jsx'; -import { ServerList } from './components/ServerList.jsx'; -import { RuntimePanel } from './components/RuntimePanel.jsx'; -import { RulesPanel } from './components/RulesPanel.jsx'; -import { LogsPanel } from './components/LogsPanel.jsx'; +import { Topbar } from './components/Topbar.jsx'; +import { Sidebar } from './components/Sidebar.jsx'; +import { StatusPane } from './components/StatusPane.jsx'; +import { OverviewPage } from './components/OverviewPage.jsx'; +import { ServersPage } from './components/ServersPage.jsx'; +import { RoutingPage } from './components/RoutingPage.jsx'; +import { LogsPage } from './components/LogsPage.jsx'; +import { SettingsPage } from './components/SettingsPage.jsx'; import { ConfigViewer } from './components/ConfigViewer.jsx'; +import { Toasts } from './components/Toasts.jsx'; + +const ROLLBACK_WINDOW_MS = 12_000; + +function getInitialPage() { + const hash = window.location.hash.replace('#/', '').replace('#', ''); + const valid = ['overview', 'servers', 'routing', 'logs', 'settings']; + return valid.includes(hash) ? hash : 'overview'; +} function App() { + const [page, setPage] = useState(getInitialPage()); const [state, setState] = useState(null); const [subscriptionUrl, setSubscriptionUrl] = useState(''); - const [editingSubscription, setEditingSubscription] = useState(false); const [servers, setServers] = useState([]); const [customRules, setCustomRules] = useState([]); const [selectedTag, setSelectedTag] = useState(''); + const [pendingTag, setPendingTag] = useState(''); const [busy, setBusy] = useState(false); - const [log, setLog] = useState([]); const [error, setError] = useState(''); const [rulesSaveStatus, setRulesSaveStatus] = useState('saved'); const [configOpen, setConfigOpen] = useState(false); + const [pings, setPings] = useState({}); + const [toasts, setToasts] = useState([]); + const [applyStatus, setApplyStatus] = useState('idle'); // idle | applying | error + const [rollbackOffer, setRollbackOffer] = useState(null); + const rulesDirtyRef = useRef(false); const rulesSaveTimerRef = useRef(null); const rulesRevisionRef = useRef(0); + const rollbackTimerRef = useRef(null); - function addLog(message) { - const time = new Date().toLocaleTimeString('ru-RU', { hour12: false }); - setLog((items) => [{ time, message }, ...items].slice(0, 8)); + function pushToast(toast) { + const id = `t-${Date.now()}-${Math.random()}`; + setToasts((prev) => [...prev, { id, ...toast }]); } + function dismissToast(id) { + setToasts((prev) => prev.filter((t) => t.id !== id)); + } + + function navigate(p) { + setPage(p); + window.location.hash = `#/${p}`; + } + + useEffect(() => { + function onHash() { setPage(getInitialPage()); } + window.addEventListener('hashchange', onHash); + return () => window.removeEventListener('hashchange', onHash); + }, []); async function loadState() { const data = await api.state(); @@ -36,86 +68,125 @@ function App() { setServers(data.servers || []); if (!rulesDirtyRef.current) setCustomRules(data.customRules || []); setSelectedTag((prev) => prev || data.selectedTag || ''); + setPendingTag((prev) => prev || data.selectedTag || ''); } useEffect(() => { - loadState().catch(() => {}); + loadState().catch((err) => setError(err.message)); const timer = setInterval(() => loadState().catch(() => {}), 5000); return () => clearInterval(timer); }, []); useEffect(() => () => { if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current); + if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current); }, []); - async function withBusy(label, fn) { + async function withBusy(label, fn, { quiet = false } = {}) { setBusy(true); setError(''); - if (label) addLog(label); try { - await fn(); + const result = await fn(); + if (!quiet && label) pushToast({ kind: 'success', title: label }); + return result; } catch (err) { setError(err.message); - addLog(`ОШИБКА: ${err.message}`); + pushToast({ kind: 'danger', title: 'Ошибка', message: err.message, duration: 6000 }); + throw err; } finally { setBusy(false); } } - async function fetchServers() { - await withBusy('Загрузка подписки', async () => { - const data = await api.subscription.fetch(subscriptionUrl); + // === Subscription === + async function fetchSubscription() { + return withBusy('Подписка обновлена', async () => { + const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || ''); setServers(data.servers || []); - setSelectedTag(data.servers?.[0]?.tag || ''); - addLog(`Найдено серверов: ${data.servers.length}`); + if (!selectedTag && data.servers?.length) { + setSelectedTag(data.servers[0].tag); + setPendingTag(data.servers[0].tag); + } await loadState(); }); } async function forgetSubscription() { if (!confirm('Удалить подписку и остановить sing-box?')) return; - await withBusy('Удаление подписки', async () => { + return withBusy('Подписка удалена', async () => { await api.subscription.forget(); setSubscriptionUrl(''); setServers([]); setSelectedTag(''); - setEditingSubscription(true); + setPendingTag(''); await loadState(); }); } - async function applyServer() { - await withBusy(`Применяем ${selectedTag}`, async () => { - const data = await api.apply(selectedTag); - addLog(`sing-box: ${data.singboxRunning ? 'работает' : 'не запущен'}`); + // === Apply with rollback offer === + async function applyServer(tag) { + const target = tag || selectedTag; + if (!target) return; + const previous = state?.selectedTag; + setApplyStatus('applying'); + try { + await withBusy('Сервер применён', async () => { + await api.apply(target); + await loadState(); + }); + setApplyStatus('idle'); + + if (previous && previous !== target) { + setRollbackOffer({ from: target, to: previous, expiresAt: Date.now() + ROLLBACK_WINDOW_MS }); + if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current); + rollbackTimerRef.current = setTimeout(() => setRollbackOffer(null), ROLLBACK_WINDOW_MS); + } + } catch { + setApplyStatus('error'); + } + } + + async function rollback() { + if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current); + setRollbackOffer(null); + return withBusy('Откат выполнен', async () => { + const data = await api.rollback(); + setSelectedTag(data.selectedTag); + setPendingTag(data.selectedTag); await loadState(); }); } + // === sing-box control === async function stopSingbox() { if (!confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return; - await withBusy('Остановка sing-box', async () => { - await api.singbox.stop(); - await loadState(); - }); + return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); }); } - async function restartSingbox() { - await withBusy('Перезапуск sing-box', async () => { - await api.singbox.restart(); - await loadState(); - }); + return withBusy('Перезапущено', async () => { await api.singbox.restart(); await loadState(); }); } - async function clearConfig() { if (!confirm('Сбросить config sing-box и остановить процесс?')) return; - await withBusy('Сброс конфига', async () => { + return withBusy('Config сброшен', async () => { await api.singbox.clear(); setSelectedTag(''); + setPendingTag(''); await loadState(); }); } + // === Rules CRUD === + function emptyRule() { + return { + id: `rule-${Date.now()}`, + name: 'Новое правило', + enabled: true, + outbound: 'direct', + domains: [], domainSuffixes: [], domainKeywords: [], + ipCidrs: [], ports: [], networks: [], + }; + } + function queueRulesSave(nextRules) { rulesDirtyRef.current = true; const revision = rulesRevisionRef.current + 1; @@ -123,35 +194,28 @@ function App() { setRulesSaveStatus('pending'); if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current); - rulesSaveTimerRef.current = setTimeout(() => { - saveRules(nextRules, { silent: true, revision }); - }, 700); + rulesSaveTimerRef.current = setTimeout(() => saveRules(nextRules, { silent: true, revision }), 700); } async function saveRules(nextRules = customRules, options = {}) { const { silent = false, revision = rulesRevisionRef.current + 1 } = options; - if (!silent) setBusy(true); setError(''); - if (!silent) addLog('Сохранение правил'); setRulesSaveStatus('saving'); - try { const data = await api.rules.save(nextRules); if (rulesRevisionRef.current === revision) { rulesDirtyRef.current = false; setCustomRules(data.rules || []); setRulesSaveStatus('saved'); - addLog(`Правил сохранено: ${data.rules.length}`); await loadState(); + if (!silent) pushToast({ kind: 'success', title: 'Правила сохранены' }); } else { setRulesSaveStatus('pending'); } } catch (err) { setError(err.message); setRulesSaveStatus('error'); - addLog(`ОШИБКА: ${err.message}`); - } finally { - if (!silent) setBusy(false); + pushToast({ kind: 'danger', title: 'Не удалось сохранить', message: err.message }); } } @@ -163,127 +227,202 @@ function App() { saveRules(customRules, { silent: false, revision }); } - function emptyRule() { - return { - id: `rule-${Date.now()}`, - name: 'Новый список', - enabled: true, - outbound: 'direct', - domains: [], - domainSuffixes: [], - domainKeywords: [], - ipCidrs: [], - ports: [], - networks: [], - }; - } - function updateRule(id, patch) { setCustomRules((rules) => { - const nextRules = rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)); - queueRulesSave(nextRules); - return nextRules; + const next = rules.map((r) => (r.id === id ? { ...r, ...patch } : r)); + queueRulesSave(next); + return next; }); } - function addRule() { setCustomRules((rules) => { - const nextRules = [emptyRule(), ...rules]; - queueRulesSave(nextRules); - return nextRules; + const next = [emptyRule(), ...rules]; + queueRulesSave(next); + return next; }); } - - function addRuleFromTemplate(template) { + function addRuleFromTemplate(tpl) { setCustomRules((rules) => { - const nextRules = [template, ...rules]; - queueRulesSave(nextRules); - return nextRules; + const next = [tpl, ...rules]; + queueRulesSave(next); + return next; }); } - function removeRule(id) { setCustomRules((rules) => { - const nextRules = rules.filter((rule) => rule.id !== id); - queueRulesSave(nextRules); - return nextRules; + const next = rules.filter((r) => r.id !== id); + queueRulesSave(next); + return next; }); } - - function reorderRules(nextRules) { - setCustomRules(nextRules); - queueRulesSave(nextRules); + function reorderRules(next) { + setCustomRules(next); + queueRulesSave(next); } + // === Computed === + const status = useMemo(() => { + if (applyStatus === 'applying') return 'applying'; + if (applyStatus === 'error') return 'error'; + if (state?.singboxRunning) return 'running'; + if (state?.configExists) return 'stopped'; + return 'no_config'; + }, [state, applyStatus]); + + const activeServer = useMemo( + () => servers.find((s) => s.tag === state?.selectedTag) || null, + [servers, state?.selectedTag], + ); + + const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving'; + const dirtyServer = pendingTag && pendingTag !== state?.selectedTag; + const dirty = dirtyRules || dirtyServer; + + const sidebarBadges = { + routing: dirtyRules ? { kind: 'warn', text: '●' } : null, + servers: dirtyServer ? { kind: 'warn', text: '●' } : null, + settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null, + }; + + // === Render === return ( -
-
-
-

VPN Proxy / Gateway

-

Прозрачный VPN-шлюз для всей сети

-

- Загрузи подписку, выбери сервер — контейнер сгенерирует gateway-конфиг для sing-box: TProxy для роутера и mixed proxy для ручных клиентов. -

-
-
- -
- {state?.singboxRunning ? 'sing-box работает' : 'sing-box остановлен'} - {state?.selectedTag || 'сервер не выбран'} -
-
-
+
+ -
-
- - - {error &&
{error}
} -
+
+ - + {page === 'overview' && ( + setConfigOpen(true)} + onNav={navigate} + /> + )} + {page === 'servers' && ( + + )} + {page === 'routing' && ( + + )} + {page === 'logs' && } + {page === 'settings' && ( + setConfigOpen(true)} + onClearConfig={clearConfig} + pushToast={pushToast} + /> + )} + + {/* Sticky bar — для routing/servers */} + {(page === 'routing' && rulesSaveStatus !== 'saved') && ( +
+
+ + + {rulesSaveStatus === 'saving' && 'Сохраняем…'} + {rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'} + {rulesSaveStatus === 'error' && 'Ошибка сохранения'} + + Изменения сохранены, но конфиг не пересобран. Применить — на странице «Серверы». +
+
+ + {state?.selectedTag && ( + + )} +
+
+ )} + + {(page === 'servers' && dirtyServer) && ( +
+
+ + Сервер не применён + Выбран: {pendingTag} +
+
+ + +
+
+ )} +
+ + setConfigOpen(true)} /> - - - - - + setConfigOpen(false)} /> - + + + {rollbackOffer && ( +
+
+ +
+ Сервер применён + Можно откатиться к «{rollbackOffer.to}» + +
+ +
+
+ )} + ); } diff --git a/src/web/api.js b/src/web/api.js index 557e17c..b5edd77 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -18,11 +18,14 @@ async function request(url, options = {}) { 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"), }, + subscription: { fetch: (url) => request("/api/subscription/fetch", { @@ -31,14 +34,36 @@ export const api = { }), forget: () => request("/api/subscription", { method: "DELETE" }), }, + apply: (selectedTag) => request("/api/apply", { method: "POST", body: JSON.stringify({ selectedTag }), }), + rollback: () => request("/api/apply/rollback", { method: "POST" }), + singbox: { stop: () => request("/api/singbox/stop", { method: "POST" }), restart: () => request("/api/singbox/restart", { method: "POST" }), clear: () => request("/api/singbox/clear", { 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" }), + }, + + route: { + check: ({ host, ip, port, network }) => + request("/api/route/check", { + method: "POST", + body: JSON.stringify({ host, ip, port, network }), + }), + }, + + configValidate: () => request("/api/config/validate", { method: "POST" }), }; diff --git a/src/web/components/ChipsInput.jsx b/src/web/components/ChipsInput.jsx new file mode 100644 index 0000000..ebad5aa --- /dev/null +++ b/src/web/components/ChipsInput.jsx @@ -0,0 +1,61 @@ +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 ( +
e.currentTarget.querySelector('input')?.focus()}> + {value.map((item) => { + const invalid = validate ? !validate(item) : false; + return ( + + {item} + + + ); + })} + setDraft(e.target.value)} + onKeyDown={onKeyDown} + onPaste={onPaste} + onBlur={() => draft.trim() && commit(draft)} + placeholder={value.length ? '' : placeholder} + /> +
+ ); +} diff --git a/src/web/components/ConfigViewer.jsx b/src/web/components/ConfigViewer.jsx index afd9659..261ef66 100644 --- a/src/web/components/ConfigViewer.jsx +++ b/src/web/components/ConfigViewer.jsx @@ -1,32 +1,37 @@ -import React, { useEffect, useState } from 'react'; +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; - api - .config() - .then((data) => { - if (!cancelled) setConfig(data.config); - }) - .catch((err) => !cancelled && setError(err.message)); - return () => { - cancelled = true; - }; + 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; - const text = config ? JSON.stringify(config, null, 2) : ''; - - function copy() { - navigator.clipboard?.writeText(text).catch(() => {}); - } - + function copy() { navigator.clipboard?.writeText(text).catch(() => {}); } function download() { const blob = new Blob([text], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -39,24 +44,41 @@ export function ConfigViewer({ open, onClose }) { return (
-
event.stopPropagation()}> -
-

Текущий конфиг sing-box

-
- - - +
e.stopPropagation()}> +
+
+

sing-box config

+ Автогенерируемый, перезаписывается при apply +
+
+ setSearch(e.target.value)} + style={{ width: 160 }} + /> + + +
- {error &&
{error}
} - {!error && !config &&

Конфиг ещё не сгенерирован.

} - {config &&
{text}
} +
+ {error &&
{error}
} + {!error && !config &&

Конфиг ещё не сгенерирован.

} + {config && ( +
+              {Array.isArray(highlighted)
+                ? highlighted.map((part, i) => (
+                    
+                      {part}
+                      {i < highlighted.length - 1 && {search}}
+                    
+                  ))
+                : text}
+            
+ )} +
); diff --git a/src/web/components/LogsPage.jsx b/src/web/components/LogsPage.jsx new file mode 100644 index 0000000..153efe4 --- /dev/null +++ b/src/web/components/LogsPage.jsx @@ -0,0 +1,136 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { formatTime } from '../utils/format.js'; + +const MAX_ENTRIES = 800; +const GROUP_WINDOW_MS = 30_000; + +function normalizeLine(line) { + return String(line || '').replace(/\x1b\[\d+m/g, '').trim(); +} + +function groupEntries(entries) { + // Группируем повторы: одинаковая нормализованная строка + одинаковый level в окне 30 сек. + 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; +} + +export function LogsPage() { + 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 ( +
+
+

Логи sing-box

+ {entries.length} / {MAX_ENTRIES} +
+ +
+ setSearch(e.target.value)} + style={{ flex: 1, minWidth: 200 }} + /> + + + + + +
+ +
+ {filtered.length === 0 &&

Логов пока нет.

} + {filtered.map((entry, index) => { + const text = normalizeLine(entry.line); + if (grouped && entry.count > 1) { + return ( +
+ {formatTime(entry.ts)} + + {entry.level} + + {text} + ×{entry.count} +
+ ); + } + return ( +
copy(`${formatTime(entry.ts)} ${entry.level} ${text}`)} + title="Двойной клик — скопировать" + > + {formatTime(entry.ts)} + {entry.level} + {text} +
+ ); + })} +
+
+ ); +} diff --git a/src/web/components/LogsPanel.jsx b/src/web/components/LogsPanel.jsx deleted file mode 100644 index 4fc4431..0000000 --- a/src/web/components/LogsPanel.jsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { formatTime } from '../utils/format.js'; - -export function LogsPanel() { - const [entries, setEntries] = useState([]); - const [paused, setPaused] = useState(false); - const [filter, setFilter] = useState('all'); - 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 > 500) next.splice(0, next.length - 500); - return next; - }); - } catch {} - }; - source.onerror = () => { - // EventSource сам делает реконнект - }; - return () => source.close(); - }, []); - - useEffect(() => { - if (paused || !containerRef.current) return; - containerRef.current.scrollTop = containerRef.current.scrollHeight; - }, [entries, paused]); - - const filtered = entries.filter((entry) => filter === 'all' || entry.level === filter); - - return ( -
-
-
- 5 -

Логи sing-box

-
-
- - - -
-
- -
- {filtered.length === 0 &&

Логов пока нет.

} - {filtered.map((entry, index) => ( -

- {formatTime(entry.ts)} - {entry.level} - {entry.line} -

- ))} -
-
- ); -} diff --git a/src/web/components/OverviewPage.jsx b/src/web/components/OverviewPage.jsx new file mode 100644 index 0000000..070c5f1 --- /dev/null +++ b/src/web/components/OverviewPage.jsx @@ -0,0 +1,159 @@ +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 ( +
+
+
+

{text.title}

+ + {state?.appliedAt ? `Последнее применение: ${formatRelative(state.appliedAt)}` : 'Конфиг ещё не применялся'} + +
+ {state?.singboxRunning ? 'sing-box online' : 'sing-box offline'} +
+ +
+ +
+
+ Активный сервер +
+ {state?.selectedTag ? ( + <> + {flagFor({ tag: state.selectedTag })} {state.selectedTag} + + ) : Не выбран} +
+
+
+ Трафик +
{traffic}
+
+
+ Правил маршрутизации +
{(state?.customRules || []).filter(r => r.enabled).length} активных
+
+
+
+ ); +} + +function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav }) { + return ( +
+
+

Быстрые действия

+
+
+ + + + +
+
+ ); +} + +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 ( +
+
+

Последние события

+ +
+ {entries.length === 0 ? ( + Пока ничего нет. + ) : ( +
+ {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 ( +
+ + {time} + {e.line} +
+ ); + })} +
+ )} +
+ ); +} + +function RoutingSummary({ state, onNav }) { + const rules = state?.customRules || []; + const enabled = rules.filter((r) => r.enabled).length; + return ( +
+
+

Маршрутизация

+ +
+
+
Private IP→ direct
+ {state?.routingRuDirect && ( +
RU (geoip/geosite)→ direct
+ )} +
Custom правил{enabled} из {rules.length}
+
Остальное→ VPN
+
+
+ ); +} + +export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav }) { + return ( +
+ +
+ + +
+ +
+ ); +} diff --git a/src/web/components/RouteChecker.jsx b/src/web/components/RouteChecker.jsx new file mode 100644 index 0000000..0683206 --- /dev/null +++ b/src/web/components/RouteChecker.jsx @@ -0,0 +1,74 @@ +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 [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 }); + 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 ( +
+

Проверить маршрут

+
+ setHost(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && check()} + style={{ minWidth: 220, flex: 1 }} + /> + setPort(e.target.value)} + style={{ width: 90 }} + /> + + +
+ + {error &&
{error}
} + + {r && ( +
+
+ {r.ruleIndex >= 0 ? `Правило #${r.ruleIndex + 1}: ${r.ruleName}` : r.ruleName} + → {r.outbound} +
+ {result.resolvedIp && result.resolvedFrom && ( + DNS: {result.resolvedFrom} → {result.resolvedIp} + )} + {r.reason} +
+ )} +
+ ); +} diff --git a/src/web/components/RoutingPage.jsx b/src/web/components/RoutingPage.jsx new file mode 100644 index 0000000..80bacd9 --- /dev/null +++ b/src/web/components/RoutingPage.jsx @@ -0,0 +1,223 @@ +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' }, +}; + +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 ( + + + + + #{index + 1} + +
+ onUpdate(rule.id, { enabled: e.target.checked })} + style={{ accentColor: 'var(--accent)' }} + /> + + {invalid && ошибки} + {conflict && конфликт} +
+ + {ob.label} + {summary(rule)} + +
+ + +
+ + + ); +} + +function TemplatesModal({ open, onClose, onAdd }) { + if (!open) return null; + return ( +
+
e.stopPropagation()}> +
+

Шаблоны маршрутизации

+ +
+
+
+ {ruleTemplates.map((tpl) => ( +
+

{tpl.label}

+ {tpl.description} + +
+ ))} +
+
+
+
+ ); +} + +export function RoutingPage({ + rules, saveStatus, busy, + onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder, +}) { + const [editingId, setEditingId] = useState(null); + const [showTemplates, setShowTemplates] = useState(false); + const [conflicts, setConflicts] = useState([]); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + 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 ( +
+ + +
+
+

Правила маршрутизации

+
+ + +
+
+ + {conflicts.length > 0 && ( +
+ +
+ {conflicts.length} конфликт(ов) обнаружено +
+ {conflicts.slice(0, 3).map((c, i) => ( +
+ #{c.ruleIndex + 1} «{c.ruleName}» перекрывается правилом #{c.conflictWithIndex + 1} «{c.conflictWithName}» +
+ ))} +
+
+
+ )} + + + Применяются сверху вниз. Перетаскивай ⠿ чтобы менять порядок. + + + {rules.length === 0 ? ( +
+

Правил пока нет

+

Добавь шаблон (например «League of Legends → direct») или создай пустое правило.

+ +
+ ) : ( +
+ + + + + + + + + + + + + + r.id)} strategy={verticalListSortingStrategy}> + {rules.map((rule, i) => ( + + ))} + + + +
#ПравилоOutboundУсловия
+
+ )} +
+ + setEditingId(null)} + onRemove={onRemove} + /> + setShowTemplates(false)} onAdd={onAddTemplate} /> +
+ ); +} diff --git a/src/web/components/RuleCard.jsx b/src/web/components/RuleCard.jsx deleted file mode 100644 index d939fa5..0000000 --- a/src/web/components/RuleCard.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import React from 'react'; -import { useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { ruleErrors, hasErrors } from '../utils/validation.js'; - -function listToText(value) { - return Array.isArray(value) ? value.join('\n') : ''; -} - -function textToList(value) { - return value - .split(/\r?\n|,/) - .map((item) => item.trim()) - .filter(Boolean); -} - -export function RuleCard({ rule, index, total, onUpdate, onRemove }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rule.id }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.6 : 1, - }; - const errors = ruleErrors(rule); - const errored = hasErrors(errors); - - return ( -
-
- - ⠿ #{index + 1}/{total} - - onUpdate(rule.id, { name: event.target.value })} - placeholder="Название списка" - /> - -
- - - -
-