feat: добавлены новые компоненты для управления правилами и серверами
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s

- Создан компонент RuleEditorDrawer для редактирования правил с поддержкой JSON.
- Добавлен компонент ServersPage для отображения и управления серверами.
- Реализован компонент SettingsPage для управления подписками и конфигурациями.
- Создан компонент Sidebar для навигации по приложению.
- Добавлен компонент StatusPane для отображения статуса сервера.
- Реализован компонент Toasts для отображения уведомлений.
- Создан компонент Topbar для отображения информации о текущем состоянии.
- Добавлен модуль country.js для определения страны по тегу сервера.

Refs: None
This commit is contained in:
2026-05-08 19:31:49 +03:00
parent a8f2c6f3f9
commit 8476ab16e5
27 changed files with 3014 additions and 1139 deletions

View File

@@ -10,6 +10,10 @@ import {
readSingboxConfig,
removeSingboxConfig,
} from "./singbox.js";
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
import { tcpPing, resolveHost } from "./ping.js";
const APPLY_HISTORY_LIMIT = 10;
fs.mkdirSync(settings.dataDir, { recursive: true });
@@ -32,7 +36,8 @@ function pushLog(level, line) {
// Sing-box пишет все логи в stderr, поэтому парсим уровень из содержимого строки.
// Формат: ESC[<n>m LEVEL ESC[0m, где ESC = \x1b
const SINGBOX_LEVEL_RE = /\x1b\[\d+m(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\x1b\[0m/i;
const SINGBOX_LEVEL_RE =
/\x1b\[\d+m(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\x1b\[0m/i;
function parseSingboxLevel(line, fallback) {
const m = line.match(SINGBOX_LEVEL_RE);
if (!m) return fallback;
@@ -192,6 +197,9 @@ function publicState() {
subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
hasSubscription: Boolean(subscriptionUrl),
customRules,
appliedHistory: state.appliedHistory || [],
rulesUpdatedAt: state.rulesUpdatedAt || null,
rulesAppliedAt: state.rulesAppliedAt || null,
...rest,
};
}
@@ -241,10 +249,18 @@ async function applySelectedServer(selectedTag) {
await startSingbox();
const prevState = readJson(settings.statePath, {});
const now = new Date().toISOString();
const previousTag = prevState.selectedTag && prevState.selectedTag !== selectedTag ? prevState.selectedTag : prevState.previousTag || null;
const history = Array.isArray(prevState.appliedHistory) ? prevState.appliedHistory : [];
const nextHistory = [{ tag: selectedTag, at: now }, ...history.filter((h) => h.tag !== selectedTag)].slice(0, APPLY_HISTORY_LIMIT);
writeJson(settings.statePath, {
...prevState,
selectedTag,
appliedAt: new Date().toISOString(),
previousTag,
appliedAt: now,
rulesAppliedAt: now,
appliedHistory: nextHistory,
});
}
@@ -306,9 +322,116 @@ async function handleApi(req, res) {
const body = await readBody(req);
const rules = normalizeCustomRules(body.rules);
writeJson(settings.customRulesPath, rules);
const prevState = readJson(settings.statePath, {});
writeJson(settings.statePath, {
...prevState,
rulesUpdatedAt: new Date().toISOString(),
});
return sendJson(res, 200, { success: true, rules });
}
if (req.method === "GET" && req.url === "/api/rules/conflicts") {
const rules = readJson(settings.customRulesPath, []);
return sendJson(res, 200, { success: true, conflicts: detectRuleConflicts(rules) });
}
if (req.method === "POST" && req.url === "/api/route/check") {
const body = await readBody(req);
const host = String(body.host || "").trim();
let ip = String(body.ip || "").trim();
const port = body.port !== undefined && body.port !== "" ? Number(body.port) : undefined;
const network = String(body.network || "").trim() || undefined;
if (!host && !ip) {
return sendJson(res, 400, { success: false, error: "Укажите домен или IP" });
}
let resolvedFrom = null;
if (!ip && host) {
const resolved = await resolveHost(host);
if (resolved) {
ip = resolved;
resolvedFrom = host;
}
}
const rules = readJson(settings.customRulesPath, []);
const cached = readJson(settings.subscriptionCachePath, null);
const state = readJson(settings.statePath, {});
const vpnTag = state.selectedTag || "vpn-out";
const result = matchRoute(
{ host, ip, port, network },
rules,
{ routingRuDirect: settings.routingRuDirect, vpnTag },
);
return sendJson(res, 200, { success: true, result, resolvedIp: ip || null, resolvedFrom });
}
if (req.method === "POST" && req.url === "/api/servers/ping") {
const body = await readBody(req);
const host = String(body.host || "").trim();
const port = Number(body.port);
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
return sendJson(res, 400, { success: false, error: "Требуются host и port" });
}
const result = await tcpPing(host, port, Number(body.timeout) || 3000);
return sendJson(res, 200, { success: true, ...result });
}
if (req.method === "POST" && req.url === "/api/servers/ping-all") {
const cached = readJson(settings.subscriptionCachePath, null);
const state = readJson(settings.statePath, {});
const servers = state.servers || cached?.servers || [];
const results = await Promise.all(
servers.map(async (server) => {
const ping = await tcpPing(server.server, server.server_port, 3000);
return { tag: server.tag, ...ping, checkedAt: new Date().toISOString() };
}),
);
return sendJson(res, 200, { success: true, results });
}
if (req.method === "POST" && req.url === "/api/config/validate") {
const cached = readJson(settings.subscriptionCachePath, null);
const customRules = readJson(settings.customRulesPath, []);
const stateData = readJson(settings.statePath, {});
const tag = stateData.selectedTag;
if (!cached?.config) {
return sendJson(res, 200, { success: true, valid: false, error: "Подписка не загружена" });
}
if (!tag) {
return sendJson(res, 200, { success: true, valid: false, error: "Сервер не выбран" });
}
try {
buildGatewayConfig({ ...cached.config, customRules }, tag);
} catch (err) {
return sendJson(res, 200, { success: true, valid: false, error: err.message });
}
if (fs.existsSync(settings.configPath)) {
try {
checkSingboxConfig();
return sendJson(res, 200, { success: true, valid: true });
} catch (err) {
return sendJson(res, 200, { success: true, valid: false, error: err.message });
}
}
return sendJson(res, 200, { success: true, valid: true, note: "Конфиг собирается без ошибок (sing-box check не выполнен — нет файла)" });
}
if (req.method === "POST" && req.url === "/api/apply/rollback") {
const stateData = readJson(settings.statePath, {});
const target = stateData.previousTag;
if (!target) {
return sendJson(res, 400, { success: false, error: "Нет предыдущего сервера для отката" });
}
await applySelectedServer(target);
return sendJson(res, 200, { success: true, selectedTag: target });
}
if (req.method === "POST" && req.url === "/api/subscription/fetch") {
const body = await readBody(req);
const url = String(body.url || "").trim();