From 943ee1321d78500fc920791c3439c1624188d59e Mon Sep 17 00:00:00 2001 From: Dushnilin Date: Tue, 1 Sep 2026 18:31:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC,=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=D1=8E=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D0=B2=20=D0=9D=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fe-app-tachyon/fill-po-translations.js | 36 + fe-app-tachyon/locales/tachyon.ru.po | 108 + fe-app-tachyon/package-lock.json | 28 +- fe-app-tachyon/package.json | 3 + fe-app-tachyon/src/tachyon/types.ts | 22 + .../resources/view/tachyon/local_devices.js | 21 +- .../resources/view/tachyon/parental.js | 442 +++- .../resources/view/tachyon/section.js | 2011 +++++++++++------ .../resources/view/tachyon/server.js | 80 +- .../resources/view/tachyon/settings.js | 1070 +++++++-- .../resources/view/tachyon/tachyon.js | 366 +-- luci-app-tachyon/po/ru/tachyon.po | 108 + tachyon/files/etc/config/tachyon | 22 + 13 files changed, 3181 insertions(+), 1136 deletions(-) diff --git a/fe-app-tachyon/fill-po-translations.js b/fe-app-tachyon/fill-po-translations.js index af70ae3c..181982ea 100644 --- a/fe-app-tachyon/fill-po-translations.js +++ b/fe-app-tachyon/fill-po-translations.js @@ -1252,6 +1252,42 @@ const dictionary = { "Rollback": "Откатить", "Save component backup before update": "Сохранять резервную копию перед обновлением", "Keep the previous working binary/package on the router flash for instant offline rollback if needed. Consumes extra storage.": "Сохранять предыдущую рабочую версию компонента в памяти роутера для быстрого локального отката. Занимает дополнительное место на flash.", + "Menu & Navigation": "Меню и навигация", + "Default Tab on Startup": "Вкладка по умолчанию при входе", + "Select which tab opens automatically when entering Tachyon.": "Выберите, какая вкладка открывается автоматически при входе в Tachyon.", + "Show 'Dashboard' tab": "Отображать вкладку «Главная»", + "Display the main overview and status dashboard in the menu.": "Отображать главный обзор и статус-дашборд в меню.", + "Show 'Sections' tab": "Отображать вкладку «Маршрутизация»", + "Display proxy sections and routing rules in the menu.": "Отображать секции прокси и правила маршрутизации в меню.", + "Show 'Servers' tab": "Отображать вкладку «Серверы»", + "Display external proxy servers (inbounds) in the menu.": "Отображать внешние прокси-серверы (inbounds) в меню.", + "Show 'Family Profiles' tab": "Отображать вкладку «Семейные профили»", + "Display family profile groups and device assignment in the menu.": "Отображать группы семейных профилей и привязку устройств в меню.", + "Show 'Parental Control' tab": "Отображать вкладку «Родительский контроль»", + "Display parental control and schedule rules in the menu.": "Отображать расписания и правила родительского контроля в меню.", + "Show 'Monitoring' tab": "Отображать вкладку «Мониторинг»", + "Display live traffic statistics and client activity in the menu.": "Отображать статистику трафика и активность клиентов в меню.", + "Show 'Diagnostics' tab": "Отображать вкладку «Диагностика»", + "Display network health checks and AI Doctor in the menu.": "Отображать сетевую диагностику и ИИ-Доктора в меню.", + "Show 'Components' tab": "Отображать вкладку «Компоненты»", + "Display component updates and version manager in the menu.": "Отображать обновления компонентов и управление версиями в меню.", + "Show 'Telegram Bot' tab": "Отображать вкладку «Telegram-бот»", + "Display the Telegram bot settings and controls in the menu.": "Отображать настройки и управление Telegram-ботом в меню.", + "Tab Order & Visibility": "Порядок и видимость вкладок", + "Customize the order and visibility of tabs in the main navigation menu. Use the arrows to reorder and checkboxes to show or hide tabs.": "Настройте порядок и видимость вкладок в главном меню навигации. Используйте стрелки для перемещения и галочки для показа или скрытия.", + "Always visible": "Всегда включено", + "Move Up": "Переместить выше", + "Move Down": "Переместить ниже", + "Main overview and status widgets": "Главный обзор и виджеты статуса", + "Routing sections, rules and strategies": "Секции маршрутизации, правила и стратегии", + "External inbound proxy servers": "Внешние входящие прокси-серверы", + "Family profiles and device grouping": "Семейные профили и группировка устройств", + "Access schedule and screen time limits": "Расписание доступа и лимиты времени", + "Traffic statistics and active connections": "Статистика трафика и активные соединения", + "System diagnostics, AI Doctor and logs": "Системная диагностика, ИИ-Доктор и логи", + "Component versions and update manager": "Версии компонентов и менеджер обновлений", + "Core configuration and preferences": "Основные настройки и параметры", + "Remote management Telegram daemon": "Демон Telegram для удаленного управления", }; function escapePoString(str) { diff --git a/fe-app-tachyon/locales/tachyon.ru.po b/fe-app-tachyon/locales/tachyon.ru.po index 29f956cf..d729dc17 100644 --- a/fe-app-tachyon/locales/tachyon.ru.po +++ b/fe-app-tachyon/locales/tachyon.ru.po @@ -5395,3 +5395,111 @@ msgstr "Сохранять резервную копию перед обновл msgid "Keep the previous working binary/package on the router flash for instant offline rollback if needed. Consumes extra storage." msgstr "Сохранять предыдущую рабочую версию компонента в памяти роутера для быстрого локального отката. Занимает дополнительное место на flash." + +msgid "Menu & Navigation" +msgstr "Меню и навигация" + +msgid "Default Tab on Startup" +msgstr "Вкладка по умолчанию при входе" + +msgid "Select which tab opens automatically when entering Tachyon." +msgstr "Выберите, какая вкладка открывается автоматически при входе в Tachyon." + +msgid "Show 'Dashboard' tab" +msgstr "Отображать вкладку «Главная»" + +msgid "Display the main overview and status dashboard in the menu." +msgstr "Отображать главный обзор и статус-дашборд в меню." + +msgid "Show 'Sections' tab" +msgstr "Отображать вкладку «Маршрутизация»" + +msgid "Display proxy sections and routing rules in the menu." +msgstr "Отображать секции прокси и правила маршрутизации в меню." + +msgid "Show 'Servers' tab" +msgstr "Отображать вкладку «Серверы»" + +msgid "Display external proxy servers (inbounds) in the menu." +msgstr "Отображать внешние прокси-серверы (inbounds) в меню." + +msgid "Show 'Family Profiles' tab" +msgstr "Отображать вкладку «Семейные профили»" + +msgid "Display family profile groups and device assignment in the menu." +msgstr "Отображать группы семейных профилей и привязку устройств в меню." + +msgid "Show 'Parental Control' tab" +msgstr "Отображать вкладку «Родительский контроль»" + +msgid "Display parental control and schedule rules in the menu." +msgstr "Отображать расписания и правила родительского контроля в меню." + +msgid "Show 'Monitoring' tab" +msgstr "Отображать вкладку «Мониторинг»" + +msgid "Display live traffic statistics and client activity in the menu." +msgstr "Отображать статистику трафика и активность клиентов в меню." + +msgid "Show 'Diagnostics' tab" +msgstr "Отображать вкладку «Диагностика»" + +msgid "Display network health checks and AI Doctor in the menu." +msgstr "Отображать сетевую диагностику и ИИ-Доктора в меню." + +msgid "Show 'Components' tab" +msgstr "Отображать вкладку «Компоненты»" + +msgid "Display component updates and version manager in the menu." +msgstr "Отображать обновления компонентов и управление версиями в меню." + +msgid "Show 'Telegram Bot' tab" +msgstr "Отображать вкладку «Telegram-бот»" + +msgid "Display the Telegram bot settings and controls in the menu." +msgstr "Отображать настройки и управление Telegram-ботом в меню." + +msgid "Tab Order & Visibility" +msgstr "Порядок и видимость вкладок" + +msgid "Customize the order and visibility of tabs in the main navigation menu. Use the arrows to reorder and checkboxes to show or hide tabs." +msgstr "Настройте порядок и видимость вкладок в главном меню навигации. Используйте стрелки для перемещения и галочки для показа или скрытия." + +msgid "Always visible" +msgstr "Всегда включено" + +msgid "Move Up" +msgstr "Переместить выше" + +msgid "Move Down" +msgstr "Переместить ниже" + +msgid "Main overview and status widgets" +msgstr "Главный обзор и виджеты статуса" + +msgid "Routing sections, rules and strategies" +msgstr "Секции маршрутизации, правила и стратегии" + +msgid "External inbound proxy servers" +msgstr "Внешние входящие прокси-серверы" + +msgid "Family profiles and device grouping" +msgstr "Семейные профили и группировка устройств" + +msgid "Access schedule and screen time limits" +msgstr "Расписание доступа и лимиты времени" + +msgid "Traffic statistics and active connections" +msgstr "Статистика трафика и активные соединения" + +msgid "System diagnostics, AI Doctor and logs" +msgstr "Системная диагностика, ИИ-Доктор и логи" + +msgid "Component versions and update manager" +msgstr "Версии компонентов и менеджер обновлений" + +msgid "Core configuration and preferences" +msgstr "Основные настройки и параметры" + +msgid "Remote management Telegram daemon" +msgstr "Демон Telegram для удаленного управления" diff --git a/fe-app-tachyon/package-lock.json b/fe-app-tachyon/package-lock.json index 7bd79b0b..b26038d3 100644 --- a/fe-app-tachyon/package-lock.json +++ b/fe-app-tachyon/package-lock.json @@ -8,6 +8,9 @@ "name": "fe-app-tachyon", "version": "1.3.11", "license": "MIT", + "dependencies": { + "@esbuild/win32-x64": "^0.28.2" + }, "devDependencies": { "@babel/parser": "7.28.4", "@babel/traverse": "7.28.4", @@ -612,15 +615,13 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", - "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", - "optional": true, "os": [ "win32" ], @@ -2422,6 +2423,23 @@ "@esbuild/win32-x64": "0.25.10" } }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", diff --git a/fe-app-tachyon/package.json b/fe-app-tachyon/package.json index aea81f27..d1ac8785 100644 --- a/fe-app-tachyon/package.json +++ b/fe-app-tachyon/package.json @@ -39,5 +39,8 @@ "overrides": { "postcss": "8.5.23", "js-yaml": "^5.2.1" + }, + "dependencies": { + "@esbuild/win32-x64": "^0.28.2" } } diff --git a/fe-app-tachyon/src/tachyon/types.ts b/fe-app-tachyon/src/tachyon/types.ts index 1be21963..09116ffe 100644 --- a/fe-app-tachyon/src/tachyon/types.ts +++ b/fe-app-tachyon/src/tachyon/types.ts @@ -428,6 +428,28 @@ export namespace Tachyon { ai_metrics_retention_hours?: string; ai_smart_cooldowns_enabled?: '0' | '1'; ai_config_validation_enabled?: '0' | '1'; + // Navigation & Menu settings + default_tab?: + | 'dashboard' + | 'section' + | 'server' + | 'profile' + | 'schedule' + | 'monitoring' + | 'diagnostic' + | 'updates' + | 'settings' + | 'telegram'; + tab_order?: string[]; + show_tab_dashboard?: '0' | '1'; + show_tab_sections?: '0' | '1'; + show_tab_servers?: '0' | '1'; + show_tab_profiles?: '0' | '1'; + show_tab_parental?: '0' | '1'; + show_tab_monitoring?: '0' | '1'; + show_tab_diagnostic?: '0' | '1'; + show_tab_updates?: '0' | '1'; + show_tab_telegram?: '0' | '1'; ai_graceful_degradation_enabled?: '0' | '1'; ai_persistent_smart_detect?: '0' | '1'; ai_adaptive_intervals_enabled?: '0' | '1'; diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/local_devices.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/local_devices.js index 889dec68..419f47b3 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/local_devices.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/local_devices.js @@ -62,7 +62,10 @@ function addLocalDeviceChoice(choices, ip, name, mac) { } } - if (normalizedMac && /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/.test(normalizedMac)) { + if ( + normalizedMac && + /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/.test(normalizedMac) + ) { const formattedMac = normalizedMac.replace(/-/g, ":"); if (normalizedName) { choices[formattedMac] = `MAC: ${formattedMac} — ${normalizedName}`; @@ -118,7 +121,12 @@ function buildRouterIpMap(networkInterfaces) { return routerIps; } -function buildLocalDeviceChoices(hostHints, dhcpLeases, networkInterfaces, dhcpHosts) { +function buildLocalDeviceChoices( + hostHints, + dhcpLeases, + networkInterfaces, + dhcpHosts, +) { const choices = {}; const routerIps = buildRouterIpMap(networkInterfaces); @@ -147,7 +155,7 @@ function buildLocalDeviceChoices(hostHints, dhcpLeases, networkInterfaces, dhcpH addLocalDeviceChoice(choices, lease.ipaddr, lease.hostname, lease.mac); }); } - + if (Array.isArray(dhcpHosts)) { dhcpHosts.forEach((host) => { if (host && host.ip && main.validateIP(host.ip).valid) { @@ -176,14 +184,17 @@ function loadLocalDeviceChoices() { callHostHints().catch(() => ({})), callDHCPLeases().catch(() => ({})), callNetworkInterfaceDump().catch(() => []), - uci.load("dhcp").then(() => uci.sections("dhcp", "host")).catch(() => []), + uci + .load("dhcp") + .then(() => uci.sections("dhcp", "host")) + .catch(() => []), ]) .then(([hostHints, dhcpLeases, networkInterfaces, dhcpHosts]) => { localDeviceChoicesCache = buildLocalDeviceChoices( hostHints, dhcpLeases, networkInterfaces, - dhcpHosts + dhcpHosts, ); return localDeviceChoicesCache; }) diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js index 9289ff4c..93ef3bb5 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js @@ -55,59 +55,130 @@ const DOMAIN_PRESETS = { label: _("Social media"), icon: "📱", domains: [ - "facebook.com", "instagram.com", "tiktok.com", "twitter.com", "x.com", - "vk.com", "snapchat.com", "reddit.com", "discord.com", "discord.gg", - "twitch.tv", "pinterest.com", "telegram.org", "t.me", "whatsapp.com", - "signal.org", "wechat.com", "youtube.com", "tumblr.com", + "facebook.com", + "instagram.com", + "tiktok.com", + "twitter.com", + "x.com", + "vk.com", + "snapchat.com", + "reddit.com", + "discord.com", + "discord.gg", + "twitch.tv", + "pinterest.com", + "telegram.org", + "t.me", + "whatsapp.com", + "signal.org", + "wechat.com", + "youtube.com", + "tumblr.com", ], }, video: { label: _("Video streaming"), icon: "🎬", domains: [ - "youtube.com", "youtu.be", "googlevideo.com", "ytimg.com", - "netflix.com", "primevideo.com", "hulu.com", "disneyplus.com", - "hbomax.com", "okko.tv", "ivi.ru", "kinopoisk.ru", - "twitch.tv", "vimeo.com", "dailymotion.com", "rutube.ru", + "youtube.com", + "youtu.be", + "googlevideo.com", + "ytimg.com", + "netflix.com", + "primevideo.com", + "hulu.com", + "disneyplus.com", + "hbomax.com", + "okko.tv", + "ivi.ru", + "kinopoisk.ru", + "twitch.tv", + "vimeo.com", + "dailymotion.com", + "rutube.ru", ], }, gaming: { label: _("Gaming"), icon: "🎮", domains: [ - "steampowered.com", "steamcommunity.com", "epicgames.com", - "roblox.com", "fortnite.com", "xbox.com", "playstation.com", - "battle.net", "ea.com", "ubisoft.com", "riotgames.com", - "minecraft.net", "origin.com", "discord.com", "discord.gg", + "steampowered.com", + "steamcommunity.com", + "epicgames.com", + "roblox.com", + "fortnite.com", + "xbox.com", + "playstation.com", + "battle.net", + "ea.com", + "ubisoft.com", + "riotgames.com", + "minecraft.net", + "origin.com", + "discord.com", + "discord.gg", ], }, adult: { label: _("Adult content"), icon: "🔞", domains: [ - "pornhub.com", "xvideos.com", "xhamster.com", "xnxx.com", - "onlyfans.com", "stripchat.com", "chaturbate.com", - "youporn.com", "redtube.com", "tubegalore.com", - "porntrex.com", "pornhd.com", "nudevista.com", - "porn.com", "spankbang.com", "eporner.com", + "pornhub.com", + "xvideos.com", + "xhamster.com", + "xnxx.com", + "onlyfans.com", + "stripchat.com", + "chaturbate.com", + "youporn.com", + "redtube.com", + "tubegalore.com", + "porntrex.com", + "pornhd.com", + "nudevista.com", + "porn.com", + "spankbang.com", + "eporner.com", ], }, messengers: { label: _("Messengers"), icon: "💬", domains: [ - "telegram.org", "t.me", "whatsapp.com", "wa.me", "viber.com", - "signal.org", "wechat.com", "qq.com", "icq.com", "discord.com", - "discord.gg", "skype.com", "zoom.us", "slack.com", "teams.microsoft.com", + "telegram.org", + "t.me", + "whatsapp.com", + "wa.me", + "viber.com", + "signal.org", + "wechat.com", + "qq.com", + "icq.com", + "discord.com", + "discord.gg", + "skype.com", + "zoom.us", + "slack.com", + "teams.microsoft.com", ], }, shopping: { label: _("Shopping"), icon: "🛒", domains: [ - "amazon.com", "ebay.com", "aliexpress.com", "ozon.ru", - "wildberries.ru", "wb.ru", "yandex.market", "market.yandex.ru", - "avito.ru", "lamoda.ru", "shein.com", "temu.com", "wish.com", + "amazon.com", + "ebay.com", + "aliexpress.com", + "ozon.ru", + "wildberries.ru", + "wb.ru", + "yandex.market", + "market.yandex.ru", + "avito.ru", + "lamoda.ru", + "shein.com", + "temu.com", + "wish.com", ], }, }; @@ -125,14 +196,22 @@ function validateTime(sectionId, value) { function normalizeDays(raw) { if (!raw) return []; - if (Array.isArray(raw)) return raw.map((d) => `${d}`.toLowerCase().trim()).filter(Boolean); - return `${raw}`.split(/\s+/).map((d) => d.toLowerCase().trim()).filter(Boolean); + if (Array.isArray(raw)) + return raw.map((d) => `${d}`.toLowerCase().trim()).filter(Boolean); + return `${raw}` + .split(/\s+/) + .map((d) => d.toLowerCase().trim()) + .filter(Boolean); } function normalizeListValues(raw) { if (!raw) return []; - if (Array.isArray(raw)) return raw.map((item) => `${item}`.trim()).filter(Boolean); - return `${raw}`.split(/\s+/).map((item) => item.trim()).filter(Boolean); + if (Array.isArray(raw)) + return raw.map((item) => `${item}`.trim()).filter(Boolean); + return `${raw}` + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean); } function isScheduleCurrentlyActive(sectionId) { @@ -191,7 +270,9 @@ function validateDevice(sectionId, value) { const str = `${value}`.trim(); if (main.validateIP(str).valid) return true; if (/^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/.test(str)) return true; - return _("Invalid IP or MAC address. Expected e.g. 192.168.1.150 or AA:BB:CC:DD:EE:FF"); + return _( + "Invalid IP or MAC address. Expected e.g. 192.168.1.150 or AA:BB:CC:DD:EE:FF", + ); } // ─── Family Profiles Section ────────────────────────────────────────────────── @@ -233,14 +314,20 @@ function createProfileContent(section) { }; // Assigned Devices column - o = section.option(form.DummyValue, "_devices_display", _("Devices (MAC / IP)")); + o = section.option( + form.DummyValue, + "_devices_display", + _("Devices (MAC / IP)"), + ); o.rawhtml = true; o.modalonly = false; o.cfgvalue = function (sectionId) { const rawIp = uci.get(UCI_PACKAGE, sectionId, "device_ip"); const devs = normalizeListValues(rawIp); if (devs.length === 0) { - return '' + _("No devices assigned") + ""; + return ( + '' + _("No devices assigned") + "" + ); } const isMacAddr = (dev) => /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/.test(dev); @@ -258,10 +345,14 @@ function createProfileContent(section) { ); }) .join(""); - return '
' + badges + "
"; + return ( + '
' + badges + "
" + ); }; o.textvalue = function (sectionId) { - const devs = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "device_ip")); + const devs = normalizeListValues( + uci.get(UCI_PACKAGE, sectionId, "device_ip"), + ); return devs.length === 0 ? _("No devices assigned") : devs.join(", "); }; @@ -270,7 +361,8 @@ function createProfileContent(section) { o.rawhtml = true; o.modalonly = false; o.cfgvalue = function (sectionId) { - const raw = Number(uci.get(UCI_PACKAGE, sectionId, "daily_quota_minutes")) || 0; + const raw = + Number(uci.get(UCI_PACKAGE, sectionId, "daily_quota_minutes")) || 0; if (raw <= 0) { return '' + _("Unlimited") + ""; } @@ -283,7 +375,8 @@ function createProfileContent(section) { ); }; o.textvalue = function (sectionId) { - const raw = Number(uci.get(UCI_PACKAGE, sectionId, "daily_quota_minutes")) || 0; + const raw = + Number(uci.get(UCI_PACKAGE, sectionId, "daily_quota_minutes")) || 0; return raw > 0 ? `${raw} ${_("min/day")}` : _("Unlimited"); }; @@ -300,22 +393,44 @@ function createProfileContent(section) { let badges = []; if (safeSearch) { - badges.push('🛡️ ' + _("SafeSearch") + ""); + badges.push( + '🛡️ ' + + _("SafeSearch") + + "", + ); } if (blockDoh) { - badges.push('🔒 ' + _("Anti-Bypass (DoH)") + ""); + badges.push( + '🔒 ' + + _("Anti-Bypass (DoH)") + + "", + ); } if (domainCount > 0) { - badges.push('🚫 ' + domainCount + " " + _("sites") + ""); + badges.push( + '🚫 ' + + domainCount + + " " + + _("sites") + + "", + ); } if (notify) { - badges.push('🔔 ' + _("Telegram") + ""); + badges.push( + '🔔 ' + + _("Telegram") + + "", + ); } if (badges.length === 0) { return '' + _("Standard") + ""; } - return '
' + badges.join("") + "
"; + return ( + '
' + + badges.join("") + + "
" + ); }; o.textvalue = function (sectionId) { const safeSearch = uci.get(UCI_PACKAGE, sectionId, "safe_search") === "1"; @@ -358,7 +473,12 @@ function createProfileContent(section) { // ─── Modal Form Options ─────────────────────────────────────────────────── // Profile Avatar selector - o = section.option(form.ListValue, "avatar", _("Profile Icon / Avatar"), _("Choose a visual icon for this profile")); + o = section.option( + form.ListValue, + "avatar", + _("Profile Icon / Avatar"), + _("Choose a visual icon for this profile"), + ); o.modalonly = true; o.default = "👶"; Object.entries(AVATAR_PRESETS).forEach(([icon, label]) => { @@ -366,7 +486,12 @@ function createProfileContent(section) { }); // Profile Label / Name - o = section.option(form.Value, "label", _("Profile Name"), _("e.g. Kids (Alice & Tim), Teenager, School PC, Guests")); + o = section.option( + form.Value, + "label", + _("Profile Name"), + _("e.g. Kids (Alice & Tim), Teenager, School PC, Guests"), + ); o.modalonly = true; o.rmempty = false; o.placeholder = _("e.g. Kids"); @@ -376,7 +501,9 @@ function createProfileContent(section) { form.DynamicList, "device_ip", _("Assigned Devices (MAC / IP)"), - _("Select LAN devices to assign to this profile, or enter IP or MAC addresses manually. MAC addresses (e.g. AA:BB:CC:DD:EE:FF) work even if the device's IP changes via DHCP."), + _( + "Select LAN devices to assign to this profile, or enter IP or MAC addresses manually. MAC addresses (e.g. AA:BB:CC:DD:EE:FF) work even if the device's IP changes via DHCP.", + ), ); o.modalonly = true; o.rmempty = true; @@ -416,7 +543,9 @@ function createProfileContent(section) { form.Flag, "safe_search", _("Enforce SafeSearch & YouTube Restricted Mode"), - _("Forces SafeSearch on Google, Yandex, Bing, DuckDuckGo and Strict Restricted Mode on YouTube via DNS rewrite for all devices in this profile."), + _( + "Forces SafeSearch on Google, Yandex, Bing, DuckDuckGo and Strict Restricted Mode on YouTube via DNS rewrite for all devices in this profile.", + ), ); o.modalonly = true; o.default = "1"; @@ -427,7 +556,9 @@ function createProfileContent(section) { form.Flag, "block_doh", _("Anti-Bypass: Block Private DNS / DoH / DoT"), - _("Blocks port 853 (DNS-over-TLS) and prevents bypass via encrypted DNS or Private Relay on smartphones for devices in this profile."), + _( + "Blocks port 853 (DNS-over-TLS) and prevents bypass via encrypted DNS or Private Relay on smartphones for devices in this profile.", + ), ); o.modalonly = true; o.default = "1"; @@ -438,7 +569,9 @@ function createProfileContent(section) { form.TextValue, "blocked_domains", _("Profile Blocked Sites (24/7)"), - _("Always block these domains for all devices in this profile. Enter one domain per line or use category preset buttons."), + _( + "Always block these domains for all devices in this profile. Enter one domain per line or use category preset buttons.", + ), ); o.modalonly = true; o.rmempty = true; @@ -448,22 +581,32 @@ function createProfileContent(section) { o.placeholder = "tiktok.com\nroblox.com"; o.validate = function (sectionId, value) { if (!value) return true; - const lines = `${value}`.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const lines = `${value}` + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); for (const line of lines) { const clean = line.replace(/^(full:|keyword:|regex:)/, ""); if (/^(full:|keyword:|regex:)/.test(line) && !clean) { return _("Invalid domain: empty prefix value"); } if (/^regex:/.test(line)) continue; - if (!/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(clean)) { + if ( + !/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(clean) + ) { return _("Invalid domain: " + line); } } return true; }; o.write = function (sectionId, value) { - const lines = `${value || ""}`.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); - const existingList = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "blocked_domains")); + const lines = `${value || ""}` + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + const existingList = normalizeListValues( + uci.get(UCI_PACKAGE, sectionId, "blocked_domains"), + ); for (const d of lines) { if (!existingList.includes(d)) { uci.add_list(UCI_PACKAGE, sectionId, "blocked_domains", d); @@ -476,14 +619,18 @@ function createProfileContent(section) { } }; o.load = function (sectionId) { - return normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "blocked_domains")).join("\n"); + return normalizeListValues( + uci.get(UCI_PACKAGE, sectionId, "blocked_domains"), + ).join("\n"); }; o.remove = function (sectionId) { uci.unset(UCI_PACKAGE, sectionId, "blocked_domains"); }; o.renderWidget = function (sectionId, optionIndex, cfgvalue) { const container = E("div", { style: "width:100%;box-sizing:border-box;" }); - const presetRow = E("div", { style: "margin-bottom:6px;display:flex;flex-wrap:wrap;gap:4px;" }); + const presetRow = E("div", { + style: "margin-bottom:6px;display:flex;flex-wrap:wrap;gap:4px;", + }); Object.entries(DOMAIN_PRESETS).forEach(([key, preset]) => { const btn = E( "button", @@ -498,7 +645,10 @@ function createProfileContent(section) { btn.addEventListener("click", function () { const textarea = container.querySelector("textarea"); if (!textarea) return; - const current = textarea.value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const current = textarea.value + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); const merged = current.slice(); for (const d of preset.domains) { if (!merged.includes(d)) merged.push(d); @@ -525,7 +675,9 @@ function createProfileContent(section) { form.Flag, "notify", _("Telegram Alerts"), - _("Send Telegram alerts to parents when daily screen time is exhausted or blocked sites are accessed."), + _( + "Send Telegram alerts to parents when daily screen time is exhausted or blocked sites are accessed.", + ), ); o.modalonly = true; o.default = "1"; @@ -543,7 +695,11 @@ function createParentalContent(section) { o.width = "5rem"; // Device / Target profile column in table - o = section.option(form.DummyValue, "_device_display", _("Profile / Devices")); + o = section.option( + form.DummyValue, + "_device_display", + _("Profile / Devices"), + ); o.rawhtml = true; o.modalonly = false; o.cfgvalue = function (sectionId) { @@ -562,8 +718,8 @@ function createParentalContent(section) { const devCount = prof ? normalizeListValues(prof.device_ip).length : 0; items.push( `` + - `${avatar} ${label} (${devCount} ${_("dev.")})` + - `` + `${avatar} ${label} (${devCount} ${_("dev.")})` + + ``, ); }); } @@ -578,9 +734,9 @@ function createParentalContent(section) { const label = isMac ? "MAC" : "IP"; items.push( `` + - `${label}:` + - dev + - "" + `${label}:` + + dev + + "", ); }); } @@ -589,11 +745,19 @@ function createParentalContent(section) { return '' + _("All devices") + ""; } - return '
' + items.join("") + "
"; + return ( + '
' + + items.join("") + + "
" + ); }; o.textvalue = function (sectionId) { - const profiles = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "profile")); - const devs = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "device_ip")); + const profiles = normalizeListValues( + uci.get(UCI_PACKAGE, sectionId, "profile"), + ); + const devs = normalizeListValues( + uci.get(UCI_PACKAGE, sectionId, "device_ip"), + ); const list = [...profiles.map((p) => `Profile: ${p}`), ...devs]; return list.length === 0 ? _("All devices") : list.join(", "); }; @@ -605,7 +769,11 @@ function createParentalContent(section) { o.cfgvalue = function (sectionId) { const target = uci.get(UCI_PACKAGE, sectionId, "target") || "all"; if (target === "all") { - return '🚫 ' + _("All Internet") + ""; + return ( + '🚫 ' + + _("All Internet") + + "" + ); } const rawSecs = uci.get(UCI_PACKAGE, sectionId, "sections"); const secNames = normalizeListValues(rawSecs); @@ -613,7 +781,9 @@ function createParentalContent(section) { secNames.push(target); } if (secNames.length === 0) { - return '' + _("No sections selected") + ""; + return ( + '' + _("No sections selected") + "" + ); } const badges = secNames .map((name) => { @@ -626,13 +796,19 @@ function createParentalContent(section) { ); }) .join(""); - return '
' + badges + "
"; + return ( + '
' + badges + "
" + ); }; o.textvalue = function (sectionId) { const target = uci.get(UCI_PACKAGE, sectionId, "target") || "all"; if (target === "all") return _("All Internet"); - const secNames = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "sections")); - return secNames.length === 0 ? _("No sections selected") : secNames.join(", "); + const secNames = normalizeListValues( + uci.get(UCI_PACKAGE, sectionId, "sections"), + ); + return secNames.length === 0 + ? _("No sections selected") + : secNames.join(", "); }; // Action column in table @@ -642,9 +818,17 @@ function createParentalContent(section) { o.cfgvalue = function (sectionId) { const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block"; if (action === "allow") { - return '✅ ' + _("Allow in interval") + ""; + return ( + '✅ ' + + _("Allow in interval") + + "" + ); } - return '🚫 ' + _("Block in interval") + ""; + return ( + '🚫 ' + + _("Block in interval") + + "" + ); }; o.textvalue = function (sectionId) { const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block"; @@ -681,7 +865,8 @@ function createParentalContent(section) { o.rawhtml = true; o.modalonly = false; o.cfgvalue = function (sectionId) { - const raw = Number(uci.get(UCI_PACKAGE, sectionId, "daily_quota_minutes")) || 0; + const raw = + Number(uci.get(UCI_PACKAGE, sectionId, "daily_quota_minutes")) || 0; if (raw <= 0) { return '' + _("None") + ""; } @@ -694,7 +879,8 @@ function createParentalContent(section) { ); }; o.textvalue = function (sectionId) { - const raw = Number(uci.get(UCI_PACKAGE, sectionId, "daily_quota_minutes")) || 0; + const raw = + Number(uci.get(UCI_PACKAGE, sectionId, "daily_quota_minutes")) || 0; return raw > 0 ? `${raw} ${_("min/day")}` : _("None"); }; @@ -703,15 +889,21 @@ function createParentalContent(section) { o.rawhtml = true; o.modalonly = false; o.cfgvalue = function (sectionId) { - const domains = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "blocked_domains")); + const domains = normalizeListValues( + uci.get(UCI_PACKAGE, sectionId, "blocked_domains"), + ); const mode = uci.get(UCI_PACKAGE, sectionId, "mode") || "block"; if (domains.length === 0) { return '' + _("None") + ""; } const modeBadge = mode === "allow" - ? '🟢 ' + _("Allow only") + "" - : '🚫 ' + _("Block") + ""; + ? '🟢 ' + + _("Allow only") + + "" + : '🚫 ' + + _("Block") + + ""; const badges = domains .map( (d) => @@ -720,13 +912,23 @@ function createParentalContent(section) { "", ) .join(""); - return "
" + modeBadge + '
' + badges + "
"; + return ( + "
" + + modeBadge + + '
' + + badges + + "
" + ); }; o.textvalue = function (sectionId) { - const domains = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "blocked_domains")); + const domains = normalizeListValues( + uci.get(UCI_PACKAGE, sectionId, "blocked_domains"), + ); const mode = uci.get(UCI_PACKAGE, sectionId, "mode") || "block"; if (domains.length === 0) return _("None"); - return (mode === "allow" ? _("Allow only: ") : _("Block: ")) + domains.join(", "); + return ( + (mode === "allow" ? _("Allow only: ") : _("Block: ")) + domains.join(", ") + ); }; // Live status badge in table @@ -746,10 +948,16 @@ function createParentalContent(section) { const active = isScheduleCurrentlyActive(sectionId); const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block"; if (active) { - const color = action === "allow" ? "var(--success-color-medium, #38a169)" : "var(--error-color-medium, #e53e3e)"; - const bg = action === "allow" ? "rgba(72,187,120,0.15)" : "rgba(229,62,62,0.15)"; - const border = action === "allow" ? "rgba(72,187,120,0.3)" : "rgba(229,62,62,0.3)"; - const text = action === "allow" ? _("Active (Allowed)") : _("Active (Blocking)"); + const color = + action === "allow" + ? "var(--success-color-medium, #38a169)" + : "var(--error-color-medium, #e53e3e)"; + const bg = + action === "allow" ? "rgba(72,187,120,0.15)" : "rgba(229,62,62,0.15)"; + const border = + action === "allow" ? "rgba(72,187,120,0.3)" : "rgba(229,62,62,0.3)"; + const text = + action === "allow" ? _("Active (Allowed)") : _("Active (Blocking)"); return ( `` + `` + @@ -769,7 +977,10 @@ function createParentalContent(section) { if (!enabled) return _("Disabled"); const active = isScheduleCurrentlyActive(sectionId); const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block"; - if (active) return action === "allow" ? _("Active (Allowed)") : _("Active (Blocking)"); + if (active) + return action === "allow" + ? _("Active (Allowed)") + : _("Active (Blocking)"); return _("Pending"); }; @@ -786,7 +997,9 @@ function createParentalContent(section) { form.DynamicList, "profile", _("Target Family Profiles"), - _("Apply this schedule rule to all devices in the selected family profile(s)."), + _( + "Apply this schedule rule to all devices in the selected family profile(s).", + ), ); o.modalonly = true; o.rmempty = true; @@ -808,7 +1021,9 @@ function createParentalContent(section) { form.DynamicList, "device_ip", _("Additional Individual Devices (IP / MAC)"), - _("Optionally specify individual devices not in a profile. MAC addresses (e.g. AA:BB:CC:DD:EE:FF) work even if the IP changes."), + _( + "Optionally specify individual devices not in a profile. MAC addresses (e.g. AA:BB:CC:DD:EE:FF) work even if the IP changes.", + ), ); o.modalonly = true; o.rmempty = true; @@ -827,7 +1042,9 @@ function createParentalContent(section) { form.ListValue, "target", _("Target Scope"), - _("Choose whether to restrict the entire internet or only specific Tachyon routing sections."), + _( + "Choose whether to restrict the entire internet or only specific Tachyon routing sections.", + ), ); o.modalonly = true; o.default = "all"; @@ -860,7 +1077,9 @@ function createParentalContent(section) { form.ListValue, "action", _("Action Mode"), - _("Select whether to disable the target during the schedule or enable it ONLY during the schedule."), + _( + "Select whether to disable the target during the schedule or enable it ONLY during the schedule.", + ), ); o.modalonly = true; o.default = "block"; @@ -884,7 +1103,9 @@ function createParentalContent(section) { form.Value, "end_time", _("End Time (HH:MM)"), - _("End of the schedule interval (e.g. 08:00). Supports spans crossing midnight."), + _( + "End of the schedule interval (e.g. 08:00). Supports spans crossing midnight.", + ), ); o.modalonly = true; o.default = "08:00"; @@ -932,7 +1153,9 @@ function createParentalContent(section) { form.TextValue, "blocked_domains", _("Blocked Sites (Domains)"), - _("Domains to block for the selected devices. Leave empty to block all internet. Enter one domain per line, e.g. youtube.com. Use the preset buttons to quickly add common categories."), + _( + "Domains to block for the selected devices. Leave empty to block all internet. Enter one domain per line, e.g. youtube.com. Use the preset buttons to quickly add common categories.", + ), ); o.modalonly = true; o.rmempty = true; @@ -942,22 +1165,32 @@ function createParentalContent(section) { o.placeholder = "youtube.com\ngooglevideo.com"; o.validate = function (sectionId, value) { if (!value) return true; - const lines = `${value}`.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const lines = `${value}` + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); for (const line of lines) { const clean = line.replace(/^(full:|keyword:|regex:)/, ""); if (/^(full:|keyword:|regex:)/.test(line) && !clean) { return _("Invalid domain: empty prefix value"); } if (/^regex:/.test(line)) continue; - if (!/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(clean)) { + if ( + !/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(clean) + ) { return _("Invalid domain: " + line); } } return true; }; o.write = function (sectionId, value) { - const lines = `${value || ""}`.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); - const existingList = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "blocked_domains")); + const lines = `${value || ""}` + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + const existingList = normalizeListValues( + uci.get(UCI_PACKAGE, sectionId, "blocked_domains"), + ); for (const d of lines) { if (!existingList.includes(d)) { uci.add_list(UCI_PACKAGE, sectionId, "blocked_domains", d); @@ -970,7 +1203,9 @@ function createParentalContent(section) { } }; o.load = function (sectionId) { - return normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "blocked_domains")).join("\n"); + return normalizeListValues( + uci.get(UCI_PACKAGE, sectionId, "blocked_domains"), + ).join("\n"); }; o.remove = function (sectionId) { uci.unset(UCI_PACKAGE, sectionId, "blocked_domains"); @@ -979,7 +1214,9 @@ function createParentalContent(section) { const container = E("div", { style: "width:100%;box-sizing:border-box;" }); // Preset buttons row - const presetRow = E("div", { style: "margin-bottom:6px;display:flex;flex-wrap:wrap;gap:4px;" }); + const presetRow = E("div", { + style: "margin-bottom:6px;display:flex;flex-wrap:wrap;gap:4px;", + }); Object.entries(DOMAIN_PRESETS).forEach(([key, preset]) => { const btn = E( "button", @@ -994,7 +1231,10 @@ function createParentalContent(section) { btn.addEventListener("click", function () { const textarea = container.querySelector("textarea"); if (!textarea) return; - const current = textarea.value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const current = textarea.value + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); const merged = current.slice(); for (const d of preset.domains) { if (!merged.includes(d)) merged.push(d); @@ -1021,7 +1261,9 @@ function createParentalContent(section) { form.ListValue, "mode", _("Content Mode"), - _("Block: deny the listed sites. Allow only (whitelist): deny everything except the listed sites. Whitelist mode requires DNS-level blocking and applies to the schedule window."), + _( + "Block: deny the listed sites. Allow only (whitelist): deny everything except the listed sites. Whitelist mode requires DNS-level blocking and applies to the schedule window.", + ), ); o.modalonly = true; o.default = "block"; @@ -1033,7 +1275,9 @@ function createParentalContent(section) { form.Flag, "dns_level", _("Block at DNS level"), - _("Return NXDOMAIN for blocked domains (recommended). When enabled, blocked domains are not resolved at all. When disabled, only direct connections are rejected."), + _( + "Return NXDOMAIN for blocked domains (recommended). When enabled, blocked domains are not resolved at all. When disabled, only direct connections are rejected.", + ), ); o.modalonly = true; o.default = "1"; @@ -1044,7 +1288,9 @@ function createParentalContent(section) { form.Flag, "notify", _("Notify in Telegram"), - _("Send a Telegram notification to admins when a blocked site is accessed. Requires the Telegram bot to be enabled."), + _( + "Send a Telegram notification to admins when a blocked site is accessed. Requires the Telegram bot to be enabled.", + ), ); o.modalonly = true; o.default = "0"; diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js index 03f19e35..18bb2183 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/section.js @@ -90,7 +90,20 @@ function isOutboundDetourTargetSection(section, currentSectionId) { sectionName && sectionName !== currentSectionId && section.enabled !== "0" && - ["connection", "proxy", "outbound", "vpn", "awg", "warp", "anytls", "snell", "mieru", "sudoku", "masque", "openvpn"].includes(action) + [ + "connection", + "proxy", + "outbound", + "vpn", + "awg", + "warp", + "anytls", + "snell", + "mieru", + "sudoku", + "masque", + "openvpn", + ].includes(action) ); } @@ -130,7 +143,22 @@ function isDnsDetourTargetSection(section, currentSectionId) { return false; } - if (["connection", "proxy", "outbound", "vpn", "awg", "warp", "anytls", "snell", "mieru", "sudoku", "masque", "openvpn"].includes(action)) { + if ( + [ + "connection", + "proxy", + "outbound", + "vpn", + "awg", + "warp", + "anytls", + "snell", + "mieru", + "sudoku", + "masque", + "openvpn", + ].includes(action) + ) { return true; } if (action === "zapret") { @@ -175,8 +203,13 @@ function dependsOnRuleConditions(option) { option.depends({ action, [condition]: /\S/ }), ), ); - ["domain", "community_lists", "_dns_rule_set", "_dns_domain_ip_lists"].forEach( - (condition) => option.depends({ action: "dns", [condition]: /\S/ }), + [ + "domain", + "community_lists", + "_dns_rule_set", + "_dns_domain_ip_lists", + ].forEach((condition) => + option.depends({ action: "dns", [condition]: /\S/ }), ); return option; } @@ -1534,7 +1567,11 @@ function configureLiveDynamicListChoices(option, getChoices) { ); const cboxElem = node.querySelector(".cbi-dropdown"); - if (cboxElem && typeof dom !== "undefined" && typeof dom.callClassMethod === "function") { + if ( + cboxElem && + typeof dom !== "undefined" && + typeof dom.callClassMethod === "function" + ) { try { dom.callClassMethod(cboxElem, "clearChoices"); dom.callClassMethod( @@ -1790,7 +1827,22 @@ function isDownloadThroughTargetSection(section, currentSectionId) { return false; } - if (["connection", "proxy", "outbound", "vpn", "awg", "warp", "anytls", "snell", "mieru", "sudoku", "masque", "openvpn"].includes(action)) { + if ( + [ + "connection", + "proxy", + "outbound", + "vpn", + "awg", + "warp", + "anytls", + "snell", + "mieru", + "sudoku", + "masque", + "openvpn", + ].includes(action) + ) { return true; } @@ -2841,7 +2893,10 @@ function addUrlTestItemOptions(itemSection, options = {}) { }; list.placeholder = _("-- Select --"); configureLiveDynamicListChoices(list, (itemId, values) => - currentOutboundNameChoices(parentSectionForItem(itemId) || "Main", values), + currentOutboundNameChoices( + parentSectionForItem(itemId) || "Main", + values, + ), ); } if (validator) { @@ -3054,7 +3109,10 @@ function addPriorityLevelItemOptions(itemSection, options = {}) { }; list.placeholder = _("-- Select --"); configureLiveDynamicListChoices(list, (itemId, values) => - currentOutboundNameChoices(parentSectionForItem(itemId) || "Main", values), + currentOutboundNameChoices( + parentSectionForItem(itemId) || "Main", + values, + ), ); } if (validator) { @@ -3564,116 +3622,119 @@ function renderStackedJsonSettingsModal(title, map, onSave) { return Promise.resolve(); } - return map.load().then(() => map.render()).then((nodes) => { - const titleNode = E("span", title ? ` » ${title}` : ""); - const originalButtonClass = buttonRow.getAttribute("class") || ""; - const originalButtonNodes = Array.from(buttonRow.childNodes); - let closed = false; - let saveButton; - let validationSummary; + return map + .load() + .then(() => map.render()) + .then((nodes) => { + const titleNode = E("span", title ? ` » ${title}` : ""); + const originalButtonClass = buttonRow.getAttribute("class") || ""; + const originalButtonNodes = Array.from(buttonRow.childNodes); + let closed = false; + let saveButton; + let validationSummary; - const clearValidationSummary = () => { - if (validationSummary && validationSummary.parentNode) { - validationSummary.parentNode.removeChild(validationSummary); - } - validationSummary = null; - }; + const clearValidationSummary = () => { + if (validationSummary && validationSummary.parentNode) { + validationSummary.parentNode.removeChild(validationSummary); + } + validationSummary = null; + }; - const showValidationSummary = (error) => { - clearValidationSummary(); + const showValidationSummary = (error) => { + clearValidationSummary(); - const message = error?.message || ""; - validationSummary = E( - "div", - { - class: - "alert-message warning fkp-stacked-settings-validation-summary", - }, - [ - E("strong", {}, _("Cannot save settings")), - E("div", {}, _("Fix the highlighted fields and save again.")), - message ? E("small", {}, message) : "", - ], - ); - buttonRow.parentNode.insertBefore(validationSummary, buttonRow); + const message = error?.message || ""; + validationSummary = E( + "div", + { + class: + "alert-message warning fkp-stacked-settings-validation-summary", + }, + [ + E("strong", {}, _("Cannot save settings")), + E("div", {}, _("Fix the highlighted fields and save again.")), + message ? E("small", {}, message) : "", + ], + ); + buttonRow.parentNode.insertBefore(validationSummary, buttonRow); - const invalidInput = nodes.querySelector(".cbi-input-invalid"); - if (invalidInput) { - invalidInput.scrollIntoView({ block: "center", behavior: "smooth" }); - invalidInput.focus({ preventScroll: true }); - } - }; + const invalidInput = nodes.querySelector(".cbi-input-invalid"); + if (invalidInput) { + invalidInput.scrollIntoView({ block: "center", behavior: "smooth" }); + invalidInput.focus({ preventScroll: true }); + } + }; + + const restoreButtonRow = () => { + buttonRow.textContent = ""; + originalButtonNodes.forEach((node) => buttonRow.appendChild(node)); + buttonRow.setAttribute("class", originalButtonClass); + }; + + const close = () => { + if (closed) { + return; + } + + closed = true; + clearValidationSummary(); + + if (nodes.parentNode) { + nodes.parentNode.removeChild(nodes); + } + if (titleNode.parentNode) { + titleNode.parentNode.removeChild(titleNode); + } + + activeMap.classList.remove("hidden"); + restoreButtonRow(); + }; + + const save = () => { + if (saveButton) { + saveButton.disabled = true; + } + clearValidationSummary(); + + return map + .parse() + .then(() => { + onSave(cleanFormSectionData(map.data.get(map.config, "settings"))); + close(); + }) + .catch((error) => { + if (saveButton) { + saveButton.disabled = false; + } + showValidationSummary(error); + }); + }; - const restoreButtonRow = () => { buttonRow.textContent = ""; - originalButtonNodes.forEach((node) => buttonRow.appendChild(node)); - buttonRow.setAttribute("class", originalButtonClass); - }; + buttonRow.append( + E( + "button", + { + class: "btn cbi-button", + click: close, + }, + _("Close"), + ), + " ", + (saveButton = E( + "button", + { + class: "btn cbi-button cbi-button-positive important", + click: save, + }, + _("Save"), + )), + ); - const close = () => { - if (closed) { - return; - } - - closed = true; - clearValidationSummary(); - - if (nodes.parentNode) { - nodes.parentNode.removeChild(nodes); - } - if (titleNode.parentNode) { - titleNode.parentNode.removeChild(titleNode); - } - - activeMap.classList.remove("hidden"); - restoreButtonRow(); - }; - - const save = () => { - if (saveButton) { - saveButton.disabled = true; - } - clearValidationSummary(); - - return map - .parse() - .then(() => { - onSave(cleanFormSectionData(map.data.get(map.config, "settings"))); - close(); - }) - .catch((error) => { - if (saveButton) { - saveButton.disabled = false; - } - showValidationSummary(error); - }); - }; - - buttonRow.textContent = ""; - buttonRow.append( - E( - "button", - { - class: "btn cbi-button", - click: close, - }, - _("Close"), - ), - " ", - (saveButton = E( - "button", - { - class: "btn cbi-button cbi-button-positive important", - click: save, - }, - _("Save"), - )), - ); - - heading.appendChild(titleNode); - activeMap.classList.add("hidden"); - activeMap.parentNode.insertBefore(nodes, activeMap.nextElementSibling); - }); + heading.appendChild(titleNode); + activeMap.classList.add("hidden"); + activeMap.parentNode.insertBefore(nodes, activeMap.nextElementSibling); + }); } function showChildItemSettingsModal(section_id, itemValue, option, settings) { @@ -3832,7 +3893,6 @@ function showUrlTestSettingsModal( }); } - function showPriorityLevelSettingsModal( groupId, itemValue, @@ -3880,7 +3940,6 @@ function showPriorityLevelSettingsModal( }); } - function createPriorityGroupItem(section_id, groupId, settings) { const created = uci.add(UCI_PACKAGE, "priority_group", groupId) || groupId; @@ -4174,7 +4233,6 @@ function ensureActionProvidersAvailabilityLoaded() { ? byedpiResult.value : null; - actionProvidersAvailabilityState.loaded = true; actionProvidersAvailabilityState.zapretInstalled = Boolean( zapret && zapret.success && zapret.data && zapret.data.zapret_installed, @@ -4219,8 +4277,6 @@ function isByedpiInstalledForUi() { return actionProvidersAvailabilityState.byedpiInstalled; } - - function isSingBoxExtendedForUi() { return actionProvidersAvailabilityState.singBoxExtended; } @@ -4361,7 +4417,6 @@ function populateActionOptionValues(option) { option.value("byedpi", getActionOptionLabel("byedpi")); } option.value("hosts", getActionOptionLabel("hosts")); - } function getConfigListValues(section_id, key) { @@ -7068,33 +7123,33 @@ function addTextConditionField(section, config) { } var DOMAIN_LIST_HINTS = { - russia_inside: "youtube.com, instagram.com, twitter.com, ...", - russia_outside: "gosuslugi.ru, ozon.ru, rzd.ru, ...", - ukraine_inside: "ukr.net, pravda.com.ua, suspilne.media, ...", - geoblock: "Все домены, заблокированные в РФ", - block: "Заблокировать совсем (без прокси)", - porn: "Pornhub, xvideos, xhamster, ...", - news: "meduza.io, bbc.com, pravda.com.ua, ...", - anime: "bato.to, mangadex.org, myanimelist.net, ...", - youtube: "youtube.com, youtu.be, googlevideo.com, ...", - discord: "discord.com, discord.gg, discordapp.com, ...", - meta: "facebook.com, instagram.com, whatsapp.com, ...", - twitter: "twitter.com, x.com, t.co, ...", - hdrezka: "hdrezka.ag, hdrezka.me, ...", - tiktok: "tiktok.com, tiktokcdn.com, ...", - telegram: "telegram.org, t.me, ...", - cloudflare: "все IP/домены Cloudflare (1.1.1.1, etc.)", - google_ai: "gemini.google.com, bard.google.com, ...", - google_play: "play.google.com, googleplay.com, ...", - hodca: "Домены на CDN: Hetzner, OVH, DigitalOcean, Cloudflare, AWS", - roblox: "roblox.com, rbxcdn.com, ...", - ads_hagezi_pro: "Блокировка рекламы (Hagezi Pro список)", - supercell: "supercell.com, clashofclans.com, ...", - github: "github.com, raw.githubusercontent.com, ...", - hetzner: "IP-адреса Hetzner (ASN)", - ovh: "IP-адреса OVH (ASN)", - digitalocean: "IP-адреса DigitalOcean (ASN)", - cloudfront: "IP-адреса CloudFront/Amazon (ASN)", + russia_inside: "youtube.com, instagram.com, twitter.com, ...", + russia_outside: "gosuslugi.ru, ozon.ru, rzd.ru, ...", + ukraine_inside: "ukr.net, pravda.com.ua, suspilne.media, ...", + geoblock: "Все домены, заблокированные в РФ", + block: "Заблокировать совсем (без прокси)", + porn: "Pornhub, xvideos, xhamster, ...", + news: "meduza.io, bbc.com, pravda.com.ua, ...", + anime: "bato.to, mangadex.org, myanimelist.net, ...", + youtube: "youtube.com, youtu.be, googlevideo.com, ...", + discord: "discord.com, discord.gg, discordapp.com, ...", + meta: "facebook.com, instagram.com, whatsapp.com, ...", + twitter: "twitter.com, x.com, t.co, ...", + hdrezka: "hdrezka.ag, hdrezka.me, ...", + tiktok: "tiktok.com, tiktokcdn.com, ...", + telegram: "telegram.org, t.me, ...", + cloudflare: "все IP/домены Cloudflare (1.1.1.1, etc.)", + google_ai: "gemini.google.com, bard.google.com, ...", + google_play: "play.google.com, googleplay.com, ...", + hodca: "Домены на CDN: Hetzner, OVH, DigitalOcean, Cloudflare, AWS", + roblox: "roblox.com, rbxcdn.com, ...", + ads_hagezi_pro: "Блокировка рекламы (Hagezi Pro список)", + supercell: "supercell.com, clashofclans.com, ...", + github: "github.com, raw.githubusercontent.com, ...", + hetzner: "IP-адреса Hetzner (ASN)", + ovh: "IP-адреса OVH (ASN)", + digitalocean: "IP-адреса DigitalOcean (ASN)", + cloudfront: "IP-адреса CloudFront/Amazon (ASN)", }; function loadRulesetValues(option) { @@ -7254,7 +7309,6 @@ function createSectionContent(section) { }; o.width = "7rem"; - o = section.taboption( "settings", form.Value, @@ -7276,11 +7330,12 @@ function createSectionContent(section) { _("What Tachyon should do when this section matches"), ); populateActionOptionValues(o); - - const super_validate = o.validate; - o.validate = function(section_id, value) { - return super_validate ? super_validate.apply(this, [section_id, value]) : true; + const super_validate = o.validate; + o.validate = function (section_id, value) { + return super_validate + ? super_validate.apply(this, [section_id, value]) + : true; }; o.default = "connection"; @@ -7321,8 +7376,12 @@ function createSectionContent(section) { o.modalonly = true; configureLiveDynamicListChoices(o, (section_id) => { const dnsType = perRuleDnsTypeState.get(section_id) || "udp"; - const servers = main.DNS_SERVERS_BY_PROTOCOL[dnsType] || main.DNS_SERVERS_BY_PROTOCOL.udp; - return Object.entries(servers).map(([value, label]) => ({ value, label: _(label) })); + const servers = + main.DNS_SERVERS_BY_PROTOCOL[dnsType] || main.DNS_SERVERS_BY_PROTOCOL.udp; + return Object.entries(servers).map(([value, label]) => ({ + value, + label: _(label), + })); }); perRuleDnsTypeOption.onchange = function (_ev, section_id, value) { const newType = value || "udp"; @@ -7332,9 +7391,13 @@ function createSectionContent(section) { if (newType !== oldType) { const widget = perRuleDnsWidgets.get(section_id); if (widget) { - const servers = main.DNS_SERVERS_BY_PROTOCOL[newType] || main.DNS_SERVERS_BY_PROTOCOL.udp; + const servers = + main.DNS_SERVERS_BY_PROTOCOL[newType] || + main.DNS_SERVERS_BY_PROTOCOL.udp; const defaultLabels = {}; - Object.entries(servers).forEach(([v, l]) => { defaultLabels[v] = _(l); }); + Object.entries(servers).forEach(([v, l]) => { + defaultLabels[v] = _(l); + }); const defaultServers = Object.keys(servers).slice(0, 1); widget.choices = defaultLabels; widget.setValue(defaultServers); @@ -7435,8 +7498,6 @@ function createSectionContent(section) { return analysis.valid ? true : analysis.message; }; - - o = section.taboption( "settings", form.TextValue, @@ -7475,7 +7536,6 @@ function createSectionContent(section) { }; configureTextareaOption(o, analyzeByedpiStrategy); - // ─── Hosts Import Helpers ────────────────────────────────────────────── function parseHostsFile(text, options) { @@ -7488,11 +7548,17 @@ function createSectionContent(section) { let skippedInvalid = 0; const loopbackIPs = new Set([ - "127.0.0.1", "0.0.0.0", "::1", - "0:0:0:0:0:0:0:1", "0000:0000:0000:0000:0000:0000:0000:0001", + "127.0.0.1", + "0.0.0.0", + "::1", + "0:0:0:0:0:0:0:1", + "0000:0000:0000:0000:0000:0000:0000:0001", ]); const loopbackHosts = new Set([ - "localhost", "localhost.localdomain", "local", "broadcasthost", + "localhost", + "localhost.localdomain", + "local", + "broadcasthost", ]); for (let i = 0; i < lines.length; i++) { @@ -7501,89 +7567,166 @@ function createSectionContent(section) { const hashIdx = line.indexOf("#"); if (hashIdx !== -1) { - if (hashIdx === 0) { skippedComments++; continue; } + if (hashIdx === 0) { + skippedComments++; + continue; + } line = line.substring(0, hashIdx).trim(); - if (!line) { skippedComments++; continue; } + if (!line) { + skippedComments++; + continue; + } } const parts = line.split(/\s+/); - if (parts.length < 2) { skippedInvalid++; continue; } + if (parts.length < 2) { + skippedInvalid++; + continue; + } const ip = parts[0].trim(); const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(ip); const isIPv6 = ip.indexOf(":") !== -1 && /^[0-9a-fA-F:]+$/.test(ip); - if (!isIPv4 && !isIPv6) { skippedInvalid++; continue; } + if (!isIPv4 && !isIPv6) { + skippedInvalid++; + continue; + } const isIpLoopback = loopbackIPs.has(ip); for (let j = 1; j < parts.length; j++) { const domain = parts[j].toLowerCase().trim(); if (!domain) continue; if (skipLoopback && (isIpLoopback || loopbackHosts.has(domain))) { - skippedLoopback++; continue; + skippedLoopback++; + continue; } if (domain.length > 253 || /^[^a-z0-9_.-]/i.test(domain)) { - skippedInvalid++; continue; + skippedInvalid++; + continue; } entries.push({ domain: domain, ip: ip }); } } - return { entries, skippedComments, skippedLoopback, skippedInvalid, totalLines: lines.length }; + return { + entries, + skippedComments, + skippedLoopback, + skippedInvalid, + totalLines: lines.length, + }; } function showImportHostsModal(section_id, dnsHostsOptRef) { - const fileInput = E("input", { type: "file", accept: ".txt,.hosts,hosts,*", style: "display:none" }); + const fileInput = E("input", { + type: "file", + accept: ".txt,.hosts,hosts,*", + style: "display:none", + }); - const fileSelectBtn = E("button", { - class: "cbi-button cbi-button-neutral", type: "button", - click: function () { fileInput.click(); }, - }, _("Choose Hosts File...")); - - const loadCurrentBtn = E("button", { - class: "cbi-button cbi-button-action", type: "button", - click: function () { - let existingEntries = []; - const uiEl = dnsHostsOptRef ? dnsHostsOptRef.getUIElement(section_id) : null; - if (uiEl && typeof uiEl.getValue === "function") { - const v = uiEl.getValue(); - if (typeof v === "string" && v) existingEntries = v.split("\n").map(l => l.trim()).filter(l => l); - } - if (!existingEntries.length) { - const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts"); - existingEntries = Array.isArray(uciVal) ? uciVal : uciVal ? [uciVal] : []; - } - textArea.value = existingEntries.join("\n"); - fileStatus.textContent = _("Loaded %d current record(s)").format(existingEntries.length); - updatePreview(); + const fileSelectBtn = E( + "button", + { + class: "cbi-button cbi-button-neutral", + type: "button", + click: function () { + fileInput.click(); + }, }, - }, _("Load Current Records")); + _("Choose Hosts File..."), + ); - const fileStatus = E("span", { - style: "margin-left:10px;font-size:0.85rem;color:var(--text-color-medium,#888);", - }, _("No file selected")); + const loadCurrentBtn = E( + "button", + { + class: "cbi-button cbi-button-action", + type: "button", + click: function () { + let existingEntries = []; + const uiEl = dnsHostsOptRef + ? dnsHostsOptRef.getUIElement(section_id) + : null; + if (uiEl && typeof uiEl.getValue === "function") { + const v = uiEl.getValue(); + if (typeof v === "string" && v) + existingEntries = v + .split("\n") + .map((l) => l.trim()) + .filter((l) => l); + } + if (!existingEntries.length) { + const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts"); + existingEntries = Array.isArray(uciVal) + ? uciVal + : uciVal + ? [uciVal] + : []; + } + textArea.value = existingEntries.join("\n"); + fileStatus.textContent = _("Loaded %d current record(s)").format( + existingEntries.length, + ); + updatePreview(); + }, + }, + _("Load Current Records"), + ); + + const fileStatus = E( + "span", + { + style: + "margin-left:10px;font-size:0.85rem;color:var(--text-color-medium,#888);", + }, + _("No file selected"), + ); const textArea = E("textarea", { - class: "cbi-input-textarea", rows: 8, - style: "width:100%;font-family:monospace;font-size:0.85rem;margin-top:8px;resize:vertical;", - placeholder: "127.0.0.1 localhost\n192.168.1.100 server.local\n10.0.0.5 example.com alias.com", + class: "cbi-input-textarea", + rows: 8, + style: + "width:100%;font-family:monospace;font-size:0.85rem;margin-top:8px;resize:vertical;", + placeholder: + "127.0.0.1 localhost\n192.168.1.100 server.local\n10.0.0.5 example.com alias.com", }); const skipLoopbackCb = E("input", { - type: "checkbox", id: "hosts-skip-loopback", checked: true, + type: "checkbox", + id: "hosts-skip-loopback", + checked: true, style: "margin-right:6px;vertical-align:middle;", }); const overwriteCb = E("input", { - type: "checkbox", id: "hosts-overwrite", checked: false, + type: "checkbox", + id: "hosts-overwrite", + checked: false, style: "margin-right:6px;vertical-align:middle;", }); - const previewBox = E("div", { - style: "margin-top:12px;padding:10px 14px;background:var(--background-color-secondary,#f8f9fa);border:1px solid var(--border-color-medium,#e0e0e0);border-radius:4px;font-size:0.85rem;line-height:1.5;", - }, [E("em", {}, _("Paste content or upload a file to preview imported DNS records."))]); + const previewBox = E( + "div", + { + style: + "margin-top:12px;padding:10px 14px;background:var(--background-color-secondary,#f8f9fa);border:1px solid var(--border-color-medium,#e0e0e0);border-radius:4px;font-size:0.85rem;line-height:1.5;", + }, + [ + E( + "em", + {}, + _("Paste content or upload a file to preview imported DNS records."), + ), + ], + ); - const importBtn = E("button", { - class: "cbi-button cbi-button-action", type: "button", disabled: true, - }, _("Import")); + const importBtn = E( + "button", + { + class: "cbi-button cbi-button-action", + type: "button", + disabled: true, + }, + _("Import"), + ); let currentParsedResult = null; @@ -7594,7 +7737,15 @@ function createSectionContent(section) { if (!text.trim()) { previewBox.innerHTML = ""; - previewBox.appendChild(E("em", {}, _("Paste content or upload a file to preview imported DNS records."))); + previewBox.appendChild( + E( + "em", + {}, + _( + "Paste content or upload a file to preview imported DNS records.", + ), + ), + ); importBtn.disabled = true; importBtn.textContent = _("Import"); currentParsedResult = null; @@ -7605,45 +7756,82 @@ function createSectionContent(section) { currentParsedResult = result; let existingEntries = []; - const uiEl = dnsHostsOptRef ? dnsHostsOptRef.getUIElement(section_id) : null; + const uiEl = dnsHostsOptRef + ? dnsHostsOptRef.getUIElement(section_id) + : null; if (uiEl && typeof uiEl.getValue === "function") { const v = uiEl.getValue(); - if (typeof v === "string" && v) existingEntries = v.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#")); + if (typeof v === "string" && v) + existingEntries = v + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); } if (!existingEntries.length) { const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts"); - existingEntries = Array.isArray(uciVal) ? uciVal : uciVal ? [uciVal] : []; + existingEntries = Array.isArray(uciVal) + ? uciVal + : uciVal + ? [uciVal] + : []; } const domainMap = new Map(); if (!overwrite) { for (const entry of existingEntries) { const parts = `${entry}`.trim().split(/\s+/); - if (parts.length >= 2) domainMap.set(parts[0].toLowerCase(), parts[1]); + if (parts.length >= 2) + domainMap.set(parts[0].toLowerCase(), parts[1]); } } - let newCount = 0, updatedCount = 0; + let newCount = 0, + updatedCount = 0; for (const item of result.entries) { - if (domainMap.has(item.domain.toLowerCase())) updatedCount++; else newCount++; + if (domainMap.has(item.domain.toLowerCase())) updatedCount++; + else newCount++; domainMap.set(item.domain.toLowerCase(), item.ip); } previewBox.innerHTML = ""; - previewBox.appendChild(E("div", { - style: "display:grid;grid-template-columns:1fr 1fr;gap:6px;row-gap:4px;", - }, [ - E("div", {}, [E("strong", {}, _("Total lines: ")), `${result.totalLines}`]), - E("div", {}, [E("strong", {}, _("Parsed records: ")), `${result.entries.length}`]), - E("div", {}, [E("strong", {}, _("Skipped loopback: ")), `${result.skippedLoopback}`]), - E("div", {}, [E("strong", {}, _("Skipped comments/invalid: ")), `${result.skippedComments + result.skippedInvalid}`]), - E("div", {}, [E("strong", {}, _("New domains: ")), `${newCount}`]), - E("div", {}, [E("strong", {}, _("Updated/duplicates: ")), `${updatedCount}`]), - ])); + previewBox.appendChild( + E( + "div", + { + style: + "display:grid;grid-template-columns:1fr 1fr;gap:6px;row-gap:4px;", + }, + [ + E("div", {}, [ + E("strong", {}, _("Total lines: ")), + `${result.totalLines}`, + ]), + E("div", {}, [ + E("strong", {}, _("Parsed records: ")), + `${result.entries.length}`, + ]), + E("div", {}, [ + E("strong", {}, _("Skipped loopback: ")), + `${result.skippedLoopback}`, + ]), + E("div", {}, [ + E("strong", {}, _("Skipped comments/invalid: ")), + `${result.skippedComments + result.skippedInvalid}`, + ]), + E("div", {}, [E("strong", {}, _("New domains: ")), `${newCount}`]), + E("div", {}, [ + E("strong", {}, _("Updated/duplicates: ")), + `${updatedCount}`, + ]), + ], + ), + ); if (result.entries.length > 0) { importBtn.disabled = false; - importBtn.textContent = _("Import (%d records)").format(result.entries.length); + importBtn.textContent = _("Import (%d records)").format( + result.entries.length, + ); } else { importBtn.disabled = true; importBtn.textContent = _("Import"); @@ -7655,7 +7843,10 @@ function createSectionContent(section) { if (!file) return; fileStatus.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`; const reader = new FileReader(); - reader.onload = function (e) { textArea.value = e.target.result || ""; updatePreview(); }; + reader.onload = function (e) { + textArea.value = e.target.result || ""; + updatePreview(); + }; reader.readAsText(file); }); @@ -7667,67 +7858,132 @@ function createSectionContent(section) { if (!currentParsedResult || !currentParsedResult.entries.length) return; const overwrite = overwriteCb.checked; - const uiEl = dnsHostsOptRef ? dnsHostsOptRef.getUIElement(section_id) : null; + const uiEl = dnsHostsOptRef + ? dnsHostsOptRef.getUIElement(section_id) + : null; let existingEntries = []; if (uiEl && typeof uiEl.getValue === "function") { const v = uiEl.getValue(); - if (typeof v === "string" && v) existingEntries = v.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#")); + if (typeof v === "string" && v) + existingEntries = v + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); } if (!existingEntries.length) { const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts"); - existingEntries = Array.isArray(uciVal) ? uciVal : uciVal ? [uciVal] : []; + existingEntries = Array.isArray(uciVal) + ? uciVal + : uciVal + ? [uciVal] + : []; } const domainMap = new Map(); if (!overwrite) { for (const entry of existingEntries) { const parts = `${entry}`.trim().split(/\s+/); - if (parts.length >= 2) domainMap.set(parts[0].toLowerCase(), parts[1]); + if (parts.length >= 2) + domainMap.set(parts[0].toLowerCase(), parts[1]); } } - for (const item of currentParsedResult.entries) domainMap.set(item.domain.toLowerCase(), item.ip); + for (const item of currentParsedResult.entries) + domainMap.set(item.domain.toLowerCase(), item.ip); const finalEntries = []; - for (const [domain, ip] of domainMap.entries()) finalEntries.push(`${domain} ${ip}`); + for (const [domain, ip] of domainMap.entries()) + finalEntries.push(`${domain} ${ip}`); uci.set(UCI_PACKAGE, section_id, "dns_hosts", finalEntries.join("\n")); - if (uiEl && typeof uiEl.setValue === "function") uiEl.setValue(finalEntries.join("\n")); + if (uiEl && typeof uiEl.setValue === "function") + uiEl.setValue(finalEntries.join("\n")); ui.hideModal(); - ui.addNotification(null, _("Successfully imported %d DNS record(s)").format(finalEntries.length), "info"); + ui.addNotification( + null, + _("Successfully imported %d DNS record(s)").format(finalEntries.length), + "info", + ); }); ui.showModal(_("Import Hosts File"), [ E("div", { class: "cbi-map" }, [ - E("div", { style: "margin-bottom:12px;display:flex;align-items:center;flex-wrap:wrap;gap:8px;" }, [ - fileInput, fileSelectBtn, loadCurrentBtn, fileStatus, - ]), - E("label", { style: "display:block;margin-bottom:4px;font-weight:bold;" }, _("Or paste hosts file content:")), + E( + "div", + { + style: + "margin-bottom:12px;display:flex;align-items:center;flex-wrap:wrap;gap:8px;", + }, + [fileInput, fileSelectBtn, loadCurrentBtn, fileStatus], + ), + E( + "label", + { style: "display:block;margin-bottom:4px;font-weight:bold;" }, + _("Or paste hosts file content:"), + ), textArea, E("div", { style: "margin-top:10px;" }, [ - E("label", { style: "display:flex;align-items:center;cursor:pointer;margin-bottom:6px;" }, [ - skipLoopbackCb, - E("span", {}, _("Skip loopback & local entries (127.0.0.1, ::1, 0.0.0.0, localhost)")), - ]), - E("label", { style: "display:flex;align-items:center;cursor:pointer;" }, [ - overwriteCb, - E("span", {}, _("Overwrite existing records (if unchecked, merge with existing records)")), - ]), + E( + "label", + { + style: + "display:flex;align-items:center;cursor:pointer;margin-bottom:6px;", + }, + [ + skipLoopbackCb, + E( + "span", + {}, + _( + "Skip loopback & local entries (127.0.0.1, ::1, 0.0.0.0, localhost)", + ), + ), + ], + ), + E( + "label", + { style: "display:flex;align-items:center;cursor:pointer;" }, + [ + overwriteCb, + E( + "span", + {}, + _( + "Overwrite existing records (if unchecked, merge with existing records)", + ), + ), + ], + ), ]), previewBox, ]), E("div", { class: "button-row", style: "margin-top:16px;" }, [ importBtn, - E("button", { class: "btn cbi-button cbi-button-neutral", click: ui.createHandlerFn(this, function () { ui.hideModal(); }) }, _("Cancel")), + E( + "button", + { + class: "btn cbi-button cbi-button-neutral", + click: ui.createHandlerFn(this, function () { + ui.hideModal(); + }), + }, + _("Cancel"), + ), ]), ]); } - // ─── Hosts Section Options ────────────────────────────────────────────── - o = section.taboption("settings", form.TextValue, "dns_hosts", _("Custom DNS Records (Hosts)"), - _("One record per line: example.com 192.168.1.100 or 192.168.1.100 example.com. Lines starting with # are ignored.")); + o = section.taboption( + "settings", + form.TextValue, + "dns_hosts", + _("Custom DNS Records (Hosts)"), + _( + "One record per line: example.com 192.168.1.100 or 192.168.1.100 example.com. Lines starting with # are ignored.", + ), + ); o.depends("action", "hosts"); o.rows = 8; o.wrap = "soft"; @@ -7740,9 +7996,10 @@ function createSectionContent(section) { return String(val || ""); }; o.write = function (section_id, value) { - const lines = String(value || "").split("\n") - .map(l => l.trim()) - .filter(l => l && !l.startsWith("#")); + const lines = String(value || "") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); if (lines.length === 0) { uci.unset(UCI_PACKAGE, section_id, "dns_hosts"); } else { @@ -7769,8 +8026,14 @@ function createSectionContent(section) { }; const _origDnsHostsRenderWidget = o.renderWidget; o.renderWidget = function (section_id, option_index, cfgvalue) { - const node = _origDnsHostsRenderWidget.call(this, section_id, option_index, cfgvalue); - const ta = node.tagName === "TEXTAREA" ? node : node.querySelector("textarea"); + const node = _origDnsHostsRenderWidget.call( + this, + section_id, + option_index, + cfgvalue, + ); + const ta = + node.tagName === "TEXTAREA" ? node : node.querySelector("textarea"); if (ta) { ta.style.fontFamily = "monospace"; ta.style.fontSize = "0.85rem"; @@ -7779,12 +8042,15 @@ function createSectionContent(section) { } const countBadge = E("span", { - style: "font-weight:600;font-size:0.85rem;margin-left:auto;color:var(--text-color-medium,#666);", + style: + "font-weight:600;font-size:0.85rem;margin-left:auto;color:var(--text-color-medium,#666);", }); function updateCount() { const text = ta ? ta.value : ""; - const validLines = text.split("\n").filter(l => l.trim() && !l.trim().startsWith("#")); + const validLines = text + .split("\n") + .filter((l) => l.trim() && !l.trim().startsWith("#")); countBadge.textContent = _("Records: %d").format(validLines.length); } @@ -7793,59 +8059,81 @@ function createSectionContent(section) { setTimeout(updateCount, 50); } - const sortBtn = E("button", { - class: "cbi-button cbi-button-neutral", - type: "button", - style: "margin-left:6px;", - click: function () { - if (!ta) return; - const lines = ta.value.split("\n") - .map(l => l.trim()) - .filter(Boolean); - const comments = lines.filter(l => l.startsWith("#")); - const records = lines.filter(l => !l.startsWith("#")).sort(); - ta.value = [...comments, ...records].join("\n"); - updateCount(); + const sortBtn = E( + "button", + { + class: "cbi-button cbi-button-neutral", + type: "button", + style: "margin-left:6px;", + click: function () { + if (!ta) return; + const lines = ta.value + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + const comments = lines.filter((l) => l.startsWith("#")); + const records = lines.filter((l) => !l.startsWith("#")).sort(); + ta.value = [...comments, ...records].join("\n"); + updateCount(); + }, }, - }, _("Sort Alphabetically")); + _("Sort Alphabetically"), + ); - const cleanBtn = E("button", { - class: "cbi-button cbi-button-neutral", - type: "button", - style: "margin-left:6px;", - click: function () { - if (!ta) return; - const lines = ta.value.split("\n") - .map(l => l.trim()) - .filter(l => l && !l.startsWith("#")); - ta.value = lines.join("\n"); - updateCount(); + const cleanBtn = E( + "button", + { + class: "cbi-button cbi-button-neutral", + type: "button", + style: "margin-left:6px;", + click: function () { + if (!ta) return; + const lines = ta.value + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); + ta.value = lines.join("\n"); + updateCount(); + }, }, - }, _("Clean Comments")); + _("Clean Comments"), + ); - const importHostsBtn = E("button", { - class: "cbi-button cbi-button-action", - type: "button", - style: "margin-left:6px;", - click: function () { - showImportHostsModal(section_id, o); + const importHostsBtn = E( + "button", + { + class: "cbi-button cbi-button-action", + type: "button", + style: "margin-left:6px;", + click: function () { + showImportHostsModal(section_id, o); + }, }, - }, _("Import Hosts File")); + _("Import Hosts File"), + ); return E("div", {}, [ node, - E("div", { style: "display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin-top:8px;" }, [ - sortBtn, - cleanBtn, - importHostsBtn, - countBadge, - ]), + E( + "div", + { + style: + "display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin-top:8px;", + }, + [sortBtn, cleanBtn, importHostsBtn, countBadge], + ), ]); }; - - o = section.taboption("settings", form.DynamicList, "hosts_list_urls", _("Remote Hosts Lists"), - _("URLs of remote hosts files to download and apply. Supports standard hosts format: IP domain.")); + o = section.taboption( + "settings", + form.DynamicList, + "hosts_list_urls", + _("Remote Hosts Lists"), + _( + "URLs of remote hosts files to download and apply. Supports standard hosts format: IP domain.", + ), + ); o.depends("action", "hosts"); o.placeholder = "https://raw.githubusercontent.com/.../hosts"; o.modalonly = true; @@ -7869,7 +8157,12 @@ function createSectionContent(section) { } function setDisabled(disabled) { - uci.set(pkg, section_id, "hosts_list_disabled", disabled.length ? disabled : null); + uci.set( + pkg, + section_id, + "hosts_list_disabled", + disabled.length ? disabled : null, + ); } const container = E("div", { class: "cbi-section-node" }); @@ -7879,14 +8172,20 @@ function createSectionContent(section) { const urls = getUrls(); const disabled = getDisabled(); - const table = E("table", { class: "cbi-section-table", style: "width:100%;" }); + const table = E("table", { + class: "cbi-section-table", + style: "width:100%;", + }); const tbody = E("tbody"); urls.forEach(function (url, idx) { const isOff = disabled.includes(url); const tr = E("tr", { class: "cbi-section-table-row" }); - const tdCheck = E("td", { class: "cbi-section-table-cell", style: "width:30px;text-align:center;" }); + const tdCheck = E("td", { + class: "cbi-section-table-cell", + style: "width:30px;text-align:center;", + }); const cb = E("input", { type: "checkbox", class: "cbi-input-checkbox", @@ -7895,7 +8194,9 @@ function createSectionContent(section) { const nowEnabled = this.checked; let d = getDisabled(); if (nowEnabled) { - d = d.filter(function (x) { return x !== url; }); + d = d.filter(function (x) { + return x !== url; + }); } else { if (!d.includes(url)) d.push(url); } @@ -7908,34 +8209,56 @@ function createSectionContent(section) { tr.appendChild(tdCheck); const tdUrl = E("td", { class: "cbi-section-table-cell" }); - const urlSpan = E("span", { - style: isOff ? "text-decoration:line-through;opacity:0.6;" : "", - }, url); + const urlSpan = E( + "span", + { + style: isOff ? "text-decoration:line-through;opacity:0.6;" : "", + }, + url, + ); tdUrl.appendChild(urlSpan); tr.appendChild(tdUrl); - const tdStatus = E("td", { class: "cbi-section-table-cell", style: "width:60px;text-align:center;white-space:nowrap;" }); - tdStatus.appendChild(E("span", { - style: isOff ? "color:#e53935;" : "color:#2e7d32;", - }, isOff ? "OFF" : "ON")); + const tdStatus = E("td", { + class: "cbi-section-table-cell", + style: "width:60px;text-align:center;white-space:nowrap;", + }); + tdStatus.appendChild( + E( + "span", + { + style: isOff ? "color:#e53935;" : "color:#2e7d32;", + }, + isOff ? "OFF" : "ON", + ), + ); tr.appendChild(tdStatus); - const tdDel = E("td", { class: "cbi-section-table-cell", style: "width:40px;text-align:center;" }); - const delBtn = E("button", { - class: "btn cbi-button cbi-button-remove", - type: "button", - title: _("Remove"), - click: function () { - let u = getUrls(); - let d = getDisabled(); - u.splice(idx, 1); - d = d.filter(function (x) { return x !== url; }); - setUrls(u); - setDisabled(d); - uci.save(); - render(); + const tdDel = E("td", { + class: "cbi-section-table-cell", + style: "width:40px;text-align:center;", + }); + const delBtn = E( + "button", + { + class: "btn cbi-button cbi-button-remove", + type: "button", + title: _("Remove"), + click: function () { + let u = getUrls(); + let d = getDisabled(); + u.splice(idx, 1); + d = d.filter(function (x) { + return x !== url; + }); + setUrls(u); + setDisabled(d); + uci.save(); + render(); + }, }, - }, "\u2715"); + "\u2715", + ); tdDel.appendChild(delBtn); tr.appendChild(tdDel); @@ -7945,7 +8268,10 @@ function createSectionContent(section) { table.appendChild(tbody); container.appendChild(table); - const addRow = E("div", { class: "cbi-section-create", style: "margin-top:8px;display:flex;gap:4px;align-items:center;" }); + const addRow = E("div", { + class: "cbi-section-create", + style: "margin-top:8px;display:flex;gap:4px;align-items:center;", + }); const input = E("input", { type: "text", @@ -7954,21 +8280,28 @@ function createSectionContent(section) { style: "flex:1;", }); - const addBtn = E("button", { - class: "btn cbi-button cbi-button-action", - type: "button", - click: function () { - const val = (input.value || "").trim(); - if (!val) return; - let u = getUrls(); - if (u.includes(val)) { input.value = ""; return; } - u.push(val); - setUrls(u); - uci.save(); - input.value = ""; - render(); + const addBtn = E( + "button", + { + class: "btn cbi-button cbi-button-action", + type: "button", + click: function () { + const val = (input.value || "").trim(); + if (!val) return; + let u = getUrls(); + if (u.includes(val)) { + input.value = ""; + return; + } + u.push(val); + setUrls(u); + uci.save(); + input.value = ""; + render(); + }, }, - }, "+"); + "+", + ); input.addEventListener("keydown", function (ev) { if (ev.key === "Enter") addBtn.click(); @@ -7983,16 +8316,25 @@ function createSectionContent(section) { return container; }; - - o = section.taboption("settings", form.Flag, "hosts_list_auto_update", _("Auto-update Hosts Lists"), - _("Automatically update remote hosts lists on schedule")); + o = section.taboption( + "settings", + form.Flag, + "hosts_list_auto_update", + _("Auto-update Hosts Lists"), + _("Automatically update remote hosts lists on schedule"), + ); o.depends("action", "hosts"); o.default = "0"; o.rmempty = false; o.modalonly = true; - o = section.taboption("settings", form.Value, "hosts_list_update_interval", _("Hosts Lists Update Interval"), - _("Use sing-box duration format like 1d, 12h or 6h")); + o = section.taboption( + "settings", + form.Value, + "hosts_list_update_interval", + _("Hosts Lists Update Interval"), + _("Use sing-box duration format like 1d, 12h or 6h"), + ); o.depends("action", "hosts"); o.depends({ action: "hosts", hosts_list_auto_update: "1" }); o.placeholder = "24h"; @@ -8006,20 +8348,21 @@ function createSectionContent(section) { : _("Use sing-box duration format like 1d, 12h or 6h"); }; - o = section.taboption( "settings", form.Button, "_generate_warp", _("AmneziaWG Generator"), - _("Automatically generate a Cloudflare WARP account and configure all AmneziaWG settings.") + _( + "Automatically generate a Cloudflare WARP account and configure all AmneziaWG settings.", + ), ); o.modalonly = true; o.inputtitle = _("Generate WARP Config"); o.depends("action", "awg"); // Custom render: beautiful gradient button with loading / success / error states - o.renderWidget = function(section_id) { + o.renderWidget = function (section_id) { if (!document.getElementById("tachyon-warp-btn-css")) { const s = document.createElement("style"); s.id = "tachyon-warp-btn-css"; @@ -8051,47 +8394,64 @@ function createSectionContent(section) { document.head.appendChild(s); } - const spinner = E("span", { "class": "twg-spinner" }); - const icon = E("span", { "class": "twg-icon" }, ["↺"]); - const label = E("span", { "class": "twg-label" }, [_("Generate WARP Config")]); - const btn = E("button", { - "class": "btn cbi-button cbi-button-neutral twg-btn", - "type": "button", - "style": "display:inline-flex;align-items:center;gap:8px;" - }, [spinner, icon, label]); + const spinner = E("span", { class: "twg-spinner" }); + const icon = E("span", { class: "twg-icon" }, ["↺"]); + const label = E("span", { class: "twg-label" }, [ + _("Generate WARP Config"), + ]); + const btn = E( + "button", + { + class: "btn cbi-button cbi-button-neutral twg-btn", + type: "button", + style: "display:inline-flex;align-items:center;gap:8px;", + }, + [spinner, icon, label], + ); - btn.addEventListener("click", (ev) => this.onclick && this.onclick.call(this, ev, section_id)); + btn.addEventListener( + "click", + (ev) => this.onclick && this.onclick.call(this, ev, section_id), + ); return btn; }; - - - o.onclick = function(ev, section_id) { - const btn = ev.target.closest(".twg-btn"); + o.onclick = function (ev, section_id) { + const btn = ev.target.closest(".twg-btn"); const label = btn ? btn.querySelector(".twg-label") : null; const origText = _("Generate WARP Config"); const setState = (state, text) => { if (!btn) return; - btn.className = "btn cbi-button cbi-button-neutral twg-btn" + (state ? " twg-" + state : ""); - btn.disabled = (state === "loading"); + btn.className = + "btn cbi-button cbi-button-neutral twg-btn" + + (state ? " twg-" + state : ""); + btn.disabled = state === "loading"; if (label && text) label.textContent = text; }; const showStatusModal = (title, msg, isError) => - ui.addNotification(title, E("p", {}, msg), isError ? "danger" : "success"); + ui.addNotification( + title, + E("p", {}, msg), + isError ? "danger" : "success", + ); - const resetAfter = (ms) => - setTimeout(() => setState("", origText), ms); + const resetAfter = (ms) => setTimeout(() => setState("", origText), ms); setState("loading", _("Generating…")); fs.exec("/usr/bin/tachyon", ["generate_warp"]) - .then(function(response) { + .then(function (response) { if (!response || (response.code ?? 0) !== 0 || !response.stdout) { setState("error", _("Error")); resetAfter(2500); - showStatusModal(_("Error"), _("Failed to generate WARP config: ") + (response?.stderr || "Unknown error"), true); + showStatusModal( + _("Error"), + _("Failed to generate WARP config: ") + + (response?.stderr || "Unknown error"), + true, + ); return; } @@ -8100,67 +8460,91 @@ function createSectionContent(section) { if (!data.success) { setState("error", _("Error")); resetAfter(2500); - showStatusModal(_("Error"), _("Failed to generate WARP config: ") + (data.message || "Unknown error"), true); + showStatusModal( + _("Error"), + _("Failed to generate WARP config: ") + + (data.message || "Unknown error"), + true, + ); return; } const getControlWidget = (opt) => { - const widget = document.getElementById(`widget.cbid.${UCI_PACKAGE}.${section_id}.${opt}`); + const widget = document.getElementById( + `widget.cbid.${UCI_PACKAGE}.${section_id}.${opt}`, + ); if (!widget) return null; if (/^(INPUT|SELECT|TEXTAREA)$/.test(widget.tagName)) return widget; - return widget.querySelector("input:not([type='hidden']), select, textarea"); + return widget.querySelector( + "input:not([type='hidden']), select, textarea", + ); }; const stripUciBinary = (val) => { if (typeof val !== "string") return val; - return val.replace(//g, "").replace(/\s/g, ""); + return val + .replace(//g, "") + .replace(/\s/g, ""); }; const setWidgetValue = (opt, val) => { const widget = getControlWidget(opt); if (widget) { widget.value = val; - widget.dispatchEvent(new Event("input", { bubbles: true })); + widget.dispatchEvent(new Event("input", { bubbles: true })); widget.dispatchEvent(new Event("change", { bubbles: true })); } uci.set(UCI_PACKAGE, section_id, opt, val); }; - setWidgetValue("awg_local_address", data.local_address); - setWidgetValue("awg_private_key", data.private_key); + setWidgetValue("awg_local_address", data.local_address); + setWidgetValue("awg_private_key", data.private_key); setWidgetValue("awg_peer_public_key", data.peer_public_key); - setWidgetValue("awg_server_address", data.server_address); - setWidgetValue("awg_server_port", data.server_port); - setWidgetValue("awg_jc", `${data.awg_jc}`); + setWidgetValue("awg_server_address", data.server_address); + setWidgetValue("awg_server_port", data.server_port); + setWidgetValue("awg_jc", `${data.awg_jc}`); setWidgetValue("awg_jmin", `${data.awg_jmin}`); setWidgetValue("awg_jmax", `${data.awg_jmax}`); - setWidgetValue("awg_s1", `${data.awg_s1}`); - setWidgetValue("awg_s2", `${data.awg_s2}`); - setWidgetValue("awg_h1", `${data.awg_h1}`); - setWidgetValue("awg_h2", `${data.awg_h2}`); - setWidgetValue("awg_h3", `${data.awg_h3}`); - setWidgetValue("awg_h4", `${data.awg_h4}`); - setWidgetValue("awg_s3", `${data.awg_s3}`); - setWidgetValue("awg_s4", `${data.awg_s4}`); - setWidgetValue("awg_i1", stripUciBinary(data.awg_i1)); - setWidgetValue("awg_i2", stripUciBinary(data.awg_i2)); - setWidgetValue("awg_i3", stripUciBinary(data.awg_i3)); - setWidgetValue("awg_i4", stripUciBinary(data.awg_i4)); - setWidgetValue("awg_i5", stripUciBinary(data.awg_i5)); + setWidgetValue("awg_s1", `${data.awg_s1}`); + setWidgetValue("awg_s2", `${data.awg_s2}`); + setWidgetValue("awg_h1", `${data.awg_h1}`); + setWidgetValue("awg_h2", `${data.awg_h2}`); + setWidgetValue("awg_h3", `${data.awg_h3}`); + setWidgetValue("awg_h4", `${data.awg_h4}`); + setWidgetValue("awg_s3", `${data.awg_s3}`); + setWidgetValue("awg_s4", `${data.awg_s4}`); + setWidgetValue("awg_i1", stripUciBinary(data.awg_i1)); + setWidgetValue("awg_i2", stripUciBinary(data.awg_i2)); + setWidgetValue("awg_i3", stripUciBinary(data.awg_i3)); + setWidgetValue("awg_i4", stripUciBinary(data.awg_i4)); + setWidgetValue("awg_i5", stripUciBinary(data.awg_i5)); setState("success", _("Generated!")); resetAfter(2500); - showStatusModal(_("Success"), _("WARP Configuration generated and loaded successfully!"), false); + showStatusModal( + _("Success"), + _("WARP Configuration generated and loaded successfully!"), + false, + ); } catch (err) { setState("error", _("Error")); resetAfter(2500); - showStatusModal(_("Error"), _("Failed to parse WARP config: ") + err.message, true); + showStatusModal( + _("Error"), + _("Failed to parse WARP config: ") + err.message, + true, + ); } }) - .catch(function(error) { + .catch(function (error) { setState("error", _("Error")); resetAfter(2500); - showStatusModal(_("Error"), _("Error running generator: ") + error.message, true); + showStatusModal( + _("Error"), + _("Error running generator: ") + error.message, + true, + ); }); }; @@ -8170,12 +8554,12 @@ function createSectionContent(section) { form.Button, "_load_awg_conf", _("Load .conf config"), - _("Import AmneziaWG settings from .conf file") + _("Import AmneziaWG settings from .conf file"), ); o.modalonly = true; o.depends("action", "awg"); - o.renderWidget = function(section_id) { + o.renderWidget = function (section_id) { // Parse INI-style .conf into { sectionName: { key: value } } const parseConf = (text) => { const out = {}; @@ -8184,7 +8568,11 @@ function createSectionContent(section) { const line = raw.trim(); if (!line || line.startsWith("#")) continue; const sm = line.match(/^\[(\w+)\]$/); - if (sm) { cur = sm[1].toLowerCase(); out[cur] = out[cur] || {}; continue; } + if (sm) { + cur = sm[1].toLowerCase(); + out[cur] = out[cur] || {}; + continue; + } const kv = line.match(/^(\w+)\s*=\s*(.+)$/); if (kv && cur) out[cur][kv[1]] = kv[2].trim(); } @@ -8193,43 +8581,67 @@ function createSectionContent(section) { // Add /32 or /128 prefix if missing; join with space const normalizeAddr = (str) => - str.split(",").map(s => s.trim()).filter(Boolean).map(a => - a.includes("/") ? a : (a.includes(":") ? a + "/128" : a + "/32") - ).join(" "); + str + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((a) => + a.includes("/") ? a : a.includes(":") ? a + "/128" : a + "/32", + ) + .join(" "); // Strip UCI binary literal format: → HEX const stripBin = (v) => - typeof v === "string" ? v.replace(/^$/, "").trim() : ""; + typeof v === "string" + ? v + .replace(/^$/, "") + .trim() + : ""; // Write value into a form widget and the UCI cache const setVal = (opt, val) => { if (val === undefined || val === null) return; const str = String(val); - const el = document.getElementById(`widget.cbid.${UCI_PACKAGE}.${section_id}.${opt}`); - const w = el && (/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName) - ? el - : el.querySelector("input:not([type='hidden']),select,textarea")); + const el = document.getElementById( + `widget.cbid.${UCI_PACKAGE}.${section_id}.${opt}`, + ); + const w = + el && + (/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName) + ? el + : el.querySelector("input:not([type='hidden']),select,textarea")); if (w) { w.value = str; - w.dispatchEvent(new Event("input", { bubbles: true })); + w.dispatchEvent(new Event("input", { bubbles: true })); w.dispatchEvent(new Event("change", { bubbles: true })); } uci.set(UCI_PACKAGE, section_id, opt, str); }; // Hidden file picker - const fileInput = E("input", { "type": "file", "accept": ".conf", "style": "display:none" }); + const fileInput = E("input", { + type: "file", + accept: ".conf", + style: "display:none", + }); - const icon = E("span", {}, ["📂"]); - const label = E("span", { "class": "twg-label" }, [_("Load .conf")]); - const btn = E("button", { - "class": "btn cbi-button cbi-button-neutral twg-btn", - "type": "button", - "style": "display:inline-flex;align-items:center;gap:8px;" - }, [icon, label]); + const icon = E("span", {}, ["📂"]); + const label = E("span", { class: "twg-label" }, [_("Load .conf")]); + const btn = E( + "button", + { + class: "btn cbi-button cbi-button-neutral twg-btn", + type: "button", + style: "display:inline-flex;align-items:center;gap:8px;", + }, + [icon, label], + ); const setBtn = (state, text) => { - btn.className = "btn cbi-button cbi-button-neutral twg-btn" + (state ? " twg-" + state : ""); + btn.className = + "btn cbi-button cbi-button-neutral twg-btn" + + (state ? " twg-" + state : ""); label.textContent = text || _("Load .conf"); }; @@ -8239,7 +8651,11 @@ function createSectionContent(section) { if (!file) return; if (!file.name.endsWith(".conf")) { - ui.addNotification(_("Error"), E("p", {}, _("Select a file with .conf extension")), "danger"); + ui.addNotification( + _("Error"), + E("p", {}, _("Select a file with .conf extension")), + "danger", + ); return; } @@ -8252,53 +8668,63 @@ function createSectionContent(section) { } catch (err) { setBtn("error", _("Error")); setTimeout(() => setBtn("", null), 2500); - ui.addNotification(_("Error"), E("p", {}, _("Failed to parse file: ") + err.message), "danger"); + ui.addNotification( + _("Error"), + E("p", {}, _("Failed to parse file: ") + err.message), + "danger", + ); return; } const iface = cfg.interface || {}; - const peer = cfg.peer || {}; + const peer = cfg.peer || {}; // --- [Interface] --- - if (iface.Address) setVal("awg_local_address", normalizeAddr(iface.Address)); - if (iface.PrivateKey) setVal("awg_private_key", iface.PrivateKey); - if (iface.MTU !== undefined) setVal("awg_mtu", iface.MTU); - if (peer.PersistentKeepalive) setVal("awg_keepalive", peer.PersistentKeepalive); - if (iface.Jc !== undefined) setVal("awg_jc", iface.Jc); - if (iface.Jmin !== undefined) setVal("awg_jmin", iface.Jmin); - if (iface.Jmax !== undefined) setVal("awg_jmax", iface.Jmax); - if (iface.S1 !== undefined) setVal("awg_s1", iface.S1); - if (iface.S2 !== undefined) setVal("awg_s2", iface.S2); - if (iface.S3 !== undefined) setVal("awg_s3", iface.S3); - if (iface.S4 !== undefined) setVal("awg_s4", iface.S4); - if (iface.H1 !== undefined) setVal("awg_h1", iface.H1); - if (iface.H2 !== undefined) setVal("awg_h2", iface.H2); - if (iface.H3 !== undefined) setVal("awg_h3", iface.H3); - if (iface.H4 !== undefined) setVal("awg_h4", iface.H4); - if (iface.I1) setVal("awg_i1", stripBin(iface.I1)); - if (iface.I2) setVal("awg_i2", stripBin(iface.I2)); - if (iface.I3) setVal("awg_i3", stripBin(iface.I3)); - if (iface.I4) setVal("awg_i4", stripBin(iface.I4)); - if (iface.I5) setVal("awg_i5", stripBin(iface.I5)); - if (iface.J1) setVal("awg_j1", stripBin(iface.J1)); - if (iface.J2) setVal("awg_j2", stripBin(iface.J2)); - if (iface.J3) setVal("awg_j3", stripBin(iface.J3)); - if (iface.Itime !== undefined) setVal("awg_itime", iface.Itime); + if (iface.Address) + setVal("awg_local_address", normalizeAddr(iface.Address)); + if (iface.PrivateKey) setVal("awg_private_key", iface.PrivateKey); + if (iface.MTU !== undefined) setVal("awg_mtu", iface.MTU); + if (peer.PersistentKeepalive) + setVal("awg_keepalive", peer.PersistentKeepalive); + if (iface.Jc !== undefined) setVal("awg_jc", iface.Jc); + if (iface.Jmin !== undefined) setVal("awg_jmin", iface.Jmin); + if (iface.Jmax !== undefined) setVal("awg_jmax", iface.Jmax); + if (iface.S1 !== undefined) setVal("awg_s1", iface.S1); + if (iface.S2 !== undefined) setVal("awg_s2", iface.S2); + if (iface.S3 !== undefined) setVal("awg_s3", iface.S3); + if (iface.S4 !== undefined) setVal("awg_s4", iface.S4); + if (iface.H1 !== undefined) setVal("awg_h1", iface.H1); + if (iface.H2 !== undefined) setVal("awg_h2", iface.H2); + if (iface.H3 !== undefined) setVal("awg_h3", iface.H3); + if (iface.H4 !== undefined) setVal("awg_h4", iface.H4); + if (iface.I1) setVal("awg_i1", stripBin(iface.I1)); + if (iface.I2) setVal("awg_i2", stripBin(iface.I2)); + if (iface.I3) setVal("awg_i3", stripBin(iface.I3)); + if (iface.I4) setVal("awg_i4", stripBin(iface.I4)); + if (iface.I5) setVal("awg_i5", stripBin(iface.I5)); + if (iface.J1) setVal("awg_j1", stripBin(iface.J1)); + if (iface.J2) setVal("awg_j2", stripBin(iface.J2)); + if (iface.J3) setVal("awg_j3", stripBin(iface.J3)); + if (iface.Itime !== undefined) setVal("awg_itime", iface.Itime); // --- [Peer] --- - if (peer.PublicKey) setVal("awg_peer_public_key", peer.PublicKey); - if (peer.PresharedKey) setVal("awg_preshared_key", peer.PresharedKey); + if (peer.PublicKey) setVal("awg_peer_public_key", peer.PublicKey); + if (peer.PresharedKey) setVal("awg_preshared_key", peer.PresharedKey); if (peer.Endpoint) { const i = peer.Endpoint.lastIndexOf(":"); if (i > 0) { setVal("awg_server_address", peer.Endpoint.slice(0, i)); - setVal("awg_server_port", peer.Endpoint.slice(i + 1)); + setVal("awg_server_port", peer.Endpoint.slice(i + 1)); } } setBtn("success", _("Loaded!")); setTimeout(() => setBtn("", null), 2000); - ui.addNotification(_("Done"), E("p", {}, _("AmneziaWG config loaded successfully!")), "success"); + ui.addNotification( + _("Done"), + E("p", {}, _("AmneziaWG config loaded successfully!")), + "success", + ); }; reader.onerror = () => { @@ -8311,7 +8737,7 @@ function createSectionContent(section) { btn.addEventListener("click", () => fileInput.click()); - return E("div", { "style": "display:contents" }, [fileInput, btn]); + return E("div", { style: "display:contents" }, [fileInput, btn]); }; o = section.taboption( @@ -8388,7 +8814,12 @@ function createSectionContent(section) { o.rmempty = true; o.depends("action", "awg"); - o = section.taboption("settings", form.Value, "awg_keepalive", _("Persistent Keepalive")); + o = section.taboption( + "settings", + form.Value, + "awg_keepalive", + _("Persistent Keepalive"), + ); o.datatype = "uinteger"; o.placeholder = "25"; o.modalonly = true; @@ -8404,22 +8835,22 @@ function createSectionContent(section) { return opt; }; - addAwgParam("awg_jc", _("Junk Packet Count (Jc)"), "", "120"); - addAwgParam("awg_jmin", _("Junk Min Size (Jmin)"), "", "23"); - addAwgParam("awg_jmax", _("Junk Max Size (Jmax)"), "", "911"); - addAwgParam("awg_s1", _("Init Packet Magic Header (S1)"), "", "0"); - addAwgParam("awg_s2", _("Response Packet Magic Header (S2)"), "", "0"); - addAwgParam("awg_s3", _("Init Packet Magic Header (S3)"), "", "0"); - addAwgParam("awg_s4", _("Response Packet Magic Header (S4)"), "", "0"); - addAwgParam("awg_h1", _("Underload Packet Magic Header (H1)"), "", "1"); - addAwgParam("awg_h2", _("Underload Packet Magic Header (H2)"), "", "2"); - addAwgParam("awg_h3", _("Underload Packet Magic Header (H3)"), "", "3"); - addAwgParam("awg_h4", _("Underload Packet Magic Header (H4)"), "", "4"); - addAwgParam("awg_i1", _("Init Packet Payload (I1)"), "", "0"); - addAwgParam("awg_i2", _("Init Packet Payload (I2)"), "", "0"); - addAwgParam("awg_i3", _("Init Packet Payload (I3)"), "", "0"); - addAwgParam("awg_i4", _("Init Packet Payload (I4)"), "", "0"); - addAwgParam("awg_i5", _("Init Packet Payload (I5)"), "", "0"); + addAwgParam("awg_jc", _("Junk Packet Count (Jc)"), "", "120"); + addAwgParam("awg_jmin", _("Junk Min Size (Jmin)"), "", "23"); + addAwgParam("awg_jmax", _("Junk Max Size (Jmax)"), "", "911"); + addAwgParam("awg_s1", _("Init Packet Magic Header (S1)"), "", "0"); + addAwgParam("awg_s2", _("Response Packet Magic Header (S2)"), "", "0"); + addAwgParam("awg_s3", _("Init Packet Magic Header (S3)"), "", "0"); + addAwgParam("awg_s4", _("Response Packet Magic Header (S4)"), "", "0"); + addAwgParam("awg_h1", _("Underload Packet Magic Header (H1)"), "", "1"); + addAwgParam("awg_h2", _("Underload Packet Magic Header (H2)"), "", "2"); + addAwgParam("awg_h3", _("Underload Packet Magic Header (H3)"), "", "3"); + addAwgParam("awg_h4", _("Underload Packet Magic Header (H4)"), "", "4"); + addAwgParam("awg_i1", _("Init Packet Payload (I1)"), "", "0"); + addAwgParam("awg_i2", _("Init Packet Payload (I2)"), "", "0"); + addAwgParam("awg_i3", _("Init Packet Payload (I3)"), "", "0"); + addAwgParam("awg_i4", _("Init Packet Payload (I4)"), "", "0"); + addAwgParam("awg_i5", _("Init Packet Payload (I5)"), "", "0"); // ── WARP (Cloudflare WARP via sing-box-extended) ────────────────────────── @@ -8477,19 +8908,34 @@ function createSectionContent(section) { // ── AnyTLS (sing-box-extended) ──────────────────────────────────────────── - o = section.taboption("settings", form.Value, "anytls_server", _("Server Address")); + o = section.taboption( + "settings", + form.Value, + "anytls_server", + _("Server Address"), + ); o.modalonly = true; o.rmempty = false; o.depends("action", "anytls"); o.validate = validateRequiredText; - o = section.taboption("settings", form.Value, "anytls_server_port", _("Server Port")); + o = section.taboption( + "settings", + form.Value, + "anytls_server_port", + _("Server Port"), + ); o.modalonly = true; o.rmempty = false; o.datatype = "port"; o.depends("action", "anytls"); - o = section.taboption("settings", form.Value, "anytls_password", _("Password")); + o = section.taboption( + "settings", + form.Value, + "anytls_password", + _("Password"), + ); o.modalonly = true; o.rmempty = false; o.password = true; @@ -8520,26 +8966,47 @@ function createSectionContent(section) { // ── Snell (sing-box-extended) ───────────────────────────────────────────── - o = section.taboption("settings", form.Value, "snell_server", _("Server Address")); + o = section.taboption( + "settings", + form.Value, + "snell_server", + _("Server Address"), + ); o.modalonly = true; o.rmempty = false; o.depends("action", "snell"); o.validate = validateRequiredText; - o = section.taboption("settings", form.Value, "snell_server_port", _("Server Port")); + o = section.taboption( + "settings", + form.Value, + "snell_server_port", + _("Server Port"), + ); o.modalonly = true; o.rmempty = false; o.datatype = "port"; o.depends("action", "snell"); - o = section.taboption("settings", form.Value, "snell_psk", _("PSK"), _("Pre-shared key")); + o = section.taboption( + "settings", + form.Value, + "snell_psk", + _("PSK"), + _("Pre-shared key"), + ); o.modalonly = true; o.rmempty = false; o.password = true; o.depends("action", "snell"); o.validate = validateRequiredText; - o = section.taboption("settings", form.ListValue, "snell_version", _("Snell Version")); + o = section.taboption( + "settings", + form.ListValue, + "snell_version", + _("Snell Version"), + ); o.modalonly = true; o.rmempty = true; o.value("4", "v4"); @@ -8551,19 +9018,34 @@ function createSectionContent(section) { // ── Mieru (sing-box-extended) ───────────────────────────────────────────── - o = section.taboption("settings", form.Value, "mieru_server", _("Server Address")); + o = section.taboption( + "settings", + form.Value, + "mieru_server", + _("Server Address"), + ); o.modalonly = true; o.rmempty = false; o.depends("action", "mieru"); o.validate = validateRequiredText; - o = section.taboption("settings", form.Value, "mieru_server_port", _("Server Port")); + o = section.taboption( + "settings", + form.Value, + "mieru_server_port", + _("Server Port"), + ); o.modalonly = true; o.rmempty = false; o.datatype = "port"; o.depends("action", "mieru"); - o = section.taboption("settings", form.ListValue, "mieru_transport", _("Transport")); + o = section.taboption( + "settings", + form.ListValue, + "mieru_transport", + _("Transport"), + ); o.modalonly = true; o.rmempty = true; o.value("TCP", "TCP"); @@ -8571,13 +9053,23 @@ function createSectionContent(section) { o.default = "TCP"; o.depends("action", "mieru"); - o = section.taboption("settings", form.Value, "mieru_username", _("Username")); + o = section.taboption( + "settings", + form.Value, + "mieru_username", + _("Username"), + ); o.modalonly = true; o.rmempty = false; o.depends("action", "mieru"); o.validate = validateRequiredText; - o = section.taboption("settings", form.Value, "mieru_password", _("Password")); + o = section.taboption( + "settings", + form.Value, + "mieru_password", + _("Password"), + ); o.modalonly = true; o.rmempty = false; o.password = true; @@ -8586,13 +9078,23 @@ function createSectionContent(section) { // ── Sudoku (sing-box-extended) ──────────────────────────────────────────── - o = section.taboption("settings", form.Value, "sudoku_server", _("Server Address")); + o = section.taboption( + "settings", + form.Value, + "sudoku_server", + _("Server Address"), + ); o.modalonly = true; o.rmempty = false; o.depends("action", "sudoku"); o.validate = validateRequiredText; - o = section.taboption("settings", form.Value, "sudoku_server_port", _("Server Port")); + o = section.taboption( + "settings", + form.Value, + "sudoku_server_port", + _("Server Port"), + ); o.modalonly = true; o.rmempty = false; o.datatype = "port"; @@ -8656,20 +9158,35 @@ function createSectionContent(section) { // ── OpenVPN (sing-box-extended) ─────────────────────────────────────────── - o = section.taboption("settings", form.Value, "openvpn_server", _("Server Address")); + o = section.taboption( + "settings", + form.Value, + "openvpn_server", + _("Server Address"), + ); o.modalonly = true; o.rmempty = false; o.depends("action", "openvpn"); o.validate = validateRequiredText; - o = section.taboption("settings", form.Value, "openvpn_server_port", _("Server Port")); + o = section.taboption( + "settings", + form.Value, + "openvpn_server_port", + _("Server Port"), + ); o.modalonly = true; o.rmempty = false; o.datatype = "port"; o.placeholder = "1194"; o.depends("action", "openvpn"); - o = section.taboption("settings", form.ListValue, "openvpn_proto", _("Protocol")); + o = section.taboption( + "settings", + form.ListValue, + "openvpn_proto", + _("Protocol"), + ); o.modalonly = true; o.rmempty = true; o.value("udp", "UDP"); @@ -9230,8 +9747,18 @@ function createSectionContent(section) { geoipModeOption.default = "exclude"; geoipModeOption.modalonly = true; [ - "connection", "awg", "warp", "anytls", "snell", "mieru", - "sudoku", "masque", "openvpn", "zapret", "zapret2", "byedpi" + "connection", + "awg", + "warp", + "anytls", + "snell", + "mieru", + "sudoku", + "masque", + "openvpn", + "zapret", + "zapret2", + "byedpi", ].forEach((act) => geoipModeOption.depends("action", act)); o = section.taboption( @@ -9264,13 +9791,27 @@ function createSectionContent(section) { let val = uci.get(UCI_PACKAGE, section_id, "geoip_country"); if (!val || val === "all") return []; if (val === "non-ru") return ["ru"]; - if (Array.isArray(val)) return val.filter((v) => v && v !== "all" && v !== "non-ru"); - let parts = String(val).trim().split(/\s+/).filter((v) => v && v !== "all" && v !== "non-ru"); + if (Array.isArray(val)) + return val.filter((v) => v && v !== "all" && v !== "non-ru"); + let parts = String(val) + .trim() + .split(/\s+/) + .filter((v) => v && v !== "all" && v !== "non-ru"); return parts; }; [ - "connection", "awg", "warp", "anytls", "snell", "mieru", - "sudoku", "masque", "openvpn", "zapret", "zapret2", "byedpi" + "connection", + "awg", + "warp", + "anytls", + "snell", + "mieru", + "sudoku", + "masque", + "openvpn", + "zapret", + "zapret2", + "byedpi", ].forEach((act) => o.depends("action", act)); const domainConditionOption = addTextConditionField(section, { @@ -9435,9 +9976,7 @@ function createSectionContent(section) { form.DynamicList, "domain_ip_lists", _("Domain and IP lists"), - _( - "Add URLs or local paths to .lst lists containing domains and subnets.", - ), + _("Add URLs or local paths to .lst lists containing domains and subnets."), ); domainIpListsOption.modalonly = true; // Both widgets map to domain_ip_lists, so neither inactive view may erase shared storage. @@ -9521,7 +10060,9 @@ function createSectionContent(section) { const dscpOption = addDynamicConditionField(section, { key: "dscp", label: _("DSCP marks (QoS)"), - description: _("Match traffic by packet DSCP mark (assigned in Windows via Policy-Based QoS). Examples: 46 (EF), 0x2e, 34 (AF41)"), + description: _( + "Match traffic by packet DSCP mark (assigned in Windows via Policy-Based QoS). Examples: 46 (EF), 0x2e, 34 (AF41)", + ), }); dependsOnRoutingAction(dscpOption); @@ -9530,7 +10071,9 @@ function createSectionContent(section) { form.MultiValue, "excluded_protocol", _("Exclude traffic types"), - _("Traffic of specified protocols (e.g. BitTorrent, QUIC, STUN) will bypass this section."), + _( + "Traffic of specified protocols (e.g. BitTorrent, QUIC, STUN) will bypass this section.", + ), ); excludedProtocolOption.value("bittorrent", _("BitTorrent / P2P")); excludedProtocolOption.value("quic", _("QUIC (HTTP/3)")); @@ -9563,9 +10106,7 @@ function createSectionContent(section) { protocolOption.rmempty = true; dependsOnRoutingAction(protocolOption); - addDashboardServerFilterOptions(section); - } function loadSectionTableOptions(sectionRef) { @@ -9615,11 +10156,15 @@ const ACTION_COLORS = { }; function ip4ToInt(ip) { - return ip.split('.').reduce((int, octet) => (int << 8) + parseInt(octet, 10), 0) >>> 0; + return ( + ip + .split(".") + .reduce((int, octet) => (int << 8) + parseInt(octet, 10), 0) >>> 0 + ); } function ipMatchesCidr(ip, cidr) { - const parts = cidr.split('/'); + const parts = cidr.split("/"); const subnet = parts[0]; const bits = parts[1] ? parseInt(parts[1], 10) : 32; @@ -9737,7 +10282,12 @@ function matchPort(port, portsList) { const parts = item.split("-"); const start = parseInt(parts[0], 10); const end = parseInt(parts[1], 10); - if (!isNaN(start) && !isNaN(end) && queryPort >= start && queryPort <= end) { + if ( + !isNaN(start) && + !isNaN(end) && + queryPort >= start && + queryPort <= end + ) { return item; } } else { @@ -9751,15 +10301,15 @@ function matchPort(port, portsList) { function detectQueryType(query) { query = query.trim(); if (/^\d+$/.test(query)) { - return 'port'; + return "port"; } - if (/^[\d./]+$/.test(query) && query.indexOf('.') !== -1) { - return 'ip'; + if (/^[\d./]+$/.test(query) && query.indexOf(".") !== -1) { + return "ip"; } - if (query.indexOf(':') !== -1) { - return 'ip'; + if (query.indexOf(":") !== -1) { + return "ip"; } - return 'domain'; + return "domain"; } async function readRulesetFile(path) { @@ -9794,7 +10344,7 @@ async function findCachedSrsFile(secName, community) { if (etcStat && etcStat.type === "file") { return etcSrs; } - + return null; } @@ -9809,24 +10359,44 @@ async function matchCommunityList(secName, community, query, type) { }; // Try /usr/bin/sing-box first - let res = await fs.exec("/usr/bin/sing-box", ["rule-set", "match", srsPath, query, "-f", "binary"]).catch(() => null); + let res = await fs + .exec("/usr/bin/sing-box", [ + "rule-set", + "match", + srsPath, + query, + "-f", + "binary", + ]) + .catch(() => null); if (checkMatch(res)) return true; // Fallback to /usr/sbin/sing-box - res = await fs.exec("/usr/sbin/sing-box", ["rule-set", "match", srsPath, query, "-f", "binary"]).catch(() => null); + res = await fs + .exec("/usr/sbin/sing-box", [ + "rule-set", + "match", + srsPath, + query, + "-f", + "binary", + ]) + .catch(() => null); if (checkMatch(res)) return true; return false; } - async function performTrace(query) { query = (query || "").trim(); if (!query) return { matched: false }; // Strip protocol and port if user pasted a URL or domain:port if (query.match(/^https?:\/\//i)) { - query = query.replace(/^https?:\/\//i, "").split("/")[0].split(":")[0]; + query = query + .replace(/^https?:\/\//i, "") + .split("/")[0] + .split(":")[0]; } const type = detectQueryType(query); @@ -9834,8 +10404,8 @@ async function performTrace(query) { const totalSections = sections.length; let queryForMatching = query; - if (type === 'ip' && query.indexOf('/') !== -1) { - queryForMatching = query.split('/')[0]; + if (type === "ip" && query.indexOf("/") !== -1) { + queryForMatching = query.split("/")[0]; } for (let i = 0; i < sections.length; i++) { @@ -9849,8 +10419,10 @@ async function performTrace(query) { const label = uci.get(UCI_PACKAGE, secName, "label") || secName; const action = uci.get(UCI_PACKAGE, secName, "action") || "connection"; - if (type === 'port') { - const ports = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "ports")); + if (type === "port") { + const ports = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "ports"), + ); const matchedPort = matchPort(queryForMatching, ports); if (matchedPort) { return { @@ -9861,13 +10433,15 @@ async function performTrace(query) { ruleType: "Ports", pattern: matchedPort, priority: i + 1, - totalSections: totalSections + totalSections: totalSections, }; } } - if (type === 'ip') { - const ipCidr = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "ip_cidr")); + if (type === "ip") { + const ipCidr = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "ip_cidr"), + ); const matchedCidr = matchIpInCidrs(queryForMatching, ipCidr); if (matchedCidr) { return { @@ -9878,14 +10452,18 @@ async function performTrace(query) { ruleType: "IPs (UCI)", pattern: matchedCidr, priority: i + 1, - totalSections: totalSections + totalSections: totalSections, }; } - const listsRuleset = await readRulesetFile(`/tmp/sing-box/rulesets/${secName}-lists-ruleset.json`); + const listsRuleset = await readRulesetFile( + `/tmp/sing-box/rulesets/${secName}-lists-ruleset.json`, + ); const matchedListIp = matchIpInRuleset(queryForMatching, listsRuleset); if (matchedListIp) { - const domainIpLists = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "domain_ip_lists")); + const domainIpLists = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "domain_ip_lists"), + ); return { matched: true, sectionName: secName, @@ -9895,14 +10473,21 @@ async function performTrace(query) { pattern: matchedListIp, sourceUrls: domainIpLists, priority: i + 1, - totalSections: totalSections + totalSections: totalSections, }; } - const remoteSubnetRuleset = await readRulesetFile(`/tmp/sing-box/rulesets/${secName}-remote-subnet-ruleset.json`); - const matchedRemoteIp = matchIpInRuleset(queryForMatching, remoteSubnetRuleset); + const remoteSubnetRuleset = await readRulesetFile( + `/tmp/sing-box/rulesets/${secName}-remote-subnet-ruleset.json`, + ); + const matchedRemoteIp = matchIpInRuleset( + queryForMatching, + remoteSubnetRuleset, + ); if (matchedRemoteIp) { - const domainIpLists = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "domain_ip_lists")); + const domainIpLists = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "domain_ip_lists"), + ); return { matched: true, sectionName: secName, @@ -9912,14 +10497,18 @@ async function performTrace(query) { pattern: matchedRemoteIp, sourceUrls: domainIpLists, priority: i + 1, - totalSections: totalSections + totalSections: totalSections, }; } // Check community list rulesets for matching IPs - const communityLists = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "community_lists")); + const communityLists = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "community_lists"), + ); for (const community of communityLists) { - if (await matchCommunityList(secName, community, queryForMatching, type)) { + if ( + await matchCommunityList(secName, community, queryForMatching, type) + ) { return { matched: true, sectionName: secName, @@ -9928,21 +10517,40 @@ async function performTrace(query) { ruleType: "Community List (" + community + ")", pattern: queryForMatching, priority: i + 1, - totalSections: totalSections + totalSections: totalSections, }; } } } - if (type === 'domain') { - const domainSuffixes = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "domain_suffix")); - const exactDomains = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "domain")).map(d => `full:${d}`); - const domainKeywords = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "domain_keyword")).map(k => `keyword:${k}`); - const domainRegexes = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "domain_regex")).map(r => `regex:${r}`); - const userDomains = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "user_domains")); - const allUciDomains = [...domainSuffixes, ...exactDomains, ...domainKeywords, ...domainRegexes, ...userDomains]; + if (type === "domain") { + const domainSuffixes = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "domain_suffix"), + ); + const exactDomains = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "domain"), + ).map((d) => `full:${d}`); + const domainKeywords = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "domain_keyword"), + ).map((k) => `keyword:${k}`); + const domainRegexes = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "domain_regex"), + ).map((r) => `regex:${r}`); + const userDomains = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "user_domains"), + ); + const allUciDomains = [ + ...domainSuffixes, + ...exactDomains, + ...domainKeywords, + ...domainRegexes, + ...userDomains, + ]; - const matchedUciDomain = matchDomainInList(queryForMatching, allUciDomains); + const matchedUciDomain = matchDomainInList( + queryForMatching, + allUciDomains, + ); if (matchedUciDomain) { return { matched: true, @@ -9952,15 +10560,22 @@ async function performTrace(query) { ruleType: "Domains (UCI)", pattern: matchedUciDomain, priority: i + 1, - totalSections: totalSections + totalSections: totalSections, }; } - const listsRuleset = await readRulesetFile(`/tmp/sing-box/rulesets/${secName}-lists-ruleset.json`); + const listsRuleset = await readRulesetFile( + `/tmp/sing-box/rulesets/${secName}-lists-ruleset.json`, + ); if (listsRuleset) { - const matchedListDomain = matchDomainInRuleset(queryForMatching, listsRuleset); + const matchedListDomain = matchDomainInRuleset( + queryForMatching, + listsRuleset, + ); if (matchedListDomain) { - const domainIpLists = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "domain_ip_lists")); + const domainIpLists = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "domain_ip_lists"), + ); return { matched: true, sectionName: secName, @@ -9970,16 +10585,23 @@ async function performTrace(query) { pattern: matchedListDomain, sourceUrls: domainIpLists, priority: i + 1, - totalSections: totalSections + totalSections: totalSections, }; } } - const remoteDomainRuleset = await readRulesetFile(`/tmp/sing-box/rulesets/${secName}-remote-domain-ruleset.json`); + const remoteDomainRuleset = await readRulesetFile( + `/tmp/sing-box/rulesets/${secName}-remote-domain-ruleset.json`, + ); if (remoteDomainRuleset) { - const matchedRemoteDomain = matchDomainInRuleset(queryForMatching, remoteDomainRuleset); + const matchedRemoteDomain = matchDomainInRuleset( + queryForMatching, + remoteDomainRuleset, + ); if (matchedRemoteDomain) { - const domainIpLists = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "domain_ip_lists")); + const domainIpLists = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "domain_ip_lists"), + ); return { matched: true, sectionName: secName, @@ -9989,15 +10611,19 @@ async function performTrace(query) { pattern: matchedRemoteDomain, sourceUrls: domainIpLists, priority: i + 1, - totalSections: totalSections + totalSections: totalSections, }; } } // Check community list rulesets for matching domains - const communityLists = normalizeOptionValues(uci.get(UCI_PACKAGE, secName, "community_lists")); + const communityLists = normalizeOptionValues( + uci.get(UCI_PACKAGE, secName, "community_lists"), + ); for (const community of communityLists) { - if (await matchCommunityList(secName, community, queryForMatching, type)) { + if ( + await matchCommunityList(secName, community, queryForMatching, type) + ) { return { matched: true, sectionName: secName, @@ -10006,7 +10632,7 @@ async function performTrace(query) { ruleType: "Community List (" + community + ")", pattern: queryForMatching, priority: i + 1, - totalSections: totalSections + totalSections: totalSections, }; } } @@ -10059,13 +10685,21 @@ function createTracerSearchWidget(sectionRef) { resultsEl.style.display = "block"; resultsEl.innerHTML = ""; resultsEl.appendChild( - E("div", { style: "color: var(--text-color-medium, #666); display: flex; align-items: center; gap: 0.5rem; font-size: 90%;" }, [ - E("span", { - class: "pdk-spinner", - style: "border: 2px solid var(--border-color, #ccc); border-top: 2px solid var(--primary-color, #4a90d9); border-radius: 50%; width: 12px; height: 12px; display: inline-block; animation: spin 1s linear infinite;" - }), - E("span", {}, _("Tracing routing rules...")) - ]) + E( + "div", + { + style: + "color: var(--text-color-medium, #666); display: flex; align-items: center; gap: 0.5rem; font-size: 90%;", + }, + [ + E("span", { + class: "pdk-spinner", + style: + "border: 2px solid var(--border-color, #ccc); border-top: 2px solid var(--primary-color, #4a90d9); border-radius: 50%; width: 12px; height: 12px; display: inline-block; animation: spin 1s linear infinite;", + }), + E("span", {}, _("Tracing routing rules...")), + ], + ), ); try { @@ -10075,73 +10709,120 @@ function createTracerSearchWidget(sectionRef) { let actionBadgeColor = ACTION_COLORS[result.action] || "#7f8c8d"; let actionLabel = String(result.action || "").toUpperCase(); - const matchDiv = E("div", { - style: `padding: 0.4rem 0.6rem; border-radius: 4px; background: var(--background-color-low, #f9f9f9); border-left: 3px solid ${actionBadgeColor}; font-size: 90%; line-height: 1.4;` - }, [ - E("div", { style: "display: flex; flex-wrap: wrap; gap: 0.8rem; align-items: center;" }, [ - E("strong", {}, _("Match Found") + ":"), - E("span", {}, [ - result.label || "", - " ", - E("span", { - style: `background: ${actionBadgeColor}; color: #fff; padding: 1px 4px; border-radius: 3px; font-weight: bold; font-size: 80%; margin-left: 0.1rem;` - }, actionLabel) - ]), - E("span", { style: "color: var(--text-color-medium, #666);" }, "|"), - E("span", {}, [ - E("strong", {}, _("Rule") + ": "), - E("code", {}, result.ruleType || ""), - ` (${result.pattern || ""})` - ]), - ...(result.sourceUrls && result.sourceUrls.length > 0 ? [ - E("span", { style: "color: var(--text-color-medium, #666);" }, "|"), - E("span", {}, [ - E("strong", {}, _("Source") + ": "), - ...result.sourceUrls.map((url, idx) => { - let short = url; - try { - const u = new URL(url); - const parts = u.pathname.split("/").filter(Boolean); - short = parts.length > 0 ? parts[parts.length - 1] : u.hostname; - } catch (e) {} - return E("span", {}, [ - idx > 0 ? ", " : "", - E("a", { - href: url, - target: "_blank", - rel: "noopener", - style: "font-family: monospace; font-size: 85%; word-break: break-all;" - }, short) - ]); - }) - ]) - ] : []), - E("span", { style: "color: var(--text-color-medium, #666);" }, "|"), - E("span", {}, [ - E("strong", {}, _("Priority") + ": "), - `${result.priority}/${result.totalSections}` - ]) - ]) - ]); + const matchDiv = E( + "div", + { + style: `padding: 0.4rem 0.6rem; border-radius: 4px; background: var(--background-color-low, #f9f9f9); border-left: 3px solid ${actionBadgeColor}; font-size: 90%; line-height: 1.4;`, + }, + [ + E( + "div", + { + style: + "display: flex; flex-wrap: wrap; gap: 0.8rem; align-items: center;", + }, + [ + E("strong", {}, _("Match Found") + ":"), + E("span", {}, [ + result.label || "", + " ", + E( + "span", + { + style: `background: ${actionBadgeColor}; color: #fff; padding: 1px 4px; border-radius: 3px; font-weight: bold; font-size: 80%; margin-left: 0.1rem;`, + }, + actionLabel, + ), + ]), + E( + "span", + { style: "color: var(--text-color-medium, #666);" }, + "|", + ), + E("span", {}, [ + E("strong", {}, _("Rule") + ": "), + E("code", {}, result.ruleType || ""), + ` (${result.pattern || ""})`, + ]), + ...(result.sourceUrls && result.sourceUrls.length > 0 + ? [ + E( + "span", + { style: "color: var(--text-color-medium, #666);" }, + "|", + ), + E("span", {}, [ + E("strong", {}, _("Source") + ": "), + ...result.sourceUrls.map((url, idx) => { + let short = url; + try { + const u = new URL(url); + const parts = u.pathname.split("/").filter(Boolean); + short = + parts.length > 0 + ? parts[parts.length - 1] + : u.hostname; + } catch (e) {} + return E("span", {}, [ + idx > 0 ? ", " : "", + E( + "a", + { + href: url, + target: "_blank", + rel: "noopener", + style: + "font-family: monospace; font-size: 85%; word-break: break-all;", + }, + short, + ), + ]); + }), + ]), + ] + : []), + E( + "span", + { style: "color: var(--text-color-medium, #666);" }, + "|", + ), + E("span", {}, [ + E("strong", {}, _("Priority") + ": "), + `${result.priority}/${result.totalSections}`, + ]), + ], + ), + ], + ); resultsEl.appendChild(matchDiv); } else { - const noMatchDiv = E("div", { - style: "padding: 0.4rem 0.6rem; border-radius: 4px; background: var(--background-color-low, #f9f9f9); border-left: 3px solid var(--warning-color, #f1c40f); font-size: 90%; color: var(--text-color-high, #333);" - }, [ - E("strong", {}, _("No Custom Rules Matched") + ": "), - _("Traffic will go through the default route (Direct/Default).") - ]); + const noMatchDiv = E( + "div", + { + style: + "padding: 0.4rem 0.6rem; border-radius: 4px; background: var(--background-color-low, #f9f9f9); border-left: 3px solid var(--warning-color, #f1c40f); font-size: 90%; color: var(--text-color-high, #333);", + }, + [ + E("strong", {}, _("No Custom Rules Matched") + ": "), + _("Traffic will go through the default route (Direct/Default)."), + ], + ); resultsEl.appendChild(noMatchDiv); } } catch (e) { console.error(e); resultsEl.innerHTML = ""; - const errorDiv = E("div", { - style: "padding: 0.6rem 0.8rem; border-radius: 4px; background: var(--background-color-low, #f9f9f9); border-left: 3px solid var(--error-color, #e74c3c); color: var(--error-color, #e74c3c); font-size: 90%;" - }, [ - E("strong", {}, _("Error running trace:")), - " " + (e?.message || String(e)) - ]); + const errorDiv = E( + "div", + { + style: + "padding: 0.6rem 0.8rem; border-radius: 4px; background: var(--background-color-low, #f9f9f9); border-left: 3px solid var(--error-color, #e74c3c); color: var(--error-color, #e74c3c); font-size: 90%;", + }, + [ + E("strong", {}, _("Error running trace:")), + " " + (e?.message || String(e)), + ], + ); resultsEl.appendChild(errorDiv); } } @@ -10172,7 +10853,7 @@ function createTracerSearchWidget(sectionRef) { function showSectionRulesModal(section_id) { const sectionLabel = uci.get(UCI_PACKAGE, section_id, "label") || section_id; - + const getCleanList = (key) => { let val = uci.get(UCI_PACKAGE, section_id, key); if (!val) return []; @@ -10196,50 +10877,96 @@ function showSectionRulesModal(section_id) { if (!items || items.length === 0) { contentNode = E("em", {}, _("Not set")); } else { - contentNode = E("div", { style: "display: flex; flex-wrap: wrap; gap: 4px; max-height: 180px; overflow-y: auto;" }, - items.map((item) => E("span", { - style: "padding: 2px 6px; border: 1px solid var(--border-color, #ccc); background: var(--background-color-low, #f9f9f9); border-radius: 3px; word-break: break-all; font-size: 90%;" - }, item)) + contentNode = E( + "div", + { + style: + "display: flex; flex-wrap: wrap; gap: 4px; max-height: 180px; overflow-y: auto;", + }, + items.map((item) => + E( + "span", + { + style: + "padding: 2px 6px; border: 1px solid var(--border-color, #ccc); background: var(--background-color-low, #f9f9f9); border-radius: 3px; word-break: break-all; font-size: 90%;", + }, + item, + ), + ), ); } return E("div", { class: "cbi-value" }, [ - E("label", { class: "cbi-value-title", style: "padding-top: 0;" }, `${title} (${items ? items.length : 0})`), - E("div", { class: "cbi-value-field" }, [ - contentNode - ]) + E( + "label", + { class: "cbi-value-title", style: "padding-top: 0;" }, + `${title} (${items ? items.length : 0})`, + ), + E("div", { class: "cbi-value-field" }, [contentNode]), ]); }; - const modalBody = E("div", { class: "cbi-section-node", style: "min-width: 320px; max-width: 650px;" }, [ - E("div", { class: "cbi-value" }, [ - E("label", { class: "cbi-value-title", style: "padding-top: 0; font-weight: bold;" }, _("Section action")), - E("div", { class: "cbi-value-field", style: "font-weight: bold; color: var(--text-color-high, #2980b9);" }, getRuleActionDisplayValue(section_id)) - ]), - createCard(_("Community Lists"), community), - createCard(_("Domains"), domains), - createCard(_("IP Addresses (IP CIDR)"), ips), - createCard(_("Rule Sets (.srs / .json)"), ruleSets), - createCard(_(".lst Lists"), domainIpLists), - (geosite.length || geoip.length) ? createCard(_("GeoSite / GeoIP"), [...geosite, ...geoip]) : null, - sourceIp.length ? createCard(_("Source Devices"), sourceIp) : null, - ports.length ? createCard(_("Ports"), ports) : null, - dscp.length ? createCard(_("DSCP marks (QoS)"), dscp) : null, - ].filter(Boolean)); + const modalBody = E( + "div", + { class: "cbi-section-node", style: "min-width: 320px; max-width: 650px;" }, + [ + E("div", { class: "cbi-value" }, [ + E( + "label", + { + class: "cbi-value-title", + style: "padding-top: 0; font-weight: bold;", + }, + _("Section action"), + ), + E( + "div", + { + class: "cbi-value-field", + style: "font-weight: bold; color: var(--text-color-high, #2980b9);", + }, + getRuleActionDisplayValue(section_id), + ), + ]), + createCard(_("Community Lists"), community), + createCard(_("Domains"), domains), + createCard(_("IP Addresses (IP CIDR)"), ips), + createCard(_("Rule Sets (.srs / .json)"), ruleSets), + createCard(_(".lst Lists"), domainIpLists), + geosite.length || geoip.length + ? createCard(_("GeoSite / GeoIP"), [...geosite, ...geoip]) + : null, + sourceIp.length ? createCard(_("Source Devices"), sourceIp) : null, + ports.length ? createCard(_("Ports"), ports) : null, + dscp.length ? createCard(_("DSCP marks (QoS)"), dscp) : null, + ].filter(Boolean), + ); ui.showModal( _("Section Rules Content") + `: ${sectionLabel}`, [ modalBody, - E("div", { class: "button-row", style: "display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px;" }, [ - E("button", { - class: "btn cbi-button cbi-button-action", - type: "button", - click: () => ui.hideModal() - }, _("Close")) - ]) + E( + "div", + { + class: "button-row", + style: + "display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px;", + }, + [ + E( + "button", + { + class: "btn cbi-button cbi-button-action", + type: "button", + click: () => ui.hideModal(), + }, + _("Close"), + ), + ], + ), ], - "cbi-modal" + "cbi-modal", ); } diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js index 471114ff..ddaaac12 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/server.js @@ -1419,13 +1419,16 @@ function buildMtprotoLink(sectionId, identity) { function buildAwgClientConfig(sectionId) { const host = getPublicHost(sectionId); const port = uci.get(UCI_PACKAGE, sectionId, "listen_port") || "51820"; - const clientPrivateKey = uci.get(UCI_PACKAGE, sectionId, "awg_client_private_key") || ""; + const clientPrivateKey = + uci.get(UCI_PACKAGE, sectionId, "awg_client_private_key") || ""; const serverPublicKey = uci.get(UCI_PACKAGE, sectionId, "awg_server_public_key") || uci.get(UCI_PACKAGE, sectionId, "awg_peer_public_key") || ""; - const presharedKey = uci.get(UCI_PACKAGE, sectionId, "awg_preshared_key") || ""; - const clientAddress = uci.get(UCI_PACKAGE, sectionId, "awg_client_address") || "10.0.0.2/32"; + const presharedKey = + uci.get(UCI_PACKAGE, sectionId, "awg_preshared_key") || ""; + const clientAddress = + uci.get(UCI_PACKAGE, sectionId, "awg_client_address") || "10.0.0.2/32"; const mtu = uci.get(UCI_PACKAGE, sectionId, "awg_mtu") || "1280"; const keepalive = uci.get(UCI_PACKAGE, sectionId, "awg_keepalive") || "25"; const dns = uci.get(UCI_PACKAGE, sectionId, "awg_dns") || "10.0.0.1"; @@ -2416,22 +2419,38 @@ function generateAwgKeypairsIfMissing(sectionId) { return Promise.resolve(); } - return Promise.all([generateAwgKeypair(sectionId), generateAwgKeypair(sectionId)]).then( - ([srvPair, cliPair]) => { - if (srvPair) { - uci.set(UCI_PACKAGE, sectionId, "awg_private_key", srvPair.private_key); - uci.set(UCI_PACKAGE, sectionId, "awg_server_public_key", srvPair.public_key); - setWidgetValue(sectionId, "awg_private_key", srvPair.private_key); - setWidgetValue(sectionId, "awg_server_public_key", srvPair.public_key); - } - if (cliPair) { - uci.set(UCI_PACKAGE, sectionId, "awg_client_private_key", cliPair.private_key); - uci.set(UCI_PACKAGE, sectionId, "awg_peer_public_key", cliPair.public_key); - setWidgetValue(sectionId, "awg_client_private_key", cliPair.private_key); - setWidgetValue(sectionId, "awg_peer_public_key", cliPair.public_key); - } - }, - ); + return Promise.all([ + generateAwgKeypair(sectionId), + generateAwgKeypair(sectionId), + ]).then(([srvPair, cliPair]) => { + if (srvPair) { + uci.set(UCI_PACKAGE, sectionId, "awg_private_key", srvPair.private_key); + uci.set( + UCI_PACKAGE, + sectionId, + "awg_server_public_key", + srvPair.public_key, + ); + setWidgetValue(sectionId, "awg_private_key", srvPair.private_key); + setWidgetValue(sectionId, "awg_server_public_key", srvPair.public_key); + } + if (cliPair) { + uci.set( + UCI_PACKAGE, + sectionId, + "awg_client_private_key", + cliPair.private_key, + ); + uci.set( + UCI_PACKAGE, + sectionId, + "awg_peer_public_key", + cliPair.public_key, + ); + setWidgetValue(sectionId, "awg_client_private_key", cliPair.private_key); + setWidgetValue(sectionId, "awg_peer_public_key", cliPair.public_key); + } + }); } function prepareServerModal(sectionId) { @@ -3301,10 +3320,7 @@ function createServerContent(section, options = {}) { addTailscaleDepends(o); o = section.option(form.ListValue, "tailscale_mode", _("Tailscale mode")); - o.value( - "singbox", - _("sing-box endpoint (userspace, default)"), - ); + o.value("singbox", _("sing-box endpoint (userspace, default)")); o.value( "native", _("Native tailscaled (tailnet reachable from LAN without client)"), @@ -3383,22 +3399,14 @@ function createServerContent(section, options = {}) { o.validate = validatePositiveInteger; o.depends("protocol", "awg"); - o = section.option( - form.Value, - "awg_keepalive", - _("Persistent Keepalive"), - ); + o = section.option(form.Value, "awg_keepalive", _("Persistent Keepalive")); o.default = "25"; o.modalonly = true; o.rmempty = false; o.validate = validatePositiveInteger; o.depends("protocol", "awg"); - o = section.option( - form.Value, - "awg_private_key", - _("Server Private Key"), - ); + o = section.option(form.Value, "awg_private_key", _("Server Private Key")); o.modalonly = true; o.rmempty = false; o.validate = validateRequired; @@ -3423,11 +3431,7 @@ function createServerContent(section, options = {}) { o.validate = validateRequired; o.depends("protocol", "awg"); - o = section.option( - form.Value, - "awg_peer_public_key", - _("Client Public Key"), - ); + o = section.option(form.Value, "awg_peer_public_key", _("Client Public Key")); o.modalonly = true; o.rmempty = false; o.validate = validateRequired; diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js index a32e968b..83a0afcb 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js @@ -152,8 +152,12 @@ function refreshOptionChoices(option, choices) { } function getDnsServerChoices(dnsType) { - const servers = main.DNS_SERVERS_BY_PROTOCOL[dnsType] || main.DNS_SERVERS_BY_PROTOCOL.udp; - return Object.entries(servers).map(([value, label]) => ({ value, label: _(label) })); + const servers = + main.DNS_SERVERS_BY_PROTOCOL[dnsType] || main.DNS_SERVERS_BY_PROTOCOL.udp; + return Object.entries(servers).map(([value, label]) => ({ + value, + label: _(label), + })); } function configureDnsDynamicList(option, getChoices, defaultValue) { @@ -234,7 +238,8 @@ const settingsDnsDynamicState = { }; function getDefaultDnsServers(dnsType) { - const servers = main.DNS_SERVERS_BY_PROTOCOL[dnsType] || main.DNS_SERVERS_BY_PROTOCOL.udp; + const servers = + main.DNS_SERVERS_BY_PROTOCOL[dnsType] || main.DNS_SERVERS_BY_PROTOCOL.udp; const keys = Object.keys(servers); return keys.length > 0 ? [keys[0]] : ["77.88.8.8"]; } @@ -260,7 +265,8 @@ function configureDnsDuration( function createWatchdogStatusWidget() { const wrapper = E("div", { id: "tachyon-watchdog-status-widget", - style: "display:flex;align-items:center;gap:12px;padding:4px 0;flex-wrap:wrap;", + style: + "display:flex;align-items:center;gap:12px;padding:4px 0;flex-wrap:wrap;", }); const indicator = E("span", { @@ -269,7 +275,8 @@ function createWatchdogStatusWidget() { const dot = E("span", { id: "tachyon-watchdog-status-dot", - style: "display:inline-block;width:10px;height:10px;border-radius:50%;background:#aaa;flex-shrink:0;", + style: + "display:inline-block;width:10px;height:10px;border-radius:50%;background:#aaa;flex-shrink:0;", }); const statusText = E("span", { id: "tachyon-watchdog-status-text" }); @@ -380,7 +387,8 @@ function createWatchdogStatusWidget() { function createResetSettingsWidget() { const wrapper = E("div", { id: "tachyon-reset-settings-widget", - style: "display:flex;align-items:center;gap:12px;padding:4px 0;flex-wrap:wrap;", + style: + "display:flex;align-items:center;gap:12px;padding:4px 0;flex-wrap:wrap;", }); const btnReset = E("button", { @@ -394,8 +402,7 @@ function createResetSettingsWidget() { }); function performReset() { - if (btnReset.disabled) - return; + if (btnReset.disabled) return; btnReset.disabled = true; msgEl.textContent = _("Resetting\u2026"); fs.exec("/usr/bin/tachyon", ["reset_settings"]) @@ -406,10 +413,17 @@ function createResetSettingsWidget() { } catch (e) { success = false; } - if (!success) - throw new Error("reset failed"); + if (!success) throw new Error("reset failed"); msgEl.textContent = ""; - ui.addNotification(null, E("p", {}, _("Settings have been reset to defaults. The page will reload.")), "info"); + ui.addNotification( + null, + E( + "p", + {}, + _("Settings have been reset to defaults. The page will reload."), + ), + "info", + ); setTimeout(function () { location.reload(); }, 1500); @@ -424,23 +438,37 @@ function createResetSettingsWidget() { ui.showModal( _("Reset Settings"), [ - E("p", {}, _("This will erase all Tachyon settings and restore the factory defaults. The service will restart. This action cannot be undone.")), + E( + "p", + {}, + _( + "This will erase all Tachyon settings and restore the factory defaults. The service will restart. This action cannot be undone.", + ), + ), E("div", { class: "button-row" }, [ - E("button", { - class: "btn cbi-button cbi-button-neutral", - type: "button", - click: function () { - ui.hideModal(); + E( + "button", + { + class: "btn cbi-button cbi-button-neutral", + type: "button", + click: function () { + ui.hideModal(); + }, }, - }, _("Cancel")), - E("button", { - class: "btn cbi-button cbi-button-negative", - type: "button", - click: function () { - ui.hideModal(); - performReset(); + _("Cancel"), + ), + E( + "button", + { + class: "btn cbi-button cbi-button-negative", + type: "button", + click: function () { + ui.hideModal(); + performReset(); + }, }, - }, _("Reset")), + _("Reset"), + ), ]), ], "cbi-modal", @@ -470,8 +498,7 @@ function createSnapshotsWidget() { }); function formatStamp(stamp) { - if (!stamp) - return ""; + if (!stamp) return ""; return stamp.replace( /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/, "$1-$2-$3 $4:$5", @@ -479,12 +506,9 @@ function createSnapshotsWidget() { } function formatSize(size) { - if (size == null) - return ""; - if (size >= 1048576) - return (size / 1048576).toFixed(1) + " MB"; - if (size >= 1024) - return Math.round(size / 1024) + " KB"; + if (size == null) return ""; + if (size >= 1048576) return (size / 1048576).toFixed(1) + " MB"; + if (size >= 1024) return Math.round(size / 1024) + " KB"; return size + " B"; } @@ -496,8 +520,7 @@ function createSnapshotsWidget() { } catch (e) { ok = false; } - if (!ok) - throw new Error("command failed"); + if (!ok) throw new Error("command failed"); }); } @@ -506,7 +529,13 @@ function createSnapshotsWidget() { .then(function () { ui.addNotification( null, - E("p", {}, _("Snapshot restored. The service restarts and the page will reload.")), + E( + "p", + {}, + _( + "Snapshot restored. The service restarts and the page will reload.", + ), + ), "info", ); setTimeout(function () { @@ -514,7 +543,11 @@ function createSnapshotsWidget() { }, 2500); }) .catch(function () { - ui.addNotification(null, E("p", {}, _("Failed to restore snapshot")), "error"); + ui.addNotification( + null, + E("p", {}, _("Failed to restore snapshot")), + "error", + ); loadSnapshots(); }); } @@ -525,7 +558,11 @@ function createSnapshotsWidget() { loadSnapshots(); }) .catch(function () { - ui.addNotification(null, E("p", {}, _("Failed to delete snapshot")), "error"); + ui.addNotification( + null, + E("p", {}, _("Failed to delete snapshot")), + "error", + ); }); } @@ -535,100 +572,158 @@ function createSnapshotsWidget() { .then(function (res) { let snapshots = []; try { - snapshots = (JSON.parse((res && res.stdout) || "").snapshots || []).slice(0, 10); + snapshots = ( + JSON.parse((res && res.stdout) || "").snapshots || [] + ).slice(0, 10); } catch (e) { snapshots = []; } if (snapshots.length === 0) { listEl.appendChild( - E("span", { - style: "font-size:12px;color:var(--text-color-medium,#888);", - }, _("No snapshots yet")), + E( + "span", + { + style: "font-size:12px;color:var(--text-color-medium,#888);", + }, + _("No snapshots yet"), + ), ); return; } snapshots.forEach(function (snap) { const row = E("div", { - style: "display:flex;align-items:center;gap:10px;padding:2px 0;flex-wrap:wrap;", + style: + "display:flex;align-items:center;gap:10px;padding:2px 0;flex-wrap:wrap;", }); - row.appendChild(E("span", { style: "font-weight:bold;" }, snap.name || "")); row.appendChild( - E("span", { - style: "font-size:12px;color:var(--text-color-medium,#888);", - }, formatStamp(snap.stamp) + (snap.size != null ? " \u00b7 " + formatSize(snap.size) : "")), + E("span", { style: "font-weight:bold;" }, snap.name || ""), ); row.appendChild( - E("button", { - class: "btn cbi-button cbi-button-action", - type: "button", - click: function () { - ui.showModal( - _("Restore Snapshot"), - [ - E("p", {}, _("This will replace the current Tachyon settings with the snapshot") + ": \u201c" + (snap.name || "") + "\u201d. The service will restart."), - E("div", { class: "button-row" }, [ - E("button", { - class: "btn cbi-button cbi-button-neutral", - type: "button", - click: function () { - ui.hideModal(); - }, - }, _("Cancel")), - E("button", { - class: "btn cbi-button cbi-button-negative", - type: "button", - click: function () { - ui.hideModal(); - performRestore(snap.file); - }, - }, _("Restore")), - ]), - ], - "cbi-modal", - ); + E( + "span", + { + style: "font-size:12px;color:var(--text-color-medium,#888);", }, - }, _("Restore")), + formatStamp(snap.stamp) + + (snap.size != null ? " \u00b7 " + formatSize(snap.size) : ""), + ), ); row.appendChild( - E("button", { - class: "btn cbi-button cbi-button-negative", - type: "button", - click: function () { - ui.showModal( - _("Delete Snapshot"), - [ - E("p", {}, _("Delete the snapshot") + ": \u201c" + (snap.name || "") + "\u201d?"), - E("div", { class: "button-row" }, [ - E("button", { - class: "btn cbi-button cbi-button-neutral", - type: "button", - click: function () { - ui.hideModal(); - }, - }, _("Cancel")), - E("button", { - class: "btn cbi-button cbi-button-negative", - type: "button", - click: function () { - ui.hideModal(); - performDelete(snap.file); - }, - }, _("Delete")), - ]), - ], - "cbi-modal", - ); + E( + "button", + { + class: "btn cbi-button cbi-button-action", + type: "button", + click: function () { + ui.showModal( + _("Restore Snapshot"), + [ + E( + "p", + {}, + _( + "This will replace the current Tachyon settings with the snapshot", + ) + + ": \u201c" + + (snap.name || "") + + "\u201d. The service will restart.", + ), + E("div", { class: "button-row" }, [ + E( + "button", + { + class: "btn cbi-button cbi-button-neutral", + type: "button", + click: function () { + ui.hideModal(); + }, + }, + _("Cancel"), + ), + E( + "button", + { + class: "btn cbi-button cbi-button-negative", + type: "button", + click: function () { + ui.hideModal(); + performRestore(snap.file); + }, + }, + _("Restore"), + ), + ]), + ], + "cbi-modal", + ); + }, }, - }, _("Delete")), + _("Restore"), + ), + ); + row.appendChild( + E( + "button", + { + class: "btn cbi-button cbi-button-negative", + type: "button", + click: function () { + ui.showModal( + _("Delete Snapshot"), + [ + E( + "p", + {}, + _("Delete the snapshot") + + ": \u201c" + + (snap.name || "") + + "\u201d?", + ), + E("div", { class: "button-row" }, [ + E( + "button", + { + class: "btn cbi-button cbi-button-neutral", + type: "button", + click: function () { + ui.hideModal(); + }, + }, + _("Cancel"), + ), + E( + "button", + { + class: "btn cbi-button cbi-button-negative", + type: "button", + click: function () { + ui.hideModal(); + performDelete(snap.file); + }, + }, + _("Delete"), + ), + ]), + ], + "cbi-modal", + ); + }, + }, + _("Delete"), + ), ); listEl.appendChild(row); }); }) .catch(function () { listEl.appendChild( - E("span", { - style: "font-size:12px;color:var(--text-color-medium,#888);", - }, _("Failed to load snapshots")), + E( + "span", + { + style: "font-size:12px;color:var(--text-color-medium,#888);", + }, + _("Failed to load snapshots"), + ), ); }); } @@ -644,33 +739,50 @@ function createSnapshotsWidget() { ui.showModal( _("Save Snapshot"), [ - E("p", {}, _("Give the snapshot a name. It is saved with the current date and can be restored from this list later.")), + E( + "p", + {}, + _( + "Give the snapshot a name. It is saved with the current date and can be restored from this list later.", + ), + ), input, E("div", { class: "button-row" }, [ - E("button", { - class: "btn cbi-button cbi-button-neutral", - type: "button", - click: function () { - ui.hideModal(); + E( + "button", + { + class: "btn cbi-button cbi-button-neutral", + type: "button", + click: function () { + ui.hideModal(); + }, }, - }, _("Cancel")), - E("button", { - class: "btn cbi-button cbi-button-positive", - type: "button", - click: function () { - const name = (input.value || "").trim(); - if (!name) - return; - ui.hideModal(); - execSnapshot(["snapshot_save", name]) - .then(function () { - loadSnapshots(); - }) - .catch(function () { - ui.addNotification(null, E("p", {}, _("Failed to save snapshot")), "error"); - }); + _("Cancel"), + ), + E( + "button", + { + class: "btn cbi-button cbi-button-positive", + type: "button", + click: function () { + const name = (input.value || "").trim(); + if (!name) return; + ui.hideModal(); + execSnapshot(["snapshot_save", name]) + .then(function () { + loadSnapshots(); + }) + .catch(function () { + ui.addNotification( + null, + E("p", {}, _("Failed to save snapshot")), + "error", + ); + }); + }, }, - }, _("Save")), + _("Save"), + ), ]), ], "cbi-modal", @@ -678,11 +790,22 @@ function createSnapshotsWidget() { }); wrapper.appendChild( - E("div", { - style: "display:flex;align-items:center;gap:12px;flex-wrap:wrap;", - }, btnSave, E("span", { - style: "font-size:12px;color:var(--text-color-medium,#888);", - }, _("Up to 10 snapshots are kept; the oldest ones are removed automatically."))), + E( + "div", + { + style: "display:flex;align-items:center;gap:12px;flex-wrap:wrap;", + }, + btnSave, + E( + "span", + { + style: "font-size:12px;color:var(--text-color-medium,#888);", + }, + _( + "Up to 10 snapshots are kept; the oldest ones are removed automatically.", + ), + ), + ), ); wrapper.appendChild(listEl); @@ -692,15 +815,27 @@ function createSnapshotsWidget() { } function createSmartDetectSectionsWidget(section_id) { - const TESTABLE_ACTIONS = ["connection", "proxy", "outbound", "vpn", "zapret", "zapret2", "byedpi"]; + const TESTABLE_ACTIONS = [ + "connection", + "proxy", + "outbound", + "vpn", + "zapret", + "zapret2", + "byedpi", + ]; const allSections = (uci.sections(UCI_PACKAGE, "section") || []) .filter(function (s) { return s.enabled !== "0" && TESTABLE_ACTIONS.indexOf(s.action) >= 0; }) - .map(function (s) { return s[".name"]; }); + .map(function (s) { + return s[".name"]; + }); if (allSections.length === 0) { - const empty = E("em", { style: "color:var(--text-color-medium,#888);font-size:0.9rem;" }); + const empty = E("em", { + style: "color:var(--text-color-medium,#888);font-size:0.9rem;", + }); empty.textContent = _("No active routing sections found."); return empty; } @@ -722,14 +857,19 @@ function createSmartDetectSectionsWidget(section_id) { // enabledSet: which sections are checked const enabledSet = {}; if (savedSections.length > 0) { - savedSections.forEach(function (name) { enabledSet[name] = true; }); + savedSections.forEach(function (name) { + enabledSet[name] = true; + }); } else if (ordered.length > 0) { enabledSet[ordered[0]] = true; } - const wrapper = E("div", { id: "smart-detect-sections-widget-" + section_id }); + const wrapper = E("div", { + id: "smart-detect-sections-widget-" + section_id, + }); const listEl = E("div", { - style: "border:1px solid var(--border-color,#dee2e6);border-radius:4px;overflow:hidden;margin-bottom:8px;max-width:480px;", + style: + "border:1px solid var(--border-color,#dee2e6);border-radius:4px;overflow:hidden;margin-bottom:8px;max-width:480px;", }); function updateValue() { @@ -743,7 +883,9 @@ function createSmartDetectSectionsWidget(section_id) { const row = E("div", { style: [ "display:flex;align-items:center;gap:10px;padding:7px 10px;", - idx < totalLen - 1 ? "border-bottom:1px solid var(--border-color,#dee2e6);" : "", + idx < totalLen - 1 + ? "border-bottom:1px solid var(--border-color,#dee2e6);" + : "", isEnabled ? "" : "opacity:0.5;", ].join(""), }); @@ -756,15 +898,18 @@ function createSmartDetectSectionsWidget(section_id) { renderSdList(); }); - const label = E("span", { style: "flex:1;font-family:monospace;font-size:0.9rem;user-select:none;" }); + const label = E("span", { + style: "flex:1;font-family:monospace;font-size:0.9rem;user-select:none;", + }); label.textContent = name; const upBtn = E("button", { class: "btn", type: "button", - style: "padding:1px 8px;font-size:0.75rem;line-height:1.4;border:1px solid var(--border-color,#ccc);border-radius:3px;background:transparent;cursor:pointer;", + style: + "padding:1px 8px;font-size:0.75rem;line-height:1.4;border:1px solid var(--border-color,#ccc);border-radius:3px;background:transparent;cursor:pointer;", }); - upBtn.disabled = (idx === 0); + upBtn.disabled = idx === 0; upBtn.textContent = "\u25b3"; upBtn.addEventListener("click", function () { if (idx > 0) { @@ -779,9 +924,10 @@ function createSmartDetectSectionsWidget(section_id) { const downBtn = E("button", { class: "btn", type: "button", - style: "padding:1px 8px;font-size:0.75rem;line-height:1.4;border:1px solid var(--border-color,#ccc);border-radius:3px;background:transparent;cursor:pointer;", + style: + "padding:1px 8px;font-size:0.75rem;line-height:1.4;border:1px solid var(--border-color,#ccc);border-radius:3px;background:transparent;cursor:pointer;", }); - downBtn.disabled = (idx === totalLen - 1); + downBtn.disabled = idx === totalLen - 1; downBtn.textContent = "\u25bd"; downBtn.addEventListener("click", function () { if (idx < ordered.length - 1) { @@ -814,7 +960,280 @@ function createSmartDetectSectionsWidget(section_id) { return wrapper; } +function createMenuTabsOrderWidget(option, section_id) { + const ALL_MENU_TABS = [ + { + id: "dashboard", + label: _("Dashboard"), + desc: _("Main overview and status widgets"), + defaultVisible: true, + fixed: false, + }, + { + id: "section", + label: _("Sections"), + desc: _("Routing sections, rules and strategies"), + defaultVisible: true, + fixed: false, + }, + { + id: "server", + label: _("Servers"), + desc: _("External inbound proxy servers"), + defaultVisible: false, + fixed: false, + }, + { + id: "profile", + label: _("Family Profiles"), + desc: _("Family profiles and device grouping"), + defaultVisible: false, + fixed: false, + }, + { + id: "schedule", + label: _("Parental Control"), + desc: _("Access schedule and screen time limits"), + defaultVisible: false, + fixed: false, + }, + { + id: "monitoring", + label: _("Monitoring"), + desc: _("Traffic statistics and active connections"), + defaultVisible: true, + fixed: false, + }, + { + id: "diagnostic", + label: _("Diagnostics"), + desc: _("System diagnostics, AI Doctor and logs"), + defaultVisible: true, + fixed: false, + }, + { + id: "updates", + label: _("Components"), + desc: _("Component versions and update manager"), + defaultVisible: true, + fixed: false, + }, + { + id: "settings", + label: _("Settings"), + desc: _("Core configuration and preferences"), + defaultVisible: true, + fixed: true, + }, + { + id: "telegram", + label: _("Telegram Bot"), + desc: _("Remote management Telegram daemon"), + defaultVisible: true, + fixed: false, + }, + ]; + + const rawTabOrder = uci.get(UCI_PACKAGE, section_id, "tab_order"); + const savedOrder = Array.isArray(rawTabOrder) + ? rawTabOrder + : typeof rawTabOrder === "string" && rawTabOrder.trim() + ? rawTabOrder.trim().split(/\s+/) + : []; + + const tabMap = {}; + ALL_MENU_TABS.forEach(function (t) { + tabMap[t.id] = t; + }); + + const ordered = []; + savedOrder.forEach(function (id) { + if (tabMap[id] && ordered.indexOf(id) < 0) { + ordered.push(id); + } + }); + ALL_MENU_TABS.forEach(function (t) { + if (ordered.indexOf(t.id) < 0) { + ordered.push(t.id); + } + }); + + const visibility = {}; + ALL_MENU_TABS.forEach(function (t) { + if (t.fixed) { + visibility[t.id] = true; + } else { + let optKey = "show_tab_" + t.id; + if (t.id === "server") optKey = "show_tab_servers"; + else if (t.id === "profile") optKey = "show_tab_profiles"; + else if (t.id === "schedule") optKey = "show_tab_parental"; + + const val = uci.get(UCI_PACKAGE, section_id, optKey); + if (val === undefined || val === null || val === "") { + visibility[t.id] = t.defaultVisible; + } else { + visibility[t.id] = val === "1" || val === true; + } + } + }); + + const wrapper = E("div", { id: "menu-tabs-order-widget-" + section_id }); + const listEl = E("div", { + style: + "border:1px solid var(--border-color,#dee2e6);border-radius:6px;overflow:hidden;margin-bottom:8px;max-width:560px;background:var(--card-bg,transparent);", + }); + + function syncToUci() { + uci.set(UCI_PACKAGE, section_id, "tab_order", ordered); + ALL_MENU_TABS.forEach(function (t) { + if (!t.fixed) { + let optKey = "show_tab_" + t.id; + if (t.id === "server") optKey = "show_tab_servers"; + else if (t.id === "profile") optKey = "show_tab_profiles"; + else if (t.id === "schedule") optKey = "show_tab_parental"; + uci.set(UCI_PACKAGE, section_id, optKey, visibility[t.id] ? "1" : "0"); + } + }); + } + + function renderTabRow(id, idx, totalLen) { + const tabMeta = tabMap[id] || { id: id, label: id, desc: "", fixed: false }; + const isVisible = Boolean(visibility[id]); + + const row = E("div", { + style: [ + "display:flex;align-items:center;gap:12px;padding:8px 12px;", + idx < totalLen - 1 + ? "border-bottom:1px solid var(--border-color,#dee2e6);" + : "", + isVisible ? "" : "opacity:0.5;background:rgba(0,0,0,0.02);", + ].join(""), + }); + + const badge = E( + "span", + { + style: + "font-size:0.75rem;font-weight:bold;color:var(--text-color-medium,#888);min-width:18px;text-align:center;", + }, + String(idx + 1), + ); + + const cb = E("input", { + type: "checkbox", + id: "cb-tab-" + id, + disabled: Boolean(tabMeta.fixed), + }); + cb.checked = isVisible; + cb.addEventListener("change", function (ev) { + visibility[id] = ev.target.checked; + syncToUci(); + renderList(); + }); + + const textWrapper = E( + "label", + { + for: "cb-tab-" + id, + style: + "flex:1;cursor:pointer;margin:0;display:flex;flex-direction:column;", + }, + [ + E("span", { style: "font-weight:600;font-size:0.95rem;" }, [ + tabMeta.label, + tabMeta.fixed + ? E( + "em", + { + style: + "font-size:0.75rem;font-weight:normal;margin-left:6px;color:var(--text-color-medium,#888);", + }, + " (" + _("Always visible") + ")", + ) + : "", + ]), + tabMeta.desc + ? E( + "span", + { + style: "font-size:0.75rem;color:var(--text-color-medium,#888);", + }, + tabMeta.desc, + ) + : "", + ], + ); + + const upBtn = E( + "button", + { + class: "btn cbi-button", + type: "button", + title: _("Move Up"), + style: + "padding:2px 8px;font-size:0.8rem;line-height:1.2;cursor:pointer;", + }, + "▲", + ); + upBtn.disabled = idx === 0; + upBtn.addEventListener("click", function (ev) { + ev.preventDefault(); + if (idx > 0) { + const tmp = ordered[idx - 1]; + ordered[idx - 1] = ordered[idx]; + ordered[idx] = tmp; + syncToUci(); + renderList(); + } + }); + + const downBtn = E( + "button", + { + class: "btn cbi-button", + type: "button", + title: _("Move Down"), + style: + "padding:2px 8px;font-size:0.8rem;line-height:1.2;cursor:pointer;", + }, + "▼", + ); + downBtn.disabled = idx === totalLen - 1; + downBtn.addEventListener("click", function (ev) { + ev.preventDefault(); + if (idx < ordered.length - 1) { + const tmp = ordered[idx + 1]; + ordered[idx + 1] = ordered[idx]; + ordered[idx] = tmp; + syncToUci(); + renderList(); + } + }); + + row.appendChild(badge); + row.appendChild(cb); + row.appendChild(textWrapper); + row.appendChild(upBtn); + row.appendChild(downBtn); + return row; + } + + function renderList() { + while (listEl.firstChild) listEl.removeChild(listEl.firstChild); + ordered.forEach(function (id, idx) { + listEl.appendChild(renderTabRow(id, idx, ordered.length)); + }); + } + + renderList(); + syncToUci(); + + wrapper.appendChild(listEl); + return wrapper; +} + function createSettingsContent(section, capabilities) { + section.tab("navigation", _("Menu & Navigation")); section.tab("dns", _("DNS Settings")); section.tab("network", _("Network & Interfaces")); section.tab("services", _("Services & Access")); @@ -822,9 +1241,48 @@ function createSettingsContent(section, capabilities) { section.tab("ai", _("AI & Watchdog")); section.tab("advanced", _("Advanced System")); - settingsDnsDynamicState.dnsType = uci.get(UCI_PACKAGE, "settings", "dns_type") || "udp"; + // ── Navigation & Menu Settings ────────────────────────────────────────── let o = section.taboption( + "navigation", + form.ListValue, + "default_tab", + _("Default Tab on Startup"), + _("Select which tab opens automatically when entering Tachyon."), + ); + o.value("dashboard", _("Dashboard")); + o.value("section", _("Sections")); + o.value("server", _("Servers")); + o.value("profile", _("Family Profiles")); + o.value("schedule", _("Parental Control")); + o.value("monitoring", _("Monitoring")); + o.value("diagnostic", _("Diagnostics")); + o.value("updates", _("Components")); + o.value("settings", _("Settings")); + o.value("telegram", _("Telegram Bot")); + o.default = "dashboard"; + o.rmempty = false; + + const tabsOrderOpt = section.taboption( + "navigation", + form.DummyValue, + "_tabs_order_widget", + _("Tab Order & Visibility"), + _( + "Customize the order and visibility of tabs in the main navigation menu. Use the arrows to reorder and checkboxes to show or hide tabs.", + ), + ); + tabsOrderOpt.rawhtml = true; + tabsOrderOpt.cfgvalue = function (section_id) { + return createMenuTabsOrderWidget(this, section_id); + }; + + // ── DNS Settings ──────────────────────────────────────────────────────── + + settingsDnsDynamicState.dnsType = + uci.get(UCI_PACKAGE, "settings", "dns_type") || "udp"; + + o = section.taboption( "dns", form.ListValue, "dns_type", @@ -849,10 +1307,14 @@ function createSettingsContent(section, capabilities) { "Main DNS server. If multiple servers are selected, a timeout switches to a backup.", ), ); - configureDnsDynamicList(dnsOption, (_section_id) => { - const dnsType = settingsDnsDynamicState.dnsType || "udp"; - return getDnsServerChoices(dnsType); - }, "77.88.8.8"); + configureDnsDynamicList( + dnsOption, + (_section_id) => { + const dnsType = settingsDnsDynamicState.dnsType || "udp"; + return getDnsServerChoices(dnsType); + }, + "77.88.8.8", + ); const bootstrapOption = section.taboption( "dns", @@ -876,9 +1338,13 @@ function createSettingsContent(section, capabilities) { const widget = settingsDnsDynamicState.widget; if (widget) { - const servers = main.DNS_SERVERS_BY_PROTOCOL[newType] || main.DNS_SERVERS_BY_PROTOCOL.udp; + const servers = + main.DNS_SERVERS_BY_PROTOCOL[newType] || + main.DNS_SERVERS_BY_PROTOCOL.udp; const defaultLabels = {}; - Object.entries(servers).forEach(([v, l]) => { defaultLabels[v] = _(l); }); + Object.entries(servers).forEach(([v, l]) => { + defaultLabels[v] = _(l); + }); const defaultServers = getDefaultDnsServers(newType); widget.choices = defaultLabels; widget.setValue(defaultServers); @@ -895,7 +1361,9 @@ function createSettingsContent(section, capabilities) { form.Flag, "fallback_wan_main", _("Enable WAN DNS Fallback for Main DNS"), - _("⚠️ If all Main DNS fail 3 times, queries will be sent to your ISP's DNS in plaintext. Only use as a last resort to prevent complete internet loss."), + _( + "⚠️ If all Main DNS fail 3 times, queries will be sent to your ISP's DNS in plaintext. Only use as a last resort to prevent complete internet loss.", + ), ); o.default = o.disabled; @@ -904,11 +1372,12 @@ function createSettingsContent(section, capabilities) { form.Flag, "fallback_wan_bootstrap", _("Enable WAN DNS Fallback for Bootstrap DNS"), - _("⚠️ If all Bootstrap DNS fail 3 times, queries will be sent to your ISP's DNS in plaintext. Only use as a last resort to prevent complete internet loss."), + _( + "⚠️ If all Bootstrap DNS fail 3 times, queries will be sent to your ISP's DNS in plaintext. Only use as a last resort to prevent complete internet loss.", + ), ); o.default = o.disabled; - o = section.taboption( "dns", form.Value, @@ -1002,7 +1471,12 @@ function createSettingsContent(section, capabilities) { // ─── DNS Strategy ──────────────────────────────────────────────────────── - o = section.taboption("dns", form.ListValue, "dns_strategy", _("DNS Strategy")); + o = section.taboption( + "dns", + form.ListValue, + "dns_strategy", + _("DNS Strategy"), + ); o.value("prefer_ipv4", _("Prefer IPv4")); o.value("ipv4_only", _("IPv4 only")); o.value("prefer_ipv6", _("Prefer IPv6")); @@ -1232,9 +1706,7 @@ function createSettingsContent(section, capabilities) { form.Flag, "isolate_p2p", _("P2P Leak Protection"), - _( - "Isolate BitTorrent traffic and force it direct to prevent VPN bans", - ), + _("Isolate BitTorrent traffic and force it direct to prevent VPN bans"), ); o.default = "0"; o.rmempty = false; @@ -1245,7 +1717,7 @@ function createSettingsContent(section, capabilities) { "p2p_ports", _("P2P Client Ports"), _( - "Pin P2P isolation to the torrent client's own ports, comma separated. Each entry is \"proto\", \"proto:port\" or \"proto:port-port\" (tcp/udp). Without a port the whole protocol is routed direct.", + 'Pin P2P isolation to the torrent client\'s own ports, comma separated. Each entry is "proto", "proto:port" or "proto:port-port" (tcp/udp). Without a port the whole protocol is routed direct.', ), ); o.depends("isolate_p2p", "1"); @@ -1258,7 +1730,9 @@ function createSettingsContent(section, capabilities) { const entries = value.split(","); for (const entry of entries) { if (!/^(tcp|udp)(:[0-9]{1,5}(-[0-9]{1,5})?)?$/.test(entry.trim())) { - return _("Each entry must be \"proto\", \"proto:port\" or \"proto:port-port\" (tcp/udp), e.g. tcp:6881,udp:6881-6889"); + return _( + 'Each entry must be "proto", "proto:port" or "proto:port-port" (tcp/udp), e.g. tcp:6881,udp:6881-6889', + ); } const portSpec = entry.split(":")[1]; @@ -1267,7 +1741,11 @@ function createSettingsContent(section, capabilities) { } const bounds = portSpec.split("-").map(Number); - if (bounds.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) { + if ( + bounds.some( + (port) => !Number.isInteger(port) || port < 1 || port > 65535, + ) + ) { return _("Ports must be between 1 and 65535"); } @@ -1284,7 +1762,9 @@ function createSettingsContent(section, capabilities) { form.Flag, "game_console_optimizer", _("Game Console Optimizer (NAT Type 1)"), - _("Bypasses UDP traffic for selected game consoles (PS5/Xbox) to achieve NAT Type 1 / 2 (Full Cone NAT) for P2P matchmaking, while keeping TCP (PSN/Auth) routed through the proxy."), + _( + "Bypasses UDP traffic for selected game consoles (PS5/Xbox) to achieve NAT Type 1 / 2 (Full Cone NAT) for P2P matchmaking, while keeping TCP (PSN/Auth) routed through the proxy.", + ), ); o.default = "0"; o.rmempty = false; @@ -1298,7 +1778,11 @@ function createSettingsContent(section, capabilities) { ); gameConsoleIpsOpt.depends("game_console_optimizer", "1"); gameConsoleIpsOpt.datatype = "ipaddr"; - gameConsoleIpsOpt.renderWidget = function (section_id, option_index, cfgvalue) { + gameConsoleIpsOpt.renderWidget = function ( + section_id, + option_index, + cfgvalue, + ) { return local_devices.createLocalDeviceDynamicListWidget( this, section_id, @@ -1365,16 +1849,29 @@ function createSettingsContent(section, capabilities) { updateListsBtn.depends("list_update_enabled", "1"); updateListsBtn.onclick = function () { ui.showModal(_("Updating lists..."), [ - E("p", { class: "spinning" }, _("Downloading and applying rule sets and lists...")), + E( + "p", + { class: "spinning" }, + _("Downloading and applying rule sets and lists..."), + ), ]); - return fs.exec("/usr/bin/tachyon", ["list_update"]) + return fs + .exec("/usr/bin/tachyon", ["list_update"]) .then(function () { ui.hideModal(); - ui.addNotification(null, E("p", _("Lists and rule sets successfully updated!")), "info"); + ui.addNotification( + null, + E("p", _("Lists and rule sets successfully updated!")), + "info", + ); }) .catch(function (err) { ui.hideModal(); - ui.addNotification(null, E("p", _("Error updating lists: ") + (err.message || err)), "error"); + ui.addNotification( + null, + E("p", _("Error updating lists: ") + (err.message || err)), + "error", + ); }); }; @@ -1688,7 +2185,9 @@ function createSettingsContent(section, capabilities) { form.Flag, "webrtc_leak_protect", _("WebRTC Leak Protection"), - _("Detects and drops STUN/TURN packets in nftables to prevent real IP leaks via WebRTC.") + _( + "Detects and drops STUN/TURN packets in nftables to prevent real IP leaks via WebRTC.", + ), ); o.default = "0"; o.rmempty = false; @@ -1698,7 +2197,9 @@ function createSettingsContent(section, capabilities) { form.Flag, "dns_doq_ech", _("Strict ECH + DoQ DNS Enforcer"), - _("Forces sing-box to tunnel DNS requests via DoQ with Encrypted Client Hello (ECH) for SNI hiding.") + _( + "Forces sing-box to tunnel DNS requests via DoQ with Encrypted Client Hello (ECH) for SNI hiding.", + ), ); o.default = "0"; o.rmempty = false; @@ -1736,11 +2237,15 @@ function createSettingsContent(section, capabilities) { } } - if (typeof network !== "undefined" && typeof network.getDevices === "function") { + if ( + typeof network !== "undefined" && + typeof network.getDevices === "function" + ) { return network.getDevices().then( L.bind(function (devices) { (devices || []).forEach((dev) => { - const name = typeof dev.getName === "function" ? dev.getName() : dev.name; + const name = + typeof dev.getName === "function" ? dev.getName() : dev.name; if (name && name !== "lo" && !this.keylist.includes(name)) { const type = typeof dev.getTypeI18n === "function" @@ -1799,7 +2304,9 @@ function createSettingsContent(section, capabilities) { form.DummyValue, "_snapshots", _("Config Snapshots"), - _("Save a snapshot of the working config and restore it later if something breaks."), + _( + "Save a snapshot of the working config and restore it later if something breaks.", + ), ); snapshotsOpt.rawhtml = true; snapshotsOpt.cfgvalue = function () { @@ -1886,13 +2393,14 @@ function createSettingsContent(section, capabilities) { o.value("en", "English"); o.default = "ru"; - o = section.taboption( "ai", form.Value, "ai_doctor_api_key", _("AI API Key"), - _("API Key for OpenAI (sk-...) or DeepSeek (optional for Ollama / LM Studio)"), + _( + "API Key for OpenAI (sk-...) or DeepSeek (optional for Ollama / LM Studio)", + ), ); o.depends("enable_ai_doctor", "1"); o.password = true; @@ -1942,106 +2450,206 @@ function createSettingsContent(section, capabilities) { // ─── AI Watchdog Settings ────────────────────────────────────────────────── // Proxy Health Monitor - o = section.taboption("ai", form.Flag, "ai_proxy_health_enabled", _("Enable Proxy Health Monitor"), - _("Periodically checks if the proxy is responding. Restarts Tachyon after consecutive failures.")); + o = section.taboption( + "ai", + form.Flag, + "ai_proxy_health_enabled", + _("Enable Proxy Health Monitor"), + _( + "Periodically checks if the proxy is responding. Restarts Tachyon after consecutive failures.", + ), + ); o.default = "1"; o.rmempty = false; o.depends("enable_watchdog", "1"); - o = section.taboption("ai", form.Value, "ai_proxy_health_interval", _("Proxy Health Check Interval (s)"), - _("How often to check proxy health in seconds (fast tier, default 30).")); + o = section.taboption( + "ai", + form.Value, + "ai_proxy_health_interval", + _("Proxy Health Check Interval (s)"), + _("How often to check proxy health in seconds (fast tier, default 30)."), + ); o.default = "30"; o.datatype = "min(15)"; o.depends("ai_proxy_health_enabled", "1"); - o = section.taboption("ai", form.Value, "ai_proxy_health_fail_threshold", _("Proxy Fail Threshold"), - _("Number of consecutive failures before restarting (default 3).")); + o = section.taboption( + "ai", + form.Value, + "ai_proxy_health_fail_threshold", + _("Proxy Fail Threshold"), + _("Number of consecutive failures before restarting (default 3)."), + ); o.default = "3"; o.datatype = "min(1)"; o.depends("ai_proxy_health_enabled", "1"); - o = section.taboption("ai", form.Value, "ai_proxy_health_url", _("Proxy Health Check URL"), - _("URL to test through the proxy (default: Cloudflare 204).")); + o = section.taboption( + "ai", + form.Value, + "ai_proxy_health_url", + _("Proxy Health Check URL"), + _("URL to test through the proxy (default: Cloudflare 204)."), + ); o.default = "https://cp.cloudflare.com/generate_204"; o.depends("ai_proxy_health_enabled", "1"); // DNS Continuous Check - o = section.taboption("ai", form.Flag, "ai_dns_continuous_enabled", _("Enable DNS Continuous Check"), - _("Faster DNS health monitoring than the default cycle. Switches bootstrap DNS on failure.")); + o = section.taboption( + "ai", + form.Flag, + "ai_dns_continuous_enabled", + _("Enable DNS Continuous Check"), + _( + "Faster DNS health monitoring than the default cycle. Switches bootstrap DNS on failure.", + ), + ); o.default = "1"; o.rmempty = false; o.depends("enable_watchdog", "1"); - o = section.taboption("ai", form.Value, "ai_dns_interval", _("DNS Check Interval (s)"), - _("How often to check DNS health in seconds (default 60).")); + o = section.taboption( + "ai", + form.Value, + "ai_dns_interval", + _("DNS Check Interval (s)"), + _("How often to check DNS health in seconds (default 60)."), + ); o.default = "60"; o.datatype = "min(30)"; o.depends("ai_dns_continuous_enabled", "1"); // Reload Dedup - o = section.taboption("ai", form.Flag, "ai_reload_dedup_enabled", _("Enable Firewall Reload Dedup"), - _("Prevents multiple reload_firewall calls within 120 seconds to avoid connection drops.")); + o = section.taboption( + "ai", + form.Flag, + "ai_reload_dedup_enabled", + _("Enable Firewall Reload Dedup"), + _( + "Prevents multiple reload_firewall calls within 120 seconds to avoid connection drops.", + ), + ); o.default = "1"; o.rmempty = false; o.depends("enable_watchdog", "1"); // Metrics - o = section.taboption("ai", form.Flag, "ai_metrics_enabled", _("Enable Health Metrics"), - _("Records proxy/DNS latency and health status in hourly buckets for analysis.")); + o = section.taboption( + "ai", + form.Flag, + "ai_metrics_enabled", + _("Enable Health Metrics"), + _( + "Records proxy/DNS latency and health status in hourly buckets for analysis.", + ), + ); o.default = "1"; o.rmempty = false; o.depends("enable_watchdog", "1"); - o = section.taboption("ai", form.Value, "ai_metrics_retention_hours", _("Metrics Retention (hours)"), - _("How many hours of metrics to keep (default 24).")); + o = section.taboption( + "ai", + form.Value, + "ai_metrics_retention_hours", + _("Metrics Retention (hours)"), + _("How many hours of metrics to keep (default 24)."), + ); o.default = "24"; o.datatype = "min(1)"; o.depends("ai_metrics_enabled", "1"); // Smart Cooldowns - o = section.taboption("ai", form.Flag, "ai_smart_cooldowns_enabled", _("Enable Smart Cooldowns"), - _("Use 3-tier check intervals: fast (15s), normal (120s), slow (300s) instead of a single interval.")); + o = section.taboption( + "ai", + form.Flag, + "ai_smart_cooldowns_enabled", + _("Enable Smart Cooldowns"), + _( + "Use 3-tier check intervals: fast (15s), normal (120s), slow (300s) instead of a single interval.", + ), + ); o.default = "1"; o.rmempty = false; o.depends("enable_watchdog", "1"); // Config Validation - o = section.taboption("ai", form.Flag, "ai_config_validation_enabled", _("Enable Config Validation"), - _("Validates sing-box config before restart to prevent boot loops on broken configs.")); + o = section.taboption( + "ai", + form.Flag, + "ai_config_validation_enabled", + _("Enable Config Validation"), + _( + "Validates sing-box config before restart to prevent boot loops on broken configs.", + ), + ); o.default = "1"; o.rmempty = false; o.depends("enable_watchdog", "1"); // Graceful Degradation - o = section.taboption("ai", form.Flag, "ai_graceful_degradation_enabled", _("Enable Graceful Degradation"), - _("If one health check fails with an error, continue running other checks instead of stopping.")); + o = section.taboption( + "ai", + form.Flag, + "ai_graceful_degradation_enabled", + _("Enable Graceful Degradation"), + _( + "If one health check fails with an error, continue running other checks instead of stopping.", + ), + ); o.default = "1"; o.rmempty = false; o.depends("enable_watchdog", "1"); // Persistent Smart Detect - o = section.taboption("ai", form.Flag, "ai_persistent_smart_detect", _("Persistent Smart Detect"), - _("Store auto-detected domains in /etc instead of /tmp to survive reboots.")); + o = section.taboption( + "ai", + form.Flag, + "ai_persistent_smart_detect", + _("Persistent Smart Detect"), + _( + "Store auto-detected domains in /etc instead of /tmp to survive reboots.", + ), + ); o.default = "1"; o.rmempty = false; o.depends("enable_watchdog", "1"); // Adaptive Intervals - o = section.taboption("ai", form.Flag, "ai_adaptive_intervals_enabled", _("Enable Adaptive Intervals"), - _("Automatically increase check intervals when healthy (to 5 min) and decrease on problems (to 120s).")); + o = section.taboption( + "ai", + form.Flag, + "ai_adaptive_intervals_enabled", + _("Enable Adaptive Intervals"), + _( + "Automatically increase check intervals when healthy (to 5 min) and decrease on problems (to 120s).", + ), + ); o.default = "1"; o.rmempty = false; o.depends("enable_watchdog", "1"); // Anomaly Detection - o = section.taboption("ai", form.Flag, "ai_anomaly_detection_enabled", _("Enable Anomaly Detection"), - _("Monitors sing-box reconnect frequency. Alerts if too many reconnects per hour.")); + o = section.taboption( + "ai", + form.Flag, + "ai_anomaly_detection_enabled", + _("Enable Anomaly Detection"), + _( + "Monitors sing-box reconnect frequency. Alerts if too many reconnects per hour.", + ), + ); o.default = "1"; o.rmempty = false; o.depends("enable_watchdog", "1"); - o = section.taboption("ai", form.Value, "ai_anomaly_reconnect_threshold", _("Reconnect Threshold"), - _("Max reconnects per hour before alert (default 10).")); + o = section.taboption( + "ai", + form.Value, + "ai_anomaly_reconnect_threshold", + _("Reconnect Threshold"), + _("Max reconnects per hour before alert (default 10)."), + ); o.default = "10"; o.datatype = "min(1)"; o.depends("ai_anomaly_detection_enabled", "1"); @@ -2077,7 +2685,9 @@ function createSettingsContent(section, capabilities) { return createSmartDetectSectionsWidget(section_id); }; sdSectionsOpt.formvalue = function (section_id) { - const el = document.getElementById("smart-detect-sections-widget-" + section_id); + const el = document.getElementById( + "smart-detect-sections-widget-" + section_id, + ); return el ? el.value : []; }; sdSectionsOpt.write = function (section_id, formvalue) { @@ -2095,7 +2705,9 @@ function createSettingsContent(section, capabilities) { form.DummyValue, "_smart_detect_domains", _("Auto-detected domains"), - _("List of domains automatically detected and the section they were added to. To edit or remove them, open the routing rules of the respective section.") + _( + "List of domains automatically detected and the section they were added to. To edit or remove them, open the routing rules of the respective section.", + ), ); sdDomainsOpt.depends("smart_detect", "1"); sdDomainsOpt.rawhtml = true; @@ -2113,10 +2725,20 @@ function createSettingsContent(section, capabilities) { return "" + _("No domains detected yet") + ""; } - function esc(s) { return String(s || "").replace(/&/g, "&").replace(//g, ">"); } - let html = "
    "; + function esc(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">"); + } + let html = '
      '; allDomains.forEach(function (item) { - html += "
    • " + esc(item.domain) + " → " + esc(item.section) + "
    • "; + html += + "
    • " + + esc(item.domain) + + " → " + + esc(item.section) + + "
    • "; }); html += "
    "; return html; @@ -2412,7 +3034,9 @@ function createTelegramContent(section) { form.ListValue, "bot_proxy_section", _("Proxy Section for Bot"), - _("Route bot API requests through this section. Leave empty to use the default mixed proxy."), + _( + "Route bot API requests through this section. Leave empty to use the default mixed proxy.", + ), ); o.depends("enabled", "1"); o.retain = true; @@ -2467,7 +3091,8 @@ function createTelegramContent(section) { ); const result = E("div", { id: "tachyon-telegram-test-result", - style: "margin-top: 8px; white-space: pre-wrap; font-family: monospace; font-size: 12px; display: none;", + style: + "margin-top: 8px; white-space: pre-wrap; font-family: monospace; font-size: 12px; display: none;", }); wrapper.appendChild(btn); wrapper.appendChild(result); @@ -2490,7 +3115,8 @@ function createTelegramContent(section) { if (!data || !data.checks) { result.textContent = - "\u274C " + _("Failed to run diagnostics — is the tachyon binary installed?"); + "\u274C " + + _("Failed to run diagnostics — is the tachyon binary installed?"); btn.disabled = false; btn.textContent = "\uD83D\uDD0C " + _("Test Connection"); return; @@ -2503,7 +3129,10 @@ function createTelegramContent(section) { } if (data.ok) { lines.push(""); - lines.push("\u2705 " + _("All checks passed — Telegram bot should work correctly")); + lines.push( + "\u2705 " + + _("All checks passed — Telegram bot should work correctly"), + ); } result.textContent = lines.join("\n"); result.style.color = data.ok ? "" : "#e53935"; @@ -2512,7 +3141,8 @@ function createTelegramContent(section) { }) .catch(function () { result.textContent = - "\u274C " + _("Failed to run diagnostics — is the tachyon binary installed?"); + "\u274C " + + _("Failed to run diagnostics — is the tachyon binary installed?"); btn.disabled = false; btn.textContent = "\uD83D\uDD0C " + _("Test Connection"); }); diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js index 4276fbec..ba242347 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/tachyon.js @@ -425,139 +425,232 @@ const EntryPoint = { }); }; - const rulesSection = tachyonMap.section( - form.GridSection, - "section", - _("Sections"), - _("Drag rows to change priority. The rule at the top is checked first."), - ); - configureGridSection( - rulesSection, - "section", - _("Section"), - _("Add a section"), - ); - section.configureSectionSection(rulesSection, { - loadActionProvidersAvailability: loadUiCapabilities, - }); - section.createSectionContent(rulesSection); + const rawTabOrder = uci.get(UCI_PACKAGE, "settings", "tab_order"); + const savedOrder = Array.isArray(rawTabOrder) + ? rawTabOrder + : typeof rawTabOrder === "string" && rawTabOrder.trim() + ? rawTabOrder.trim().split(/\s+/) + : []; - const serverSection = tachyonMap.section( - form.GridSection, - "server", - _("Servers"), - _("Accept external proxy connections and route them with sing-box."), - ); - configureGridSection( - serverSection, - "server", - _("Server"), - _("Add a server inbound"), - ); - serverSectionRef = serverSection; - server.configureServerSection(serverSection, { - loadCapabilities: loadUiCapabilities, - }); - server.createServerContent(serverSection, uiCapabilities); - - const profileSection = tachyonMap.section( - form.GridSection, - "profile", - _("Family Profiles"), - _("Group devices into family profiles to manage content filtering, SafeSearch, and screen time limits in one place."), - ); - configureGridSection( - profileSection, - "profile", - _("Family Profile"), - _("Add a family profile"), - ); - parental.createProfileContent(profileSection); - - const parentalSection = tachyonMap.section( - form.GridSection, - "schedule", - _("Parental Control"), - _("Block internet access or specific proxy sections for family profiles or individual devices based on time and days."), - ); - configureGridSection( - parentalSection, - "schedule", - _("Schedule Rule"), - _("Add a schedule rule"), - ); - parental.createParentalContent(parentalSection); - - const settingsSection = tachyonMap.section( - form.TypedSection, - "settings", - _("Settings"), - ); - settingsSection.anonymous = true; - settingsSection.addremove = false; - settingsSection.cfgsections = function () { - return ["settings"]; - }; - settings.createSettingsContent(settingsSection, uiCapabilities); - - const telegramSection = tachyonMap.section( - form.TypedSection, - "telegram", - _("Telegram Bot"), - ); - telegramSection.anonymous = true; - telegramSection.addremove = false; - telegramSection.cfgsections = function () { - return ["telegram"]; - }; - settings.createTelegramContent(telegramSection); - const diagnosticSection = tachyonMap.section( - form.TypedSection, - "diagnostic", - _("Diagnostics"), - ); - diagnosticSection.anonymous = true; - diagnosticSection.addremove = false; - diagnosticSection.cfgsections = function () { - return ["diagnostic"]; - }; - diagnostic.createDiagnosticContent(diagnosticSection); - - const dashboardSection = tachyonMap.section( - form.TypedSection, + const defaultTabOrder = [ "dashboard", - _("Dashboard"), - ); - dashboardSection.anonymous = true; - dashboardSection.addremove = false; - dashboardSection.cfgsections = function () { - return ["dashboard"]; - }; - dashboard.createDashboardContent(dashboardSection); - - const monitoringSection = tachyonMap.section( - form.TypedSection, + "section", + "server", + "profile", + "schedule", "monitoring", - _("Monitoring"), - ); - monitoringSection.anonymous = true; - monitoringSection.addremove = false; - monitoringSection.cfgsections = function () { - return ["monitoring"]; - }; - monitoring.createMonitoringContent(monitoringSection); - - const updatesSection = tachyonMap.section( - form.TypedSection, + "diagnostic", "updates", - _("Components"), - ); - updatesSection.anonymous = true; - updatesSection.addremove = false; - updatesSection.cfgsections = function () { - return ["updates"]; + "settings", + "telegram", + ]; + + const tabOrder = []; + savedOrder.forEach((id) => { + if (defaultTabOrder.indexOf(id) >= 0 && tabOrder.indexOf(id) < 0) { + tabOrder.push(id); + } + }); + defaultTabOrder.forEach((id) => { + if (tabOrder.indexOf(id) < 0) { + tabOrder.push(id); + } + }); + + const defaultTab = + uci.get(UCI_PACKAGE, "settings", "default_tab") || "dashboard"; + + const isTabVisible = (id) => { + if (id === "settings") return true; + if (id === "server") { + return ( + uci.get(UCI_PACKAGE, "settings", "show_tab_servers") === "1" || + uci.get(UCI_PACKAGE, "settings", "show_tab_server") === "1" + ); + } + if (id === "profile") { + return ( + uci.get(UCI_PACKAGE, "settings", "show_tab_profiles") === "1" || + uci.get(UCI_PACKAGE, "settings", "show_tab_profile") === "1" + ); + } + if (id === "schedule") { + return ( + uci.get(UCI_PACKAGE, "settings", "show_tab_parental") === "1" || + uci.get(UCI_PACKAGE, "settings", "show_tab_schedule") === "1" + ); + } + return uci.get(UCI_PACKAGE, "settings", "show_tab_" + id) !== "0"; }; - updates.createUpdatesContent(updatesSection); + + const sectionFactories = { + dashboard: () => { + const s = tachyonMap.section( + form.TypedSection, + "dashboard", + _("Dashboard"), + ); + s.anonymous = true; + s.addremove = false; + s.cfgsections = function () { + return ["dashboard"]; + }; + dashboard.createDashboardContent(s); + return s; + }, + section: () => { + const s = tachyonMap.section( + form.GridSection, + "section", + _("Sections"), + _( + "Drag rows to change priority. The rule at the top is checked first.", + ), + ); + configureGridSection(s, "section", _("Section"), _("Add a section")); + section.configureSectionSection(s, { + loadActionProvidersAvailability: loadUiCapabilities, + }); + section.createSectionContent(s); + return s; + }, + server: () => { + const s = tachyonMap.section( + form.GridSection, + "server", + _("Servers"), + _("Accept external proxy connections and route them with sing-box."), + ); + configureGridSection( + s, + "server", + _("Server"), + _("Add a server inbound"), + ); + serverSectionRef = s; + server.configureServerSection(s, { + loadCapabilities: loadUiCapabilities, + }); + server.createServerContent(s, uiCapabilities); + return s; + }, + profile: () => { + const s = tachyonMap.section( + form.GridSection, + "profile", + _("Family Profiles"), + _( + "Group devices into family profiles to manage content filtering, SafeSearch, and screen time limits in one place.", + ), + ); + configureGridSection( + s, + "profile", + _("Family Profile"), + _("Add a family profile"), + ); + parental.createProfileContent(s); + return s; + }, + schedule: () => { + const s = tachyonMap.section( + form.GridSection, + "schedule", + _("Parental Control"), + _( + "Block internet access or specific proxy sections for family profiles or individual devices based on time and days.", + ), + ); + configureGridSection( + s, + "schedule", + _("Schedule Rule"), + _("Add a schedule rule"), + ); + parental.createParentalContent(s); + return s; + }, + monitoring: () => { + const s = tachyonMap.section( + form.TypedSection, + "monitoring", + _("Monitoring"), + ); + s.anonymous = true; + s.addremove = false; + s.cfgsections = function () { + return ["monitoring"]; + }; + monitoring.createMonitoringContent(s); + return s; + }, + diagnostic: () => { + const s = tachyonMap.section( + form.TypedSection, + "diagnostic", + _("Diagnostics"), + ); + s.anonymous = true; + s.addremove = false; + s.cfgsections = function () { + return ["diagnostic"]; + }; + diagnostic.createDiagnosticContent(s); + return s; + }, + updates: () => { + const s = tachyonMap.section( + form.TypedSection, + "updates", + _("Components"), + ); + s.anonymous = true; + s.addremove = false; + s.cfgsections = function () { + return ["updates"]; + }; + updates.createUpdatesContent(s); + return s; + }, + settings: () => { + const s = tachyonMap.section( + form.TypedSection, + "settings", + _("Settings"), + ); + s.anonymous = true; + s.addremove = false; + s.cfgsections = function () { + return ["settings"]; + }; + settings.createSettingsContent(s, uiCapabilities); + return s; + }, + telegram: () => { + const s = tachyonMap.section( + form.TypedSection, + "telegram", + _("Telegram Bot"), + ); + s.anonymous = true; + s.addremove = false; + s.cfgsections = function () { + return ["telegram"]; + }; + settings.createTelegramContent(s); + return s; + }, + }; + + // Instantiate enabled sections in configured tab order + for (const tabId of tabOrder) { + if ( + isTabVisible(tabId) && + typeof sectionFactories[tabId] === "function" + ) { + sectionFactories[tabId](); + } + } await loadUiCapabilities().catch(() => null); @@ -567,6 +660,23 @@ const EntryPoint = { logWatcherStartDelayMs: 5000, }); + if (!window.location.hash || window.location.hash === "#") { + const targetTabKey = defaultTab; + window.requestAnimationFrame(() => { + const tabLink = document.querySelector( + `.cbi-tab[data-tab="cbi-tachyon-${targetTabKey}"] a, ` + + `.cbi-tab[data-tab="${targetTabKey}"] a, ` + + `[data-tab="cbi-tachyon-${targetTabKey}"] > a, ` + + `[data-tab="${targetTabKey}"] > a, ` + + `li[data-tab="cbi-tachyon-${targetTabKey}"] a, ` + + `li[data-tab="${targetTabKey}"] a`, + ); + if (tabLink) { + tabLink.click(); + } + }); + } + return rendered; }, }; diff --git a/luci-app-tachyon/po/ru/tachyon.po b/luci-app-tachyon/po/ru/tachyon.po index 29f956cf..d729dc17 100644 --- a/luci-app-tachyon/po/ru/tachyon.po +++ b/luci-app-tachyon/po/ru/tachyon.po @@ -5395,3 +5395,111 @@ msgstr "Сохранять резервную копию перед обновл msgid "Keep the previous working binary/package on the router flash for instant offline rollback if needed. Consumes extra storage." msgstr "Сохранять предыдущую рабочую версию компонента в памяти роутера для быстрого локального отката. Занимает дополнительное место на flash." + +msgid "Menu & Navigation" +msgstr "Меню и навигация" + +msgid "Default Tab on Startup" +msgstr "Вкладка по умолчанию при входе" + +msgid "Select which tab opens automatically when entering Tachyon." +msgstr "Выберите, какая вкладка открывается автоматически при входе в Tachyon." + +msgid "Show 'Dashboard' tab" +msgstr "Отображать вкладку «Главная»" + +msgid "Display the main overview and status dashboard in the menu." +msgstr "Отображать главный обзор и статус-дашборд в меню." + +msgid "Show 'Sections' tab" +msgstr "Отображать вкладку «Маршрутизация»" + +msgid "Display proxy sections and routing rules in the menu." +msgstr "Отображать секции прокси и правила маршрутизации в меню." + +msgid "Show 'Servers' tab" +msgstr "Отображать вкладку «Серверы»" + +msgid "Display external proxy servers (inbounds) in the menu." +msgstr "Отображать внешние прокси-серверы (inbounds) в меню." + +msgid "Show 'Family Profiles' tab" +msgstr "Отображать вкладку «Семейные профили»" + +msgid "Display family profile groups and device assignment in the menu." +msgstr "Отображать группы семейных профилей и привязку устройств в меню." + +msgid "Show 'Parental Control' tab" +msgstr "Отображать вкладку «Родительский контроль»" + +msgid "Display parental control and schedule rules in the menu." +msgstr "Отображать расписания и правила родительского контроля в меню." + +msgid "Show 'Monitoring' tab" +msgstr "Отображать вкладку «Мониторинг»" + +msgid "Display live traffic statistics and client activity in the menu." +msgstr "Отображать статистику трафика и активность клиентов в меню." + +msgid "Show 'Diagnostics' tab" +msgstr "Отображать вкладку «Диагностика»" + +msgid "Display network health checks and AI Doctor in the menu." +msgstr "Отображать сетевую диагностику и ИИ-Доктора в меню." + +msgid "Show 'Components' tab" +msgstr "Отображать вкладку «Компоненты»" + +msgid "Display component updates and version manager in the menu." +msgstr "Отображать обновления компонентов и управление версиями в меню." + +msgid "Show 'Telegram Bot' tab" +msgstr "Отображать вкладку «Telegram-бот»" + +msgid "Display the Telegram bot settings and controls in the menu." +msgstr "Отображать настройки и управление Telegram-ботом в меню." + +msgid "Tab Order & Visibility" +msgstr "Порядок и видимость вкладок" + +msgid "Customize the order and visibility of tabs in the main navigation menu. Use the arrows to reorder and checkboxes to show or hide tabs." +msgstr "Настройте порядок и видимость вкладок в главном меню навигации. Используйте стрелки для перемещения и галочки для показа или скрытия." + +msgid "Always visible" +msgstr "Всегда включено" + +msgid "Move Up" +msgstr "Переместить выше" + +msgid "Move Down" +msgstr "Переместить ниже" + +msgid "Main overview and status widgets" +msgstr "Главный обзор и виджеты статуса" + +msgid "Routing sections, rules and strategies" +msgstr "Секции маршрутизации, правила и стратегии" + +msgid "External inbound proxy servers" +msgstr "Внешние входящие прокси-серверы" + +msgid "Family profiles and device grouping" +msgstr "Семейные профили и группировка устройств" + +msgid "Access schedule and screen time limits" +msgstr "Расписание доступа и лимиты времени" + +msgid "Traffic statistics and active connections" +msgstr "Статистика трафика и активные соединения" + +msgid "System diagnostics, AI Doctor and logs" +msgstr "Системная диагностика, ИИ-Доктор и логи" + +msgid "Component versions and update manager" +msgstr "Версии компонентов и менеджер обновлений" + +msgid "Core configuration and preferences" +msgstr "Основные настройки и параметры" + +msgid "Remote management Telegram daemon" +msgstr "Демон Telegram для удаленного управления" diff --git a/tachyon/files/etc/config/tachyon b/tachyon/files/etc/config/tachyon index 4935de69..0bf7f6fb 100644 --- a/tachyon/files/etc/config/tachyon +++ b/tachyon/files/etc/config/tachyon @@ -58,6 +58,28 @@ config settings 'settings' # ─── AI Watchdog settings ───────────────────────────────────────────── option ai_proxy_health_enabled '1' option ai_proxy_health_interval '30' + + # ─── Navigation & Menu settings ─────────────────────────────────────── + option default_tab 'dashboard' + list tab_order 'dashboard' + list tab_order 'section' + list tab_order 'server' + list tab_order 'profile' + list tab_order 'schedule' + list tab_order 'monitoring' + list tab_order 'diagnostic' + list tab_order 'updates' + list tab_order 'settings' + list tab_order 'telegram' + option show_tab_dashboard '1' + option show_tab_sections '1' + option show_tab_servers '0' + option show_tab_profiles '0' + option show_tab_parental '0' + option show_tab_monitoring '1' + option show_tab_diagnostic '1' + option show_tab_updates '1' + option show_tab_telegram '1' option ai_proxy_health_fail_threshold '3' option ai_proxy_health_url 'https://cp.cloudflare.com/generate_204' option ai_dns_continuous_enabled '1'