Remove legacy vpn proxy code and simplify the client
This commit is contained in:
@@ -1,101 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { settings } from "./config.js";
|
||||
|
||||
const DEFAULT_CLIENT_SETTINGS = {
|
||||
homeBypassEnabled: false,
|
||||
sharedProxyEnabled: false,
|
||||
sharedProxyControlUrl: "",
|
||||
sharedProxy: null,
|
||||
};
|
||||
|
||||
function normalizeProxyPort(value, fallback = settings.proxyPort) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
const min = Number.isInteger(settings.clientProxyPortStart)
|
||||
? settings.clientProxyPortStart
|
||||
: settings.proxyPort;
|
||||
const max = Number.isInteger(settings.clientProxyPortEnd)
|
||||
? settings.clientProxyPortEnd
|
||||
: min;
|
||||
const fallbackPort =
|
||||
Number.isInteger(fallback) && fallback >= min && fallback <= max
|
||||
? fallback
|
||||
: min;
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
return fallbackPort;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readJson(filePath, fallback) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function normalizeUrl(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!["http:", "https:"].includes(url.protocol)) return "";
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
return url.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSharedProxy(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const host = String(value.host || "").trim();
|
||||
const port = Number.parseInt(value.port, 10);
|
||||
const protocol = value.protocol === "http" ? "http" : "socks5";
|
||||
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
protocol,
|
||||
checkedAt: value.checkedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeClientSettings(input = {}) {
|
||||
const sharedProxy = normalizeSharedProxy(input.sharedProxy);
|
||||
const sharedProxyEnabled = Boolean(input.sharedProxyEnabled && sharedProxy);
|
||||
return {
|
||||
homeBypassEnabled: Boolean(input.homeBypassEnabled),
|
||||
proxyPort: normalizeProxyPort(input.proxyPort),
|
||||
sharedProxyEnabled,
|
||||
sharedProxyControlUrl: normalizeUrl(input.sharedProxyControlUrl),
|
||||
sharedProxy,
|
||||
};
|
||||
}
|
||||
|
||||
export function readClientSettings() {
|
||||
return normalizeClientSettings({
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
proxyPort: settings.proxyPort,
|
||||
...readJson(settings.clientSettingsPath, {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function writeClientSettings(input) {
|
||||
const normalized = normalizeClientSettings({
|
||||
...readClientSettings(),
|
||||
...(input && typeof input === "object" ? input : {}),
|
||||
});
|
||||
writeJson(settings.clientSettingsPath, normalized);
|
||||
return normalized;
|
||||
}
|
||||
@@ -9,29 +9,12 @@ const proxyPort = parsePort(
|
||||
process.env.PROXY_PORT,
|
||||
process.env.APP_MODE === "client" ? 8082 : 8080,
|
||||
);
|
||||
const clientProxyPortStart = parsePort(
|
||||
process.env.CLIENT_PROXY_PORT_START,
|
||||
proxyPort,
|
||||
);
|
||||
const clientProxyPortEnd = parsePort(
|
||||
process.env.CLIENT_PROXY_PORT_END,
|
||||
clientProxyPortStart,
|
||||
);
|
||||
|
||||
export const settings = {
|
||||
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||
port: parsePort(process.env.PORT, 3456),
|
||||
proxyPort,
|
||||
clientProxyPortStart,
|
||||
clientProxyPortEnd,
|
||||
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
||||
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
||||
tproxySourceBypassChain:
|
||||
process.env.TPROXY_SOURCE_BYPASS_CHAIN || "VPN_PROXY_SRC_BYPASS",
|
||||
tproxySourceForwardChain:
|
||||
process.env.TPROXY_SOURCE_FORWARD_CHAIN || "VPN_PROXY_FWD_BYPASS",
|
||||
tproxySourceNatChain:
|
||||
process.env.TPROXY_SOURCE_NAT_CHAIN || "VPN_PROXY_NAT_BYPASS",
|
||||
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
||||
dataDir,
|
||||
distDir: process.env.DIST_DIR || "/app/dist",
|
||||
@@ -39,16 +22,9 @@ export const settings = {
|
||||
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
|
||||
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
||||
statePath: path.join(dataDir, "state.json"),
|
||||
customRulesPath: path.join(dataDir, "custom-rules.json"),
|
||||
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
|
||||
clientSettingsPath: path.join(dataDir, "client-settings.json"),
|
||||
devicesPath: path.join(dataDir, "devices.json"),
|
||||
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||
hwidPath: path.join(dataDir, "hwid"),
|
||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
||||
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
||||
logLevel: process.env.LOG_LEVEL || "info",
|
||||
appName: "VPN Proxy Gateway",
|
||||
};
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { settings } from "./config.js";
|
||||
|
||||
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block", "bypass"]);
|
||||
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
|
||||
export const DEFAULT_DEVICE_MODE = "vpn";
|
||||
export const DEFAULT_PROXY_MODE = "vpn";
|
||||
export const TPROXY_INBOUND = "tproxy-in";
|
||||
export const MIXED_INBOUND = "mixed-in";
|
||||
|
||||
const IPISH_RE = /^[\.\d:/]+$/;
|
||||
|
||||
function readJson(filePath, fallback) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function normalizeDeviceMode(mode, fallback = "rules") {
|
||||
const value = String(mode || "").trim().toLowerCase();
|
||||
return DEVICE_MODES.has(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeDefaultMode(mode) {
|
||||
const value = String(mode || "").trim().toLowerCase();
|
||||
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_DEVICE_MODE;
|
||||
}
|
||||
|
||||
function normalizeProxyMode(mode) {
|
||||
const value = String(mode || "").trim().toLowerCase();
|
||||
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_PROXY_MODE;
|
||||
}
|
||||
|
||||
function normalizeIp(ip) {
|
||||
const value = String(ip || "").trim();
|
||||
return value && IPISH_RE.test(value) ? value : "";
|
||||
}
|
||||
|
||||
function normalizeMac(mac) {
|
||||
return String(mac || "").trim();
|
||||
}
|
||||
|
||||
function fromLegacyDeviceRules(input) {
|
||||
const rules = Array.isArray(input) ? input : [];
|
||||
const devices = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
const sourceIps = Array.isArray(rule?.sourceIps) ? rule.sourceIps : [];
|
||||
const mode = normalizeDeviceMode(rule?.outbound, "direct");
|
||||
sourceIps.forEach((sourceIp, ipIndex) => {
|
||||
const ip = normalizeIp(sourceIp);
|
||||
if (!ip) return;
|
||||
devices.push({
|
||||
id: String(rule.id || `dev-${devices.length}`) + `-${ipIndex}`,
|
||||
name: String(rule.name || `Устройство ${devices.length + 1}`).trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
ip,
|
||||
mac: "",
|
||||
mode,
|
||||
lastSeen: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
defaultTransparentMode: DEFAULT_DEVICE_MODE,
|
||||
proxyDefaultMode: DEFAULT_PROXY_MODE,
|
||||
devices,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDeviceProfiles(input) {
|
||||
const raw =
|
||||
input && typeof input === "object" && !Array.isArray(input)
|
||||
? input
|
||||
: { devices: input };
|
||||
const rawDevices = Array.isArray(raw.devices) ? raw.devices : [];
|
||||
|
||||
return {
|
||||
defaultTransparentMode: normalizeDefaultMode(
|
||||
raw.defaultTransparentMode || raw.defaultMode,
|
||||
),
|
||||
proxyDefaultMode: normalizeProxyMode(raw.proxyDefaultMode),
|
||||
devices: rawDevices.map((device, index) => ({
|
||||
id: String(device.id || `dev-${Date.now()}-${index}`),
|
||||
name: String(device.name || `Устройство ${index + 1}`).trim(),
|
||||
enabled: device.enabled !== false,
|
||||
ip: normalizeIp(device.ip || device.sourceIp),
|
||||
mac: normalizeMac(device.mac),
|
||||
mode: normalizeDeviceMode(device.mode || device.outbound, "rules"),
|
||||
lastSeen: device.lastSeen || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function readDeviceProfiles() {
|
||||
if (fs.existsSync(settings.devicesPath)) {
|
||||
return normalizeDeviceProfiles(readJson(settings.devicesPath, null));
|
||||
}
|
||||
|
||||
if (fs.existsSync(settings.deviceRulesPath)) {
|
||||
return normalizeDeviceProfiles(
|
||||
fromLegacyDeviceRules(readJson(settings.deviceRulesPath, [])),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
defaultTransparentMode: DEFAULT_DEVICE_MODE,
|
||||
proxyDefaultMode: DEFAULT_PROXY_MODE,
|
||||
devices: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function writeDeviceProfiles(value) {
|
||||
const normalized = normalizeDeviceProfiles(value);
|
||||
writeJson(settings.devicesPath, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeCidr(ip) {
|
||||
const value = normalizeIp(ip);
|
||||
if (!value) return "";
|
||||
return value.includes("/") ? value : `${value}/32`;
|
||||
}
|
||||
|
||||
export function deviceCidrs(devices, modes) {
|
||||
const allowedModes = new Set(Array.isArray(modes) ? modes : [modes]);
|
||||
return (Array.isArray(devices) ? devices : [])
|
||||
.filter((device) => device.enabled !== false && allowedModes.has(device.mode))
|
||||
.map((device) => normalizeCidr(device.ip))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function legacyDeviceRulesFromProfiles(profiles) {
|
||||
const { devices } = normalizeDeviceProfiles(profiles);
|
||||
return devices.map((device) => ({
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
enabled: device.enabled,
|
||||
sourceIps: device.ip ? [device.ip] : [],
|
||||
outbound: device.mode === "rules" ? "direct" : device.mode,
|
||||
}));
|
||||
}
|
||||
1618
src/server/index.js
1618
src/server/index.js
File diff suppressed because it is too large
Load Diff
@@ -1,340 +0,0 @@
|
||||
// Простой симулятор роутинга sing-box.
|
||||
// Берём список customRules + safety/RU-direct и определяем, какое правило сработает.
|
||||
// Для geoip-ru / geosite-category-ru возвращаем "может сработать" — без скачанного ruleset
|
||||
// мы не можем точно сказать, попадает ли IP/домен в RU.
|
||||
|
||||
import net from "node:net";
|
||||
import { TPROXY_INBOUND, MIXED_INBOUND } from "./devices.js";
|
||||
|
||||
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 normalizeCidr(ip) {
|
||||
const value = String(ip || "").trim();
|
||||
if (!value) return "";
|
||||
return value.includes("/") ? value : `${value}/32`;
|
||||
}
|
||||
|
||||
function deviceMatchesSourceIp(device, sourceIp) {
|
||||
if (!device?.ip || !sourceIp) return false;
|
||||
return ipInCidr(sourceIp, normalizeCidr(device.ip));
|
||||
}
|
||||
|
||||
function modeOutbound(mode, vpnTag) {
|
||||
if (mode === "vpn") return `${vpnTag} (VPN)`;
|
||||
if (mode === "direct" || mode === "block") return mode;
|
||||
return null;
|
||||
}
|
||||
|
||||
function likelyRuHost(host) {
|
||||
const value = String(host || "").toLowerCase();
|
||||
return value === "ru" || value.endsWith(".ru");
|
||||
}
|
||||
|
||||
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",
|
||||
deviceProfiles = {
|
||||
defaultTransparentMode: "vpn",
|
||||
proxyDefaultMode: "vpn",
|
||||
devices: [],
|
||||
},
|
||||
} = options;
|
||||
const rules = Array.isArray(customRules) ? customRules : [];
|
||||
const inbound = target.inbound || TPROXY_INBOUND;
|
||||
const sourceIp = target.sourceIp || "";
|
||||
const devices = Array.isArray(deviceProfiles.devices)
|
||||
? deviceProfiles.devices
|
||||
: [];
|
||||
const matchedDevice = devices.find(
|
||||
(device) =>
|
||||
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
|
||||
);
|
||||
|
||||
if (
|
||||
inbound === TPROXY_INBOUND &&
|
||||
matchedDevice &&
|
||||
matchedDevice.mode === "bypass"
|
||||
) {
|
||||
return {
|
||||
matched: "kernel-bypass",
|
||||
ruleIndex: -1,
|
||||
ruleId: matchedDevice.id,
|
||||
ruleName: `${matchedDevice.name} -> bypass TProxy`,
|
||||
outbound: "direct",
|
||||
reason: "Source IP исключён на уровне iptables до попадания в sing-box",
|
||||
};
|
||||
}
|
||||
|
||||
// 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. global custom rules apply to every inbound before fallbacks.
|
||||
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: "Совпадение по global custom rule",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. RU direct is global. Without a local rule-set DB we only detect obvious .ru hosts.
|
||||
if (routingRuDirect && likelyRuHost(target.host)) {
|
||||
return {
|
||||
matched: "geo",
|
||||
ruleIndex: -2,
|
||||
ruleName: "geosite-category-ru → direct",
|
||||
outbound: "direct",
|
||||
reason: "Домен выглядит как RU; точное попадание в rule-set проверит sing-box",
|
||||
};
|
||||
}
|
||||
|
||||
// 4. transparent device defaults.
|
||||
if (inbound === TPROXY_INBOUND && matchedDevice) {
|
||||
const outbound = modeOutbound(matchedDevice.mode, vpnTag);
|
||||
if (outbound) {
|
||||
return {
|
||||
matched: "device-default",
|
||||
ruleIndex: -1,
|
||||
ruleId: matchedDevice.id,
|
||||
ruleName: `${matchedDevice.name} → ${matchedDevice.mode}`,
|
||||
outbound,
|
||||
reason: "Fallback устройства после global rules",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 5. explicit proxy default.
|
||||
if (inbound === MIXED_INBOUND) {
|
||||
const mode = deviceProfiles.proxyDefaultMode || "vpn";
|
||||
return {
|
||||
matched: "proxy-default",
|
||||
ruleIndex: -1,
|
||||
ruleName: `mixed-in default → ${mode}`,
|
||||
outbound: modeOutbound(mode, vpnTag) || `${vpnTag} (VPN)`,
|
||||
reason: "Fallback explicit HTTP/SOCKS proxy после global rules",
|
||||
};
|
||||
}
|
||||
|
||||
// 6. unknown transparent device default.
|
||||
if (inbound === TPROXY_INBOUND) {
|
||||
const mode = deviceProfiles.defaultTransparentMode || "vpn";
|
||||
return {
|
||||
matched: "transparent-default",
|
||||
ruleIndex: -1,
|
||||
ruleName: `transparent default → ${mode}`,
|
||||
outbound: modeOutbound(mode, vpnTag) || "direct",
|
||||
reason: "Fallback unknown transparent device после global rules",
|
||||
};
|
||||
}
|
||||
|
||||
// 7. final → direct
|
||||
return {
|
||||
matched: "final",
|
||||
ruleIndex: -3,
|
||||
ruleName: "final",
|
||||
outbound: "direct",
|
||||
reason: "Не сработало ни одно правило — итоговый final отправляет напрямую",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Детектор конфликтов: ищет правила, перекрытые предыдущими.
|
||||
* Простая эвристика: если правило-кандидат полностью перекрывается ранее идущим
|
||||
* по доменам/суффиксам/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;
|
||||
}
|
||||
@@ -1,352 +1,71 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { settings } from "./config.js";
|
||||
import {
|
||||
MIXED_INBOUND,
|
||||
TPROXY_INBOUND,
|
||||
normalizeCidr,
|
||||
readDeviceProfiles,
|
||||
} from "./devices.js";
|
||||
import { readClientSettings } from "./clientSettings.js";
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { settings } from './config.js';
|
||||
|
||||
const PROXY_TYPES = new Set([
|
||||
"vless",
|
||||
"vmess",
|
||||
"trojan",
|
||||
"shadowsocks",
|
||||
"hysteria2",
|
||||
]);
|
||||
const CUSTOM_OUTBOUNDS = new Set(["direct", "vpn", "block"]);
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
const MIXED_INBOUND = 'mixed-in';
|
||||
const TPROXY_INBOUND = 'tproxy-in';
|
||||
|
||||
function findOutbound(subscriptionConfig, selectedTag) {
|
||||
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
|
||||
? subscriptionConfig.outbounds
|
||||
: [];
|
||||
const exact = outbounds.find(
|
||||
(outbound) =>
|
||||
outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type),
|
||||
);
|
||||
if (exact) return exact;
|
||||
|
||||
const trimmedTag = String(selectedTag || "").trim();
|
||||
return outbounds.find(
|
||||
(outbound) =>
|
||||
String(outbound.tag || "").trim() === trimmedTag &&
|
||||
PROXY_TYPES.has(outbound.type),
|
||||
);
|
||||
const tag = String(selectedTag || '').trim();
|
||||
return outbounds.find((outbound) => (
|
||||
String(outbound.tag || '').trim() === tag && PROXY_TYPES.has(outbound.type)
|
||||
));
|
||||
}
|
||||
|
||||
function readCustomRuleSets() {
|
||||
try {
|
||||
if (!fs.existsSync(settings.customRuleSetsPath)) return [];
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(settings.customRuleSetsPath, "utf8"),
|
||||
);
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function ruleSetDownloadDetour(vpnTag) {
|
||||
const detour = String(settings.ruleSetDownloadDetour || "vpn").trim();
|
||||
if (!detour || detour === "vpn") return vpnTag;
|
||||
return detour;
|
||||
}
|
||||
|
||||
function ruleSets(customRuleSets = [], vpnTag = "direct") {
|
||||
const downloadDetour = ruleSetDownloadDetour(vpnTag);
|
||||
const builtIn = settings.routingRuDirect
|
||||
? [
|
||||
{
|
||||
type: "remote",
|
||||
tag: "geoip-ru",
|
||||
format: "binary",
|
||||
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs",
|
||||
download_detour: downloadDetour,
|
||||
},
|
||||
{
|
||||
type: "remote",
|
||||
tag: "geosite-category-ru",
|
||||
format: "binary",
|
||||
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs",
|
||||
download_detour: downloadDetour,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const custom = (Array.isArray(customRuleSets) ? customRuleSets : [])
|
||||
.filter((rs) => rs.tag && rs.url)
|
||||
.map((rs) => ({
|
||||
type: "remote",
|
||||
tag: String(rs.tag).trim(),
|
||||
format: rs.format || "binary",
|
||||
url: String(rs.url).trim(),
|
||||
download_detour: downloadDetour,
|
||||
}));
|
||||
|
||||
// Пользовательские rule-sets не должны дублировать встроенные
|
||||
const builtInTags = new Set(builtIn.map((rs) => rs.tag));
|
||||
const merged = [
|
||||
...builtIn,
|
||||
...custom.filter((rs) => !builtInTags.has(rs.tag)),
|
||||
];
|
||||
return merged;
|
||||
}
|
||||
|
||||
function uniqueClean(values) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(Array.isArray(values) ? values : [])
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parsePorts(values) {
|
||||
return uniqueClean(values)
|
||||
.map((value) => Number.parseInt(value, 10))
|
||||
.filter((value) => Number.isInteger(value) && value > 0 && value <= 65535);
|
||||
}
|
||||
|
||||
function toSingboxRule(customRule, vpnTag, baseRule = {}) {
|
||||
if (!customRule?.enabled) return null;
|
||||
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
|
||||
|
||||
const rule = { ...baseRule };
|
||||
const domains = uniqueClean(customRule.domains);
|
||||
const domainSuffixes = uniqueClean(customRule.domainSuffixes);
|
||||
const domainKeywords = uniqueClean(customRule.domainKeywords);
|
||||
const ipCidrs = uniqueClean(customRule.ipCidrs);
|
||||
const ports = parsePorts(customRule.ports);
|
||||
const networks = uniqueClean(customRule.networks).filter((network) =>
|
||||
["tcp", "udp"].includes(network),
|
||||
);
|
||||
|
||||
if (domains.length) rule.domain = domains;
|
||||
if (domainSuffixes.length) rule.domain_suffix = domainSuffixes;
|
||||
if (domainKeywords.length) rule.domain_keyword = domainKeywords;
|
||||
if (ipCidrs.length) rule.ip_cidr = ipCidrs;
|
||||
if (ports.length) rule.port = ports;
|
||||
if (networks.length) rule.network = networks;
|
||||
|
||||
const ruleSetsRef = uniqueClean(customRule.ruleSets);
|
||||
if (ruleSetsRef.length) rule.rule_set = ruleSetsRef;
|
||||
|
||||
if (
|
||||
!rule.domain &&
|
||||
!rule.domain_suffix &&
|
||||
!rule.domain_keyword &&
|
||||
!rule.ip_cidr &&
|
||||
!rule.port &&
|
||||
!rule.network &&
|
||||
!rule.rule_set
|
||||
) {
|
||||
return null;
|
||||
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
||||
const clientMode = settings.appMode === 'client';
|
||||
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
||||
if (!vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
|
||||
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) {
|
||||
vpnOutbound.packet_encoding = 'xudp';
|
||||
}
|
||||
|
||||
rule.outbound = customRule.outbound === "vpn" ? vpnTag : customRule.outbound;
|
||||
return rule;
|
||||
}
|
||||
|
||||
function customRouteRules(customRules, vpnTag, baseRule = {}) {
|
||||
return (Array.isArray(customRules) ? customRules : [])
|
||||
.map((rule) => toSingboxRule(rule, vpnTag, baseRule))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// ─── Device rules (маршрутизация по source IP) ──────────────────────────────
|
||||
|
||||
function modeOutbound(mode, vpnTag) {
|
||||
if (mode === "vpn") return vpnTag;
|
||||
if (mode === "direct" || mode === "block") return mode;
|
||||
return null;
|
||||
}
|
||||
|
||||
function deviceDefaultRouteRule(device, vpnTag) {
|
||||
if (!device?.enabled) return null;
|
||||
const outbound = modeOutbound(device.mode, vpnTag);
|
||||
if (!outbound) return null;
|
||||
|
||||
const cidr = normalizeCidr(device.ip);
|
||||
if (!cidr) return null;
|
||||
|
||||
return {
|
||||
inbound: [TPROXY_INBOUND],
|
||||
source_ip_cidr: [cidr],
|
||||
outbound,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceDefaultRouteRules(devices, vpnTag) {
|
||||
return (Array.isArray(devices) ? devices : [])
|
||||
.map((device) => deviceDefaultRouteRule(device, vpnTag))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function inboundDefaultRule(inbound, mode, vpnTag) {
|
||||
const outbound = modeOutbound(mode, vpnTag);
|
||||
if (!outbound) return null;
|
||||
return { inbound: [inbound], outbound };
|
||||
}
|
||||
|
||||
function ruDirectRule() {
|
||||
if (!settings.routingRuDirect) return null;
|
||||
return {
|
||||
rule_set: ["geoip-ru", "geosite-category-ru"],
|
||||
outbound: "direct",
|
||||
};
|
||||
}
|
||||
|
||||
function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
|
||||
const deviceProfiles = readDeviceProfiles();
|
||||
const rules = [
|
||||
{
|
||||
ip_is_private: true,
|
||||
outbound: "direct",
|
||||
},
|
||||
];
|
||||
|
||||
// Global rules apply to every inbound before contextual fallbacks.
|
||||
rules.push(...customRouteRules(customRules, vpnTag));
|
||||
|
||||
const ruRule = ruDirectRule();
|
||||
if (ruRule) rules.push(ruRule);
|
||||
|
||||
if (includeTransparent) {
|
||||
// Device defaults are only transparent-gateway fallbacks after global rules.
|
||||
rules.push(...deviceDefaultRouteRules(deviceProfiles.devices, vpnTag));
|
||||
}
|
||||
|
||||
const proxyFallback = inboundDefaultRule(
|
||||
MIXED_INBOUND,
|
||||
deviceProfiles.proxyDefaultMode,
|
||||
vpnTag,
|
||||
);
|
||||
if (proxyFallback) rules.push(proxyFallback);
|
||||
|
||||
if (includeTransparent) {
|
||||
const transparentFallback = inboundDefaultRule(
|
||||
TPROXY_INBOUND,
|
||||
deviceProfiles.defaultTransparentMode,
|
||||
vpnTag,
|
||||
);
|
||||
if (transparentFallback) rules.push(transparentFallback);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
function sharedProxyOutbound(sharedProxy) {
|
||||
if (!sharedProxy?.host || !sharedProxy?.port) return null;
|
||||
if (sharedProxy.protocol === "http") {
|
||||
return {
|
||||
type: "http",
|
||||
tag: "shared-proxy",
|
||||
server: sharedProxy.host,
|
||||
server_port: sharedProxy.port,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "socks",
|
||||
tag: "shared-proxy",
|
||||
server: sharedProxy.host,
|
||||
server_port: sharedProxy.port,
|
||||
version: "5",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGatewayConfig(
|
||||
subscriptionConfig,
|
||||
selectedTag,
|
||||
{ bypassAll = false } = {},
|
||||
) {
|
||||
const customRuleSets = readCustomRuleSets();
|
||||
const clientMode = settings.appMode === "client";
|
||||
const clientSettings = clientMode ? readClientSettings() : null;
|
||||
const sharedOutbound =
|
||||
clientMode && clientSettings?.sharedProxyEnabled
|
||||
? sharedProxyOutbound(clientSettings.sharedProxy)
|
||||
: null;
|
||||
const directOnlyClient = clientMode && clientSettings?.homeBypassEnabled;
|
||||
const selectedOutbound = sharedOutbound
|
||||
? null
|
||||
: findOutbound(subscriptionConfig, selectedTag);
|
||||
if (!sharedOutbound && !directOnlyClient && !selectedOutbound) {
|
||||
throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||
}
|
||||
|
||||
const vpnOutbound = selectedOutbound ? clone(selectedOutbound) : null;
|
||||
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
|
||||
if (vpnOutbound?.type === "vless" && !vpnOutbound.packet_encoding) {
|
||||
vpnOutbound.packet_encoding = "xudp";
|
||||
}
|
||||
|
||||
const clientOutbound = sharedOutbound
|
||||
? sharedOutbound.tag
|
||||
: clientSettings?.homeBypassEnabled
|
||||
? "direct"
|
||||
: vpnOutbound.tag;
|
||||
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
|
||||
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
|
||||
const inbounds = [
|
||||
...(clientMode
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "tproxy",
|
||||
tag: "tproxy-in",
|
||||
listen: "::",
|
||||
listen_port: settings.tproxyPort,
|
||||
sniff: true,
|
||||
sniff_override_destination: true,
|
||||
},
|
||||
]),
|
||||
...(!clientMode ? [{
|
||||
type: 'tproxy',
|
||||
tag: TPROXY_INBOUND,
|
||||
listen: '::',
|
||||
listen_port: settings.tproxyPort,
|
||||
sniff: true,
|
||||
sniff_override_destination: true,
|
||||
}] : []),
|
||||
{
|
||||
type: "mixed",
|
||||
tag: "mixed-in",
|
||||
type: 'mixed',
|
||||
tag: MIXED_INBOUND,
|
||||
listen: settings.bindIp,
|
||||
listen_port: mixedProxyPort,
|
||||
listen_port: settings.proxyPort,
|
||||
sniff: true,
|
||||
set_system_proxy: false,
|
||||
},
|
||||
];
|
||||
const rules = clientMode
|
||||
? [{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }]
|
||||
: [
|
||||
{ inbound: [TPROXY_INBOUND], outbound: vpnOutbound.tag },
|
||||
{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag },
|
||||
];
|
||||
|
||||
return {
|
||||
log: {
|
||||
level: settings.logLevel,
|
||||
timestamp: true,
|
||||
},
|
||||
log: { level: settings.logLevel, timestamp: true },
|
||||
experimental: {
|
||||
cache_file: {
|
||||
enabled: true,
|
||||
path: settings.cachePath,
|
||||
},
|
||||
},
|
||||
dns: {
|
||||
independent_cache: true,
|
||||
cache_file: { enabled: true, path: settings.cachePath },
|
||||
},
|
||||
dns: { independent_cache: true },
|
||||
inbounds,
|
||||
outbounds: [
|
||||
...(sharedOutbound ? [sharedOutbound] : vpnOutbound ? [vpnOutbound] : []),
|
||||
{ type: "direct", tag: "direct" },
|
||||
{ type: "block", tag: "block" },
|
||||
vpnOutbound,
|
||||
{ type: 'direct', tag: 'direct' },
|
||||
{ type: 'block', tag: 'block' },
|
||||
],
|
||||
route: {
|
||||
rule_set: bypassAll || clientMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
|
||||
rules: bypassAll
|
||||
? [{ ip_is_private: true, outbound: "direct" }]
|
||||
: clientMode
|
||||
? proxyOnlyRules
|
||||
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
|
||||
includeTransparent: !clientMode,
|
||||
}),
|
||||
final: "direct",
|
||||
rule_set: [],
|
||||
rules,
|
||||
final: vpnOutbound.tag,
|
||||
...(clientMode ? {} : { auto_detect_interface: true }),
|
||||
},
|
||||
};
|
||||
@@ -354,24 +73,9 @@ export function buildGatewayConfig(
|
||||
|
||||
export function writeSingboxConfig(config) {
|
||||
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
settings.configPath,
|
||||
JSON.stringify(config, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export function readSingboxConfig() {
|
||||
if (!fs.existsSync(settings.configPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(settings.configPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export function removeSingboxConfig() {
|
||||
if (fs.existsSync(settings.configPath)) {
|
||||
fs.rmSync(settings.configPath);
|
||||
}
|
||||
fs.rmSync(settings.configPath, { force: true });
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { settings } from "./config.js";
|
||||
import { deviceCidrs, normalizeCidr } from "./devices.js";
|
||||
|
||||
const DEFAULT_NAT_BYPASS_CIDRS =
|
||||
"0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4";
|
||||
|
||||
function splitCidrs(value) {
|
||||
return String(value || "")
|
||||
.split(/[\s,]+/)
|
||||
.map((item) => normalizeCidr(item))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function unique(list) {
|
||||
return [...new Set(list)];
|
||||
}
|
||||
|
||||
export function sourceBypassCidrs(
|
||||
profiles,
|
||||
envCidrs = process.env.TPROXY_BYPASS_SOURCE_CIDRS || "",
|
||||
) {
|
||||
return unique([
|
||||
...splitCidrs(envCidrs),
|
||||
...deviceCidrs(profiles?.devices || [], "bypass"),
|
||||
]);
|
||||
}
|
||||
|
||||
export function buildSourceBypassIptablesCommands(
|
||||
cidrs,
|
||||
{
|
||||
chain = settings.tproxySourceBypassChain,
|
||||
forwardChain = settings.tproxySourceForwardChain,
|
||||
natChain = settings.tproxySourceNatChain,
|
||||
natBypassCidrs = splitCidrs(
|
||||
process.env.BYPASS_CIDRS || DEFAULT_NAT_BYPASS_CIDRS,
|
||||
),
|
||||
} = {},
|
||||
) {
|
||||
return [
|
||||
["-w", "-t", "mangle", "-F", chain],
|
||||
["-w", "-F", forwardChain],
|
||||
["-w", "-t", "nat", "-F", natChain],
|
||||
...cidrs.map((cidr) => [
|
||||
"-w",
|
||||
"-t",
|
||||
"mangle",
|
||||
"-A",
|
||||
chain,
|
||||
"-s",
|
||||
cidr,
|
||||
"-j",
|
||||
"ACCEPT",
|
||||
]),
|
||||
...cidrs.flatMap((cidr) => [
|
||||
["-w", "-A", forwardChain, "-s", cidr, "-j", "ACCEPT"],
|
||||
[
|
||||
"-w",
|
||||
"-A",
|
||||
forwardChain,
|
||||
"-d",
|
||||
cidr,
|
||||
"-m",
|
||||
"conntrack",
|
||||
"--ctstate",
|
||||
"RELATED,ESTABLISHED",
|
||||
"-j",
|
||||
"ACCEPT",
|
||||
],
|
||||
]),
|
||||
...natBypassCidrs.map((cidr) => [
|
||||
"-w",
|
||||
"-t",
|
||||
"nat",
|
||||
"-A",
|
||||
natChain,
|
||||
"-d",
|
||||
cidr,
|
||||
"-j",
|
||||
"RETURN",
|
||||
]),
|
||||
...cidrs.map((cidr) => [
|
||||
"-w",
|
||||
"-t",
|
||||
"nat",
|
||||
"-A",
|
||||
natChain,
|
||||
"-s",
|
||||
cidr,
|
||||
"-j",
|
||||
"MASQUERADE",
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function syncTproxySourceBypass(profiles, options = {}) {
|
||||
if (settings.appMode !== "gateway") {
|
||||
return { success: true, skipped: true, cidrs: [] };
|
||||
}
|
||||
|
||||
const cidrs = sourceBypassCidrs(
|
||||
profiles,
|
||||
options.envCidrs ?? process.env.TPROXY_BYPASS_SOURCE_CIDRS,
|
||||
);
|
||||
const commands = buildSourceBypassIptablesCommands(cidrs, options);
|
||||
|
||||
for (const args of commands) {
|
||||
const result = spawnSync("iptables", args, {
|
||||
encoding: "utf8",
|
||||
timeout: 1000,
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
return {
|
||||
success: false,
|
||||
cidrs,
|
||||
error:
|
||||
result.error?.message ||
|
||||
(result.stderr || result.stdout || "iptables command failed").trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, cidrs };
|
||||
}
|
||||
549
src/web/App.jsx
549
src/web/App.jsx
@@ -1,85 +1,22 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
import { api } from './api.js';
|
||||
import { Topbar } from './components/Topbar.jsx';
|
||||
import { Sidebar } from './components/Sidebar.jsx';
|
||||
import { StatusPane } from './components/StatusPane.jsx';
|
||||
import { OverviewPage } from './components/OverviewPage.jsx';
|
||||
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
||||
import { ServersPage } from './components/ServersPage.jsx';
|
||||
import { RoutingPage } from './components/RoutingPage.jsx';
|
||||
import { LogsPage } from './components/LogsPage.jsx';
|
||||
import { SettingsPage } from './components/SettingsPage.jsx';
|
||||
import { ConfigViewer } from './components/ConfigViewer.jsx';
|
||||
import { Toasts } from './components/Toasts.jsx';
|
||||
|
||||
const ROLLBACK_WINDOW_MS = 12_000;
|
||||
|
||||
function getInitialPage() {
|
||||
const hash = window.location.hash.replace('#/', '').replace('#', '');
|
||||
const valid = ['overview', 'servers', 'routing', 'logs', 'settings'];
|
||||
return valid.includes(hash) ? hash : 'overview';
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [page, setPage] = useState(getInitialPage());
|
||||
const [state, setState] = useState(null);
|
||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||
const [servers, setServers] = useState([]);
|
||||
const [customRules, setCustomRules] = useState([]);
|
||||
const [devicesConfig, setDevicesConfig] = useState({
|
||||
defaultTransparentMode: 'vpn',
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: [],
|
||||
});
|
||||
const [selectedTag, setSelectedTag] = useState('');
|
||||
const [pendingTag, setPendingTag] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [pings, setPings] = useState({});
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [applyStatus, setApplyStatus] = useState('idle'); // idle | applying | error
|
||||
const [rollbackOffer, setRollbackOffer] = useState(null);
|
||||
|
||||
const rulesDirtyRef = useRef(false);
|
||||
const rulesSaveTimerRef = useRef(null);
|
||||
const rulesRevisionRef = useRef(0);
|
||||
const rollbackTimerRef = useRef(null);
|
||||
|
||||
function pushToast(toast) {
|
||||
const id = `t-${Date.now()}-${Math.random()}`;
|
||||
setToasts((prev) => [...prev, { id, ...toast }]);
|
||||
}
|
||||
function dismissToast(id) {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
function navigate(p) {
|
||||
setPage(p);
|
||||
window.location.hash = `#/${p}`;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onHash() { setPage(getInitialPage()); }
|
||||
window.addEventListener('hashchange', onHash);
|
||||
return () => window.removeEventListener('hashchange', onHash);
|
||||
}, []);
|
||||
|
||||
async function loadState() {
|
||||
const data = await api.state();
|
||||
setState(data);
|
||||
setServers(data.servers || []);
|
||||
if (!rulesDirtyRef.current) setCustomRules(data.customRules || []);
|
||||
setDevicesConfig(data.devicesConfig || {
|
||||
defaultTransparentMode: 'vpn',
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||
setPendingTag((current) => current || data.selectedTag || '');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,496 +25,72 @@ function App() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.mode === 'client' && page !== 'overview') {
|
||||
navigate('overview');
|
||||
}
|
||||
}, [state?.mode, page]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
|
||||
}, []);
|
||||
|
||||
async function withBusy(label, fn, { quiet = false } = {}) {
|
||||
async function run(action) {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await fn();
|
||||
if (!quiet && label && state?.mode !== 'client') {
|
||||
pushToast({ kind: 'success', title: label });
|
||||
}
|
||||
return result;
|
||||
await action();
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message, duration: 6000 });
|
||||
throw err;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// === Subscription ===
|
||||
async function fetchSubscription() {
|
||||
return withBusy('Подписка обновлена', async () => {
|
||||
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
|
||||
return run(async () => {
|
||||
const data = await api.subscription.fetch(subscriptionUrl);
|
||||
setServers(data.servers || []);
|
||||
if (data.servers?.length) {
|
||||
if (state?.mode === 'client') {
|
||||
setSelectedTag('');
|
||||
setPendingTag('');
|
||||
} else {
|
||||
const nextTag = data.servers.some((server) => server.tag === selectedTag)
|
||||
? selectedTag
|
||||
: data.servers[0].tag;
|
||||
setSelectedTag(nextTag);
|
||||
setPendingTag(nextTag);
|
||||
}
|
||||
}
|
||||
await loadState();
|
||||
setPendingTag('');
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshSubscriptionInfo() {
|
||||
const data = await api.subscription.refreshInfo();
|
||||
setState((prev) => prev ? { ...prev, userInfo: data.userInfo, fetchedAt: data.fetchedAt } : prev);
|
||||
setState((current) => current ? {
|
||||
...current,
|
||||
userInfo: data.userInfo,
|
||||
fetchedAt: data.fetchedAt,
|
||||
} : current);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function forgetSubscription() {
|
||||
if (!confirm('Удалить подписку и остановить sing-box?')) return;
|
||||
return withBusy('Подписка удалена', async () => {
|
||||
if (!confirm('Удалить подписку и остановить VPN?')) return;
|
||||
return run(async () => {
|
||||
await api.subscription.forget();
|
||||
setSubscriptionUrl('');
|
||||
setServers([]);
|
||||
setSelectedTag('');
|
||||
setPendingTag('');
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
// === Apply with rollback offer ===
|
||||
async function applyServer(tag) {
|
||||
const target = tag || selectedTag;
|
||||
if (!target) return;
|
||||
const previous = state?.selectedTag;
|
||||
setApplyStatus('applying');
|
||||
try {
|
||||
await withBusy('Сервер применён', async () => {
|
||||
await api.apply(target);
|
||||
await loadState();
|
||||
});
|
||||
setApplyStatus('idle');
|
||||
|
||||
if (state?.mode !== 'client' && previous && previous !== target) {
|
||||
setRollbackOffer({ from: target, to: previous, expiresAt: Date.now() + ROLLBACK_WINDOW_MS });
|
||||
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
|
||||
rollbackTimerRef.current = setTimeout(() => setRollbackOffer(null), ROLLBACK_WINDOW_MS);
|
||||
}
|
||||
} catch {
|
||||
setApplyStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function rollback() {
|
||||
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
|
||||
setRollbackOffer(null);
|
||||
return withBusy('Откат выполнен', async () => {
|
||||
const data = await api.rollback();
|
||||
setSelectedTag(data.selectedTag);
|
||||
setPendingTag(data.selectedTag);
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
// === sing-box control ===
|
||||
async function stopSingbox(confirmFirst = true) {
|
||||
if (confirmFirst && !confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
|
||||
return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); });
|
||||
}
|
||||
async function restartSingbox() {
|
||||
return withBusy('Перезапущено', async () => { await api.singbox.restart(); await loadState(); });
|
||||
}
|
||||
async function clearConfig() {
|
||||
if (!confirm('Сбросить config sing-box и остановить процесс?')) return;
|
||||
return withBusy('Config сброшен', async () => {
|
||||
await api.singbox.clear();
|
||||
setSelectedTag('');
|
||||
setPendingTag('');
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleBypass() {
|
||||
const next = !state?.bypassMode;
|
||||
return withBusy(
|
||||
next ? 'Обход правил включён — весь трафик напрямую' : 'Обход правил отключён',
|
||||
async () => {
|
||||
await api.bypass(next);
|
||||
await loadState();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function flushDirectCache() {
|
||||
return withBusy('Bypass-кэш сброшен', async () => {
|
||||
await api.directCache.flush();
|
||||
await loadState();
|
||||
});
|
||||
}
|
||||
|
||||
// === Devices ===
|
||||
async function saveDevicesConfig(nextConfig) {
|
||||
try {
|
||||
const data = await api.devices.save(nextConfig);
|
||||
setDevicesConfig({
|
||||
defaultTransparentMode: data.defaultTransparentMode || data.defaultMode || 'vpn',
|
||||
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setState((prev) => prev ? {
|
||||
...prev,
|
||||
devicesUpdatedAt: data.devicesUpdatedAt,
|
||||
sourceBypassCidrs: data.sourceBypassCidrs,
|
||||
} : prev);
|
||||
if (data.sourceBypassResult && data.sourceBypassResult.success === false) {
|
||||
pushToast({
|
||||
kind: 'warning',
|
||||
title: 'Bypass сохранён, но не применён',
|
||||
message: data.sourceBypassResult.error,
|
||||
duration: 7000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function addDevice() {
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: [
|
||||
...devicesConfig.devices,
|
||||
{ id: `dev-${Date.now()}`, name: 'Новое устройство', enabled: true, ip: '', mac: '', mode: 'direct', lastSeen: null },
|
||||
],
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function updateDevice(id, patch) {
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: devicesConfig.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function removeDevice(id) {
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: devicesConfig.devices.filter((d) => d.id !== id),
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function updateDeviceDefaults(patch) {
|
||||
const nextConfig = { ...devicesConfig, ...patch };
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
// === Rules CRUD ===
|
||||
function emptyRule() {
|
||||
return {
|
||||
id: `rule-${Date.now()}`,
|
||||
name: 'Новое правило',
|
||||
enabled: true,
|
||||
outbound: 'direct',
|
||||
domains: [], domainSuffixes: [], domainKeywords: [],
|
||||
ipCidrs: [], ports: [], networks: [],
|
||||
};
|
||||
}
|
||||
|
||||
function queueRulesSave(nextRules) {
|
||||
rulesDirtyRef.current = true;
|
||||
const revision = rulesRevisionRef.current + 1;
|
||||
rulesRevisionRef.current = revision;
|
||||
setRulesSaveStatus('pending');
|
||||
|
||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||
rulesSaveTimerRef.current = setTimeout(() => saveRules(nextRules, { silent: true, revision }), 700);
|
||||
}
|
||||
|
||||
async function saveRules(nextRules = customRules, options = {}) {
|
||||
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
|
||||
setError('');
|
||||
setRulesSaveStatus('saving');
|
||||
try {
|
||||
const data = await api.rules.save(nextRules);
|
||||
if (rulesRevisionRef.current === revision) {
|
||||
rulesDirtyRef.current = false;
|
||||
setCustomRules(data.rules || []);
|
||||
setRulesSaveStatus('saved');
|
||||
await loadState();
|
||||
if (!silent) pushToast({ kind: 'success', title: 'Правила сохранены' });
|
||||
} else {
|
||||
setRulesSaveStatus('pending');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setRulesSaveStatus('error');
|
||||
pushToast({ kind: 'danger', title: 'Не удалось сохранить', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function saveRulesNow() {
|
||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||
rulesDirtyRef.current = true;
|
||||
const revision = rulesRevisionRef.current + 1;
|
||||
rulesRevisionRef.current = revision;
|
||||
saveRules(customRules, { silent: false, revision });
|
||||
}
|
||||
|
||||
function updateRule(id, patch) {
|
||||
setCustomRules((rules) => {
|
||||
const next = rules.map((r) => (r.id === id ? { ...r, ...patch } : r));
|
||||
queueRulesSave(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function addRule() {
|
||||
setCustomRules((rules) => {
|
||||
const next = [emptyRule(), ...rules];
|
||||
queueRulesSave(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function addRuleFromTemplate(tpl) {
|
||||
setCustomRules((rules) => {
|
||||
const next = [tpl, ...rules];
|
||||
queueRulesSave(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function removeRule(id) {
|
||||
setCustomRules((rules) => {
|
||||
const next = rules.filter((r) => r.id !== id);
|
||||
queueRulesSave(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function reorderRules(next) {
|
||||
setCustomRules(next);
|
||||
queueRulesSave(next);
|
||||
}
|
||||
|
||||
// === Computed ===
|
||||
const status = useMemo(() => {
|
||||
if (applyStatus === 'applying') return 'applying';
|
||||
if (applyStatus === 'error') return 'error';
|
||||
if (state?.singboxRunning) return 'running';
|
||||
if (state?.configExists) return 'stopped';
|
||||
return 'no_config';
|
||||
}, [state, applyStatus]);
|
||||
|
||||
const activeServer = useMemo(
|
||||
() => servers.find((s) => s.tag === state?.selectedTag) || null,
|
||||
[servers, state?.selectedTag],
|
||||
);
|
||||
const isClientMode = state?.mode === 'client';
|
||||
|
||||
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
|
||||
const dirtyDevices = Boolean(
|
||||
state?.devicesUpdatedAt &&
|
||||
(!state?.rulesAppliedAt || state.devicesUpdatedAt > state.rulesAppliedAt),
|
||||
);
|
||||
const dirtyServer = pendingTag && pendingTag !== state?.selectedTag;
|
||||
const dirtyRouting = dirtyRules || dirtyDevices;
|
||||
const dirty = dirtyRouting || dirtyServer;
|
||||
|
||||
const sidebarBadges = {
|
||||
routing: dirtyRouting ? { kind: 'warn', text: '●' } : null,
|
||||
servers: dirtyServer ? { kind: 'warn', text: '●' } : null,
|
||||
settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null,
|
||||
};
|
||||
|
||||
// === Render ===
|
||||
if (!state) return <div className="app-loading">VPN</div>;
|
||||
|
||||
return (
|
||||
<div className={`app${isClientMode ? ' client-app' : ''}`}>
|
||||
{!isClientMode && (
|
||||
<Topbar
|
||||
state={state}
|
||||
status={status}
|
||||
activeServer={activeServer}
|
||||
dirty={dirty}
|
||||
onRestart={restartSingbox}
|
||||
onTryApply={rollback}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
|
||||
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
|
||||
|
||||
<div className="app client-app">
|
||||
<div className="app-body client-mode">
|
||||
<main className="app-main">
|
||||
{(page === 'overview' || isClientMode) && (
|
||||
isClientMode ? (
|
||||
<ClientOverviewPage
|
||||
state={state}
|
||||
status={status}
|
||||
activeServer={activeServer}
|
||||
busy={busy}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
servers={servers}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
|
||||
onForgetSubscription={forgetSubscription}
|
||||
onApply={applyServer}
|
||||
onRestart={restartSingbox}
|
||||
onStop={() => stopSingbox(false)}
|
||||
/>
|
||||
) : (
|
||||
<OverviewPage
|
||||
state={state}
|
||||
status={status}
|
||||
busy={busy}
|
||||
onRestart={restartSingbox}
|
||||
onStop={stopSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
onNav={navigate}
|
||||
onBypassToggle={toggleBypass}
|
||||
onFlushDirectCache={flushDirectCache}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{page === 'servers' && !isClientMode && (
|
||||
<ServersPage
|
||||
state={state}
|
||||
servers={servers}
|
||||
selectedTag={selectedTag}
|
||||
setSelectedTag={setSelectedTag}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
busy={busy}
|
||||
onApply={applyServer}
|
||||
onRollback={rollback}
|
||||
pings={pings}
|
||||
setPings={setPings}
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
)}
|
||||
{page === 'routing' && !isClientMode && (
|
||||
<RoutingPage
|
||||
rules={customRules}
|
||||
saveStatus={rulesSaveStatus}
|
||||
busy={busy}
|
||||
onAdd={addRule}
|
||||
onAddTemplate={addRuleFromTemplate}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
onSaveNow={saveRulesNow}
|
||||
onReorder={reorderRules}
|
||||
devicesConfig={devicesConfig}
|
||||
onUpdateDeviceDefaults={updateDeviceDefaults}
|
||||
onAddDevice={addDevice}
|
||||
onUpdateDevice={updateDevice}
|
||||
onRemoveDevice={removeDevice}
|
||||
/>
|
||||
)}
|
||||
{page === 'logs' && !isClientMode && <LogsPage devices={devicesConfig.devices} />}
|
||||
{page === 'settings' && !isClientMode && (
|
||||
<SettingsPage
|
||||
state={state}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
busy={busy}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onForgetSubscription={forgetSubscription}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
onClearConfig={clearConfig}
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sticky bar — для routing/servers */}
|
||||
{(page === 'routing' && dirtyRouting) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
|
||||
<strong>
|
||||
{rulesSaveStatus === 'saving' && 'Сохраняем…'}
|
||||
{rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'}
|
||||
{rulesSaveStatus === 'saved' && dirtyDevices && 'Изменения устройств сохранены'}
|
||||
{rulesSaveStatus === 'error' && 'Ошибка сохранения'}
|
||||
</strong>
|
||||
<small className="muted">Конфиг sing-box нужно пересобрать и применить.</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
{rulesSaveStatus !== 'saved' && (
|
||||
<button className="btn btn-secondary sm" onClick={saveRulesNow}>Сохранить сейчас</button>
|
||||
)}
|
||||
{state?.selectedTag && (
|
||||
<button className="btn btn-primary sm" onClick={() => applyServer(state.selectedTag)} disabled={busy}>
|
||||
Применить config
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(page === 'servers' && dirtyServer) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className="dot warning" />
|
||||
<strong>Сервер не применён</strong>
|
||||
<small className="muted">Выбран: {pendingTag}</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-ghost sm" onClick={() => setPendingTag(state?.selectedTag || '')}>Отменить</button>
|
||||
<button className="btn btn-primary sm" onClick={() => applyServer(pendingTag)} disabled={busy}>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{!isClientMode && (
|
||||
<StatusPane
|
||||
<ClientOverviewPage
|
||||
state={state}
|
||||
busy={busy}
|
||||
onStop={stopSingbox}
|
||||
onRestart={restartSingbox}
|
||||
onShowConfig={() => setConfigOpen(true)}
|
||||
error={error}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
servers={servers}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
|
||||
onForgetSubscription={forgetSubscription}
|
||||
onApply={(tag) => run(() => api.apply(tag))}
|
||||
onRestart={() => run(api.singbox.restart)}
|
||||
onStop={() => run(api.singbox.stop)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />
|
||||
<Toasts items={toasts} onDismiss={dismissToast} />
|
||||
|
||||
{rollbackOffer && (
|
||||
<div className="toasts">
|
||||
<div className="toast warning">
|
||||
<span className="dot warning" style={{ marginTop: 4 }} />
|
||||
<div className="body">
|
||||
<strong>Сервер применён</strong>
|
||||
<small>Можно откатиться к «{rollbackOffer.to}»</small>
|
||||
<button className="btn btn-link" onClick={rollback} style={{ padding: 0, marginTop: 4 }}>
|
||||
↶ Откатить
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setRollbackOffer(null)}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
111
src/web/api.js
111
src/web/api.js
@@ -2,113 +2,36 @@ async function request(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
'content-type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || (data && data.success === false)) {
|
||||
throw new Error(
|
||||
data?.error || `Запрос ${url} завершился ошибкой ${response.status}`,
|
||||
);
|
||||
if (!response.ok || data?.success === false) {
|
||||
throw new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
state: () => request("/api/state"),
|
||||
config: () => request("/api/config"),
|
||||
|
||||
rules: {
|
||||
get: () => request("/api/rules"),
|
||||
save: (rules) =>
|
||||
request("/api/rules", { method: "PUT", body: JSON.stringify({ rules }) }),
|
||||
conflicts: () => request("/api/rules/conflicts"),
|
||||
},
|
||||
|
||||
deviceRules: {
|
||||
get: () => request("/api/device-rules"),
|
||||
save: (deviceRules) =>
|
||||
request("/api/device-rules", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ deviceRules }),
|
||||
}),
|
||||
},
|
||||
|
||||
devices: {
|
||||
get: () => request("/api/devices"),
|
||||
save: (devicesConfig) =>
|
||||
request("/api/devices", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(devicesConfig),
|
||||
}),
|
||||
},
|
||||
|
||||
ruleSets: {
|
||||
get: () => request("/api/rule-sets"),
|
||||
save: (ruleSets) =>
|
||||
request("/api/rule-sets", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ruleSets }),
|
||||
}),
|
||||
lookup: (tag, url) =>
|
||||
request("/api/rule-sets/lookup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ tag, url }),
|
||||
}),
|
||||
sagernetCatalog: () => request("/api/rule-sets/sagernet-catalog"),
|
||||
},
|
||||
|
||||
state: () => request('/api/state'),
|
||||
subscription: {
|
||||
fetch: (url) =>
|
||||
request("/api/subscription/fetch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
refreshInfo: () => request("/api/subscription/refresh-info", { method: "POST" }),
|
||||
forget: () => request("/api/subscription", { method: "DELETE" }),
|
||||
},
|
||||
|
||||
apply: (selectedTag) =>
|
||||
request("/api/apply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ selectedTag }),
|
||||
fetch: (url) => request('/api/subscription/fetch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
rollback: () => request("/api/apply/rollback", { method: "POST" }),
|
||||
|
||||
refreshInfo: () => request('/api/subscription/refresh-info', { method: 'POST' }),
|
||||
forget: () => request('/api/subscription', { method: 'DELETE' }),
|
||||
},
|
||||
apply: (selectedTag) => request('/api/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ selectedTag }),
|
||||
}),
|
||||
singbox: {
|
||||
stop: () => request("/api/singbox/stop", { method: "POST" }),
|
||||
restart: () => request("/api/singbox/restart", { method: "POST" }),
|
||||
clear: () => request("/api/singbox/clear", { method: "POST" }),
|
||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||
},
|
||||
|
||||
servers: {
|
||||
ping: (host, port) =>
|
||||
request("/api/servers/ping", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ host, port }),
|
||||
}),
|
||||
pingAll: () => request("/api/servers/ping-all", { method: "POST" }),
|
||||
pingAll: () => request('/api/servers/ping-all', { method: 'POST' }),
|
||||
},
|
||||
|
||||
bypass: (enabled) =>
|
||||
request("/api/bypass", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
|
||||
directCache: {
|
||||
get: () => request("/api/direct-cache"),
|
||||
flush: () => request("/api/direct-cache", { method: "DELETE" }),
|
||||
},
|
||||
|
||||
route: {
|
||||
check: ({ host, ip, port, network, sourceIp, inbound }) =>
|
||||
request("/api/route/check", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ host, ip, port, network, sourceIp, inbound }),
|
||||
}),
|
||||
},
|
||||
|
||||
configValidate: () => request("/api/config/validate", { method: "POST" }),
|
||||
};
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Chip input. Items separated by Enter, comma, или space (для CIDR/портов).
|
||||
* Невалидные элементы помечаются красным.
|
||||
*/
|
||||
export function ChipsInput({ value = [], onChange, placeholder = '', validate, splitter = /[\s,]/ }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
function commit(text) {
|
||||
const parts = String(text).split(splitter).map((p) => p.trim()).filter(Boolean);
|
||||
if (!parts.length) return;
|
||||
const next = Array.from(new Set([...value, ...parts]));
|
||||
onChange(next);
|
||||
setDraft('');
|
||||
}
|
||||
|
||||
function remove(item) {
|
||||
onChange(value.filter((v) => v !== item));
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
if (draft.trim()) commit(draft);
|
||||
} else if (e.key === 'Backspace' && !draft && value.length) {
|
||||
onChange(value.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
function onPaste(e) {
|
||||
const text = e.clipboardData.getData('text');
|
||||
if (text && splitter.test(text)) {
|
||||
e.preventDefault();
|
||||
commit(text);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chips" onClick={(e) => e.currentTarget.querySelector('input')?.focus()}>
|
||||
{value.map((item) => {
|
||||
const invalid = validate ? !validate(item) : false;
|
||||
return (
|
||||
<span key={item} className={`chip ${invalid ? 'error' : ''}`}>
|
||||
{item}
|
||||
<button type="button" onClick={() => remove(item)} title="Убрать">×</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
className="chip-input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
onBlur={() => draft.trim() && commit(draft)}
|
||||
placeholder={value.length ? '' : placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { formatBytes } from '../utils/format.js';
|
||||
export function ClientOverviewPage({
|
||||
state,
|
||||
busy,
|
||||
error,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
servers,
|
||||
@@ -25,6 +26,7 @@ export function ClientOverviewPage({
|
||||
onRestart,
|
||||
onStop,
|
||||
}) {
|
||||
const isGateway = state?.mode === 'gateway';
|
||||
const connected = Boolean(state?.singboxRunning);
|
||||
const hasSubscription = Boolean(state?.hasSubscription);
|
||||
const selectedTag = pendingTag || state?.selectedTag || '';
|
||||
@@ -41,7 +43,8 @@ export function ClientOverviewPage({
|
||||
const subscriptionInputRef = useRef(null);
|
||||
const subscriptionRef = useRef(null);
|
||||
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||
const proxyUrls = localProxyUrls(state?.proxyPort);
|
||||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||
const usage = subscriptionUsage(state?.userInfo);
|
||||
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
||||
const hasUsage = Boolean(
|
||||
@@ -152,7 +155,7 @@ export function ClientOverviewPage({
|
||||
|
||||
async function copyProxy(kind) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(proxyUrls[kind]);
|
||||
await navigator.clipboard.writeText(kind === 'gateway' ? gatewayAddress : proxyUrls[kind]);
|
||||
setCopiedProxy(kind);
|
||||
setTimeout(() => setCopiedProxy(''), 800);
|
||||
} catch {
|
||||
@@ -191,7 +194,7 @@ export function ClientOverviewPage({
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={connected}
|
||||
aria-label={connected ? 'Выключить VPN' : 'Включить VPN'}
|
||||
aria-label={connected ? `Выключить ${isGateway ? 'Gateway' : 'VPN'}` : `Включить ${isGateway ? 'Gateway' : 'VPN'}`}
|
||||
disabled={busy || (!connected && !canStart)}
|
||||
onClick={toggleConnection}
|
||||
>
|
||||
@@ -201,7 +204,9 @@ export function ClientOverviewPage({
|
||||
</button>
|
||||
<div className="client-state-copy" aria-live="polite">
|
||||
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
||||
{connected ? 'VPN включён' : 'VPN выключен'}
|
||||
{isGateway
|
||||
? connected ? 'Gateway включён' : 'Gateway выключен'
|
||||
: connected ? 'VPN включён' : 'VPN выключен'}
|
||||
</h2>
|
||||
<div className="client-state-detail">
|
||||
{connected ? (
|
||||
@@ -216,12 +221,28 @@ export function ClientOverviewPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="client-proxies" aria-label="Локальный прокси">
|
||||
<span className="client-proxy-label">Адрес</span>
|
||||
<strong className="client-proxy-address">
|
||||
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||
</strong>
|
||||
<div>
|
||||
<section className="client-proxies" aria-label={isGateway ? 'Адреса Gateway и Proxy' : 'Локальный прокси'}>
|
||||
{isGateway && (
|
||||
<div className="client-access-point">
|
||||
<span className="client-proxy-label">Gateway</span>
|
||||
<strong className="client-proxy-address">{gatewayAddress}</strong>
|
||||
<button
|
||||
className={copiedProxy === 'gateway' ? 'is-copied' : ''}
|
||||
type="button"
|
||||
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
|
||||
onClick={() => copyProxy('gateway')}
|
||||
>
|
||||
<span className="client-copy-label">КОПИРОВАТЬ</span>
|
||||
{copiedProxy === 'gateway' && <span className="client-copy-feedback">Copied</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="client-access-point">
|
||||
<span className="client-proxy-label">{isGateway ? 'Proxy' : 'Адрес'}</span>
|
||||
<strong className="client-proxy-address">
|
||||
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||
</strong>
|
||||
<div className="client-proxy-actions">
|
||||
{[
|
||||
['socks5', 'SOCKS5'],
|
||||
['http', 'HTTP'],
|
||||
@@ -237,11 +258,14 @@ export function ClientOverviewPage({
|
||||
{copiedProxy === kind && <span className="client-copy-feedback">Copied</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{error && <p className="client-error" role="alert">{error}</p>}
|
||||
|
||||
<div className="client-form">
|
||||
<div
|
||||
ref={subscriptionRef}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
export function ConfigViewer({ open, onClose }) {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setConfig(null);
|
||||
setError('');
|
||||
api.config()
|
||||
.then((data) => { if (!cancelled) setConfig(data.config); })
|
||||
.catch((err) => { if (!cancelled) setError(err.message); });
|
||||
return () => { cancelled = true; };
|
||||
}, [open]);
|
||||
|
||||
const text = useMemo(() => (config ? JSON.stringify(config, null, 2) : ''), [config]);
|
||||
|
||||
const highlighted = useMemo(() => {
|
||||
if (!search || !text) return text;
|
||||
try {
|
||||
const re = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
return text.split(re);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}, [text, search]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function copy() { navigator.clipboard?.writeText(text).catch(() => {}); }
|
||||
function download() {
|
||||
const blob = new Blob([text], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sing-box-config.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h3>sing-box config</h3>
|
||||
<small className="muted">Автогенерируемый, перезаписывается при apply</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Поиск…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<button className="btn btn-ghost sm" disabled={!config} onClick={copy}>Копировать</button>
|
||||
<button className="btn btn-ghost sm" disabled={!config} onClick={download}>Скачать</button>
|
||||
<button className="btn btn-secondary sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{error && <div className="conflict-banner danger">{error}</div>}
|
||||
{!error && !config && <p className="muted">Конфиг ещё не сгенерирован.</p>}
|
||||
{config && (
|
||||
<pre className="config-view">
|
||||
{Array.isArray(highlighted)
|
||||
? highlighted.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{part}
|
||||
{i < highlighted.length - 1 && <mark style={{ background: 'var(--warning-dim)', color: 'var(--warning)' }}>{search}</mark>}
|
||||
</React.Fragment>
|
||||
))
|
||||
: text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { formatTime } from '../utils/format.js';
|
||||
|
||||
const MAX_ENTRIES = 800;
|
||||
const MAX_TRAFFIC = 500;
|
||||
const GROUP_WINDOW_MS = 30_000;
|
||||
|
||||
function normalizeLine(line) {
|
||||
return String(line || '').replace(/\x1b\[\d+m/g, '').trim();
|
||||
}
|
||||
|
||||
function groupEntries(entries) {
|
||||
const out = [];
|
||||
for (const e of entries) {
|
||||
const key = `${e.level}|${normalizeLine(e.line)}`;
|
||||
const last = out[out.length - 1];
|
||||
const ts = new Date(e.ts).getTime();
|
||||
if (last && last._key === key && ts - last._lastTs < GROUP_WINDOW_MS) {
|
||||
last.count += 1;
|
||||
last._lastTs = ts;
|
||||
last.lastTs = e.ts;
|
||||
} else {
|
||||
out.push({ ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const CATEGORY_BADGE = {
|
||||
direct: { cls: 'success', label: 'direct' },
|
||||
vpn: { cls: 'info', label: 'VPN' },
|
||||
block: { cls: 'danger', label: 'block' },
|
||||
other: { cls: '', label: 'other' },
|
||||
};
|
||||
|
||||
function getDeviceName(sourceIp, devices) {
|
||||
if (!sourceIp || !devices?.length) return null;
|
||||
for (const d of devices) {
|
||||
if (d.enabled === false) continue;
|
||||
const ip = d.ip || d.sourceIp || (d.sourceIps || [])[0];
|
||||
const plain = ip?.endsWith('/32') ? ip.slice(0, -3) : ip;
|
||||
if (plain === sourceIp) return d.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function groupTraffic(list, sortBy = 'time') {
|
||||
const map = new Map();
|
||||
for (const e of list) {
|
||||
const key = `${e.sourceIp || ''}|${e.category}|${e.host}|${e.port}|${e.matchedRule || ''}`;
|
||||
const ts = new Date(e.ts).getTime();
|
||||
if (map.has(key)) {
|
||||
const g = map.get(key);
|
||||
g.count++;
|
||||
g._lastTs = ts;
|
||||
g.lastTs = e.ts;
|
||||
} else {
|
||||
map.set(key, { ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
|
||||
}
|
||||
}
|
||||
const arr = Array.from(map.values());
|
||||
if (sortBy === 'count') return arr.sort((a, b) => b.count - a.count || b._lastTs - a._lastTs);
|
||||
return arr.sort((a, b) => b._lastTs - a._lastTs);
|
||||
}
|
||||
|
||||
function TrafficTab({ devices = [] }) {
|
||||
const [traffic, setTraffic] = useState([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [filter, setFilter] = useState('all'); // all | direct | vpn | block
|
||||
const [search, setSearch] = useState('');
|
||||
const [grouped, setGrouped] = useState(true);
|
||||
const [sortBy, setSortBy] = useState('count'); // time | count
|
||||
const [autoscroll, setAutoscroll] = useState(true);
|
||||
const containerRef = useRef(null);
|
||||
const pausedRef = useRef(false);
|
||||
|
||||
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource('/api/traffic/stream');
|
||||
source.onmessage = (ev) => {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
const entry = JSON.parse(ev.data);
|
||||
setTraffic((prev) => {
|
||||
const next = [...prev, entry];
|
||||
if (next.length > MAX_TRAFFIC) next.splice(0, next.length - MAX_TRAFFIC);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
return () => source.close();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = traffic;
|
||||
if (filter !== 'all') list = list.filter((e) => e.category === filter);
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
list = list.filter((e) =>
|
||||
e.host?.toLowerCase().includes(s) ||
|
||||
String(e.port || '').includes(s) ||
|
||||
e.outbound?.toLowerCase().includes(s) ||
|
||||
e.matchedRule?.toLowerCase().includes(s) ||
|
||||
e.sourceIp?.toLowerCase().includes(s) ||
|
||||
getDeviceName(e.sourceIp, devices)?.toLowerCase().includes(s),
|
||||
);
|
||||
}
|
||||
return grouped ? groupTraffic(list, sortBy) : list;
|
||||
}, [traffic, filter, search, grouped, sortBy, devices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoscroll || !containerRef.current) return;
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}, [filtered, autoscroll]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { direct: 0, vpn: 0, block: 0 };
|
||||
for (const e of traffic) if (e.category in c) c[e.category]++;
|
||||
return c;
|
||||
}, [traffic]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
|
||||
<div className="filter-bar" style={{ marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Поиск: host, порт, правило…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 180 }}
|
||||
/>
|
||||
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
<option value="all">Все ({traffic.length})</option>
|
||||
<option value="direct">direct ({counts.direct})</option>
|
||||
<option value="vpn">VPN ({counts.vpn})</option>
|
||||
<option value="block">block ({counts.block})</option>
|
||||
</select>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} />
|
||||
Группировать
|
||||
</label>
|
||||
{grouped && (
|
||||
<select className="select" value={sortBy} onChange={(e) => setSortBy(e.target.value)} style={{ width: 'auto' }}>
|
||||
<option value="count">По частоте</option>
|
||||
<option value="time">По времени</option>
|
||||
</select>
|
||||
)}
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} />
|
||||
Автоскролл
|
||||
</label>
|
||||
<button className="btn btn-ghost sm" onClick={() => setPaused((p) => !p)}>
|
||||
{paused ? '▶ Продолжить' : '⏸ Пауза'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
onClick={() => { setTraffic([]); fetch('/api/traffic', { method: 'DELETE' }).catch(() => {}); }}
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{traffic.length === 0 ? (
|
||||
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>
|
||||
Ожидаем трафик… Убедитесь что sing-box запущен и уровень логов не выше INFO.
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef} style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table className="table" style={{ fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 70 }}>Время</th>
|
||||
<th style={{ width: 70 }}>Туннель</th>
|
||||
<th style={{ width: 110 }}>Устройство</th>
|
||||
<th>Хост / IP</th>
|
||||
<th style={{ width: 55 }}>Порт</th>
|
||||
<th>Правило</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((e, i) => {
|
||||
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
|
||||
const deviceName = getDeviceName(e.sourceIp, devices);
|
||||
return (
|
||||
<tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}>
|
||||
<td className="muted text-mono" style={{ whiteSpace: 'nowrap' }}>{formatTime(e.ts)}</td>
|
||||
<td>
|
||||
<span className={`badge ${badge.cls}`} style={{ fontSize: 11 }}>{badge.label}</span>
|
||||
</td>
|
||||
<td className="text-mono" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 110 }}>
|
||||
{deviceName
|
||||
? <span style={{ fontSize: 11 }}>{deviceName}</span>
|
||||
: e.sourceIp
|
||||
? <span className="muted" style={{ fontSize: 10 }}>{e.sourceIp}</span>
|
||||
: <span className="muted" style={{ fontSize: 11 }}>—</span>}
|
||||
</td>
|
||||
<td className="text-mono" style={{ wordBreak: 'break-all' }}>{e.host || '—'}</td>
|
||||
<td className="muted text-mono">{e.port || '—'}</td>
|
||||
<td>
|
||||
{e.matchedRule
|
||||
? <span className="badge info" style={{ fontSize: 11 }}>{e.matchedRule}</span>
|
||||
: <span className="muted" style={{ fontSize: 11 }}>—</span>}
|
||||
</td>
|
||||
<td className="muted text-mono" style={{ textAlign: 'right', fontSize: 11 }}>
|
||||
{e.count > 1 && <span className="repeat">×{e.count}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsPage({ devices = [] }) {
|
||||
const [tab, setTab] = useState('traffic'); // traffic | logs
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [autoscroll, setAutoscroll] = useState(true);
|
||||
const [grouped, setGrouped] = useState(true);
|
||||
const containerRef = useRef(null);
|
||||
const pausedRef = useRef(false);
|
||||
|
||||
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource('/api/logs/stream');
|
||||
source.onmessage = (event) => {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
const entry = JSON.parse(event.data);
|
||||
setEntries((prev) => {
|
||||
const next = [...prev, entry];
|
||||
if (next.length > MAX_ENTRIES) next.splice(0, next.length - MAX_ENTRIES);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
return () => source.close();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = entries;
|
||||
if (filter !== 'all') list = list.filter((e) => e.level === filter);
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
list = list.filter((e) => normalizeLine(e.line).toLowerCase().includes(s));
|
||||
}
|
||||
return grouped ? groupEntries(list) : list;
|
||||
}, [entries, filter, search, grouped]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoscroll || !containerRef.current) return;
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}, [filtered, autoscroll]);
|
||||
|
||||
function copy(text) {
|
||||
navigator.clipboard?.writeText(text).catch(() => {});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ display: 'flex', flexDirection: 'column', minHeight: 'calc(100vh - 160px)' }}>
|
||||
<div className="card-header">
|
||||
<h2>Логи sing-box</h2>
|
||||
<div className="tabs" style={{ marginLeft: 'auto', marginBottom: 0 }}>
|
||||
<button className={`tab ${tab === 'traffic' ? 'active' : ''}`} onClick={() => setTab('traffic')}>Трафик</button>
|
||||
<button className={`tab ${tab === 'logs' ? 'active' : ''}`} onClick={() => setTab('logs')}>Системные логи</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'traffic' && <TrafficTab devices={devices} />}
|
||||
|
||||
{tab === 'logs' && (
|
||||
<>
|
||||
<div className="filter-bar" style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Поиск по тексту…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
<option value="all">Все уровни</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="error">error</option>
|
||||
<option value="debug">debug</option>
|
||||
</select>
|
||||
<label className="checkbox"><input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} /> Группировать</label>
|
||||
<label className="checkbox"><input type="checkbox" checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} /> Автоскролл</label>
|
||||
<button className="btn btn-ghost sm" onClick={() => setPaused((p) => !p)}>{paused ? '▶ Продолжить' : '⏸ Пауза'}</button>
|
||||
<button className="btn btn-ghost sm" onClick={() => setEntries([])}>Очистить</button>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="logs-stream">
|
||||
{filtered.length === 0 && <p className="muted">Логов пока нет.</p>}
|
||||
{filtered.map((entry, index) => {
|
||||
const text = normalizeLine(entry.line);
|
||||
if (grouped && entry.count > 1) {
|
||||
return (
|
||||
<div key={`${entry.ts}-${index}`} className="log-group">
|
||||
<span className="log-time mono">{formatTime(entry.ts)}</span>
|
||||
<span className={`log-level text-${entry.level === 'error' ? 'danger' : entry.level === 'warning' ? 'warning' : 'info'}`}>
|
||||
{entry.level}
|
||||
</span>
|
||||
<span className="log-text">{text}</span>
|
||||
<span className="repeat">×{entry.count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`${entry.ts}-${index}`}
|
||||
className={`log-line ${entry.level}`}
|
||||
onDoubleClick={() => copy(`${formatTime(entry.ts)} ${entry.level} ${text}`)}
|
||||
title="Двойной клик — скопировать"
|
||||
>
|
||||
<span className="log-time">{formatTime(entry.ts)}</span>
|
||||
<span className="log-level">{entry.level}</span>
|
||||
<span className="log-text">{text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { formatRelative, formatBytes } from '../utils/format.js';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
function StatusHero({ state, status }) {
|
||||
const text = {
|
||||
running: { title: '🟢 VPN-шлюз работает', kind: 'success' },
|
||||
applying: { title: '🟠 Применяем изменения…', kind: 'warning' },
|
||||
error: { title: '🔴 Ошибка', kind: 'danger' },
|
||||
stopped: { title: '⚫ Шлюз остановлен', kind: 'neutral' },
|
||||
no_config: { title: '⚪ Шлюз не настроен', kind: 'neutral' },
|
||||
}[status];
|
||||
|
||||
const userInfo = state?.userInfo;
|
||||
const traffic = userInfo
|
||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : 'без лимита'}`
|
||||
: 'нет данных';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex-between">
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 4 }}>{text.title}</h2>
|
||||
<small className="muted">
|
||||
{state?.appliedAt ? `Последнее применение: ${formatRelative(state.appliedAt)}` : 'Конфиг ещё не применялся'}
|
||||
</small>
|
||||
</div>
|
||||
<span className={`badge ${text.kind}`}>{state?.singboxRunning ? 'sing-box online' : 'sing-box offline'}</span>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="grid-3">
|
||||
<div>
|
||||
<small className="muted">Активный сервер</small>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{state?.selectedTag ? (
|
||||
<>
|
||||
<strong>{flagFor({ tag: state.selectedTag })} {state.selectedTag}</strong>
|
||||
</>
|
||||
) : <span className="muted">Не выбран</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<small className="muted">Трафик</small>
|
||||
<div style={{ marginTop: 4 }}><strong>{traffic}</strong></div>
|
||||
</div>
|
||||
<div>
|
||||
<small className="muted">Правил маршрутизации</small>
|
||||
<div style={{ marginTop: 4 }}><strong>{(state?.customRules || []).filter(r => r.enabled).length} активных</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3>Быстрые действия</h3>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-primary" disabled={busy} onClick={() => onNav('servers')}>
|
||||
⋆ Сменить сервер
|
||||
</button>
|
||||
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>
|
||||
↻ Перезапустить
|
||||
</button>
|
||||
<button className="btn btn-secondary" disabled={busy || !state?.singboxRunning} onClick={onStop}>
|
||||
■ Остановить
|
||||
</button>
|
||||
<button className="btn btn-ghost" disabled={!state?.configExists} onClick={onShowConfig}>
|
||||
⌘ Показать config
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${state?.bypassMode ? 'btn-warning' : 'btn-ghost'}`}
|
||||
disabled={busy || !state?.singboxRunning}
|
||||
onClick={onBypassToggle}
|
||||
title="Весь трафик напрямую — для диагностики"
|
||||
>
|
||||
{state?.bypassMode ? '⚠ Обход правил ВКЛЮЧЁН' : '↗ Весь трафик напрямую'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentEvents({ onNav }) {
|
||||
const [entries, setEntries] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/logs')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const list = (data.logs || []).slice(-15).reverse();
|
||||
setEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3>Последние события</h3>
|
||||
<button className="btn btn-link" onClick={() => onNav('logs')}>Открыть логи →</button>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<small className="muted">Пока ничего нет.</small>
|
||||
) : (
|
||||
<div className="events-list">
|
||||
{entries.slice(0, 8).map((e, i) => {
|
||||
const dot = e.level === 'error' ? 'danger'
|
||||
: e.level === 'warning' ? 'warning'
|
||||
: 'success';
|
||||
const time = new Date(e.ts).toLocaleTimeString('ru-RU', { hour12: false });
|
||||
return (
|
||||
<div key={`${e.ts}-${i}`} className="event-row">
|
||||
<span className={`dot ${dot}`} />
|
||||
<span className="event-time">{time}</span>
|
||||
<span className="text-truncate" title={e.line}>{e.line}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutingSummary({ state, onNav, onFlushDirectCache }) {
|
||||
const rules = state?.customRules || [];
|
||||
const enabled = rules.filter((r) => r.enabled).length;
|
||||
const cacheCount = state?.directBypassCount || 0;
|
||||
const cacheAvailable = state?.directBypassAvailable && state?.directBypassEnabled;
|
||||
const transparentDefault = state?.devicesConfig?.defaultTransparentMode || 'vpn';
|
||||
const proxyDefault = state?.devicesConfig?.proxyDefaultMode || 'vpn';
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3>Маршрутизация</h3>
|
||||
<button className="btn btn-link" onClick={() => onNav('routing')}>Открыть правила →</button>
|
||||
</div>
|
||||
<div className="kv-list">
|
||||
<div className="row"><span className="key">Private IP</span><span className="val text-success">→ direct</span></div>
|
||||
{state?.routingRuDirect && (
|
||||
<div className="row"><span className="key">RU (geoip/geosite)</span><span className="val text-success">→ direct</span></div>
|
||||
)}
|
||||
<div className="row"><span className="key">Global custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
|
||||
<div className="row"><span className="key">Transparent fallback</span><span className="val">→ {transparentDefault}</span></div>
|
||||
<div className="row"><span className="key">Proxy fallback</span><span className="val text-warning">→ {proxyDefault}</span></div>
|
||||
{cacheAvailable && (
|
||||
<div className="row">
|
||||
<span className="key">Direct bypass cache</span>
|
||||
<span className="val" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="text-success">{cacheCount} IP</span>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={onFlushDirectCache} title="Сбросить — все IP снова пройдут через sing-box один раз">
|
||||
✕ сбросить
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle, onFlushDirectCache }) {
|
||||
return (
|
||||
<div className="section-stack">
|
||||
{state?.bypassMode && (
|
||||
<div className="alert alert-warning" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<strong>⚠ Режим обхода правил активен</strong>
|
||||
<span className="muted">— весь трафик идёт напрямую, VPN-правила не применяются.</span>
|
||||
<button className="btn btn-sm btn-warning" style={{ marginLeft: 'auto' }} onClick={onBypassToggle}>
|
||||
Отключить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<StatusHero state={state} status={status} />
|
||||
<div className="grid-2">
|
||||
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} onBypassToggle={onBypassToggle} />
|
||||
<RoutingSummary state={state} onNav={onNav} onFlushDirectCache={onFlushDirectCache} />
|
||||
</div>
|
||||
<RecentEvents onNav={onNav} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
export function RouteChecker() {
|
||||
const [host, setHost] = useState('');
|
||||
const [port, setPort] = useState('443');
|
||||
const [network, setNetwork] = useState('tcp');
|
||||
const [sourceIp, setSourceIp] = useState('');
|
||||
const [inbound, setInbound] = useState('tproxy-in');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function check() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await api.route.check({
|
||||
host,
|
||||
port: port || undefined,
|
||||
network,
|
||||
sourceIp: sourceIp || undefined,
|
||||
inbound,
|
||||
});
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const r = result?.result;
|
||||
const kind = r?.outbound?.startsWith('direct') ? 'success'
|
||||
: r?.outbound === 'block' ? 'danger'
|
||||
: r?.outbound?.includes('VPN') || r?.outbound?.includes('vpn') ? 'info'
|
||||
: 'warning';
|
||||
|
||||
return (
|
||||
<div className="card flat compact">
|
||||
<div className="card-header no-margin"><h3>Проверить маршрут</h3></div>
|
||||
<div className="filter-bar" style={{ marginTop: 12 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="домен или IP (riotgames.com)"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && check()}
|
||||
style={{ minWidth: 220, flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="port"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<select className="select" value={network} onChange={(e) => setNetwork(e.target.value)} style={{ width: 90 }}>
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="source IP"
|
||||
value={sourceIp}
|
||||
onChange={(e) => setSourceIp(e.target.value)}
|
||||
style={{ width: 145 }}
|
||||
/>
|
||||
<select className="select" value={inbound} onChange={(e) => setInbound(e.target.value)} style={{ width: 130 }}>
|
||||
<option value="tproxy-in">tproxy-in</option>
|
||||
<option value="mixed-in">mixed-in</option>
|
||||
</select>
|
||||
<button className="btn btn-primary" onClick={check} disabled={busy || !host}>Проверить</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="field-error" style={{ marginTop: 10 }}>{error}</div>}
|
||||
|
||||
{r && (
|
||||
<div className="route-result" style={{ marginTop: 12 }}>
|
||||
<div className="flex-between">
|
||||
<strong>{r.ruleIndex >= 0 ? `Правило #${r.ruleIndex + 1}: ${r.ruleName}` : r.ruleName}</strong>
|
||||
<span className={`badge ${kind}`}>→ {r.outbound}</span>
|
||||
</div>
|
||||
{result.resolvedIp && result.resolvedFrom && (
|
||||
<small className="muted text-mono">DNS: {result.resolvedFrom} → {result.resolvedIp}</small>
|
||||
)}
|
||||
<small className="muted">{r.reason}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { ruleTemplates } from '../templates/ruleTemplates.js';
|
||||
import { ruleErrors, hasErrors } from '../utils/validation.js';
|
||||
import { RuleEditorDrawer } from './RuleEditorDrawer.jsx';
|
||||
import { RouteChecker } from './RouteChecker.jsx';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const OUTBOUND_KIND = {
|
||||
direct: { kind: 'success', label: 'direct' },
|
||||
vpn: { kind: 'info', label: 'VPN' },
|
||||
block: { kind: 'danger', label: 'block' },
|
||||
};
|
||||
|
||||
const DEVICE_MODES = {
|
||||
bypass: { kind: 'warning', label: 'bypass TProxy', hint: 'мимо sing-box; ручной proxy отдельно' },
|
||||
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
|
||||
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
|
||||
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
|
||||
block: { kind: 'danger', label: 'block', hint: 'fallback после global rules' },
|
||||
};
|
||||
|
||||
function DeviceModeSelect({ value, onChange }) {
|
||||
return (
|
||||
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="bypass">bypass TProxy</option>
|
||||
<option value="direct">direct</option>
|
||||
<option value="vpn">VPN</option>
|
||||
<option value="rules">default</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemove }) {
|
||||
const devices = devicesConfig?.devices || [];
|
||||
const defaultTransparentMode = devicesConfig?.defaultTransparentMode || devicesConfig?.defaultMode || 'vpn';
|
||||
const proxyDefaultMode = devicesConfig?.proxyDefaultMode || 'vpn';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div>
|
||||
<h2>Устройства</h2>
|
||||
<small className="muted">bypass TProxy применяется до sing-box. Остальные режимы — fallback после global rules.</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<label className="field" style={{ minWidth: 180, margin: 0 }}>
|
||||
<span className="field-label">Transparent default</span>
|
||||
<select
|
||||
className="select sm"
|
||||
value={defaultTransparentMode}
|
||||
onChange={(e) => onDefaultsChange({ defaultTransparentMode: e.target.value })}
|
||||
>
|
||||
<option value="direct">direct</option>
|
||||
<option value="vpn">VPN</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field" style={{ minWidth: 160, margin: 0 }}>
|
||||
<span className="field-label">Proxy default</span>
|
||||
<select
|
||||
className="select sm"
|
||||
value={proxyDefaultMode}
|
||||
onChange={(e) => onDefaultsChange({ proxyDefaultMode: e.target.value })}
|
||||
>
|
||||
<option value="vpn">VPN</option>
|
||||
<option value="direct">direct</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn btn-primary sm" onClick={onAdd}>
|
||||
+ Добавить устройство
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{devices.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '16px 0' }}>
|
||||
<p style={{ margin: 0 }}>Нет профилей устройств. Неизвестные transparent-устройства используют transparent default.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}></th>
|
||||
<th>Название</th>
|
||||
<th style={{ width: 170 }}>IP</th>
|
||||
<th style={{ width: 150 }}>MAC</th>
|
||||
<th style={{ width: 150 }}>Mode</th>
|
||||
<th>Поведение</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((dev) => {
|
||||
const mode = DEVICE_MODES[dev.mode] || DEVICE_MODES.rules;
|
||||
return (
|
||||
<tr key={dev.id} className={dev.enabled !== false ? '' : 'disabled'}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dev.enabled !== false}
|
||||
onChange={(e) => onUpdate(dev.id, { enabled: e.target.checked })}
|
||||
style={{ accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input sm"
|
||||
value={dev.name || ''}
|
||||
onChange={(e) => onUpdate(dev.id, { name: e.target.value })}
|
||||
placeholder="Название устройства"
|
||||
style={{ width: '100%', minWidth: 120 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input sm"
|
||||
value={dev.ip || ''}
|
||||
onChange={(e) => onUpdate(dev.id, { ip: e.target.value })}
|
||||
placeholder="192.168.1.50"
|
||||
style={{ width: '100%', minWidth: 140 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input sm"
|
||||
value={dev.mac || ''}
|
||||
onChange={(e) => onUpdate(dev.id, { mac: e.target.value })}
|
||||
placeholder="опционально"
|
||||
style={{ width: '100%', minWidth: 120 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<DeviceModeSelect value={dev.mode} onChange={(mode) => onUpdate(dev.id, { mode })} />
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${mode.kind}`}>{mode.label}</span>
|
||||
<small className="muted" style={{ marginLeft: 8 }}>{mode.hint}</small>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
onClick={() => {
|
||||
if (confirm('Удалить устройство?')) onRemove(dev.id);
|
||||
}}
|
||||
>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function summary(rule) {
|
||||
const parts = [];
|
||||
const totalDomains = (rule.domains?.length || 0) + (rule.domainSuffixes?.length || 0) + (rule.domainKeywords?.length || 0);
|
||||
if (totalDomains) parts.push(`${totalDomains} дом.`);
|
||||
if (rule.ipCidrs?.length) parts.push(`${rule.ipCidrs.length} CIDR`);
|
||||
if (rule.ports?.length) parts.push(`${rule.ports.length} портов`);
|
||||
if (rule.networks?.length) parts.push(rule.networks.join('/'));
|
||||
return parts.join(' · ') || '—';
|
||||
}
|
||||
|
||||
function SortableRuleRow({ rule, index, total, onEdit, onUpdate, onRemove, conflict }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rule.id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
|
||||
const errors = ruleErrors(rule);
|
||||
const invalid = hasErrors(errors);
|
||||
const ob = OUTBOUND_KIND[rule.outbound] || OUTBOUND_KIND.direct;
|
||||
|
||||
return (
|
||||
<tr ref={setNodeRef} style={style} className={`rule-row ${rule.enabled ? '' : 'disabled'} ${invalid ? 'invalid' : ''}`}>
|
||||
<td style={{ width: 30 }}>
|
||||
<span className="drag-handle" {...attributes} {...listeners} title="Перетащить">⠿</span>
|
||||
</td>
|
||||
<td style={{ width: 36 }} className="muted text-mono">#{index + 1}</td>
|
||||
<td>
|
||||
<div className="flex" style={{ alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled !== false}
|
||||
onChange={(e) => onUpdate(rule.id, { enabled: e.target.checked })}
|
||||
style={{ accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
<button className="btn btn-link" style={{ padding: 0, fontWeight: 600 }} onClick={() => onEdit(rule.id)}>
|
||||
{rule.name || '(без названия)'}
|
||||
</button>
|
||||
{invalid && <span className="badge danger">ошибки</span>}
|
||||
{conflict && <span className={`badge ${conflict.severity === 'warning' ? 'warning' : 'info'}`} title={`Перекрывается с #${conflict.conflictWithIndex + 1}`}>конфликт</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td><span className={`badge ${ob.kind}`}>{ob.label}</span></td>
|
||||
<td className="muted" style={{ fontSize: 12 }}>{summary(rule)}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="row-actions">
|
||||
<button className="btn btn-ghost sm" onClick={() => onEdit(rule.id)}>Редактировать</button>
|
||||
<button className="btn btn-ghost sm" onClick={() => { if (confirm('Удалить правило?')) onRemove(rule.id); }}>×</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatesModal({ open, onClose, onAdd }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<h3>Шаблоны маршрутизации</h3>
|
||||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="template-grid">
|
||||
{ruleTemplates.map((tpl) => (
|
||||
<div key={tpl.key} className="template-card">
|
||||
<h4>{tpl.label}</h4>
|
||||
<small>{tpl.description}</small>
|
||||
<button className="btn btn-secondary sm" onClick={() => { onAdd(tpl.build()); onClose(); }}>
|
||||
+ Добавить
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoutingPage({
|
||||
rules, saveStatus, busy,
|
||||
onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder,
|
||||
devicesConfig, onUpdateDeviceDefaults, onAddDevice, onUpdateDevice, onRemoveDevice,
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
const [conflicts, setConflicts] = useState([]);
|
||||
const [availableRuleSets, setAvailableRuleSets] = useState([]);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
api.ruleSets.get().then((data) => setAvailableRuleSets(data.ruleSets || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const t = setTimeout(() => {
|
||||
api.rules.conflicts().then((data) => { if (!cancelled) setConflicts(data.conflicts || []); }).catch(() => {});
|
||||
}, 600);
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [rules]);
|
||||
|
||||
const conflictsByRuleId = useMemo(() => {
|
||||
const map = {};
|
||||
for (const c of conflicts) map[c.ruleId] = c;
|
||||
return map;
|
||||
}, [conflicts]);
|
||||
|
||||
function handleDragEnd(event) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = rules.findIndex((r) => r.id === active.id);
|
||||
const newIndex = rules.findIndex((r) => r.id === over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
onReorder(arrayMove(rules, oldIndex, newIndex));
|
||||
}
|
||||
|
||||
const editing = rules.find((r) => r.id === editingId) || null;
|
||||
|
||||
return (
|
||||
<div className="section-stack">
|
||||
<RouteChecker />
|
||||
|
||||
<DevicesCard
|
||||
devicesConfig={devicesConfig}
|
||||
onDefaultsChange={onUpdateDeviceDefaults}
|
||||
onAdd={onAddDevice}
|
||||
onUpdate={onUpdateDevice}
|
||||
onRemove={onRemoveDevice}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Правила маршрутизации</h2>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-secondary sm" onClick={() => setShowTemplates(true)}>Шаблоны</button>
|
||||
<button className="btn btn-primary sm" onClick={() => { const newId = `rule-${Date.now()}`; onAdd(); setTimeout(() => setEditingId(newId), 50); }}>
|
||||
+ Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conflicts.length > 0 && (
|
||||
<div className="conflict-banner" style={{ marginBottom: 12 }}>
|
||||
<span>⚠</span>
|
||||
<div>
|
||||
<strong>{conflicts.length} конфликт(ов) обнаружено</strong>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{conflicts.slice(0, 3).map((c, i) => (
|
||||
<div key={i} style={{ fontSize: 12 }}>
|
||||
#{c.ruleIndex + 1} «{c.ruleName}» перекрывается правилом #{c.conflictWithIndex + 1} «{c.conflictWithName}»
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<small className="muted" style={{ display: 'block', marginBottom: 8 }}>
|
||||
Применяются <strong>сверху вниз</strong>. Перетаскивай ⠿ чтобы менять порядок.
|
||||
</small>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>Правил пока нет</h3>
|
||||
<p>Добавь шаблон (например «League of Legends → direct») или создай пустое правило.</p>
|
||||
<button className="btn btn-primary" onClick={() => setShowTemplates(true)} style={{ marginTop: 12 }}>
|
||||
Открыть шаблоны
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>#</th>
|
||||
<th>Правило</th>
|
||||
<th>Outbound</th>
|
||||
<th>Условия</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={rules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
|
||||
{rules.map((rule, i) => (
|
||||
<SortableRuleRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
index={i}
|
||||
total={rules.length}
|
||||
onEdit={setEditingId}
|
||||
onUpdate={onUpdate}
|
||||
onRemove={onRemove}
|
||||
conflict={conflictsByRuleId[rule.id]}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RuleEditorDrawer
|
||||
rule={editing}
|
||||
onUpdate={onUpdate}
|
||||
onClose={() => setEditingId(null)}
|
||||
onRemove={onRemove}
|
||||
availableRuleSets={availableRuleSets}
|
||||
/>
|
||||
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChipsInput } from './ChipsInput.jsx';
|
||||
import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
||||
const RULE_SET_TAG = /^[a-z0-9][a-z0-9_.@!-]*$/i;
|
||||
const validDomain = (v) => DOMAIN.test(String(v).trim());
|
||||
const validRuleSetTag = (v) => RULE_SET_TAG.test(String(v).trim());
|
||||
|
||||
const RS_PAGE_SIZE = 100;
|
||||
const RS_TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключ', cidr: 'CIDR', regex: 'regex' };
|
||||
|
||||
function RuleSetBrowseModal({ tag, url, rule, onPatch, onClose }) {
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.ruleSets.lookup(tag, url)
|
||||
.then((d) => { setData(d); setStatus('done'); })
|
||||
.catch((err) => { setError(err.message); setStatus('error'); });
|
||||
}, [tag, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'done') setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}, [status]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data?.entries) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
return data.entries.filter((e) => {
|
||||
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
|
||||
if (!q) return true;
|
||||
return e.value.toLowerCase().includes(q);
|
||||
});
|
||||
}, [data, search, typeFilter]);
|
||||
|
||||
function onSearchChange(v) { setSearch(v); setPage(0); }
|
||||
function onTypeChange(v) { setTypeFilter(v); setPage(0); }
|
||||
|
||||
function addEntry(entry) {
|
||||
const val = entry.value;
|
||||
switch (entry.type) {
|
||||
case 'domain': {
|
||||
const cur = new Set(rule.domains || []);
|
||||
if (!cur.has(val)) onPatch({ domains: [...(rule.domains || []), val] });
|
||||
break;
|
||||
}
|
||||
case 'suffix': {
|
||||
const cur = new Set(rule.domainSuffixes || []);
|
||||
if (!cur.has(val)) onPatch({ domainSuffixes: [...(rule.domainSuffixes || []), val] });
|
||||
break;
|
||||
}
|
||||
case 'keyword': {
|
||||
const cur = new Set(rule.domainKeywords || []);
|
||||
if (!cur.has(val)) onPatch({ domainKeywords: [...(rule.domainKeywords || []), val] });
|
||||
break;
|
||||
}
|
||||
case 'cidr': {
|
||||
const cur = new Set(rule.ipCidrs || []);
|
||||
if (!cur.has(val)) onPatch({ ipCidrs: [...(rule.ipCidrs || []), val] });
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / RS_PAGE_SIZE);
|
||||
const pageItems = filtered.slice(page * RS_PAGE_SIZE, (page + 1) * RS_PAGE_SIZE);
|
||||
|
||||
const addedValues = useMemo(() => new Set([
|
||||
...(rule.domains || []),
|
||||
...(rule.domainSuffixes || []),
|
||||
...(rule.domainKeywords || []),
|
||||
...(rule.ipCidrs || []),
|
||||
]), [rule]);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" style={{ zIndex: 1100 }} onClick={onClose}>
|
||||
<div
|
||||
className="modal lg"
|
||||
style={{ maxWidth: 680, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>Содержимое: <code style={{ fontSize: 14 }}>{tag}</code></h3>
|
||||
<small className="muted">Кликните запись чтобы добавить в правило</small>
|
||||
</div>
|
||||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
|
||||
{status === 'loading' && (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Скачивание и декомпиляция…<br />
|
||||
<small>Может занять 10–30 секунд</small>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="conflict-banner danger"><span>✗</span><div>{error}</div></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'done' && data && (
|
||||
<>
|
||||
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span className="badge info">всего: {data.stats.total.toLocaleString()}</span>
|
||||
{data.stats.domain > 0 && <span className="badge">доменов: {data.stats.domain.toLocaleString()}</span>}
|
||||
{data.stats.suffix > 0 && <span className="badge">суффиксов: {data.stats.suffix.toLocaleString()}</span>}
|
||||
{data.stats.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
|
||||
</div>
|
||||
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Поиск: youtube, 149.154, .ru…"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
<select className="select" style={{ width: 130 }} value={typeFilter} onChange={(e) => onTypeChange(e.target.value)}>
|
||||
<option value="all">Все типы</option>
|
||||
{Object.entries(RS_TYPE_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>Ничего не найдено</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', padding: '6px 0' }}>
|
||||
{filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
|
||||
{totalPages > 1 && ` · стр. ${page + 1}/${totalPages}`}
|
||||
<span className="muted" style={{ marginLeft: 12 }}>— нажмите строку чтобы добавить в правило</span>
|
||||
</div>
|
||||
<table className="table" style={{ fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr><th style={{ width: 70 }}>Тип</th><th>Значение</th><th style={{ width: 30 }}></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((e, i) => {
|
||||
const already = addedValues.has(e.value);
|
||||
return (
|
||||
<tr
|
||||
key={i}
|
||||
style={{ cursor: already ? 'default' : 'pointer', opacity: already ? 0.5 : 1 }}
|
||||
onClick={() => !already && addEntry(e)}
|
||||
title={already ? 'Уже добавлено' : `Добавить в ${e.type === 'cidr' ? 'IP/CIDR' : e.type === 'suffix' ? 'суффиксы' : e.type === 'keyword' ? 'ключевые слова' : 'домены'}`}
|
||||
>
|
||||
<td><span className="badge" style={{ fontSize: 10 }}>{RS_TYPE_LABELS[e.type] || e.type}</span></td>
|
||||
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 14 }}>{already ? '✓' : '+'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex" style={{ gap: 8, padding: '10px 0', justifyContent: 'center' }}>
|
||||
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage(0)}>«</button>
|
||||
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>‹</button>
|
||||
<span className="muted" style={{ lineHeight: '28px', fontSize: 12 }}>{page + 1} / {totalPages}</span>
|
||||
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}>›</button>
|
||||
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>»</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder', availableRuleSets = [] }) {
|
||||
const [view, setView] = useState(mode); // builder | json
|
||||
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
const [browseTag, setBrowseTag] = useState(null); // { tag, url } | null
|
||||
const errors = ruleErrors(rule);
|
||||
|
||||
// Индекс URL по тегу из доступных rule-sets
|
||||
const ruleSetUrlMap = useMemo(() => {
|
||||
const map = {};
|
||||
for (const rs of availableRuleSets) map[rs.tag] = rs.url;
|
||||
return map;
|
||||
}, [availableRuleSets]);
|
||||
|
||||
function patch(p) {
|
||||
onUpdate(rule.id, p);
|
||||
}
|
||||
|
||||
function applyJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonDraft);
|
||||
onUpdate(rule.id, { ...parsed, id: rule.id });
|
||||
setJsonError('');
|
||||
} catch (err) {
|
||||
setJsonError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer-body">
|
||||
<div className="tabs">
|
||||
<button className={`tab ${view === 'builder' ? 'active' : ''}`} onClick={() => setView('builder')}>Конструктор</button>
|
||||
<button className={`tab ${view === 'json' ? 'active' : ''}`} onClick={() => { setJsonDraft(JSON.stringify(rule, null, 2)); setView('json'); }}>Raw JSON</button>
|
||||
</div>
|
||||
|
||||
{view === 'builder' ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<span className="field-label">Название</span>
|
||||
<input className="input" value={rule.name} onChange={(e) => patch({ name: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<span className="field-label">Outbound</span>
|
||||
<select className="select" value={rule.outbound} onChange={(e) => patch({ outbound: e.target.value })}>
|
||||
<option value="direct">direct (напрямую)</option>
|
||||
<option value="vpn">vpn (через выбранный сервер)</option>
|
||||
<option value="block">block (заблокировать)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="field-label">Состояние</span>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled !== false}
|
||||
onChange={(e) => patch({ enabled: e.target.checked })}
|
||||
/>
|
||||
Правило включено
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Rule-sets (geo-базы)</span>
|
||||
<ChipsInput
|
||||
value={rule.ruleSets || []}
|
||||
onChange={(v) => patch({ ruleSets: v })}
|
||||
placeholder="geosite-runet"
|
||||
validate={validRuleSetTag}
|
||||
/>
|
||||
{/* Кнопки просмотра содержимого для выбранных rule-sets */}
|
||||
{(rule.ruleSets || []).length > 0 && (
|
||||
<div className="field-hint" style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
||||
{(rule.ruleSets || []).map((tag) => {
|
||||
const url = ruleSetUrlMap[tag];
|
||||
return url ? (
|
||||
<button
|
||||
key={tag}
|
||||
className="btn btn-ghost sm"
|
||||
style={{ padding: '0 6px', fontSize: 11 }}
|
||||
onClick={() => setBrowseTag({ tag, url })}
|
||||
title={`Просмотреть содержимое ${tag}`}
|
||||
>
|
||||
🔍 {tag}
|
||||
</button>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{availableRuleSets.length > 0 && (
|
||||
<div className="field-hint">
|
||||
Доступны:{' '}
|
||||
{availableRuleSets.map((rs) => (
|
||||
<span key={rs.tag} style={{ display: 'inline-flex', alignItems: 'center', marginRight: 4 }}>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
style={{ padding: '0 6px', marginRight: 2 }}
|
||||
onClick={() => {
|
||||
const current = new Set(rule.ruleSets || []);
|
||||
if (!current.has(rs.tag)) {
|
||||
patch({ ruleSets: [...(rule.ruleSets || []), rs.tag] });
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ {rs.tag}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
style={{ padding: '0 4px', fontSize: 12 }}
|
||||
onClick={() => setBrowseTag({ tag: rs.tag, url: rs.url })}
|
||||
title="Просмотреть содержимое"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{availableRuleSets.length === 0 && (
|
||||
<span className="field-hint">
|
||||
Настройте rule-sets в Настройках, затем вводите их теги здесь
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Домены (точное совпадение)</span>
|
||||
<ChipsInput
|
||||
value={rule.domains || []}
|
||||
onChange={(v) => patch({ domains: v })}
|
||||
placeholder="riotgames.com"
|
||||
validate={validDomain}
|
||||
/>
|
||||
{errors.domains.length > 0 && <span className="field-error">Невалидно: {errors.domains.join(', ')}</span>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Суффиксы доменов</span>
|
||||
<ChipsInput
|
||||
value={rule.domainSuffixes || []}
|
||||
onChange={(v) => patch({ domainSuffixes: v })}
|
||||
placeholder="riotcdn.net"
|
||||
validate={validDomain}
|
||||
/>
|
||||
{errors.domainSuffixes.length > 0 && <span className="field-error">Невалидно: {errors.domainSuffixes.join(', ')}</span>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">IP / CIDR</span>
|
||||
<ChipsInput
|
||||
value={rule.ipCidrs || []}
|
||||
onChange={(v) => patch({ ipCidrs: v })}
|
||||
placeholder="104.160.128.0/19"
|
||||
validate={isValidCidr}
|
||||
/>
|
||||
{errors.ipCidrs.length > 0 && <span className="field-error">Невалидно: {errors.ipCidrs.join(', ')}</span>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Порты (число или диапазон 5000-6000)</span>
|
||||
<ChipsInput
|
||||
value={rule.ports || []}
|
||||
onChange={(v) => patch({ ports: v })}
|
||||
placeholder="443"
|
||||
validate={(p) => {
|
||||
const s = String(p);
|
||||
if (s.includes('-')) {
|
||||
const [a, b] = s.split('-');
|
||||
return isValidPort(a) && isValidPort(b);
|
||||
}
|
||||
return isValidPort(p);
|
||||
}}
|
||||
/>
|
||||
{errors.ports.length > 0 && <span className="field-error">Невалидно: {errors.ports.join(', ')}</span>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Протоколы</span>
|
||||
<div className="flex">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(rule.networks || []).includes('tcp')}
|
||||
onChange={(e) => {
|
||||
const set = new Set(rule.networks || []);
|
||||
e.target.checked ? set.add('tcp') : set.delete('tcp');
|
||||
patch({ networks: Array.from(set) });
|
||||
}}
|
||||
/>
|
||||
TCP
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(rule.networks || []).includes('udp')}
|
||||
onChange={(e) => {
|
||||
const set = new Set(rule.networks || []);
|
||||
e.target.checked ? set.add('udp') : set.delete('udp');
|
||||
patch({ networks: Array.from(set) });
|
||||
}}
|
||||
/>
|
||||
UDP
|
||||
</label>
|
||||
<span className="field-hint">Если ничего — оба</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field">
|
||||
<span className="field-label">Сырой JSON правила</span>
|
||||
<textarea
|
||||
className="textarea"
|
||||
style={{ minHeight: 320 }}
|
||||
value={jsonDraft}
|
||||
onChange={(e) => setJsonDraft(e.target.value)}
|
||||
/>
|
||||
{jsonError && <span className="field-error">{jsonError}</span>}
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-primary" onClick={applyJson}>Применить JSON</button>
|
||||
<button className="btn btn-ghost" onClick={() => setJsonDraft(JSON.stringify(rule, null, 2))}>Сбросить</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{browseTag && (
|
||||
<RuleSetBrowseModal
|
||||
tag={browseTag.tag}
|
||||
url={browseTag.url}
|
||||
rule={rule}
|
||||
onPatch={(p) => patch(p)}
|
||||
onClose={() => setBrowseTag(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove, availableRuleSets = [] }) {
|
||||
if (!rule) return null;
|
||||
const errors = ruleErrors(rule);
|
||||
const invalid = hasErrors(errors);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-backdrop" onClick={onClose} />
|
||||
<aside className="drawer">
|
||||
<div className="drawer-head">
|
||||
<div>
|
||||
<h3>Редактирование правила</h3>
|
||||
<small className="muted">{rule.name || '(без названия)'}</small>
|
||||
</div>
|
||||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
<RuleEditor rule={rule} onUpdate={onUpdate} onClose={onClose} onRemove={onRemove} availableRuleSets={availableRuleSets} />
|
||||
<div className="drawer-foot">
|
||||
<button className="btn btn-danger" onClick={() => { if (confirm('Удалить правило?')) { onRemove(rule.id); onClose(); } }}>Удалить</button>
|
||||
<div className="btn-group">
|
||||
{invalid && <span className="badge danger">Есть ошибки</span>}
|
||||
<button className="btn btn-primary" onClick={onClose}>Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
import { formatRelative } from '../utils/format.js';
|
||||
|
||||
function PingCell({ ping }) {
|
||||
if (!ping) return <span className="muted">—</span>;
|
||||
if (ping.checking) return <span className="badge neutral pulse">проверяем…</span>;
|
||||
if (!ping.ok) return <span className="badge danger" title={ping.error}>offline</span>;
|
||||
const ms = ping.latency;
|
||||
const kind = ms < 80 ? 'success' : ms < 200 ? 'warning' : 'danger';
|
||||
return <span className={`badge ${kind}`}>{ms} ms</span>;
|
||||
}
|
||||
|
||||
function StatusCell({ ping }) {
|
||||
if (!ping) return <span className="badge neutral">unknown</span>;
|
||||
if (ping.checking) return <span className="badge neutral pulse">…</span>;
|
||||
return ping.ok
|
||||
? <span className="badge success">● online</span>
|
||||
: <span className="badge danger">● offline</span>;
|
||||
}
|
||||
|
||||
export function ServersPage({
|
||||
state,
|
||||
servers,
|
||||
selectedTag,
|
||||
setSelectedTag,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
busy,
|
||||
onApply,
|
||||
onRollback,
|
||||
pings,
|
||||
setPings,
|
||||
pushToast,
|
||||
}) {
|
||||
const [filter, setFilter] = useState('all'); // all | online
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
async function pingOne(server) {
|
||||
setPings((prev) => ({ ...prev, [server.tag]: { checking: true } }));
|
||||
try {
|
||||
const res = await api.servers.ping(server.server, server.server_port);
|
||||
setPings((prev) => ({
|
||||
...prev,
|
||||
[server.tag]: { ok: res.ok, latency: res.latency, error: res.error, checkedAt: new Date().toISOString() },
|
||||
}));
|
||||
} catch (err) {
|
||||
setPings((prev) => ({ ...prev, [server.tag]: { ok: false, error: err.message } }));
|
||||
}
|
||||
}
|
||||
|
||||
async function pingAll() {
|
||||
setPings((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const s of servers) next[s.tag] = { checking: true };
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const res = await api.servers.pingAll();
|
||||
const map = {};
|
||||
for (const r of res.results || []) {
|
||||
map[r.tag] = { ok: r.ok, latency: r.latency, error: r.error, checkedAt: r.checkedAt };
|
||||
}
|
||||
setPings((prev) => ({ ...prev, ...map }));
|
||||
pushToast({ kind: 'success', title: 'Пинг завершён' });
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Ошибка пинга', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return servers.filter((s) => {
|
||||
if (search && !s.tag.toLowerCase().includes(search.toLowerCase()) && !s.server.toLowerCase().includes(search.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (filter === 'online' && !pings[s.tag]?.ok) return false;
|
||||
return true;
|
||||
});
|
||||
}, [servers, search, filter, pings]);
|
||||
|
||||
const pendingDifferent = pendingTag && pendingTag !== state?.selectedTag;
|
||||
const activeServer = servers.find((s) => s.tag === state?.selectedTag);
|
||||
const pendingServer = servers.find((s) => s.tag === pendingTag);
|
||||
|
||||
if (!servers.length) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<h3>Серверы ещё не загружены</h3>
|
||||
<p>Загрузите подписку в разделе «Настройки», чтобы получить список серверов.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section-stack">
|
||||
{pendingDifferent && (
|
||||
<div className="card" style={{ borderColor: 'var(--warning)' }}>
|
||||
<div className="flex-between">
|
||||
<div>
|
||||
<strong>Выбран: {flagFor(pendingServer)} {pendingServer?.tag}</strong>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>
|
||||
Текущий: {state?.selectedTag ? `${flagFor(activeServer)} ${state.selectedTag}` : 'нет'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-ghost" onClick={() => setPendingTag(state?.selectedTag || '')} disabled={busy}>
|
||||
Отменить
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => onApply(pendingTag)} disabled={busy}>
|
||||
Применить изменения
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Серверы ({servers.length})</h2>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-secondary sm" onClick={pingAll} disabled={busy}>
|
||||
⚡ Проверить все
|
||||
</button>
|
||||
{state?.previousTag && (
|
||||
<button className="btn btn-ghost sm" onClick={onRollback} disabled={busy}>
|
||||
↶ Откатить ({state.previousTag})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar" style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Поиск по тегу или хосту…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
<option value="all">Все</option>
|
||||
<option value="online">Только online</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 16 }}></th>
|
||||
<th>Сервер</th>
|
||||
<th>Хост</th>
|
||||
<th>Тип</th>
|
||||
<th>Ping</th>
|
||||
<th>Статус</th>
|
||||
<th style={{ textAlign: 'right' }}>Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((server) => {
|
||||
const isActive = server.tag === state?.selectedTag;
|
||||
const isPending = server.tag === pendingTag && !isActive;
|
||||
const ping = pings[server.tag];
|
||||
return (
|
||||
<tr key={server.tag} className={isActive ? 'active' : ''}>
|
||||
<td>{flagFor(server)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<strong>{server.tag}</strong>
|
||||
{isActive && <span className="badge success">ACTIVE</span>}
|
||||
{isPending && <span className="badge warning">pending</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-mono muted">{server.server}:{server.server_port}</td>
|
||||
<td><span className="badge neutral">{server.type}</span></td>
|
||||
<td><PingCell ping={ping} /></td>
|
||||
<td><StatusCell ping={ping} /></td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button className="btn btn-ghost sm" onClick={() => pingOne(server)} disabled={busy}>
|
||||
Ping
|
||||
</button>
|
||||
{isActive ? (
|
||||
<button className="btn btn-secondary sm" disabled>Активен</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary sm"
|
||||
onClick={() => { setSelectedTag(server.tag); setPendingTag(server.tag); }}
|
||||
disabled={busy}
|
||||
>
|
||||
Выбрать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!filtered.length && (
|
||||
<tr><td colSpan={7} className="muted" style={{ padding: 24, textAlign: 'center' }}>Ничего не найдено</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,784 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { formatRelative } from '../utils/format.js';
|
||||
|
||||
const TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключевое слово', cidr: 'CIDR', regex: 'regex' };
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
function RuleSetLookupModal({ tag, url, onClose }) {
|
||||
const [state, setState] = useState('idle'); // idle | loading | done | error
|
||||
const [error, setError] = useState('');
|
||||
const [data, setData] = useState(null); // { entries, stats, cachedAt }
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setState('loading');
|
||||
api.ruleSets.lookup(tag, url)
|
||||
.then((res) => { setData(res); setState('done'); })
|
||||
.catch((err) => { setError(err.message); setState('error'); });
|
||||
}, [tag, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'done') setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}, [state]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data?.entries) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
return data.entries.filter((e) => {
|
||||
if (filterType !== 'all' && e.type !== filterType) return false;
|
||||
if (!q) return true;
|
||||
return e.value.toLowerCase().includes(q);
|
||||
});
|
||||
}, [data, search, filterType]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const pageItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
function onSearchChange(v) { setSearch(v); setPage(0); }
|
||||
function onTypeChange(v) { setFilterType(v); setPage(0); }
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal lg" style={{ maxWidth: 720, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>Содержимое: <code style={{ fontSize: 14 }}>{tag}</code></h3>
|
||||
<small className="muted">{url}</small>
|
||||
</div>
|
||||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
|
||||
{state === 'loading' && (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Скачивание и декомпиляция…<br />
|
||||
<small>Может занять 10–30 секунд</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="conflict-banner danger"><span>✗</span><div>{error}</div></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'done' && data && (
|
||||
<>
|
||||
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="flex" style={{ gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span className="badge info">всего: {data.stats.total.toLocaleString()}</span>
|
||||
{data.stats.domain > 0 && <span className="badge">доменов: {data.stats.domain.toLocaleString()}</span>}
|
||||
{data.stats.suffix > 0 && <span className="badge">суффиксов: {data.stats.suffix.toLocaleString()}</span>}
|
||||
{data.stats.keyword > 0 && <span className="badge">ключ. слов: {data.stats.keyword.toLocaleString()}</span>}
|
||||
{data.stats.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
|
||||
{data.stats.regex > 0 && <span className="badge">regex: {data.stats.regex.toLocaleString()}</span>}
|
||||
<span className="muted" style={{ fontSize: 12, marginLeft: 'auto' }}>
|
||||
кеш: {formatRelative(data.cachedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Поиск: youtube, 149.154, .ru…"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
<select className="select" style={{ width: 140 }} value={filterType} onChange={(e) => onTypeChange(e.target.value)}>
|
||||
<option value="all">Все типы</option>
|
||||
{Object.entries(TYPE_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>Ничего не найдено</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', padding: '8px 0' }}>
|
||||
Найдено: {filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
|
||||
{totalPages > 1 && ` · страница ${page + 1} из ${totalPages}`}
|
||||
</div>
|
||||
<table className="table" style={{ fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr><th style={{ width: 80 }}>Тип</th><th>Значение</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td><span className="badge">{TYPE_LABELS[e.type] || e.type}</span></td>
|
||||
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex" style={{ gap: 8, padding: '12px 0', justifyContent: 'center' }}>
|
||||
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage(0)}>«</button>
|
||||
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>‹</button>
|
||||
<span className="muted" style={{ lineHeight: '28px', fontSize: 13 }}>{page + 1} / {totalPages}</span>
|
||||
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}>›</button>
|
||||
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>»</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Каталог готовых rule-set источников для sing-box (.srs формат)
|
||||
// Источники: SagerNet (официальные, используются как встроенные), runetfreedom (RKN-реестр)
|
||||
const RULE_SET_CATALOG = [
|
||||
{
|
||||
tag: 'geosite-runet',
|
||||
url: 'https://github.com/runetfreedom/russia-blocked-geosite/releases/latest/download/rule-set/ru.srs',
|
||||
source: 'runetfreedom',
|
||||
category: 'RU',
|
||||
description: 'Заблокированные в РФ домены по реестру РКН. Обновляется автоматически из официальных источников.',
|
||||
examples: ['rutracker.org', 'youtube.com', 'instagram.com', 'facebook.com', 'twitter.com'],
|
||||
use: 'vpn — маршрутизировать заблокированные домены через VPN.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geoip-ru',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs',
|
||||
source: 'SagerNet/sing-geoip',
|
||||
category: 'RU',
|
||||
description: 'IP-диапазоны, зарегистрированные в России (RIPE NCC). Покрывает российские хостинги, банки, госсайты.',
|
||||
examples: ['77.88.0.0/18 (Яндекс)', '95.173.128.0/19 (МТС)', '213.180.192.0/19 (Яндекс)'],
|
||||
use: 'direct — российские сервисы без VPN.',
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-category-ru',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'RU',
|
||||
description: 'Домены российских сервисов: Яндекс, VK, Mail.ru, Сбербанк, банки, госуслуги. Не заблокированные, а просто российские.',
|
||||
examples: ['yandex.ru', 'vk.com', 'mail.ru', 'sberbank.ru', 'gosuslugi.ru', 'ozon.ru'],
|
||||
use: 'direct — чтобы российские сайты открывались с российским IP (нужно для оплаты и т.п.).',
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-google',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-google.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Сервисы',
|
||||
description: 'Все домены Google: поиск, Gmail, YouTube, Drive, Maps, Google API, reCAPTCHA и пр.',
|
||||
examples: ['google.com', 'googleapis.com', 'googlevideo.com', 'gstatic.com', 'ggpht.com'],
|
||||
use: 'vpn — если Google заблокирован или нужна стабильная работа сервисов.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-youtube',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-youtube.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Сервисы',
|
||||
description: 'Только домены YouTube и связанных CDN. Меньше чем полный Google.',
|
||||
examples: ['youtube.com', 'youtu.be', 'ytimg.com', 'googlevideo.com'],
|
||||
use: 'vpn — для разблокировки YouTube.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-telegram',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-telegram.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Сервисы',
|
||||
description: 'Домены и IP Telegram. Включает CDN, API и голосовые серверы.',
|
||||
examples: ['telegram.org', 't.me', 'telegra.ph', '149.154.160.0/20'],
|
||||
use: 'vpn — разблокировка в РФ. direct — если хочешь избежать задержек.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-openai',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-openai.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Сервисы',
|
||||
description: 'ChatGPT, OpenAI API, Dall-E и другие сервисы OpenAI.',
|
||||
examples: ['openai.com', 'chatgpt.com', 'oaistatic.com', 'oaiusercontent.com'],
|
||||
use: 'vpn — OpenAI заблокирован в РФ и ряде других стран.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-apple',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-apple.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Сервисы',
|
||||
description: 'App Store, iCloud, Apple CDN, push-уведомления (APNs), iMessage.',
|
||||
examples: ['apple.com', 'icloud.com', 'mzstatic.com', 'apple-cloudkit.com'],
|
||||
use: 'direct — Apple обычно работает без VPN. vpn — если нужен другой регион App Store.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-github',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-github.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Разработка',
|
||||
description: 'GitHub, GitHub Actions, GitHub Pages, raw.githubusercontent.com.',
|
||||
examples: ['github.com', 'githubusercontent.com', 'github.io', 'githubassets.com'],
|
||||
use: 'vpn — если GitHub замедлен или заблокирован.',
|
||||
builtIn: false,
|
||||
},
|
||||
{
|
||||
tag: 'geosite-category-ads-all',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ads-all.srs',
|
||||
source: 'SagerNet/sing-geosite',
|
||||
category: 'Блокировка',
|
||||
description: 'Рекламные сети, трекеры, аналитика. Тысячи доменов.',
|
||||
examples: ['doubleclick.net', 'googlesyndication.com', 'amazon-adsystem.com'],
|
||||
use: 'block — блокировка рекламы и трекеров на уровне DNS.',
|
||||
builtIn: false,
|
||||
},
|
||||
];
|
||||
|
||||
function SubscriptionCard({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetch, onForget, pushToast }) {
|
||||
const [editing, setEditing] = useState(!state?.hasSubscription);
|
||||
|
||||
useEffect(() => { if (!state?.hasSubscription) setEditing(true); }, [state?.hasSubscription]);
|
||||
|
||||
const masked = state?.hasSubscription && !editing;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Подписка</h2>
|
||||
{state?.hasSubscription && (
|
||||
<span className="badge success">● активна</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{masked ? (
|
||||
<div className="kv-list">
|
||||
<div className="row">
|
||||
<span className="key">URL</span>
|
||||
<span className="val text-mono">{state.subscriptionHost}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span className="key">Серверов</span>
|
||||
<span className="val">{state.servers?.length || 0}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span className="key">Загружено</span>
|
||||
<span className="val">{state.fetchedAt ? formatRelative(state.fetchedAt) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="field">
|
||||
<span className="field-label">Subscription URL</span>
|
||||
<div className="subscription-input">
|
||||
<input
|
||||
className="input"
|
||||
value={subscriptionUrl}
|
||||
onChange={(e) => setSubscriptionUrl(e.target.value)}
|
||||
placeholder="https://provider.example/sub/..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="btn-group" style={{ marginTop: 16 }}>
|
||||
{masked ? (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setEditing(true)} disabled={busy}>Изменить URL</button>
|
||||
<button className="btn btn-secondary" onClick={onFetch} disabled={busy}>↻ Обновить серверы</button>
|
||||
<button className="btn btn-danger" onClick={onForget} disabled={busy}>Удалить подписку</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={async () => { await onFetch(); setEditing(false); }}
|
||||
disabled={busy || !subscriptionUrl}
|
||||
>
|
||||
{busy ? 'Загрузка…' : 'Загрузить серверы'}
|
||||
</button>
|
||||
{state?.hasSubscription && (
|
||||
<button className="btn btn-ghost" onClick={() => setEditing(false)}>Отмена</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigCard({ state, busy, onShowConfig, onClearConfig, pushToast }) {
|
||||
const [validation, setValidation] = useState(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
|
||||
async function validate() {
|
||||
setValidating(true);
|
||||
try {
|
||||
const data = await api.configValidate();
|
||||
setValidation(data);
|
||||
pushToast({
|
||||
kind: data.valid ? 'success' : 'danger',
|
||||
title: data.valid ? 'Config валиден' : 'Config невалиден',
|
||||
message: data.error || data.note,
|
||||
});
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Ошибка проверки', message: err.message });
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>sing-box config</h2>
|
||||
{validation && (
|
||||
<span className={`badge ${validation.valid ? 'success' : 'danger'}`}>
|
||||
{validation.valid ? '✓ валиден' : '✗ ошибка'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="kv-list">
|
||||
<div className="row"><span className="key">Файл</span><span className="val">{state?.configExists ? 'есть' : 'нет'}</span></div>
|
||||
<div className="row"><span className="key">Применено</span><span className="val">{state?.appliedAt ? formatRelative(state.appliedAt) : '—'}</span></div>
|
||||
</div>
|
||||
<div className="btn-group" style={{ marginTop: 16 }}>
|
||||
<button className="btn btn-secondary" disabled={!state?.configExists} onClick={onShowConfig}>Показать config</button>
|
||||
<button className="btn btn-secondary" disabled={validating || !state?.configExists} onClick={validate}>
|
||||
{validating ? 'Проверяем…' : '✓ Валидировать'}
|
||||
</button>
|
||||
<button className="btn btn-danger" disabled={busy || !state?.configExists} onClick={onClearConfig}>
|
||||
Сбросить config
|
||||
</button>
|
||||
</div>
|
||||
{validation && !validation.valid && validation.error && (
|
||||
<div className="conflict-banner danger" style={{ marginTop: 12 }}>
|
||||
<span>✗</span><div>{validation.error}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CATALOG_CATEGORIES = ['Все', ...Array.from(new Set(RULE_SET_CATALOG.map((r) => r.category)))];
|
||||
|
||||
function CatalogEntry({ entry, added, busy, onAdd, onLookup }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '10px 14px', marginBottom: 8 }}>
|
||||
<div className="flex" style={{ alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="flex" style={{ alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<strong className="text-mono" style={{ fontSize: 13 }}>{entry.tag}</strong>
|
||||
<span className="badge info" style={{ fontSize: 11 }}>{entry.category}</span>
|
||||
<span className="muted" style={{ fontSize: 12 }}>{entry.source}</span>
|
||||
{entry.builtIn && (
|
||||
<span className="badge success" style={{ fontSize: 11 }} title="Загружается автоматически при включённом RU direct">встроен</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, marginTop: 2, color: 'var(--text)' }}>{entry.description}</div>
|
||||
</div>
|
||||
<div className="flex" style={{ gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title="Примеры и подсказка"
|
||||
>
|
||||
{open ? '▲' : '▼'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
onClick={() => onLookup(entry)}
|
||||
title="Просмотреть содержимое и искать внутри"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
disabled={busy || added}
|
||||
onClick={() => onAdd(entry)}
|
||||
>
|
||||
{added ? '✓ добавлен' : '+ Добавить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
||||
<span className="muted">Примеры содержимого: </span>
|
||||
{entry.examples.map((ex, i) => (
|
||||
<span key={i}>
|
||||
<code style={{ background: 'var(--bg-muted)', borderRadius: 3, padding: '1px 5px', fontSize: 11 }}>{ex}</code>
|
||||
{i < entry.examples.length - 1 ? ' ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<span className="muted">Рекомендуемый outbound: </span>
|
||||
<span>{entry.use}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SagerNetSearchCard({ ruleSets, onAdd, busy }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [status, setStatus] = useState('idle'); // idle | loading | done | error
|
||||
const [catalog, setCatalog] = useState(null); // { geosite, geoip, cachedAt }
|
||||
const [error, setError] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [repoFilter, setRepoFilter] = useState('all'); // all | geosite | geoip
|
||||
|
||||
function load() {
|
||||
if (status !== 'idle') return;
|
||||
setStatus('loading');
|
||||
api.ruleSets.sagernetCatalog()
|
||||
.then((d) => { setCatalog(d); setStatus('done'); })
|
||||
.catch((err) => { setError(err.message); setStatus('error'); });
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!open && status === 'idle') load();
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
const toItem = (repo) => (name) => ({ name, repo, url: `https://cdn.jsdelivr.net/gh/SagerNet/sing-${repo}@rule-set/${name}.srs` });
|
||||
const gs = repoFilter !== 'geoip' ? (catalog.geosite || []).map(toItem('geosite')) : [];
|
||||
const gi = repoFilter !== 'geosite' ? (catalog.geoip || []).map(toItem('geoip')) : [];
|
||||
const all = [...gs, ...gi];
|
||||
if (!q) return all;
|
||||
return all.filter((item) => item.name.includes(q));
|
||||
}, [catalog, query, repoFilter]);
|
||||
|
||||
const addedTags = new Set(ruleSets.map((rs) => rs.tag));
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header" style={{ cursor: 'pointer' }} onClick={toggle}>
|
||||
<h2>Поиск в каталоге SagerNet</h2>
|
||||
<div className="flex" style={{ gap: 8, alignItems: 'center' }}>
|
||||
{status === 'done' && catalog && (
|
||||
<span className="badge info" style={{ fontSize: 11 }}>
|
||||
{(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} rule-sets
|
||||
</span>
|
||||
)}
|
||||
<span className="muted" style={{ fontSize: 13 }}>{open ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{status === 'loading' && (
|
||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Загрузка списка из GitHub…
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="conflict-banner danger" style={{ marginTop: 8 }}>
|
||||
<span>✗</span><div>{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
<>
|
||||
<small className="muted" style={{ display: 'block', marginBottom: 12 }}>
|
||||
Полный список rule-sets из репозиториев <strong>SagerNet/sing-geosite</strong> и <strong>SagerNet/sing-geoip</strong>.
|
||||
Ищите по имени: <code>steam</code>, <code>gaming</code>, <code>netflix</code>, <code>apple</code> и т.д.
|
||||
Кеш обновляется раз в 24 ч.
|
||||
</small>
|
||||
{catalog.fallback && (
|
||||
<div className="conflict-banner warning" style={{ marginBottom: 12 }}>
|
||||
<span>!</span><div>{catalog.warning || 'Показан встроенный fallback-каталог.'}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, minWidth: 180 }}
|
||||
placeholder="steam, gaming, netflix, cloudflare…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<select className="select" style={{ width: 130 }} value={repoFilter} onChange={(e) => setRepoFilter(e.target.value)}>
|
||||
<option value="all">geosite + geoip</option>
|
||||
<option value="geosite">только geosite</option>
|
||||
<option value="geoip">только geoip</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{query.trim() === '' ? (
|
||||
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>
|
||||
Введите запрос — покажем совпадения ({(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} доступно)
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>Ничего не найдено</div>
|
||||
) : (
|
||||
<table className="table" style={{ fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 60 }}>Тип</th>
|
||||
<th>Тег</th>
|
||||
<th style={{ width: 120 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.slice(0, 100).map((item) => (
|
||||
<tr key={item.name}>
|
||||
<td><span className={`badge ${item.repo === 'geosite' ? 'info' : ''}`} style={{ fontSize: 11 }}>{item.repo}</span></td>
|
||||
<td className="text-mono">{item.name}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
{addedTags.has(item.name) ? (
|
||||
<span className="badge success" style={{ fontSize: 11 }}>✓ добавлен</span>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
disabled={busy}
|
||||
onClick={() => onAdd({ tag: item.name, url: item.url })}
|
||||
>
|
||||
+ Добавить
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{results.length > 100 && (
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 8 }}>
|
||||
Показано 100 из {results.length} — уточните запрос
|
||||
</div>
|
||||
)}
|
||||
<div className="muted" style={{ fontSize: 11, marginTop: 12 }}>
|
||||
кеш: {catalog.cachedAt ? formatRelative(catalog.cachedAt) : '—'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleSetsCard({ pushToast }) {
|
||||
const [ruleSets, setRuleSets] = useState([]);
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('Все');
|
||||
const [lookup, setLookup] = useState(null); // { tag, url }
|
||||
|
||||
useEffect(() => {
|
||||
api.ruleSets.get().then((d) => setRuleSets(d.ruleSets || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function save(next) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api.ruleSets.save(next);
|
||||
setRuleSets(data.ruleSets || []);
|
||||
pushToast({ kind: 'success', title: 'Rule-sets сохранены' });
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addNew() {
|
||||
const tag = newTag.trim();
|
||||
const url = newUrl.trim();
|
||||
if (!tag || !url) return;
|
||||
if (!/^[a-z0-9][a-z0-9_.@!-]*$/i.test(tag)) {
|
||||
pushToast({ kind: 'danger', title: 'Невалидный тег', message: 'Буквы, цифры и символы - _ . @ !' });
|
||||
return;
|
||||
}
|
||||
if (ruleSets.some((rs) => rs.tag === tag)) {
|
||||
pushToast({ kind: 'danger', title: 'Тег уже существует' });
|
||||
return;
|
||||
}
|
||||
const next = [...ruleSets, { tag, url }];
|
||||
setNewTag('');
|
||||
setNewUrl('');
|
||||
save(next);
|
||||
}
|
||||
|
||||
function remove(tag) {
|
||||
save(ruleSets.filter((rs) => rs.tag !== tag));
|
||||
}
|
||||
|
||||
function addFromCatalog(entry) {
|
||||
if (ruleSets.some((rs) => rs.tag === entry.tag)) {
|
||||
pushToast({ kind: 'info', title: `${entry.tag} уже добавлен` });
|
||||
return;
|
||||
}
|
||||
save([...ruleSets, { tag: entry.tag, url: entry.url }]);
|
||||
}
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = RULE_SET_CATALOG.filter((entry) => {
|
||||
if (category !== 'Все' && entry.category !== category) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
entry.tag.includes(q) ||
|
||||
entry.description.toLowerCase().includes(q) ||
|
||||
entry.source.toLowerCase().includes(q) ||
|
||||
entry.examples.some((ex) => ex.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Источники (rule-sets)</h2>
|
||||
</div>
|
||||
<small className="muted" style={{ display: 'block', marginBottom: 16 }}>
|
||||
Geo-базы в формате <strong>.srs</strong> (sing-box). Sing-box скачает их автоматически при применении.
|
||||
<strong> .dat файлы (v2ray) не поддерживаются</strong>.
|
||||
</small>
|
||||
|
||||
{ruleSets.length > 0 && (
|
||||
<>
|
||||
<div className="field-label" style={{ marginBottom: 6 }}>Подключённые</div>
|
||||
<table className="table" style={{ marginBottom: 20 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Тег</th>
|
||||
<th>URL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ruleSets.map((rs) => (
|
||||
<tr key={rs.tag}>
|
||||
<td className="text-mono" style={{ whiteSpace: 'nowrap' }}>{rs.tag}</td>
|
||||
<td className="muted" style={{ fontSize: 12, wordBreak: 'break-all' }}>{rs.url}</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn btn-ghost sm" style={{ marginRight: 4 }} onClick={() => setLookup(rs)} title="Просмотреть содержимое">🔍</button>
|
||||
<button className="btn btn-ghost sm" disabled={busy} onClick={() => remove(rs.tag)}>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Каталог</div>
|
||||
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, minWidth: 180 }}
|
||||
placeholder="Поиск: telegram, реклама, youtube…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<select className="select" style={{ width: 140 }} value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
{CATALOG_CATEGORIES.map((c) => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="muted" style={{ fontSize: 13, marginBottom: 12 }}>Ничего не найдено</div>
|
||||
)}
|
||||
{filtered.map((entry) => (
|
||||
<CatalogEntry
|
||||
key={entry.tag}
|
||||
entry={entry}
|
||||
added={ruleSets.some((rs) => rs.tag === entry.tag)}
|
||||
busy={busy}
|
||||
onAdd={addFromCatalog}
|
||||
onLookup={(e) => setLookup(e)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="field" style={{ marginTop: 16 }}>
|
||||
<span className="field-label">Добавить свой .srs</span>
|
||||
<div className="flex" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 200 }}
|
||||
placeholder="тег (напр. geosite-custom)"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
placeholder="https://…/rule-set.srs"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={busy || !newTag || !newUrl} onClick={addNew}>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SagerNetSearchCard ruleSets={ruleSets} onAdd={addFromCatalog} busy={busy} />
|
||||
{lookup && (
|
||||
<RuleSetLookupModal
|
||||
tag={lookup.tag}
|
||||
url={lookup.url}
|
||||
onClose={() => setLookup(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PortsCard({ state }) {
|
||||
const isClient = state?.mode === 'client';
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header"><h2>{isClient ? 'Локальные порты' : 'Порты и маршруты'}</h2></div>
|
||||
<div className="kv-list">
|
||||
<div className="row"><span className="key">UI</span><span className="val text-mono">:{state?.port || 3456}</span></div>
|
||||
<div className="row"><span className="key">HTTP/SOCKS proxy</span><span className="val text-mono">{isClient ? '127.0.0.1' : state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}</span></div>
|
||||
{!isClient && <div className="row"><span className="key">TProxy</span><span className="val text-mono">:{state?.tproxyPort || 7895}</span></div>}
|
||||
<div className="row"><span className="key">RU direct (geoip-ru)</span><span className="val">{state?.routingRuDirect ? 'включено' : 'выключено'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage({
|
||||
state, subscriptionUrl, setSubscriptionUrl, busy,
|
||||
onFetchSubscription, onForgetSubscription, onShowConfig, onClearConfig, pushToast,
|
||||
}) {
|
||||
return (
|
||||
<div className="section-stack">
|
||||
<SubscriptionCard
|
||||
state={state}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
busy={busy}
|
||||
onFetch={onFetchSubscription}
|
||||
onForget={onForgetSubscription}
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
<ConfigCard
|
||||
state={state}
|
||||
busy={busy}
|
||||
onShowConfig={onShowConfig}
|
||||
onClearConfig={onClearConfig}
|
||||
pushToast={pushToast}
|
||||
/>
|
||||
<RuleSetsCard pushToast={pushToast} />
|
||||
<PortsCard state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const NAV = [
|
||||
{ id: 'overview', label: 'Обзор', ico: '◉' },
|
||||
{ id: 'servers', label: 'Серверы', ico: '⋆' },
|
||||
{ id: 'routing', label: 'Маршрутизация', ico: '⇅' },
|
||||
{ id: 'logs', label: 'Логи', ico: '≡' },
|
||||
{ id: 'settings', label: 'Настройки', ico: '⚙' },
|
||||
];
|
||||
|
||||
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
|
||||
const items = mode === 'client'
|
||||
? NAV.filter((item) => item.id !== 'routing')
|
||||
: NAV;
|
||||
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
{items.map((item) => {
|
||||
const badge = badges[item.id];
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`sidebar-item${active === item.id ? ' active' : ''}`}
|
||||
onClick={() => onChange(item.id)}
|
||||
>
|
||||
<span className="ico">{item.ico}</span>
|
||||
{item.label}
|
||||
{badge && (
|
||||
<span className={`badge ${badge.kind || ''}`}>{badge.text}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import React from 'react';
|
||||
import { formatBytes, formatRelative } from '../utils/format.js';
|
||||
|
||||
function StatusRow({ label, value, kind }) {
|
||||
return (
|
||||
<div className="row">
|
||||
<span className="key">{label}</span>
|
||||
<span className={`val ${kind ? 'text-' + kind : ''}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPane({ state, busy, onStop, onRestart, onShowConfig }) {
|
||||
const userInfo = state?.userInfo;
|
||||
const traffic = userInfo
|
||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : '∞'}`
|
||||
: '—';
|
||||
|
||||
let singboxStatus = 'Остановлен';
|
||||
let singboxKind = 'muted';
|
||||
if (state?.singboxRunning) {
|
||||
singboxStatus = `работает · ${formatRelative(state.singboxStartedAt)}`;
|
||||
singboxKind = 'success';
|
||||
} else if (state?.configExists) {
|
||||
singboxStatus = 'остановлен (конфиг есть)';
|
||||
singboxKind = 'warning';
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="status-pane">
|
||||
<div className="card compact flat">
|
||||
<div className="card-header no-margin">
|
||||
<h3>sing-box</h3>
|
||||
<span className={`badge ${state?.singboxRunning ? 'success' : 'neutral'}`}>
|
||||
{state?.singboxRunning ? '● online' : '○ offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="kv-list" style={{ marginTop: 12 }}>
|
||||
<StatusRow label="Статус" value={singboxStatus} kind={singboxKind} />
|
||||
<StatusRow label="UI порт" value={`:${state?.port || 3456}`} />
|
||||
<StatusRow label="Mixed proxy" value={`${state?.proxyBindIp || '0.0.0.0'}:${state?.proxyPort || 8080}`} />
|
||||
<StatusRow label="TProxy" value={`:${state?.tproxyPort || 7895}`} />
|
||||
<StatusRow label="RU direct" value={state?.routingRuDirect ? 'включено' : 'выключено'} />
|
||||
<StatusRow label="Трафик" value={traffic} />
|
||||
<StatusRow
|
||||
label="Применено"
|
||||
value={state?.appliedAt ? formatRelative(state.appliedAt) : 'не применено'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="btn-group" style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<button
|
||||
className="btn btn-secondary sm block"
|
||||
disabled={busy || !state?.configExists}
|
||||
onClick={onRestart}
|
||||
>
|
||||
↻ Перезапустить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost sm block"
|
||||
disabled={busy || !state?.singboxRunning}
|
||||
onClick={onStop}
|
||||
>
|
||||
■ Остановить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost sm block"
|
||||
disabled={!state?.configExists}
|
||||
onClick={onShowConfig}
|
||||
>
|
||||
⌘ Показать config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state?.appliedHistory?.length > 0 && (
|
||||
<div className="card compact flat">
|
||||
<h4 style={{ marginBottom: 8 }}>История применений</h4>
|
||||
<div className="events-list">
|
||||
{state.appliedHistory.slice(0, 5).map((h) => (
|
||||
<div key={h.at} className="event-row" style={{ gridTemplateColumns: '1fr auto' }}>
|
||||
<span className="text-truncate">{h.tag}</span>
|
||||
<span className="event-time">{formatRelative(h.at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
export function Toasts({ items, onDismiss }) {
|
||||
useEffect(() => {
|
||||
const timers = items.map((t) =>
|
||||
t.sticky ? null : setTimeout(() => onDismiss(t.id), t.duration || 4000),
|
||||
);
|
||||
return () => timers.forEach((t) => t && clearTimeout(t));
|
||||
}, [items, onDismiss]);
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<div className="toasts">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`toast ${t.kind || ''}`}>
|
||||
<span className={`dot ${t.kind || ''}`} style={{ marginTop: 4 }} />
|
||||
<div className="body">
|
||||
<strong>{t.title}</strong>
|
||||
{t.message && <small>{t.message}</small>}
|
||||
{t.action && (
|
||||
<button className="btn btn-link sm" onClick={t.action.onClick} style={{ marginTop: 4, padding: 0 }}>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => onDismiss(t.id)} title="Закрыть">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from 'react';
|
||||
import { formatBytes, formatRelative } from '../utils/format.js';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const map = {
|
||||
running: { dot: 'success', text: 'Работает', cls: '' },
|
||||
applying: { dot: 'warning pulse', text: 'Применяем…', cls: '' },
|
||||
error: { dot: 'danger', text: 'Ошибка', cls: '' },
|
||||
stopped: { dot: '', text: 'Остановлен', cls: '' },
|
||||
no_config: { dot: '', text: 'Не настроен', cls: '' },
|
||||
};
|
||||
const cfg = map[status] || map.stopped;
|
||||
return (
|
||||
<span className="flex">
|
||||
<span className={`dot ${cfg.dot}`} />
|
||||
<strong>{cfg.text}</strong>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApply }) {
|
||||
const userInfo = state?.userInfo;
|
||||
const traffic = userInfo
|
||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
|
||||
: null;
|
||||
|
||||
const isClient = state?.mode === 'client';
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-brand">
|
||||
<span className="logo-dot" />
|
||||
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
|
||||
</div>
|
||||
|
||||
<div className="topbar-status">
|
||||
<StatusBadge status={status} />
|
||||
{activeServer && (
|
||||
<div className="status-text">
|
||||
<strong>
|
||||
{flagFor(activeServer)} {activeServer.tag}
|
||||
</strong>
|
||||
<small>
|
||||
{activeServer.server}:{activeServer.server_port}
|
||||
{state?.appliedAt ? ` · применено ${formatRelative(state.appliedAt)}` : ''}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{!activeServer && (
|
||||
<small className="muted">Сервер не выбран</small>
|
||||
)}
|
||||
{traffic && <span className="badge neutral">{traffic}</span>}
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
{!isClient && dirty && (
|
||||
<span className="badge warning">● Несохранённые изменения</span>
|
||||
)}
|
||||
{!isClient && state?.previousTag && (
|
||||
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
|
||||
↶ Откат
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary sm"
|
||||
onClick={onRestart}
|
||||
disabled={!state?.configExists}
|
||||
title="Перезапустить sing-box"
|
||||
>
|
||||
↻ Перезапуск
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,843 +1,54 @@
|
||||
/* Системные шрифты — без загрузки из интернета */
|
||||
|
||||
:root {
|
||||
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, sans-serif;
|
||||
--font-head: 'Segoe UI', system-ui, -apple-system, Roboto, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Cascadia Code', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
color-scheme: dark;
|
||||
|
||||
/* Surfaces */
|
||||
--bg: #06110d;
|
||||
--surface: #0b1b14;
|
||||
--surface-2: #10251b;
|
||||
--surface-3: #163224;
|
||||
--border: #214734;
|
||||
--border-strong: #2c5d44;
|
||||
|
||||
/* Text */
|
||||
--text: #effff5;
|
||||
--muted: #9fbaaa;
|
||||
--subtle: #6f8c7c;
|
||||
|
||||
/* Roles */
|
||||
--success: #6dff9d;
|
||||
--success-dim: rgba(109, 255, 157, 0.14);
|
||||
--warning: #ffd166;
|
||||
--warning-dim: rgba(255, 209, 102, 0.14);
|
||||
--danger: #ff5c5c;
|
||||
--danger-dim: rgba(255, 92, 92, 0.14);
|
||||
--accent: #b7ff63;
|
||||
--accent-dim: rgba(183, 255, 99, 0.14);
|
||||
--info: #8ed4ff;
|
||||
--info-dim: rgba(142, 212, 255, 0.14);
|
||||
|
||||
/* Radii */
|
||||
--radius-card: 20px;
|
||||
--radius-input: 12px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
/* Shadow */
|
||||
--shadow-card: 0 12px 36px rgba(0, 0, 0, 0.32);
|
||||
--shadow-modal: 0 32px 80px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* Layout */
|
||||
--topbar-h: 64px;
|
||||
--sidebar-w: 220px;
|
||||
--status-w: 280px;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
min-height: 100%;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font: 700 16px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
color: light-dark(oklch(0.42 0.01 145), oklch(0.76 0.01 145));
|
||||
background: light-dark(oklch(0.965 0.006 145), oklch(0.18 0.012 145));
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button, input, select, textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-head);
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
h1 { font-size: 22px; }
|
||||
h2 { font-size: 18px; }
|
||||
h3 { font-size: 15px; color: var(--text); }
|
||||
h4 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
|
||||
p { margin: 0; }
|
||||
small { font-size: 12px; color: var(--muted); }
|
||||
code, .mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ============ Layout ============ */
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, rgba(109, 255, 157, 0.05), transparent 32rem),
|
||||
radial-gradient(circle at 92% 100%, rgba(142, 212, 255, 0.04), transparent 28rem),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.app-body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr var(--status-w);
|
||||
min-height: 0;
|
||||
}
|
||||
.app-body.client-mode {
|
||||
grid-template-columns: 1fr;
|
||||
background:
|
||||
radial-gradient(circle at 10% 0%, rgba(142, 212, 255, 0.08), transparent 28rem),
|
||||
linear-gradient(180deg, #07110f 0%, #070d11 60%, #06090d 100%);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: var(--space-6);
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-body { grid-template-columns: var(--sidebar-w) 1fr; }
|
||||
.status-pane { display: none; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-body { grid-template-columns: 1fr; }
|
||||
.app-body.client-mode { grid-template-columns: 1fr; }
|
||||
.sidebar { display: none; }
|
||||
.app-main { padding: var(--space-4); }
|
||||
}
|
||||
|
||||
/* ============ Topbar ============ */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-6);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
height: var(--topbar-h);
|
||||
}
|
||||
.topbar-brand {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.topbar-brand .logo-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent);
|
||||
}
|
||||
.topbar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-status .status-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar-status .status-text strong { font-weight: 600; font-size: 13px; }
|
||||
.topbar-status .status-text small { font-size: 11px; color: var(--subtle); }
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* ============ Sidebar ============ */
|
||||
|
||||
.sidebar {
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.sidebar-item:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.sidebar-item.active {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.sidebar-item .ico { width: 18px; opacity: 0.85; flex: 0 0 18px; }
|
||||
.sidebar-item .badge {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-3);
|
||||
color: var(--muted);
|
||||
}
|
||||
.sidebar-item .badge.warn { background: var(--warning-dim); color: var(--warning); }
|
||||
.sidebar-item .badge.danger { background: var(--danger-dim); color: var(--danger); }
|
||||
|
||||
/* ============ Status pane ============ */
|
||||
|
||||
.status-pane {
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ============ Cards & panels ============ */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-5);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.card.compact { padding: var(--space-4); }
|
||||
.card.flat { box-shadow: none; background: var(--surface-2); }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.card-header.no-margin { margin-bottom: 0; }
|
||||
|
||||
.section-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ============ Buttons ============ */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 8px 14px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn.lg { padding: 12px 18px; font-size: 14px; }
|
||||
.btn.sm { padding: 6px 10px; font-size: 12px; }
|
||||
.btn.block { width: 100%; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #061608;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-strong); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: var(--border-strong); }
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--danger-dim); }
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.btn-link:hover:not(:disabled) { text-decoration: underline; }
|
||||
|
||||
.btn-group {
|
||||
display: inline-flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ============ Inputs ============ */
|
||||
|
||||
.input, .select, .textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
padding: 9px 12px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.input:focus, .select:focus, .textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
.textarea { min-height: 80px; resize: vertical; font-family: var(--font-mono); font-size: 12px; }
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.field-error { color: var(--danger); font-size: 11px; }
|
||||
.field-hint { color: var(--subtle); font-size: 11px; }
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.checkbox input { width: 14px; height: 14px; accent-color: var(--accent); }
|
||||
|
||||
/* ============ Badges & dots ============ */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--surface-3);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.success { background: var(--success-dim); color: var(--success); border-color: rgba(109, 255, 157, 0.3); }
|
||||
.badge.warning { background: var(--warning-dim); color: var(--warning); border-color: rgba(255, 209, 102, 0.3); }
|
||||
.badge.danger { background: var(--danger-dim); color: var(--danger); border-color: rgba(255, 92, 92, 0.3); }
|
||||
.badge.info { background: var(--info-dim); color: var(--info); border-color: rgba(142, 212, 255, 0.3); }
|
||||
.badge.neutral { background: var(--surface-3); color: var(--muted); }
|
||||
|
||||
.dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--subtle);
|
||||
flex: 0 0 8px;
|
||||
}
|
||||
.dot.success { background: var(--success); box-shadow: 0 0 8px var(--success); }
|
||||
.dot.warning { background: var(--warning); box-shadow: 0 0 8px var(--warning); }
|
||||
.dot.danger { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
|
||||
.dot.info { background: var(--info); box-shadow: 0 0 8px var(--info); }
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ============ Lists & tables ============ */
|
||||
|
||||
.kv-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.kv-list .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.kv-list .row:last-child { border-bottom: 0; }
|
||||
.kv-list .key { color: var(--muted); }
|
||||
.kv-list .val { color: var(--text); font-weight: 500; }
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.table th, .table td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table th {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.table tbody tr { transition: background 0.1s; }
|
||||
.table tbody tr:hover { background: var(--surface-2); }
|
||||
.table tbody tr.active { background: var(--accent-dim); }
|
||||
.table .row-actions { display: flex; gap: var(--space-2); justify-content: flex-end; }
|
||||
|
||||
/* ============ Tag input (chips) ============ */
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.chip.error { border-color: var(--danger); color: var(--danger); }
|
||||
.chip button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.chip button:hover { color: var(--danger); }
|
||||
.chips .chip-input {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/* ============ Sticky action bar ============ */
|
||||
|
||||
.sticky-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin: var(--space-6) calc(-1 * var(--space-6)) calc(-1 * var(--space-6));
|
||||
padding: var(--space-3) var(--space-6);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
z-index: 5;
|
||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ============ Drawer & modal ============ */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 8, 5, 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-modal);
|
||||
width: min(720px, 100%);
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal.lg { width: min(960px, 100%); }
|
||||
.modal-head, .modal-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-foot { border-top: 1px solid var(--border); border-bottom: 0; }
|
||||
.modal-body { padding: var(--space-5); overflow-y: auto; flex: 1; }
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 8, 5, 0.55);
|
||||
backdrop-filter: blur(3px);
|
||||
z-index: 90;
|
||||
}
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
width: min(540px, 100%);
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-modal);
|
||||
z-index: 91;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.drawer-head, .drawer-foot {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.drawer-foot { border-top: 1px solid var(--border); border-bottom: 0; }
|
||||
.drawer-body { padding: var(--space-5); overflow-y: auto; flex: 1; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
|
||||
/* ============ Toasts ============ */
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
top: calc(var(--topbar-h) + 12px);
|
||||
right: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 200;
|
||||
max-width: 360px;
|
||||
}
|
||||
.toast {
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
box-shadow: var(--shadow-card);
|
||||
font-size: 13px;
|
||||
}
|
||||
.toast.success { border-color: rgba(109, 255, 157, 0.3); }
|
||||
.toast.danger { border-color: rgba(255, 92, 92, 0.3); }
|
||||
.toast.warning { border-color: rgba(255, 209, 102, 0.3); }
|
||||
.toast .body { flex: 1; }
|
||||
.toast .body small { display: block; color: var(--muted); margin-top: 2px; }
|
||||
.toast button { background: none; border: none; color: var(--muted); cursor: pointer; }
|
||||
|
||||
/* ============ Logs ============ */
|
||||
|
||||
.logs-stream {
|
||||
background: #02080a;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
}
|
||||
.log-line {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 60px 1fr;
|
||||
gap: 10px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.log-line:hover { background: var(--surface-2); }
|
||||
.log-line .log-time { color: var(--subtle); }
|
||||
.log-line .log-level { font-size: 10px; text-transform: uppercase; padding-top: 2px; font-weight: 600; }
|
||||
.log-line.info .log-level { color: var(--info); }
|
||||
.log-line.warning .log-level { color: var(--warning); }
|
||||
.log-line.error .log-level { color: var(--danger); }
|
||||
.log-line.debug .log-level { color: var(--subtle); }
|
||||
.log-line .log-text { color: var(--text); }
|
||||
|
||||
.log-group {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 60px 1fr auto;
|
||||
gap: 10px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-2);
|
||||
margin-bottom: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.log-group .repeat { color: var(--warning); font-size: 11px; }
|
||||
|
||||
/* ============ Misc ============ */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty-state h3 { margin-bottom: var(--space-2); color: var(--text); }
|
||||
|
||||
.flex { display: flex; gap: var(--space-3); align-items: center; }
|
||||
.flex-col { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.flex-between { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.flex-1 { flex: 1; min-width: 0; }
|
||||
.spacer { flex: 1; }
|
||||
.muted { color: var(--muted); }
|
||||
.text-success { color: var(--success); }
|
||||
.text-warning { color: var(--warning); }
|
||||
.text-danger { color: var(--danger); }
|
||||
.text-mono { font-family: var(--font-mono); }
|
||||
.text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.config-view {
|
||||
background: #02080a;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
overflow: auto;
|
||||
max-height: 60vh;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* drag handle for rules sortable */
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
}
|
||||
.drag-handle:hover { color: var(--text); background: var(--surface-2); }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
|
||||
.rule-row.disabled { opacity: 0.5; }
|
||||
.rule-row.invalid td:first-child { box-shadow: inset 3px 0 0 var(--danger); }
|
||||
.conflict-banner {
|
||||
background: var(--warning-dim);
|
||||
border: 1px solid rgba(255, 209, 102, 0.3);
|
||||
color: var(--warning);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-input);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.conflict-banner.danger { background: var(--danger-dim); border-color: rgba(255, 92, 92, 0.3); color: var(--danger); }
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.template-card {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.template-card h4 { color: var(--text); text-transform: none; letter-spacing: 0; font-size: 13px; }
|
||||
.template-card small { color: var(--subtle); font-size: 11px; line-height: 1.4; }
|
||||
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 16px 60px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.event-row:hover { background: var(--surface-2); }
|
||||
.event-row .event-time { color: var(--subtle); font-family: var(--font-mono); font-size: 11px; }
|
||||
|
||||
.route-result {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.tab:hover { color: var(--text); }
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-bar .input, .filter-bar .select { width: auto; min-width: 140px; }
|
||||
|
||||
.subscription-input {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: stretch;
|
||||
}
|
||||
.subscription-input .input { flex: 1; }
|
||||
|
||||
/* ============ Client overview ============ */
|
||||
|
||||
.app.client-app {
|
||||
@@ -1037,6 +248,7 @@ code, .mono {
|
||||
.client-form {
|
||||
position: absolute;
|
||||
left: calc(50% + 130px);
|
||||
min-width: 0;
|
||||
width: min(360px, calc(50% - 154px));
|
||||
display: grid;
|
||||
gap: 36px;
|
||||
@@ -1046,6 +258,7 @@ code, .mono {
|
||||
|
||||
.client-subscription {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 88px;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@@ -1246,6 +459,9 @@ code, .mono {
|
||||
}
|
||||
|
||||
.client-subscription-domain-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -1255,6 +471,8 @@ code, .mono {
|
||||
}
|
||||
|
||||
.client-subscription-summary {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: 88px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1265,6 +483,7 @@ code, .mono {
|
||||
color: var(--client-muted);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.client-subscription.is-editing .client-subscription-summary {
|
||||
@@ -1281,6 +500,8 @@ code, .mono {
|
||||
}
|
||||
|
||||
.client-subscription-domain-button strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--client-text);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
@@ -1288,6 +509,8 @@ code, .mono {
|
||||
opacity: 0.88;
|
||||
text-shadow: 0 0 20px oklch(0.62 0.05 151 / 0.24);
|
||||
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-subscription-domain-button:hover strong {
|
||||
@@ -1520,12 +743,33 @@ code, .mono {
|
||||
text-shadow: 0 0 12px oklch(0.62 0.05 151 / 0.12);
|
||||
}
|
||||
|
||||
.client-proxies > div {
|
||||
.client-access-point {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.client-access-point + .client-access-point {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.client-proxy-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.client-error {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: min(420px, 90vw);
|
||||
color: oklch(0.62 0.2 28);
|
||||
font: 600 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
text-align: center;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.client-proxies button {
|
||||
position: relative;
|
||||
width: 86px;
|
||||
@@ -1648,11 +892,3 @@ code, .mono {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* For drawer rule editor */
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@media (max-width: 600px) { .field-row { grid-template-columns: 1fr; } }
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
// Готовые шаблоны правил роутинга. domains/suffixes/cidr/ports собраны из публичных
|
||||
// reference-конфигов sing-box. Это пресеты «на старт», а не исчерпывающие списки.
|
||||
|
||||
let counter = 0;
|
||||
function id(prefix) {
|
||||
counter += 1;
|
||||
return `${prefix}-${Date.now()}-${counter}`;
|
||||
}
|
||||
|
||||
function template(name, outbound, fields) {
|
||||
return {
|
||||
id: id("tpl"),
|
||||
name,
|
||||
enabled: true,
|
||||
outbound,
|
||||
domains: [],
|
||||
domainSuffixes: [],
|
||||
domainKeywords: [],
|
||||
ipCidrs: [],
|
||||
ports: [],
|
||||
networks: [],
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
export const ruleTemplates = [
|
||||
{
|
||||
key: "lol-direct",
|
||||
label: "League of Legends → direct",
|
||||
description: "Riot/LoL домены и порты — играть напрямую без VPN.",
|
||||
build: () =>
|
||||
template("League of Legends", "direct", {
|
||||
domainSuffixes: [
|
||||
"leagueoflegends.com",
|
||||
"riotgames.com",
|
||||
"riotcdn.net",
|
||||
"dyn.riotcdn.net",
|
||||
],
|
||||
ports: ["5000", "5223", "5222", "8088"],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "discord-direct",
|
||||
label: "Discord/Vesktop → direct",
|
||||
description: "Discord voice/video и WebSocket напрямую.",
|
||||
build: () =>
|
||||
template("Discord", "direct", {
|
||||
domainSuffixes: [
|
||||
"discord.com",
|
||||
"discord.gg",
|
||||
"discord.media",
|
||||
"discordapp.com",
|
||||
"discordapp.net",
|
||||
],
|
||||
ports: ["50000-65535"],
|
||||
networks: ["udp"],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "telegram-vpn",
|
||||
label: "Telegram → VPN",
|
||||
description: "Telegram через выбранный VPN outbound.",
|
||||
build: () =>
|
||||
template("Telegram", "vpn", {
|
||||
domainSuffixes: [
|
||||
"telegram.org",
|
||||
"t.me",
|
||||
"telegram.me",
|
||||
"telegra.ph",
|
||||
"tdesktop.com",
|
||||
],
|
||||
ipCidrs: [
|
||||
"149.154.160.0/20",
|
||||
"91.108.4.0/22",
|
||||
"91.108.8.0/22",
|
||||
"91.108.12.0/22",
|
||||
"91.108.16.0/22",
|
||||
"91.108.56.0/22",
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "youtube-vpn",
|
||||
label: "YouTube → VPN",
|
||||
description: "YouTube/Google Video через VPN.",
|
||||
build: () =>
|
||||
template("YouTube", "vpn", {
|
||||
domainSuffixes: [
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"ytimg.com",
|
||||
"googlevideo.com",
|
||||
"youtube-nocookie.com",
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "steam-direct",
|
||||
label: "Steam → direct",
|
||||
description: "Загрузка/обновления Steam напрямую.",
|
||||
build: () =>
|
||||
template("Steam", "direct", {
|
||||
domainSuffixes: [
|
||||
"steampowered.com",
|
||||
"steamcontent.com",
|
||||
"steamcommunity.com",
|
||||
"steamserver.net",
|
||||
"cm.steampowered.com",
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "ads-block",
|
||||
label: "Реклама → block",
|
||||
description: "Базовый набор рекламных доменов — заблокировать.",
|
||||
build: () =>
|
||||
template("Реклама (block)", "block", {
|
||||
domainSuffixes: [
|
||||
"doubleclick.net",
|
||||
"googlesyndication.com",
|
||||
"googleadservices.com",
|
||||
"adservice.google.com",
|
||||
"adnxs.com",
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -28,10 +28,11 @@ export function subscriptionDomain(subscriptionHost) {
|
||||
}
|
||||
}
|
||||
|
||||
export function localProxyUrls(port = 8082) {
|
||||
export function localProxyUrls(port = 8082, host = '127.0.0.1') {
|
||||
const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
||||
return {
|
||||
socks5: `socks5://127.0.0.1:${port}`,
|
||||
http: `http://127.0.0.1:${port}`,
|
||||
socks5: `socks5://${urlHost}:${port}`,
|
||||
http: `http://${urlHost}:${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// Грубое определение страны по тегу сервера и/или хосту.
|
||||
// Это эвристика — мы не делаем GeoIP-lookup.
|
||||
|
||||
const COUNTRIES = [
|
||||
{ re: /\b(ru|россия|russia|moscow|spb)\b/i, code: "RU", flag: "🇷🇺" },
|
||||
{ re: /\b(de|germany|frankfurt|berlin|deu)\b/i, code: "DE", flag: "🇩🇪" },
|
||||
{ re: /\b(nl|netherlands|amsterdam|holland)\b/i, code: "NL", flag: "🇳🇱" },
|
||||
{
|
||||
re: /\b(us|usa|america|new[-_ ]?york|chicago|miami)\b/i,
|
||||
code: "US",
|
||||
flag: "🇺🇸",
|
||||
},
|
||||
{ re: /\b(uk|britain|london|england)\b/i, code: "GB", flag: "🇬🇧" },
|
||||
{ re: /\b(fr|france|paris)\b/i, code: "FR", flag: "🇫🇷" },
|
||||
{ re: /\b(jp|japan|tokyo)\b/i, code: "JP", flag: "🇯🇵" },
|
||||
{ re: /\b(sg|singapore)\b/i, code: "SG", flag: "🇸🇬" },
|
||||
{ re: /\b(hk|hongkong|hong[-_ ]?kong)\b/i, code: "HK", flag: "🇭🇰" },
|
||||
{ re: /\b(fi|finland|helsinki)\b/i, code: "FI", flag: "🇫🇮" },
|
||||
{ re: /\b(se|sweden|stockholm)\b/i, code: "SE", flag: "🇸🇪" },
|
||||
{ re: /\b(pl|poland|warsaw)\b/i, code: "PL", flag: "🇵🇱" },
|
||||
{ re: /\b(tr|turkey|istanbul)\b/i, code: "TR", flag: "🇹🇷" },
|
||||
{ re: /\b(ua|ukraine|kiev|kyiv)\b/i, code: "UA", flag: "🇺🇦" },
|
||||
];
|
||||
|
||||
export function detectCountry(...inputs) {
|
||||
const text = inputs.filter(Boolean).join(" ").toLowerCase();
|
||||
for (const c of COUNTRIES) {
|
||||
if (c.re.test(text)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function flagFor(server) {
|
||||
if (!server) return "";
|
||||
const detected = detectCountry(server.tag, server.server);
|
||||
return detected?.flag || "🌐";
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Простые валидаторы для полей правил роутинга. Возвращают массив ошибочных строк.
|
||||
|
||||
const IPV4 =
|
||||
/^((25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(25[0-5]|2[0-4]\d|[01]?\d?\d)$/;
|
||||
const IPV6 = /^[0-9a-f:]+$/i;
|
||||
const DOMAIN =
|
||||
/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
||||
|
||||
export function invalidCidrs(values) {
|
||||
return (values || []).filter((value) => !isValidCidr(value));
|
||||
}
|
||||
|
||||
export function isValidCidr(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) return false;
|
||||
const [addr, mask] = trimmed.split("/");
|
||||
if (!addr) return false;
|
||||
|
||||
if (IPV4.test(addr)) {
|
||||
if (mask === undefined) return true;
|
||||
const m = Number(mask);
|
||||
return Number.isInteger(m) && m >= 0 && m <= 32;
|
||||
}
|
||||
if (IPV6.test(addr) && addr.includes(":")) {
|
||||
if (mask === undefined) return true;
|
||||
const m = Number(mask);
|
||||
return Number.isInteger(m) && m >= 0 && m <= 128;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function invalidPorts(values) {
|
||||
return (values || []).filter((value) => !isValidPort(value));
|
||||
}
|
||||
|
||||
export function isValidPort(value) {
|
||||
const n = Number.parseInt(String(value).trim(), 10);
|
||||
return Number.isInteger(n) && n > 0 && n <= 65535;
|
||||
}
|
||||
|
||||
export function invalidDomains(values) {
|
||||
return (values || []).filter((value) => !DOMAIN.test(String(value).trim()));
|
||||
}
|
||||
|
||||
export function ruleErrors(rule) {
|
||||
return {
|
||||
domains: invalidDomains(rule.domains),
|
||||
domainSuffixes: invalidDomains(rule.domainSuffixes),
|
||||
ipCidrs: invalidCidrs(rule.ipCidrs),
|
||||
ports: invalidPorts(rule.ports),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasErrors(errors) {
|
||||
return Object.values(errors).some((arr) => arr.length > 0);
|
||||
}
|
||||
Reference in New Issue
Block a user