feat: добавлены новые компоненты для управления правилами и серверами
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s
- Создан компонент RuleEditorDrawer для редактирования правил с поддержкой JSON. - Добавлен компонент ServersPage для отображения и управления серверами. - Реализован компонент SettingsPage для управления подписками и конфигурациями. - Создан компонент Sidebar для навигации по приложению. - Добавлен компонент StatusPane для отображения статуса сервера. - Реализован компонент Toasts для отображения уведомлений. - Создан компонент Topbar для отображения информации о текущем состоянии. - Добавлен модуль country.js для определения страны по тегу сервера. Refs: None
This commit is contained in:
@@ -9,7 +9,8 @@ export const settings = {
|
|||||||
bindIp: process.env.PROXY_BIND_IP || "127.0.0.1",
|
bindIp: process.env.PROXY_BIND_IP || "127.0.0.1",
|
||||||
dataDir,
|
dataDir,
|
||||||
distDir: process.env.DIST_DIR || "/app/dist",
|
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",
|
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
||||||
statePath: path.join(dataDir, "state.json"),
|
statePath: path.join(dataDir, "state.json"),
|
||||||
customRulesPath: path.join(dataDir, "custom-rules.json"),
|
customRulesPath: path.join(dataDir, "custom-rules.json"),
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
readSingboxConfig,
|
readSingboxConfig,
|
||||||
removeSingboxConfig,
|
removeSingboxConfig,
|
||||||
} from "./singbox.js";
|
} 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 });
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
|
||||||
@@ -32,7 +36,8 @@ function pushLog(level, line) {
|
|||||||
|
|
||||||
// Sing-box пишет все логи в stderr, поэтому парсим уровень из содержимого строки.
|
// Sing-box пишет все логи в stderr, поэтому парсим уровень из содержимого строки.
|
||||||
// Формат: ESC[<n>m LEVEL ESC[0m, где ESC = \x1b
|
// Формат: ESC[<n>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) {
|
function parseSingboxLevel(line, fallback) {
|
||||||
const m = line.match(SINGBOX_LEVEL_RE);
|
const m = line.match(SINGBOX_LEVEL_RE);
|
||||||
if (!m) return fallback;
|
if (!m) return fallback;
|
||||||
@@ -192,6 +197,9 @@ function publicState() {
|
|||||||
subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
|
subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
|
||||||
hasSubscription: Boolean(subscriptionUrl),
|
hasSubscription: Boolean(subscriptionUrl),
|
||||||
customRules,
|
customRules,
|
||||||
|
appliedHistory: state.appliedHistory || [],
|
||||||
|
rulesUpdatedAt: state.rulesUpdatedAt || null,
|
||||||
|
rulesAppliedAt: state.rulesAppliedAt || null,
|
||||||
...rest,
|
...rest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -241,10 +249,18 @@ async function applySelectedServer(selectedTag) {
|
|||||||
await startSingbox();
|
await startSingbox();
|
||||||
|
|
||||||
const prevState = readJson(settings.statePath, {});
|
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, {
|
writeJson(settings.statePath, {
|
||||||
...prevState,
|
...prevState,
|
||||||
selectedTag,
|
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 body = await readBody(req);
|
||||||
const rules = normalizeCustomRules(body.rules);
|
const rules = normalizeCustomRules(body.rules);
|
||||||
writeJson(settings.customRulesPath, 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 });
|
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") {
|
if (req.method === "POST" && req.url === "/api/subscription/fetch") {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const url = String(body.url || "").trim();
|
const url = String(body.url || "").trim();
|
||||||
|
|||||||
44
src/server/ping.js
Normal file
44
src/server/ping.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
222
src/server/routeMatcher.js
Normal file
222
src/server/routeMatcher.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
401
src/web/App.jsx
401
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 { createRoot } from 'react-dom/client';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { SubscriptionPanel } from './components/SubscriptionPanel.jsx';
|
import { Topbar } from './components/Topbar.jsx';
|
||||||
import { ServerList } from './components/ServerList.jsx';
|
import { Sidebar } from './components/Sidebar.jsx';
|
||||||
import { RuntimePanel } from './components/RuntimePanel.jsx';
|
import { StatusPane } from './components/StatusPane.jsx';
|
||||||
import { RulesPanel } from './components/RulesPanel.jsx';
|
import { OverviewPage } from './components/OverviewPage.jsx';
|
||||||
import { LogsPanel } from './components/LogsPanel.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 { 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() {
|
function App() {
|
||||||
|
const [page, setPage] = useState(getInitialPage());
|
||||||
const [state, setState] = useState(null);
|
const [state, setState] = useState(null);
|
||||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||||
const [editingSubscription, setEditingSubscription] = useState(false);
|
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
const [customRules, setCustomRules] = useState([]);
|
const [customRules, setCustomRules] = useState([]);
|
||||||
const [selectedTag, setSelectedTag] = useState('');
|
const [selectedTag, setSelectedTag] = useState('');
|
||||||
|
const [pendingTag, setPendingTag] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [log, setLog] = useState([]);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
|
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
|
||||||
const [configOpen, setConfigOpen] = useState(false);
|
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 rulesDirtyRef = useRef(false);
|
||||||
const rulesSaveTimerRef = useRef(null);
|
const rulesSaveTimerRef = useRef(null);
|
||||||
const rulesRevisionRef = useRef(0);
|
const rulesRevisionRef = useRef(0);
|
||||||
|
const rollbackTimerRef = useRef(null);
|
||||||
|
|
||||||
function addLog(message) {
|
function pushToast(toast) {
|
||||||
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
|
const id = `t-${Date.now()}-${Math.random()}`;
|
||||||
setLog((items) => [{ time, message }, ...items].slice(0, 8));
|
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() {
|
async function loadState() {
|
||||||
const data = await api.state();
|
const data = await api.state();
|
||||||
@@ -36,86 +68,125 @@ function App() {
|
|||||||
setServers(data.servers || []);
|
setServers(data.servers || []);
|
||||||
if (!rulesDirtyRef.current) setCustomRules(data.customRules || []);
|
if (!rulesDirtyRef.current) setCustomRules(data.customRules || []);
|
||||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||||
|
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadState().catch(() => {});
|
loadState().catch((err) => setError(err.message));
|
||||||
const timer = setInterval(() => loadState().catch(() => {}), 5000);
|
const timer = setInterval(() => loadState().catch(() => {}), 5000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
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);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
if (label) addLog(label);
|
|
||||||
try {
|
try {
|
||||||
await fn();
|
const result = await fn();
|
||||||
|
if (!quiet && label) pushToast({ kind: 'success', title: label });
|
||||||
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
addLog(`ОШИБКА: ${err.message}`);
|
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message, duration: 6000 });
|
||||||
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchServers() {
|
// === Subscription ===
|
||||||
await withBusy('Загрузка подписки', async () => {
|
async function fetchSubscription() {
|
||||||
const data = await api.subscription.fetch(subscriptionUrl);
|
return withBusy('Подписка обновлена', async () => {
|
||||||
|
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
|
||||||
setServers(data.servers || []);
|
setServers(data.servers || []);
|
||||||
setSelectedTag(data.servers?.[0]?.tag || '');
|
if (!selectedTag && data.servers?.length) {
|
||||||
addLog(`Найдено серверов: ${data.servers.length}`);
|
setSelectedTag(data.servers[0].tag);
|
||||||
|
setPendingTag(data.servers[0].tag);
|
||||||
|
}
|
||||||
await loadState();
|
await loadState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forgetSubscription() {
|
async function forgetSubscription() {
|
||||||
if (!confirm('Удалить подписку и остановить sing-box?')) return;
|
if (!confirm('Удалить подписку и остановить sing-box?')) return;
|
||||||
await withBusy('Удаление подписки', async () => {
|
return withBusy('Подписка удалена', async () => {
|
||||||
await api.subscription.forget();
|
await api.subscription.forget();
|
||||||
setSubscriptionUrl('');
|
setSubscriptionUrl('');
|
||||||
setServers([]);
|
setServers([]);
|
||||||
setSelectedTag('');
|
setSelectedTag('');
|
||||||
setEditingSubscription(true);
|
setPendingTag('');
|
||||||
await loadState();
|
await loadState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyServer() {
|
// === Apply with rollback offer ===
|
||||||
await withBusy(`Применяем ${selectedTag}`, async () => {
|
async function applyServer(tag) {
|
||||||
const data = await api.apply(selectedTag);
|
const target = tag || selectedTag;
|
||||||
addLog(`sing-box: ${data.singboxRunning ? 'работает' : 'не запущен'}`);
|
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();
|
await loadState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === sing-box control ===
|
||||||
async function stopSingbox() {
|
async function stopSingbox() {
|
||||||
if (!confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
|
if (!confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
|
||||||
await withBusy('Остановка sing-box', async () => {
|
return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); });
|
||||||
await api.singbox.stop();
|
|
||||||
await loadState();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restartSingbox() {
|
async function restartSingbox() {
|
||||||
await withBusy('Перезапуск sing-box', async () => {
|
return withBusy('Перезапущено', async () => { await api.singbox.restart(); await loadState(); });
|
||||||
await api.singbox.restart();
|
|
||||||
await loadState();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearConfig() {
|
async function clearConfig() {
|
||||||
if (!confirm('Сбросить config sing-box и остановить процесс?')) return;
|
if (!confirm('Сбросить config sing-box и остановить процесс?')) return;
|
||||||
await withBusy('Сброс конфига', async () => {
|
return withBusy('Config сброшен', async () => {
|
||||||
await api.singbox.clear();
|
await api.singbox.clear();
|
||||||
setSelectedTag('');
|
setSelectedTag('');
|
||||||
|
setPendingTag('');
|
||||||
await loadState();
|
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) {
|
function queueRulesSave(nextRules) {
|
||||||
rulesDirtyRef.current = true;
|
rulesDirtyRef.current = true;
|
||||||
const revision = rulesRevisionRef.current + 1;
|
const revision = rulesRevisionRef.current + 1;
|
||||||
@@ -123,35 +194,28 @@ function App() {
|
|||||||
setRulesSaveStatus('pending');
|
setRulesSaveStatus('pending');
|
||||||
|
|
||||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||||
rulesSaveTimerRef.current = setTimeout(() => {
|
rulesSaveTimerRef.current = setTimeout(() => saveRules(nextRules, { silent: true, revision }), 700);
|
||||||
saveRules(nextRules, { silent: true, revision });
|
|
||||||
}, 700);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveRules(nextRules = customRules, options = {}) {
|
async function saveRules(nextRules = customRules, options = {}) {
|
||||||
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
|
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
|
||||||
if (!silent) setBusy(true);
|
|
||||||
setError('');
|
setError('');
|
||||||
if (!silent) addLog('Сохранение правил');
|
|
||||||
setRulesSaveStatus('saving');
|
setRulesSaveStatus('saving');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.rules.save(nextRules);
|
const data = await api.rules.save(nextRules);
|
||||||
if (rulesRevisionRef.current === revision) {
|
if (rulesRevisionRef.current === revision) {
|
||||||
rulesDirtyRef.current = false;
|
rulesDirtyRef.current = false;
|
||||||
setCustomRules(data.rules || []);
|
setCustomRules(data.rules || []);
|
||||||
setRulesSaveStatus('saved');
|
setRulesSaveStatus('saved');
|
||||||
addLog(`Правил сохранено: ${data.rules.length}`);
|
|
||||||
await loadState();
|
await loadState();
|
||||||
|
if (!silent) pushToast({ kind: 'success', title: 'Правила сохранены' });
|
||||||
} else {
|
} else {
|
||||||
setRulesSaveStatus('pending');
|
setRulesSaveStatus('pending');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
setRulesSaveStatus('error');
|
setRulesSaveStatus('error');
|
||||||
addLog(`ОШИБКА: ${err.message}`);
|
pushToast({ kind: 'danger', title: 'Не удалось сохранить', message: err.message });
|
||||||
} finally {
|
|
||||||
if (!silent) setBusy(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,112 +227,108 @@ function App() {
|
|||||||
saveRules(customRules, { silent: false, revision });
|
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) {
|
function updateRule(id, patch) {
|
||||||
setCustomRules((rules) => {
|
setCustomRules((rules) => {
|
||||||
const nextRules = rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule));
|
const next = rules.map((r) => (r.id === id ? { ...r, ...patch } : r));
|
||||||
queueRulesSave(nextRules);
|
queueRulesSave(next);
|
||||||
return nextRules;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRule() {
|
function addRule() {
|
||||||
setCustomRules((rules) => {
|
setCustomRules((rules) => {
|
||||||
const nextRules = [emptyRule(), ...rules];
|
const next = [emptyRule(), ...rules];
|
||||||
queueRulesSave(nextRules);
|
queueRulesSave(next);
|
||||||
return nextRules;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function addRuleFromTemplate(tpl) {
|
||||||
function addRuleFromTemplate(template) {
|
|
||||||
setCustomRules((rules) => {
|
setCustomRules((rules) => {
|
||||||
const nextRules = [template, ...rules];
|
const next = [tpl, ...rules];
|
||||||
queueRulesSave(nextRules);
|
queueRulesSave(next);
|
||||||
return nextRules;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRule(id) {
|
function removeRule(id) {
|
||||||
setCustomRules((rules) => {
|
setCustomRules((rules) => {
|
||||||
const nextRules = rules.filter((rule) => rule.id !== id);
|
const next = rules.filter((r) => r.id !== id);
|
||||||
queueRulesSave(nextRules);
|
queueRulesSave(next);
|
||||||
return nextRules;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function reorderRules(next) {
|
||||||
function reorderRules(nextRules) {
|
setCustomRules(next);
|
||||||
setCustomRules(nextRules);
|
queueRulesSave(next);
|
||||||
queueRulesSave(nextRules);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// === Computed ===
|
||||||
<main className="shell">
|
const status = useMemo(() => {
|
||||||
<section className="hero panel">
|
if (applyStatus === 'applying') return 'applying';
|
||||||
<div>
|
if (applyStatus === 'error') return 'error';
|
||||||
<p className="eyebrow">VPN Proxy / Gateway</p>
|
if (state?.singboxRunning) return 'running';
|
||||||
<h1>Прозрачный VPN-шлюз для всей сети</h1>
|
if (state?.configExists) return 'stopped';
|
||||||
<p className="lead">
|
return 'no_config';
|
||||||
Загрузи подписку, выбери сервер — контейнер сгенерирует gateway-конфиг для sing-box: TProxy для роутера и mixed proxy для ручных клиентов.
|
}, [state, applyStatus]);
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="status-card">
|
|
||||||
<span className={state?.singboxRunning ? 'dot on' : 'dot'} />
|
|
||||||
<div>
|
|
||||||
<strong>{state?.singboxRunning ? 'sing-box работает' : 'sing-box остановлен'}</strong>
|
|
||||||
<small>{state?.selectedTag || 'сервер не выбран'}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid">
|
const activeServer = useMemo(
|
||||||
<div className="panel primary-flow">
|
() => servers.find((s) => s.tag === state?.selectedTag) || null,
|
||||||
<SubscriptionPanel
|
[servers, state?.selectedTag],
|
||||||
subscriptionUrl={subscriptionUrl}
|
);
|
||||||
setSubscriptionUrl={setSubscriptionUrl}
|
|
||||||
hasSubscription={Boolean(state?.hasSubscription)}
|
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
|
||||||
subscriptionHost={state?.subscriptionHost}
|
const dirtyServer = pendingTag && pendingTag !== state?.selectedTag;
|
||||||
busy={busy}
|
const dirty = dirtyRules || dirtyServer;
|
||||||
onFetch={fetchServers}
|
|
||||||
onForget={forgetSubscription}
|
const sidebarBadges = {
|
||||||
editing={editingSubscription || !state?.hasSubscription}
|
routing: dirtyRules ? { kind: 'warn', text: '●' } : null,
|
||||||
setEditing={setEditingSubscription}
|
servers: dirtyServer ? { kind: 'warn', text: '●' } : null,
|
||||||
|
settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Render ===
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Topbar
|
||||||
|
state={state}
|
||||||
|
status={status}
|
||||||
|
activeServer={activeServer}
|
||||||
|
dirty={dirty}
|
||||||
|
onRestart={restartSingbox}
|
||||||
|
onTryApply={rollback}
|
||||||
/>
|
/>
|
||||||
<ServerList
|
|
||||||
|
<div className="app-body">
|
||||||
|
<Sidebar active={page} onChange={navigate} badges={sidebarBadges} />
|
||||||
|
|
||||||
|
<main className="app-main">
|
||||||
|
{page === 'overview' && (
|
||||||
|
<OverviewPage
|
||||||
|
state={state}
|
||||||
|
status={status}
|
||||||
|
busy={busy}
|
||||||
|
onRestart={restartSingbox}
|
||||||
|
onStop={stopSingbox}
|
||||||
|
onShowConfig={() => setConfigOpen(true)}
|
||||||
|
onNav={navigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{page === 'servers' && (
|
||||||
|
<ServersPage
|
||||||
|
state={state}
|
||||||
servers={servers}
|
servers={servers}
|
||||||
selectedTag={selectedTag}
|
selectedTag={selectedTag}
|
||||||
setSelectedTag={setSelectedTag}
|
setSelectedTag={setSelectedTag}
|
||||||
|
pendingTag={pendingTag}
|
||||||
|
setPendingTag={setPendingTag}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onApply={applyServer}
|
onApply={applyServer}
|
||||||
|
onRollback={rollback}
|
||||||
|
pings={pings}
|
||||||
|
setPings={setPings}
|
||||||
|
pushToast={pushToast}
|
||||||
/>
|
/>
|
||||||
{error && <div className="error">{error}</div>}
|
)}
|
||||||
</div>
|
{page === 'routing' && (
|
||||||
|
<RoutingPage
|
||||||
<RuntimePanel
|
|
||||||
state={state}
|
|
||||||
log={log}
|
|
||||||
busy={busy}
|
|
||||||
onStop={stopSingbox}
|
|
||||||
onRestart={restartSingbox}
|
|
||||||
onClear={clearConfig}
|
|
||||||
onShowConfig={() => setConfigOpen(true)}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<RulesPanel
|
|
||||||
rules={customRules}
|
rules={customRules}
|
||||||
saveStatus={rulesSaveStatus}
|
saveStatus={rulesSaveStatus}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -279,11 +339,90 @@ function App() {
|
|||||||
onSaveNow={saveRulesNow}
|
onSaveNow={saveRulesNow}
|
||||||
onReorder={reorderRules}
|
onReorder={reorderRules}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{page === 'logs' && <LogsPage />}
|
||||||
|
{page === 'settings' && (
|
||||||
|
<SettingsPage
|
||||||
|
state={state}
|
||||||
|
subscriptionUrl={subscriptionUrl}
|
||||||
|
setSubscriptionUrl={setSubscriptionUrl}
|
||||||
|
busy={busy}
|
||||||
|
onFetchSubscription={fetchSubscription}
|
||||||
|
onForgetSubscription={forgetSubscription}
|
||||||
|
onShowConfig={() => setConfigOpen(true)}
|
||||||
|
onClearConfig={clearConfig}
|
||||||
|
pushToast={pushToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<LogsPanel />
|
{/* Sticky bar — для routing/servers */}
|
||||||
|
{(page === 'routing' && rulesSaveStatus !== 'saved') && (
|
||||||
|
<div className="sticky-bar">
|
||||||
|
<div className="flex">
|
||||||
|
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
|
||||||
|
<strong>
|
||||||
|
{rulesSaveStatus === 'saving' && 'Сохраняем…'}
|
||||||
|
{rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'}
|
||||||
|
{rulesSaveStatus === 'error' && 'Ошибка сохранения'}
|
||||||
|
</strong>
|
||||||
|
<small className="muted">Изменения сохранены, но конфиг не пересобран. Применить — на странице «Серверы».</small>
|
||||||
|
</div>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button className="btn btn-secondary sm" onClick={saveRulesNow}>Сохранить сейчас</button>
|
||||||
|
{state?.selectedTag && (
|
||||||
|
<button className="btn btn-primary sm" onClick={() => applyServer(state.selectedTag)} disabled={busy}>
|
||||||
|
Применить config
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(page === 'servers' && dirtyServer) && (
|
||||||
|
<div className="sticky-bar">
|
||||||
|
<div className="flex">
|
||||||
|
<span className="dot warning" />
|
||||||
|
<strong>Сервер не применён</strong>
|
||||||
|
<small className="muted">Выбран: {pendingTag}</small>
|
||||||
|
</div>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button className="btn btn-ghost sm" onClick={() => setPendingTag(state?.selectedTag || '')}>Отменить</button>
|
||||||
|
<button className="btn btn-primary sm" onClick={() => applyServer(pendingTag)} disabled={busy}>
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<StatusPane
|
||||||
|
state={state}
|
||||||
|
busy={busy}
|
||||||
|
onStop={stopSingbox}
|
||||||
|
onRestart={restartSingbox}
|
||||||
|
onShowConfig={() => setConfigOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />
|
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />
|
||||||
</main>
|
<Toasts items={toasts} onDismiss={dismissToast} />
|
||||||
|
|
||||||
|
{rollbackOffer && (
|
||||||
|
<div className="toasts">
|
||||||
|
<div className="toast warning">
|
||||||
|
<span className="dot warning" style={{ marginTop: 4 }} />
|
||||||
|
<div className="body">
|
||||||
|
<strong>Сервер применён</strong>
|
||||||
|
<small>Можно откатиться к «{rollbackOffer.to}»</small>
|
||||||
|
<button className="btn btn-link" onClick={rollback} style={{ padding: 0, marginTop: 4 }}>
|
||||||
|
↶ Откатить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setRollbackOffer(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ async function request(url, options = {}) {
|
|||||||
export const api = {
|
export const api = {
|
||||||
state: () => request("/api/state"),
|
state: () => request("/api/state"),
|
||||||
config: () => request("/api/config"),
|
config: () => request("/api/config"),
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
get: () => request("/api/rules"),
|
get: () => request("/api/rules"),
|
||||||
save: (rules) =>
|
save: (rules) =>
|
||||||
request("/api/rules", { method: "PUT", body: JSON.stringify({ rules }) }),
|
request("/api/rules", { method: "PUT", body: JSON.stringify({ rules }) }),
|
||||||
|
conflicts: () => request("/api/rules/conflicts"),
|
||||||
},
|
},
|
||||||
|
|
||||||
subscription: {
|
subscription: {
|
||||||
fetch: (url) =>
|
fetch: (url) =>
|
||||||
request("/api/subscription/fetch", {
|
request("/api/subscription/fetch", {
|
||||||
@@ -31,14 +34,36 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
forget: () => request("/api/subscription", { method: "DELETE" }),
|
forget: () => request("/api/subscription", { method: "DELETE" }),
|
||||||
},
|
},
|
||||||
|
|
||||||
apply: (selectedTag) =>
|
apply: (selectedTag) =>
|
||||||
request("/api/apply", {
|
request("/api/apply", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ selectedTag }),
|
body: JSON.stringify({ selectedTag }),
|
||||||
}),
|
}),
|
||||||
|
rollback: () => request("/api/apply/rollback", { method: "POST" }),
|
||||||
|
|
||||||
singbox: {
|
singbox: {
|
||||||
stop: () => request("/api/singbox/stop", { method: "POST" }),
|
stop: () => request("/api/singbox/stop", { method: "POST" }),
|
||||||
restart: () => request("/api/singbox/restart", { method: "POST" }),
|
restart: () => request("/api/singbox/restart", { method: "POST" }),
|
||||||
clear: () => request("/api/singbox/clear", { 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" }),
|
||||||
};
|
};
|
||||||
|
|||||||
61
src/web/components/ChipsInput.jsx
Normal file
61
src/web/components/ChipsInput.jsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,37 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
|
||||||
export function ConfigViewer({ open, onClose }) {
|
export function ConfigViewer({ open, onClose }) {
|
||||||
const [config, setConfig] = useState(null);
|
const [config, setConfig] = useState(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api
|
setConfig(null);
|
||||||
.config()
|
setError('');
|
||||||
.then((data) => {
|
api.config()
|
||||||
if (!cancelled) setConfig(data.config);
|
.then((data) => { if (!cancelled) setConfig(data.config); })
|
||||||
})
|
.catch((err) => { if (!cancelled) setError(err.message); });
|
||||||
.catch((err) => !cancelled && setError(err.message));
|
return () => { cancelled = true; };
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [open]);
|
}, [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;
|
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() {
|
function download() {
|
||||||
const blob = new Blob([text], { type: 'application/json' });
|
const blob = new Blob([text], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -39,24 +44,41 @@ export function ConfigViewer({ open, onClose }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" onClick={onClose}>
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-head">
|
||||||
<h3>Текущий конфиг sing-box</h3>
|
<div>
|
||||||
<div className="rules-actions">
|
<h3>sing-box config</h3>
|
||||||
<button className="ghost-button" type="button" disabled={!config} onClick={copy}>
|
<small className="muted">Автогенерируемый, перезаписывается при apply</small>
|
||||||
Скопировать
|
</div>
|
||||||
</button>
|
<div className="btn-group">
|
||||||
<button className="ghost-button" type="button" disabled={!config} onClick={download}>
|
<input
|
||||||
Скачать
|
className="input"
|
||||||
</button>
|
placeholder="Поиск…"
|
||||||
<button className="ghost-button solid" type="button" onClick={onClose}>
|
value={search}
|
||||||
Закрыть
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
</button>
|
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>
|
</div>
|
||||||
{error && <div className="error">{error}</div>}
|
<div className="modal-body">
|
||||||
{!error && !config && <p>Конфиг ещё не сгенерирован.</p>}
|
{error && <div className="conflict-banner danger">{error}</div>}
|
||||||
{config && <pre className="config-view">{text}</pre>}
|
{!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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
136
src/web/components/LogsPage.jsx
Normal file
136
src/web/components/LogsPage.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="card" style={{ display: 'flex', flexDirection: 'column', minHeight: 'calc(100vh - 160px)' }}>
|
||||||
|
<div className="card-header">
|
||||||
|
<h2>Логи sing-box</h2>
|
||||||
|
<small className="muted">{entries.length} / {MAX_ENTRIES}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<section className="panel logs-panel">
|
|
||||||
<div className="rules-header">
|
|
||||||
<div className="section-title">
|
|
||||||
<span>5</span>
|
|
||||||
<h2>Логи sing-box</h2>
|
|
||||||
</div>
|
|
||||||
<div className="rules-actions">
|
|
||||||
<select value={filter} onChange={(event) => setFilter(event.target.value)}>
|
|
||||||
<option value="all">все уровни</option>
|
|
||||||
<option value="info">info</option>
|
|
||||||
<option value="error">error</option>
|
|
||||||
</select>
|
|
||||||
<button className="ghost-button" type="button" onClick={() => setPaused((p) => !p)}>
|
|
||||||
{paused ? 'Возобновить' : 'Пауза'}
|
|
||||||
</button>
|
|
||||||
<button className="ghost-button" type="button" onClick={() => setEntries([])}>
|
|
||||||
Очистить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={containerRef} className="logs-stream">
|
|
||||||
{filtered.length === 0 && <p className="empty">Логов пока нет.</p>}
|
|
||||||
{filtered.map((entry, index) => (
|
|
||||||
<p key={`${entry.ts}-${index}`} className={`log-line log-${entry.level}`}>
|
|
||||||
<span className="log-time">{formatTime(entry.ts)}</span>
|
|
||||||
<span className="log-level">{entry.level}</span>
|
|
||||||
<span className="log-text">{entry.line}</span>
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
159
src/web/components/OverviewPage.jsx
Normal file
159
src/web/components/OverviewPage.jsx
Normal file
@@ -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 (
|
||||||
|
<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 }) {
|
||||||
|
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>
|
||||||
|
</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 }) {
|
||||||
|
const rules = state?.customRules || [];
|
||||||
|
const enabled = rules.filter((r) => r.enabled).length;
|
||||||
|
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">Custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
|
||||||
|
<div className="row"><span className="key">Остальное</span><span className="val text-warning">→ VPN</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav }) {
|
||||||
|
return (
|
||||||
|
<div className="section-stack">
|
||||||
|
<StatusHero state={state} status={status} />
|
||||||
|
<div className="grid-2">
|
||||||
|
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} />
|
||||||
|
<RoutingSummary state={state} onNav={onNav} />
|
||||||
|
</div>
|
||||||
|
<RecentEvents onNav={onNav} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/web/components/RouteChecker.jsx
Normal file
74
src/web/components/RouteChecker.jsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
src/web/components/RoutingPage.jsx
Normal file
223
src/web/components/RoutingPage.jsx
Normal file
@@ -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 (
|
||||||
|
<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,
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<div className="section-stack">
|
||||||
|
<RouteChecker />
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<article ref={setNodeRef} style={style} className={errored ? 'rule-card invalid' : 'rule-card'}>
|
|
||||||
<div className="rule-top">
|
|
||||||
<span className="drag-handle" {...attributes} {...listeners} title="Перетащить">
|
|
||||||
⠿ #{index + 1}/{total}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
value={rule.name}
|
|
||||||
onChange={(event) => onUpdate(rule.id, { name: event.target.value })}
|
|
||||||
placeholder="Название списка"
|
|
||||||
/>
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={rule.enabled}
|
|
||||||
onChange={(event) => onUpdate(rule.id, { enabled: event.target.checked })}
|
|
||||||
/>
|
|
||||||
включено
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>Outbound</span>
|
|
||||||
<select value={rule.outbound} onChange={(event) => onUpdate(rule.id, { outbound: event.target.value })}>
|
|
||||||
<option value="direct">direct (напрямую)</option>
|
|
||||||
<option value="vpn">vpn (через выбранный сервер)</option>
|
|
||||||
<option value="block">block (заблокировать)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="rule-fields">
|
|
||||||
<label className={errors.domains.length ? 'field has-error' : 'field'}>
|
|
||||||
<span>Домены (точное совпадение)</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.domains)}
|
|
||||||
onChange={(event) => onUpdate(rule.id, { domains: textToList(event.target.value) })}
|
|
||||||
placeholder="riotgames.com"
|
|
||||||
/>
|
|
||||||
{errors.domains.length > 0 && <small className="error">Невалидно: {errors.domains.join(', ')}</small>}
|
|
||||||
</label>
|
|
||||||
<label className={errors.domainSuffixes.length ? 'field has-error' : 'field'}>
|
|
||||||
<span>Суффиксы доменов</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.domainSuffixes)}
|
|
||||||
onChange={(event) => onUpdate(rule.id, { domainSuffixes: textToList(event.target.value) })}
|
|
||||||
placeholder={'leagueoflegends.com\nriotcdn.net'}
|
|
||||||
/>
|
|
||||||
{errors.domainSuffixes.length > 0 && <small className="error">Невалидно: {errors.domainSuffixes.join(', ')}</small>}
|
|
||||||
</label>
|
|
||||||
<label className={errors.ipCidrs.length ? 'field has-error' : 'field'}>
|
|
||||||
<span>IP CIDR</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.ipCidrs)}
|
|
||||||
onChange={(event) => onUpdate(rule.id, { ipCidrs: textToList(event.target.value) })}
|
|
||||||
placeholder="104.160.128.0/19"
|
|
||||||
/>
|
|
||||||
{errors.ipCidrs.length > 0 && <small className="error">Невалидно: {errors.ipCidrs.join(', ')}</small>}
|
|
||||||
</label>
|
|
||||||
<label className={errors.ports.length ? 'field has-error' : 'field'}>
|
|
||||||
<span>Порты</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.ports)}
|
|
||||||
onChange={(event) => onUpdate(rule.id, { ports: textToList(event.target.value) })}
|
|
||||||
placeholder={'5000\n5223'}
|
|
||||||
/>
|
|
||||||
{errors.ports.length > 0 && <small className="error">Невалидно: {errors.ports.join(', ')}</small>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rule-footer">
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(rule.networks || []).includes('tcp')}
|
|
||||||
onChange={(event) => {
|
|
||||||
const set = new Set(rule.networks || []);
|
|
||||||
event.target.checked ? set.add('tcp') : set.delete('tcp');
|
|
||||||
onUpdate(rule.id, { networks: Array.from(set) });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
tcp
|
|
||||||
</label>
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(rule.networks || []).includes('udp')}
|
|
||||||
onChange={(event) => {
|
|
||||||
const set = new Set(rule.networks || []);
|
|
||||||
event.target.checked ? set.add('udp') : set.delete('udp');
|
|
||||||
onUpdate(rule.id, { networks: Array.from(set) });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
udp
|
|
||||||
</label>
|
|
||||||
<button className="danger-button" type="button" onClick={() => onRemove(rule.id)}>
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
195
src/web/components/RuleEditorDrawer.jsx
Normal file
195
src/web/components/RuleEditorDrawer.jsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ChipsInput } from './ChipsInput.jsx';
|
||||||
|
import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.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 validDomain = (v) => DOMAIN.test(String(v).trim());
|
||||||
|
|
||||||
|
export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder' }) {
|
||||||
|
const [view, setView] = useState(mode); // builder | json
|
||||||
|
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
|
||||||
|
const [jsonError, setJsonError] = useState('');
|
||||||
|
const errors = ruleErrors(rule);
|
||||||
|
|
||||||
|
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">Домены (точное совпадение)</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove }) {
|
||||||
|
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} />
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
closestCenter,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { RuleCard } from './RuleCard.jsx';
|
|
||||||
import { ruleTemplates } from '../templates/ruleTemplates.js';
|
|
||||||
|
|
||||||
export function RulesPanel({ rules, saveStatus, busy, onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder }) {
|
|
||||||
const [templateKey, setTemplateKey] = useState('');
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleDragEnd(event) {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
const oldIndex = rules.findIndex((rule) => rule.id === active.id);
|
|
||||||
const newIndex = rules.findIndex((rule) => rule.id === over.id);
|
|
||||||
if (oldIndex < 0 || newIndex < 0) return;
|
|
||||||
onReorder(arrayMove(rules, oldIndex, newIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddTemplate() {
|
|
||||||
const tpl = ruleTemplates.find((t) => t.key === templateKey);
|
|
||||||
if (!tpl) return;
|
|
||||||
onAddTemplate(tpl.build());
|
|
||||||
setTemplateKey('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveLabel =
|
|
||||||
saveStatus === 'saving'
|
|
||||||
? 'Сохраняем…'
|
|
||||||
: saveStatus === 'pending'
|
|
||||||
? 'Сохранить сейчас'
|
|
||||||
: saveStatus === 'error'
|
|
||||||
? 'Повторить сохранение'
|
|
||||||
: 'Сохранено';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="panel rules-panel">
|
|
||||||
<div className="rules-header">
|
|
||||||
<div className="section-title">
|
|
||||||
<span>4</span>
|
|
||||||
<h2>Правила маршрутизации</h2>
|
|
||||||
</div>
|
|
||||||
<div className="rules-actions">
|
|
||||||
<select value={templateKey} onChange={(event) => setTemplateKey(event.target.value)}>
|
|
||||||
<option value="">Шаблон…</option>
|
|
||||||
{ruleTemplates.map((tpl) => (
|
|
||||||
<option key={tpl.key} value={tpl.key}>
|
|
||||||
{tpl.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button className="ghost-button" type="button" disabled={!templateKey} onClick={handleAddTemplate}>
|
|
||||||
Добавить шаблон
|
|
||||||
</button>
|
|
||||||
<button className="ghost-button" type="button" onClick={onAdd}>
|
|
||||||
Пустое правило
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button solid"
|
|
||||||
type="button"
|
|
||||||
disabled={busy || saveStatus === 'saving'}
|
|
||||||
onClick={onSaveNow}
|
|
||||||
>
|
|
||||||
{saveLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="rules-note">
|
|
||||||
Правила применяются <strong>сверху вниз</strong> (first match wins). Перетаскивай за «⠿» чтобы менять порядок.
|
|
||||||
Они вставляются после safety private-direct и до RU-direct. Для игр указывай домены, суффиксы, CIDR или порты.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
||||||
<SortableContext items={rules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
|
|
||||||
<div className="rule-grid">
|
|
||||||
{rules.length === 0 && (
|
|
||||||
<div className="empty rule-empty">
|
|
||||||
Нет правил. Добавь шаблон (например «League of Legends → direct») или пустое правило.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{rules.map((rule, index) => (
|
|
||||||
<RuleCard
|
|
||||||
key={rule.id}
|
|
||||||
rule={rule}
|
|
||||||
index={index}
|
|
||||||
total={rules.length}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
onRemove={onRemove}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { formatBytes, formatRelative } from '../utils/format.js';
|
|
||||||
|
|
||||||
export function RuntimePanel({ state, log, busy, onStop, onRestart, onClear, onShowConfig }) {
|
|
||||||
const userInfo = state?.userInfo;
|
|
||||||
const traffic = userInfo
|
|
||||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${
|
|
||||||
userInfo.total ? formatBytes(userInfo.total) : 'без лимита'
|
|
||||||
}`
|
|
||||||
: 'нет данных';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="panel details">
|
|
||||||
<div className="section-title">
|
|
||||||
<span>3</span>
|
|
||||||
<h2>Шлюз</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>UI</dt>
|
|
||||||
<dd>:{state?.port || 3456}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Mixed proxy</dt>
|
|
||||||
<dd>:{state?.proxyPort || 8080}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>TProxy</dt>
|
|
||||||
<dd>:{state?.tproxyPort || 7895}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>RU direct</dt>
|
|
||||||
<dd>{state?.routingRuDirect ? 'включено' : 'выключено'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Трафик</dt>
|
|
||||||
<dd>{traffic}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>sing-box</dt>
|
|
||||||
<dd>
|
|
||||||
{state?.singboxRunning
|
|
||||||
? `работает${state.singboxStartedAt ? ` (${formatRelative(state.singboxStartedAt)})` : ''}`
|
|
||||||
: 'остановлен'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Применено</dt>
|
|
||||||
<dd>{state?.appliedAt ? formatRelative(state.appliedAt) : 'не применено'}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="runtime-actions">
|
|
||||||
<button className="ghost-button" type="button" disabled={busy || !state?.singboxRunning} onClick={onStop}>
|
|
||||||
Остановить
|
|
||||||
</button>
|
|
||||||
<button className="ghost-button" type="button" disabled={busy || !state?.configExists} onClick={onRestart}>
|
|
||||||
Перезапустить
|
|
||||||
</button>
|
|
||||||
<button className="ghost-button" type="button" disabled={busy || !state?.configExists} onClick={onClear}>
|
|
||||||
Сбросить конфиг
|
|
||||||
</button>
|
|
||||||
<button className="ghost-button" type="button" disabled={!state?.configExists} onClick={onShowConfig}>
|
|
||||||
Показать config
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="route-card">
|
|
||||||
<span>Политика роутинга</span>
|
|
||||||
<p>private IP → direct</p>
|
|
||||||
<p>geoip-ru / geosite-category-ru → direct</p>
|
|
||||||
<p>остальное → выбранный VPN outbound</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="logs">
|
|
||||||
{log.length === 0 && <p>Ожидание действий…</p>}
|
|
||||||
{log.map((entry, index) => (
|
|
||||||
<p key={`${entry.time}-${index}`}>
|
|
||||||
<span>{entry.time}</span> {entry.message}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export function ServerList({ servers, selectedTag, setSelectedTag, busy, onApply }) {
|
|
||||||
return (
|
|
||||||
<div className="primary-block">
|
|
||||||
<div className="section-title compact">
|
|
||||||
<span>2</span>
|
|
||||||
<h2>Серверы</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="server-list">
|
|
||||||
{servers.length === 0 && <div className="empty">Серверы ещё не загружены</div>}
|
|
||||||
{servers.map((server) => (
|
|
||||||
<button
|
|
||||||
key={server.tag}
|
|
||||||
className={server.tag === selectedTag ? 'server active' : 'server'}
|
|
||||||
onClick={() => setSelectedTag(server.tag)}
|
|
||||||
>
|
|
||||||
<strong>{server.tag}</strong>
|
|
||||||
<small>
|
|
||||||
{server.type} / {server.server}:{server.server_port}
|
|
||||||
</small>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="button apply" disabled={busy || !selectedTag} onClick={onApply}>
|
|
||||||
Применить выбранный сервер
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
210
src/web/components/ServersPage.jsx
Normal file
210
src/web/components/ServersPage.jsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/web/components/SettingsPage.jsx
Normal file
171
src/web/components/SettingsPage.jsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { formatRelative } from '../utils/format.js';
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PortsCard({ state }) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header"><h2>Порты и маршруты</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">Mixed proxy (http+socks5)</span><span className="val text-mono">:{state?.proxyPort || 8080}</span></div>
|
||||||
|
<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>
|
||||||
|
<small className="muted" style={{ display: 'block', marginTop: 10 }}>
|
||||||
|
Эти параметры задаются в config.js на сервере.
|
||||||
|
</small>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
<PortsCard state={state} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/web/components/Sidebar.jsx
Normal file
33
src/web/components/Sidebar.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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 = {} }) {
|
||||||
|
return (
|
||||||
|
<nav className="sidebar">
|
||||||
|
{NAV.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/web/components/StatusPane.jsx
Normal file
91
src/web/components/StatusPane.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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?.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export function SubscriptionPanel({
|
|
||||||
subscriptionUrl,
|
|
||||||
setSubscriptionUrl,
|
|
||||||
hasSubscription,
|
|
||||||
subscriptionHost,
|
|
||||||
busy,
|
|
||||||
onFetch,
|
|
||||||
onForget,
|
|
||||||
editing,
|
|
||||||
setEditing,
|
|
||||||
}) {
|
|
||||||
const masked = hasSubscription && !editing;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="primary-block">
|
|
||||||
<div className="section-title">
|
|
||||||
<span>1</span>
|
|
||||||
<h2>Подписка</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>Subscription URL</span>
|
|
||||||
{masked ? (
|
|
||||||
<div className="masked-row">
|
|
||||||
<code className="masked">{subscriptionHost}</code>
|
|
||||||
<button className="ghost-button" type="button" onClick={() => setEditing(true)}>
|
|
||||||
Изменить
|
|
||||||
</button>
|
|
||||||
<button className="danger-button" type="button" disabled={busy} onClick={onForget}>
|
|
||||||
Забыть
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
value={subscriptionUrl}
|
|
||||||
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
|
||||||
placeholder="https://provider.example/sub/..."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{!masked && (
|
|
||||||
<button
|
|
||||||
className="button"
|
|
||||||
disabled={busy || !subscriptionUrl}
|
|
||||||
onClick={() => {
|
|
||||||
onFetch();
|
|
||||||
setEditing(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{busy ? 'Загрузка…' : 'Загрузить серверы'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
32
src/web/components/Toasts.jsx
Normal file
32
src/web/components/Toasts.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/web/components/Topbar.jsx
Normal file
74
src/web/components/Topbar.jsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar-brand">
|
||||||
|
<span className="logo-dot" />
|
||||||
|
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">
|
||||||
|
{dirty && (
|
||||||
|
<span className="badge warning">● Несохранённые изменения</span>
|
||||||
|
)}
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
↻ Restart
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
1243
src/web/styles.css
1243
src/web/styles.css
File diff suppressed because it is too large
Load Diff
33
src/web/utils/country.js
Normal file
33
src/web/utils/country.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Грубое определение страны по тегу сервера и/или хосту.
|
||||||
|
// Это эвристика — мы не делаем 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 || "🌐";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user