From 31748c050e4b936ca814fb146418b86e4d56f4ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 21 Oct 2026 17:11:15 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=88=20Sync=202026-10-21=2017:11:15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gately/Makefile | 4 +- gately/files/gately.config | 3 + gately/files/gately.init | 71 +- .../resources/view/gately/settings.js | 123 +-- luci-app-gately/po/templates/gately.pot | 12 - luci-app-gately/po/zh_Hans/gately.po | 12 - .../root/etc/uci-defaults/66_luci-gately | 9 +- .../usr/share/rpcd/acl.d/luci-app-gately.json | 2 +- .../root/usr/share/rpcd/ucode/luci.gately | 117 +-- luci-app-open-box/Makefile | 4 +- luci-app-trafficctl/Makefile | 4 +- .../resources/view/trafficctl/settings.js | 746 ++++++++++++++++++ .../resources/view/trafficctl/status.js | 662 +--------------- .../po/templates/luci-app-trafficctl.pot | 9 + .../luci/menu.d/luci-app-trafficctl.json | 11 + meow/Makefile | 4 +- 16 files changed, 900 insertions(+), 893 deletions(-) create mode 100644 luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/settings.js diff --git a/gately/Makefile b/gately/Makefile index 40085ee00..3e2d9925d 100644 --- a/gately/Makefile +++ b/gately/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gately -PKG_VERSION:=0.5.3 -PKG_RELEASE:=3 +PKG_VERSION:=0.5.4 +PKG_RELEASE:=4 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)? diff --git a/gately/files/gately.config b/gately/files/gately.config index b9e7665cc..7b91b2fb9 100644 --- a/gately/files/gately.config +++ b/gately/files/gately.config @@ -2,3 +2,6 @@ config gately 'main' option enabled '0' option configdir '/etc/gately' option initial_password 'admin123456' + option admin_port '16680' + option admin_safe_url '' + option admin_internet '0' diff --git a/gately/files/gately.init b/gately/files/gately.init index 597eaadad..4da762832 100644 --- a/gately/files/gately.init +++ b/gately/files/gately.init @@ -7,22 +7,71 @@ USE_PROCD=1 PROG=/usr/bin/gately -start_service() { - config_load gately - - local enabled configdir applydir initial_password - config_get_bool enabled main enabled 0 - [ "$enabled" -eq 1 ] || return 0 - - config_get configdir main configdir '/etc/gately' - config_get applydir main applydir '' - config_get initial_password main initial_password 'admin123456' +sync_gately_config() { + local configdir="$1" + local admin_port="$2" + local admin_safe_url="$3" + local admin_internet="$4" + local initial_password="$5" mkdir -p "$configdir" chmod 700 "$configdir" + if [ -x /usr/bin/ucode ]; then + GATELY_CFG_DIR="$configdir" \ + GATELY_PORT="$admin_port" \ + GATELY_SAFE_URL="$admin_safe_url" \ + GATELY_INTERNET="$admin_internet" \ + /usr/bin/ucode -e ' + import { access, readfile, writefile, chmod } from "fs"; + let dir = getenv("GATELY_CFG_DIR") || "/etc/gately"; + let port = trim(getenv("GATELY_PORT") || "16680"); + let safe = trim(getenv("GATELY_SAFE_URL") || ""); + let inet = (getenv("GATELY_INTERNET") === "1"); + if (length(safe) > 0 && substr(safe, 0, 1) !== "/") { + safe = "/" + safe; + } + let path = dir + "/gately.json"; + let cfg = {}; + if (access(path, "r")) { + try { cfg = json(readfile(path) || "{}"); } catch (e) { cfg = {}; } + } + cfg.listen = ":" + port; + cfg.safePath = safe; + cfg.lanOnly = !inet; + writefile(path, sprintf("%.2J\n", cfg)); + chmod(path, 0600); + ' 2>/dev/null + fi + + local pw_stamp="$configdir/.last_initial_password" + local old_pw="" + [ -f "$pw_stamp" ] && old_pw="$(cat "$pw_stamp" 2>/dev/null)" + if [ ! -f "$configdir/account.json" ] || [ "$initial_password" != "$old_pw" ]; then + rm -f "$configdir/account.json" "$configdir/auth.json" + printf '%s' "$initial_password" > "$pw_stamp" + chmod 600 "$pw_stamp" + fi +} + +start_service() { + config_load gately + + local enabled configdir applydir initial_password admin_port admin_safe_url admin_internet + config_get_bool enabled main enabled 0 + config_get configdir main configdir '/etc/gately' + config_get applydir main applydir '' + config_get initial_password main initial_password 'admin123456' + config_get admin_port main admin_port '16680' + config_get admin_safe_url main admin_safe_url '' + config_get_bool admin_internet main admin_internet 0 + + sync_gately_config "$configdir" "$admin_port" "$admin_safe_url" "$admin_internet" "$initial_password" + + [ "$enabled" -eq 1 ] || return 0 + procd_open_instance gately - procd_set_param command "$PROG" -c "$configdir" + procd_set_param command "$PROG" -c "$configdir" -listen ":$admin_port" [ -n "$applydir" ] && procd_append_param command -apply "$applydir" [ -n "$initial_password" ] && procd_set_param env GATELY_INITIAL_PASSWORD="$initial_password" diff --git a/luci-app-gately/htdocs/luci-static/resources/view/gately/settings.js b/luci-app-gately/htdocs/luci-static/resources/view/gately/settings.js index 282f73a2c..5b88a8bf6 100644 --- a/luci-app-gately/htdocs/luci-static/resources/view/gately/settings.js +++ b/luci-app-gately/htdocs/luci-static/resources/view/gately/settings.js @@ -1,143 +1,45 @@ 'use strict'; 'require view'; -'require rpc'; -'require uci'; -'require ui'; -'require dom'; 'require form'; -var callGetInfo = rpc.declare({ object: 'luci.gately', method: 'get_info', expect: { } }); -var callSetConfig = rpc.declare({ object: 'luci.gately', method: 'set_config', params: ['key', 'value'], expect: { } }); -var callService = rpc.declare({ object: 'luci.gately', method: 'service', params: ['action'], expect: { } }); - return view.extend({ - pendingTasks: [], - - load: function() { - return Promise.all([ - uci.load('gately'), - callGetInfo() - ]); - }, - - queueSetConfig: function(key, value) { - var p = callSetConfig(key, value).then(function(res) { - if (res && res.ret !== 0 && res.error) { - ui.addNotification(null, E('p', res.error), 'error'); - } - return res; - }); - this.pendingTasks.push(p); - return p; - }, - - handleSave: function(ev) { - var self = this; - this.pendingTasks = []; - var map = this.formMap; - if (!map) return Promise.resolve(); - - return map.save(null, true).then(function() { - return Promise.all(self.pendingTasks); - }).then(function() { - return uci.save(); - }).then(function() { - return callSetConfig('commit_uci', ''); - }).then(function() { - if (ui.changes && typeof ui.changes.setIndicator === 'function') { - ui.changes.setIndicator(0); - } - }).catch(function(err) { - ui.addNotification(null, E('p', err.message || String(err)), 'error'); - return Promise.reject(err); - }); - }, - - handleSaveApply: function(ev, mode) { - var self = this; - return this.handleSave(ev).then(function() { - var enabled = uci.get('gately', 'main', 'enabled'); - var action = (enabled === '1') ? 'restart' : 'stop'; - ui.showModal(null, [ E('p', { class: 'spinning' }, _('Updating configuration and restarting service...')) ]); - return callService(action).then(function() { - ui.hideModal(); - if (ui.changes && typeof ui.changes.setIndicator === 'function') { - ui.changes.setIndicator(0); - } - ui.addNotification(null, E('p', _('Configuration updated successfully')), 'info'); - uci.unload('gately'); - return self.load().then(function(data) { - return self.render(data); - }).then(function(node) { - var mapEl = document.querySelector('.cbi-map'); - if (mapEl && mapEl.parentNode) dom.content(mapEl.parentNode, node); - }); - }); - }); - }, - - render: function(data) { - var self = this; - var baseConf = (data[1] && data[1].base_conf) ? data[1].base_conf : {}; + render: function() { var m, s, o; m = new form.Map('gately', _('Gately Settings'), _('Gately is an open-source, auditable network gateway toolkit (Lucky alternative) integrating DDNS, STUN, port forwarding, reverse proxy, ACME certificates, and WAF.')); - this.formMap = m; s = m.section(form.NamedSection, 'main', 'gately', _('Global Settings')); s.anonymous = true; s.addremove = false; - // Enable service o = s.option(form.Flag, 'enabled', _('Enable')); o.rmempty = false; - // Config Directory (UCI) o = s.option(form.Value, 'configdir', _('Config Directory Path')); o.rmempty = false; + o.default = '/etc/gately'; o.placeholder = '/etc/gately'; - // Initial Password (UCI + account.json reset on change) o = s.option(form.Value, 'initial_password', _('Initial Admin Password')); o.password = true; o.rmempty = false; o.default = 'admin123456'; o.placeholder = 'admin123456'; + o.datatype = 'minlength(8)'; o.description = _('Admin password (at least 8 characters, default: admin123456). Modifying this and clicking Save & Apply will update the admin password immediately.'); - o.validate = function(section_id, value) { - if (!value || value.length < 8) { - return _('Password must be at least 8 characters'); - } - return true; - }; - o.write = function(section_id, value) { - uci.set('gately', section_id, 'initial_password', value); - self.queueSetConfig('initial_password', value); - }; - // Admin Panel Settings (gately.json) s = m.section(form.TypedSection, 'gately', _('Admin Panel Settings')); s.anonymous = true; s.addremove = false; - // Admin Port o = s.option(form.Value, 'admin_port', _('Admin Web Listen Port')); o.datatype = 'port'; o.rmempty = false; + o.default = '16680'; o.placeholder = '16680'; - o.load = function(section_id) { - return baseConf.AdminWebListenPort || '16680'; - }; - o.write = function(section_id, value) { - self.queueSetConfig('admin_http_port', value); - }; - // Admin Safe URL (safePath in gately.json) o = s.option(form.Value, 'admin_safe_url', _('Admin Safe URL')); o.placeholder = '/secret-entry'; - o.load = function(section_id) { - return baseConf.SafeURL || ''; - }; o.validate = function(section_id, value) { if (!value) return true; var v = value.trim(); @@ -150,25 +52,10 @@ return view.extend({ } return true; }; - o.write = function(section_id, value) { - self.queueSetConfig('admin_safe_url', value); - }; - o.remove = function(section_id) { - self.queueSetConfig('admin_safe_url', ''); - }; - // Allow Internet Access (!lanOnly in gately.json) o = s.option(form.Flag, 'admin_internet', _('Allow Internet Access')); o.rmempty = false; - o.load = function(section_id) { - return baseConf.AllowInternetaccess ? '1' : '0'; - }; - o.write = function(section_id, value) { - self.queueSetConfig('switch_Internetaccess', (value === '1')); - }; - o.remove = function(section_id) { - self.queueSetConfig('switch_Internetaccess', false); - }; + o.default = '0'; return m.render(); } diff --git a/luci-app-gately/po/templates/gately.pot b/luci-app-gately/po/templates/gately.pot index 8721a6df3..ddbd3c5e6 100644 --- a/luci-app-gately/po/templates/gately.pot +++ b/luci-app-gately/po/templates/gately.pot @@ -91,9 +91,6 @@ msgstr "" msgid "Admin password (at least 8 characters, default: admin123456). Modifying this and clicking Save & Apply will update the admin password immediately." msgstr "" -msgid "Password must be at least 8 characters" -msgstr "" - msgid "Admin Panel Settings" msgstr "" @@ -112,15 +109,6 @@ msgstr "" msgid "Allow Internet Access" msgstr "" -msgid "Updating configuration and restarting service..." -msgstr "" - -msgid "Configuration updated successfully" -msgstr "" - -msgid "Failed to update configuration" -msgstr "" - msgid "Real-time query of the Gately running logs to diagnose DDNS updates, STUN traversal, and reverse proxy rules." msgstr "" diff --git a/luci-app-gately/po/zh_Hans/gately.po b/luci-app-gately/po/zh_Hans/gately.po index 7d2fd3651..70b6a26b1 100644 --- a/luci-app-gately/po/zh_Hans/gately.po +++ b/luci-app-gately/po/zh_Hans/gately.po @@ -91,9 +91,6 @@ msgstr "初始管理员密码" msgid "Admin password (at least 8 characters, default: admin123456). Modifying this and clicking Save & Apply will update the admin password immediately." msgstr "管理员密码(至少 8 位字符,默认:admin123456)。修改此项并点击“保存并应用”将立即更新 admin 密码。" -msgid "Password must be at least 8 characters" -msgstr "密码长度至少需要 8 位字符" - msgid "Admin Panel Settings" msgstr "管理后台设置" @@ -112,15 +109,6 @@ msgstr "安全入口不能以 /api 或 /assets 开头,且不能包含空格、 msgid "Allow Internet Access" msgstr "允许外网访问管理后台" -msgid "Updating configuration and restarting service..." -msgstr "正在更新配置并重启服务..." - -msgid "Configuration updated successfully" -msgstr "配置已更新并生效" - -msgid "Failed to update configuration" -msgstr "更新配置失败" - msgid "Real-time query of the Gately running logs to diagnose DDNS updates, STUN traversal, and reverse proxy rules." msgstr "实时查询 Gately 运行日志,方便排查 DDNS 更新、STUN 穿透及反向代理规则执行情况。" diff --git a/luci-app-gately/root/etc/uci-defaults/66_luci-gately b/luci-app-gately/root/etc/uci-defaults/66_luci-gately index c1483a30c..40161724f 100644 --- a/luci-app-gately/root/etc/uci-defaults/66_luci-gately +++ b/luci-app-gately/root/etc/uci-defaults/66_luci-gately @@ -5,10 +5,11 @@ uci -q batch <<-EOF >/dev/null commit gately EOF -if [ -z "$(uci -q get gately.main.initial_password)" ]; then - uci -q set gately.main.initial_password='admin123456' - uci -q commit gately -fi +[ -z "$(uci -q get gately.main.configdir)" ] && uci -q set gately.main.configdir='/etc/gately' +[ -z "$(uci -q get gately.main.initial_password)" ] && uci -q set gately.main.initial_password='admin123456' +[ -z "$(uci -q get gately.main.admin_port)" ] && uci -q set gately.main.admin_port='16680' +[ -z "$(uci -q get gately.main.admin_internet)" ] && uci -q set gately.main.admin_internet='0' +uci -q commit gately rm -f /tmp/luci-indexcache* 2>/dev/null rm -f /tmp/luci-modulecache/* 2>/dev/null diff --git a/luci-app-gately/root/usr/share/rpcd/acl.d/luci-app-gately.json b/luci-app-gately/root/usr/share/rpcd/acl.d/luci-app-gately.json index 0c53240d9..c46dbdeb4 100644 --- a/luci-app-gately/root/usr/share/rpcd/acl.d/luci-app-gately.json +++ b/luci-app-gately/root/usr/share/rpcd/acl.d/luci-app-gately.json @@ -12,7 +12,7 @@ }, "write": { "ubus": { - "luci.gately": [ "set_config", "service" ] + "luci.gately": [ "service" ] }, "uci": [ "gately" ], "file": { diff --git a/luci-app-gately/root/usr/share/rpcd/ucode/luci.gately b/luci-app-gately/root/usr/share/rpcd/ucode/luci.gately index fa56e132f..5de4eae09 100644 --- a/luci-app-gately/root/usr/share/rpcd/ucode/luci.gately +++ b/luci-app-gately/root/usr/share/rpcd/ucode/luci.gately @@ -2,7 +2,7 @@ 'use strict'; -import { access, open, popen, readfile, writefile, unlink, mkdir, chmod } from 'fs'; +import { access, popen, readfile } from 'fs'; import { cursor } from 'uci'; const BIN_PATH = '/usr/bin/gately'; @@ -14,10 +14,16 @@ function get_uci_config() { let enabled = c.get('gately', 'main', 'enabled') || '0'; let configdir = c.get('gately', 'main', 'configdir') || '/etc/gately'; let initial_password = c.get('gately', 'main', 'initial_password') || 'admin123456'; + let admin_port = c.get('gately', 'main', 'admin_port') || '16680'; + let admin_safe_url = c.get('gately', 'main', 'admin_safe_url') || ''; + let admin_internet = c.get('gately', 'main', 'admin_internet') || '0'; return { enabled: (enabled === '1'), configdir: configdir, - initial_password: initial_password + initial_password: initial_password, + admin_port: admin_port, + admin_safe_url: admin_safe_url, + admin_internet: (admin_internet === '1') }; } @@ -59,7 +65,6 @@ function get_arch() { return arch; } -// Gately stores global settings in /gately.json (settingsName = "gately") function read_gately_json(configdir) { let path = configdir + '/gately.json'; if (!access(path, 'r')) { @@ -74,16 +79,6 @@ function read_gately_json(configdir) { } } -function write_gately_json(configdir, settings) { - mkdir(configdir, 0700); - let path = configdir + '/gately.json'; - let content = sprintf('%.2J\n', settings); - let res = writefile(path, content); - chmod(path, 0600); - return res; -} - -// Gately stores admin account in /account.json (accountName = "account") function read_account_json(configdir) { let path = configdir + '/account.json'; if (!access(path, 'r')) { @@ -98,17 +93,6 @@ function read_account_json(configdir) { } } -function clear_account_json(configdir) { - let acc_file = configdir + '/account.json'; - if (access(acc_file, 'f')) { - unlink(acc_file); - } - let legacy_auth = configdir + '/auth.json'; - if (access(legacy_auth, 'f')) { - unlink(legacy_auth); - } -} - function parse_port_from_listen(listen_str) { if (!listen_str) return '16680'; let idx = rindex(listen_str, ':'); @@ -118,19 +102,6 @@ function parse_port_from_listen(listen_str) { return listen_str; } -function build_listen_with_port(old_listen, new_port) { - let port = trim('' + (new_port || '16680')); - if (index(port, ':') >= 0) { - return port; - } - if (old_listen && index(old_listen, ':') > 0) { - let idx = rindex(old_listen, ':'); - let host = substr(old_listen, 0, idx); - return host + ':' + port; - } - return ':' + port; -} - const methods = { get_status: { call: function() { @@ -152,13 +123,11 @@ const methods = { let settings = read_gately_json(uci_cfg.configdir); let account = read_account_json(uci_cfg.configdir); - let effective_listen = settings.listen || ':16680'; - let admin_port = parse_port_from_listen(effective_listen); - let safe_path = settings.safePath || ''; + let admin_port = uci_cfg.admin_port || parse_port_from_listen(settings.listen) || '16680'; + let safe_path = uci_cfg.admin_safe_url || settings.safePath || ''; if (length(safe_path) > 0 && substr(safe_path, 0, 1) !== '/') { safe_path = '/' + safe_path; } - let lan_only = (settings.lanOnly === true); let admin_user = account.username || 'admin'; let admin_password = uci_cfg.initial_password || 'admin123456'; @@ -174,17 +143,7 @@ const methods = { Go: ver.go || '-' }), adminPort: admin_port, - adminListen: effective_listen, - adminSafeUrl: safe_path, - base_conf: { - Listen: effective_listen, - AdminWebListenPort: admin_port, - HTTPSListen: settings.httpsListen || '', - ForceHTTPS: settings.forceHTTPS ? true : false, - SafeURL: safe_path, - LANOnly: lan_only, - AllowInternetaccess: !lan_only - } + adminSafeUrl: safe_path }; } }, @@ -203,64 +162,10 @@ const methods = { } }, - set_config: { - args: { key: 'key', value: 'value' }, - call: function(req) { - let key = req.args?.key; - let val = req.args?.value; - let uci_cfg = get_uci_config(); - let configdir = uci_cfg.configdir; - let settings = read_gately_json(configdir); - - if (key === 'admin_http_port') { - settings.listen = build_listen_with_port(settings.listen, val); - write_gately_json(configdir, settings); - return { ret: 0 }; - } else if (key === 'admin_safe_url') { - let sp = trim('' + (val || '')); - if (length(sp) > 0 && substr(sp, 0, 1) !== '/') { - sp = '/' + sp; - } - settings.safePath = sp; - write_gately_json(configdir, settings); - return { ret: 0 }; - } else if (key === 'switch_Internetaccess') { - let allow = (val === true || val === 'true' || val === '1' || val === 1); - settings.lanOnly = !allow; - write_gately_json(configdir, settings); - return { ret: 0 }; - } else if (key === 'initial_password') { - let pw = trim('' + (val || '')); - if (length(pw) > 0 && length(pw) < 8) { - return { ret: 1, error: '初始密码至少 8 位' }; - } - let c = cursor(); - c.load('gately'); - c.set('gately', 'main', 'initial_password', pw); - c.commit('gately'); - clear_account_json(configdir); - return { ret: 0 }; - } else if (key === 'commit_uci') { - let c = cursor(); - c.load('gately'); - c.commit('gately'); - system('uci -q commit gately >/dev/null 2>&1'); - return { ret: 0 }; - } - - return { ret: 1, error: 'Unsupported config key: ' + key }; - } - }, - service: { args: { action: 'action' }, call: function(req) { let action = req.args?.action; - let c = cursor(); - c.load('gately'); - c.commit('gately'); - system('uci -q commit gately >/dev/null 2>&1'); - if (action === 'restart' || action === 'reload') { let res = system(INIT_SCRIPT + ' stop >/dev/null 2>&1; sleep 1; ' + INIT_SCRIPT + ' start >/dev/null 2>&1'); return { result: (res === 0), ret: res }; diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index cc4daf026..6a65eab80 100644 --- a/luci-app-open-box/Makefile +++ b/luci-app-open-box/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-open-box -PKG_VERSION:=0.1.343 -PKG_RELEASE:=39 +PKG_VERSION:=0.1.344 +PKG_RELEASE:=40 PKG_MAINTAINER:=liandu2024 PKG_LICENSE:=GPL-3.0-or-later diff --git a/luci-app-trafficctl/Makefile b/luci-app-trafficctl/Makefile index 5c8caaa2f..b97d834bc 100644 --- a/luci-app-trafficctl/Makefile +++ b/luci-app-trafficctl/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-trafficctl -PKG_VERSION:=1.23.1 -PKG_RELEASE:=14 +PKG_VERSION:=1.24.0 +PKG_RELEASE:=15 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Denis Iusupov diff --git a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/settings.js b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/settings.js new file mode 100644 index 000000000..780bd3428 --- /dev/null +++ b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/settings.js @@ -0,0 +1,746 @@ +'use strict'; +'require view'; +'require rpc'; +'require ui'; + +// Settings, moved off the Devices page (#90). The reporter's complaint was +// that the main view had become cluttered; @the-e3n chose one page with +// collapsible sections over sub-tabs, on the grounds that Ctrl-F then finds +// any setting — which was also the better argument. +// +// What stayed behind on Devices is what controls that page rather than the +// package: visible columns, poll and averaging window, the panel toggles, and +// the flow-offload BANNER (as opposed to the panel) — a warning about whether +// the numbers on that page can be believed belongs next to the numbers. +// +// The four sections here were already self-contained: each is lazy-loaded +// into its own container and none of them read any Devices-page state, which +// is why this is a move rather than a rewrite. + +(function() { + if (!document.querySelector('link[href*="trafficctl/status.css"]')) { + var lnk = document.createElement('link'); + lnk.rel = 'stylesheet'; + lnk.type = 'text/css'; + lnk.href = '/luci-static/resources/view/trafficctl/status.css'; + document.head.appendChild(lnk); + } +})(); + +var callTelegramGet = rpc.declare({ + object: 'luci.trafficctl', + method: 'telegram_config_get' +}); + +var callTelegramSet = rpc.declare({ + object: 'luci.trafficctl', + method: 'telegram_config_set', + params: ['enabled', 'bot_token', 'chat_id', 'poll_interval', + 'notify_new_device', 'notify_known_device', 'control_enabled', 'notify_template', + 'btn_block_inet', 'btn_block_wifi', 'btn_limiter', 'btn_shaper'] +}); + +var callTelegramTest = rpc.declare({ + object: 'luci.trafficctl', + method: 'telegram_test', + params: ['bot_token', 'chat_id', 'message'] +}); + +var callLoggingGet = rpc.declare({ + object: 'luci.trafficctl', + method: 'logging_config_get' +}); + +var callLoggingSet = rpc.declare({ + object: 'luci.trafficctl', + method: 'logging_config_set', + params: ['enabled', 'log_file', 'max_lines', 'syslog', + 'log_blocks', 'log_ratelimits', 'log_shapes', 'log_telegram', 'log_config', 'persist_rules'] +}); + +var callNewDeviceGet = rpc.declare({ + object: 'luci.trafficctl', + method: 'newdevice_config_get' +}); + +var callNewDeviceSet = rpc.declare({ + object: 'luci.trafficctl', + method: 'newdevice_config_set', + params: ['enabled', 'limit_kbit', 'limit_mode'] +}); + +var callConfigGet = rpc.declare({ + object: 'luci.trafficctl', + method: 'config_get' +}); + +var callConfigSet = rpc.declare({ + object: 'luci.trafficctl', + method: 'config_set', + params: ['enabled', 'default_mode', 'sw', 'hw', 'poll_interval', 'avg_window'] +}); + +var RATE_PRESETS = [ + {v:'0', l: _('Off')}, + {v:'1000', l:_('1 Mbit/s')}, + {v:'2000', l:_('2 Mbit/s')}, + {v:'5000', l:_('5 Mbit/s')}, + {v:'10000', l:_('10 Mbit/s')}, + {v:'25000', l:_('25 Mbit/s')}, + {v:'50000', l:_('50 Mbit/s')}, + {v:'100000', l:_('100 Mbit/s')}, + {v:'custom', l: _('Custom…')} +]; + +return view.extend({ + handleSaveApply: null, + handleSave: null, + handleReset: null, + + render: function() { + // Named as it was on the Devices page, so every appendChild below is + // the line it always was. Not collapsed here: this view IS the panel. + var settingsBody = E('div', {'class':'tc-settings-body'}); + + function mkToggle(id, label, checked, onChange) { + var cb = E('input', { 'type': 'checkbox', 'id': id, 'class': 'tc-toggle-input' }); + cb.checked = !!checked; + cb.addEventListener('change', onChange); + var track = E('label', { 'class': 'tc-toggle', 'for': id }); + return E('div', { 'class': 'tc-toggle-wrap' }, [ + cb, track, + E('label', { 'for': id, 'style': 'cursor:pointer;font-size:12px;user-select:none;color:currentColor' }, label) + ]); + } + function mkLabel(t) { + return E('span', { 'class': 'tc-inline-label' }, t); + } + + + var sectionLabel = function(t) { return E('div', {'class':'tc-section-label'}, t); }; + + function mkChipPick(options, currentValue, onChange) { + var wrapper = E('span', {'style':'display:inline-flex;flex-wrap:wrap;gap:2px;align-items:center'}); + var selected = currentValue; + var chips = []; + options.forEach(function(opt) { + var chip = E('span', {'class': opt.v === selected ? 'tc-chip tc-chip--active' : 'tc-chip'}, opt.l); + chip.addEventListener('click', function() { + selected = opt.v; + chips.forEach(function(c) { c.className = c._v === selected ? 'tc-chip tc-chip--active' : 'tc-chip'; }); + onChange(opt.v); + }); + chip._v = opt.v; + chips.push(chip); + wrapper.appendChild(chip); + }); + return { el: wrapper, getValue: function() { return selected; }, setValue: function(v) { selected = v; chips.forEach(function(c) { c.className = c._v === v ? 'tc-chip tc-chip--active' : 'tc-chip'; }); } }; + } + + function mkCollapsible(title, content, startOpen) { + var body = E('div', {'class': 'tc-collapsible-body' + (startOpen ? '' : ' tc-hidden')}); + if (content) body.appendChild(content); + var arrow = E('span', {'class':'tc-c-muted', 'style':'font-size:11px'}, startOpen ? ' ▾' : ' ▸'); + var label = sectionLabel(title); + label.style.cursor = 'pointer'; + label.appendChild(arrow); + label.addEventListener('click', function() { + var open = !body.classList.contains('tc-hidden'); + body.classList.toggle('tc-hidden'); + arrow.textContent = open ? ' ▸' : ' ▾'; + }); + return {label: label, body: body, el: E('div', {}, [label, body])}; + } + + + var tgSection = mkCollapsible(_('Telegram Bot'), null, false); + var tgLoaded = false; + tgSection.label.addEventListener('click', function() { + if (!tgLoaded && !tgSection.body.classList.contains('tc-hidden')) { + tgLoaded = true; + loadTelegramUI(tgSection.body); + } + }); + + function loadTelegramUI(container) { + var statusSpan = E('span', {'style':'font-size:12px;margin-left:8px;color:var(--tc-muted)'}, _('Loading…')); + container.appendChild(statusSpan); + + callTelegramGet().then(function(cfg) { + while (container.firstChild) container.removeChild(container.firstChild); + + var section = E('div', {'class':'tg-section'}); + container.appendChild(section); + + // ── Auto-save with debounce ── + var saveTimer = null; + var setupDone = false; + var saveStatus = E('span', {'class':'tg-save-status'}); + var doSave = function() { + if (!setupDone) return; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(function() { + saveStatus.textContent = _('Saving…'); + saveStatus.style.color = 'var(--tc-muted)'; + var tk = tokenInput.value; + if (tk === '' && cfg.bot_token_set) tk = '***'; + var inetEl = container.querySelector('#tm-tg-inet'); + var wifiEl = container.querySelector('#tm-tg-wifi'); + var limitEl = container.querySelector('#tm-tg-limit'); + var shapeEl = container.querySelector('#tm-tg-shape'); + callTelegramSet( + container.querySelector('#tm-tg-enabled').checked, + tk, + chatInput.value, + parseInt(cfg.poll_interval) || 3, + container.querySelector('#tm-tg-new').checked, + container.querySelector('#tm-tg-known').checked, + controlMode, + templateArea.value, + inetEl ? inetEl.checked : cfg.btn_block_inet, + wifiEl ? wifiEl.checked : cfg.btn_block_wifi, + limitEl ? limitEl.checked : cfg.btn_limiter, + shapeEl ? shapeEl.checked : cfg.btn_shaper + ).then(function(res) { + if (res && res.ok) { + saveStatus.textContent = '✓'; + saveStatus.style.color = 'var(--tc-ok)'; + cfg.bot_token_set = !!(tk && tk !== '***') || cfg.bot_token_set; + if (tk && tk !== '***') { + tokenInput.value = ''; + tokenInput.type = 'password'; + tokenInput.placeholder = '•••••••• ✓ ' + _('saved'); + } + } else { + saveStatus.textContent = '✗ ' + (res && res.msg || 'error'); + saveStatus.style.color = 'var(--tc-err)'; + } + }).catch(function(e) { + saveStatus.textContent = '✗ ' + e.message; + saveStatus.style.color = 'var(--tc-err)'; + }); + }, 600); + }; + + // ── Status dot ── + var dot = E('span', { + 'class': 'tg-status-dot ' + (cfg.bot_running ? 'tg-status-dot--on' : 'tg-status-dot--off'), + 'title': cfg.bot_running ? _('Bot is running') : _('Bot is stopped') + }); + + // ── Enabled toggle ── + var tgEnabled = mkToggle('tm-tg-enabled', _('Enabled'), cfg.enabled, doSave); + section.appendChild(E('div', {'class':'tg-row'}, [tgEnabled, dot, saveStatus])); + + // ── Token + Chat ID ── + var tokenInput = E('input', { + 'type': 'password', + 'class': 'tg-input tg-input--token', + 'value': '', + 'placeholder': cfg.bot_token_set ? '•••••••• ✓ ' + _('saved') : _('Paste bot token') + }); + tokenInput.addEventListener('change', doSave); + var eyeBtn = E('span', {'class':'tg-eye','title':_('Show/hide token')}, '👁'); + eyeBtn.addEventListener('click', function() { + tokenInput.type = tokenInput.type === 'password' ? 'text' : 'password'; + }); + var chatInput = E('input', { + 'type': 'text', + 'class': 'tg-input tg-input--chat', + 'value': cfg.chat_id || '', + 'placeholder': _('Chat ID') + }); + chatInput.addEventListener('change', doSave); + var testResult = E('span', {'style':'font-size:11px;margin-left:4px'}); + var testBtn = E('button', {'class':'tg-btn'}, _('Test')); + testBtn.addEventListener('click', function() { + testBtn.disabled = true; + testResult.textContent = _('Sending…'); + testResult.style.color = 'var(--tc-muted)'; + var tk = tokenInput.value || '***'; + callTelegramTest(tk, chatInput.value, templateArea.value || '').then(function(res) { + testResult.textContent = (res && res.ok) ? '✓ ' + (res.msg || 'OK') : '✗ ' + (res && res.msg || 'error'); + testResult.style.color = (res && res.ok) ? 'var(--tc-ok)' : 'var(--tc-err)'; + }).catch(function(e) { + testResult.textContent = '✗ ' + e.message; + testResult.style.color = 'var(--tc-err)'; + }).then(function() { testBtn.disabled = false; }); + }); + section.appendChild(E('div', {'class':'tg-row'}, [ + E('span', {'class':'tg-label'}, _('Token:')), tokenInput, eyeBtn, + E('span', {'style':'width:12px'}), + E('span', {'class':'tg-label'}, _('Chat ID:')), chatInput, + testBtn, testResult + ])); + + // ── Mode segmented control ── + var controlMode = cfg.control_enabled !== false; + var controlSection = E('div', {}); + var notifySection = E('div', {}); + function setMode(ctrl) { + controlMode = ctrl; + segControl.className = 'tg-segmented__item' + (ctrl ? ' tg-segmented__item--active' : ''); + segNotify.className = 'tg-segmented__item' + (!ctrl ? ' tg-segmented__item--active' : ''); + controlSection.classList.toggle('tc-hidden', !ctrl); + doSave(); + } + + var segNotify = document.createElement('div'); + segNotify.className = 'tg-segmented__item' + (!controlMode ? ' tg-segmented__item--active' : ''); + segNotify.textContent = '🔔 ' + _('Notifications only'); + segNotify.onclick = function() { setMode(false); }; + + var segControl = document.createElement('div'); + segControl.className = 'tg-segmented__item' + (controlMode ? ' tg-segmented__item--active' : ''); + segControl.textContent = '🎛 ' + _('Full control'); + segControl.onclick = function() { setMode(true); }; + + var segmented = E('div', {'class':'tg-segmented'}, [segNotify, segControl]); + + section.appendChild(E('div', {'class':'tg-row'}, [segmented])); + + // ── Notifications ── + notifySection.appendChild(E('div', {'class':'tg-divider'}, _('Notifications'))); + var notifyNew = mkToggle('tm-tg-new', _('New devices'), cfg.notify_new_device, doSave); + var notifyKnown = mkToggle('tm-tg-known', _('Known devices'), cfg.notify_known_device, doSave); + notifySection.appendChild(E('div', {'class':'tg-row'}, [notifyNew, notifyKnown])); + + // ── Custom template ── + var templateArea = E('textarea', { + 'class': 'tg-input tg-input--template', + 'placeholder': '🆕 New device\\n{{ name }} ({{ ip }})\\nMAC: {{ mac }}\\nLink: {{ link }}' + }, cfg.notify_template || ''); + templateArea.addEventListener('input', function() { renderPreview(); doSave(); }); + + var previewBubble = E('div', {'class':'tg-bubble'}); + var defaultTpl = '🆕 New device\n{{ name }} ({{ ip }})\nMAC: {{ mac }}\nLink: {{ link }}'; + var renderPreview = function() { + var tpl = templateArea.value || defaultTpl; + var txt = tpl + .replace(/\{\{\s*name\s*\}\}/g, 'MacBookPro') + .replace(/\{\{\s*ip\s*\}\}/g, '192.168.0.100') + .replace(/\{\{\s*mac\s*\}\}/g, 'aa:bb:cc:dd:ee:ff') + .replace(/\{\{\s*link\s*\}\}/g, '5G') + .replace(/\{\{\s*date\s*\}\}/g, new Date().toISOString().slice(0,10)) + .replace(/\{\{\s*time\s*\}\}/g, new Date().toTimeString().slice(0,5)) + .replace(/\{\{\s*datetime\s*\}\}/g, new Date().toISOString().slice(0,10) + ' ' + new Date().toTimeString().slice(0,5)) + .replace(/\{\{\s*router\s*\}\}/g, 'OpenWrt') + .replace(/\{\{\s*ssid\s*\}\}/g, 'MyNetwork_5G') + .replace(/\{\{\s*signal\s*\}\}/g, '-52') + .replace(/\{\{\s*freq\s*\}\}/g, '5GHz') + .replace(/\{\{\s*iface\s*\}\}/g, 'wlan1') + .replace(/\{\{\s*clients\s*\}\}/g, '12') + .replace(/\{\{\s*uptime\s*\}\}/g, '3d 5h') + .replace(/\{\{\s*wan_ip\s*\}\}/g, '85.192.48.1') + .replace(/\{\{\s*load\s*\}\}/g, '0.42') + .replace(/\{\{\s*conns\s*\}\}/g, '47'); + previewBubble.innerHTML = txt.replace(/\\n/g, '
').replace(/\n/g, '
'); + }; + renderPreview(); + + var varRef = E('div', {'style':'font-size:12px;line-height:1.7;color:currentColor'}, [ + E('div', {'style':'font-weight:600;margin-bottom:4px'}, _('Variables')), + E('div', {}, [E('code', {}, '{{ name }}'), document.createTextNode(' — ' + _('device hostname'))]), + E('div', {}, [E('code', {}, '{{ ip }}'), document.createTextNode(' — ' + _('IP address'))]), + E('div', {}, [E('code', {}, '{{ mac }}'), document.createTextNode(' — ' + _('MAC address'))]), + E('div', {}, [E('code', {}, '{{ link }}'), document.createTextNode(' — ' + _('connection type (5G, LAN)'))]), + E('div', {}, [E('code', {}, '{{ date }}'), document.createTextNode(' — ' + _('date (2026-05-26)'))]), + E('div', {}, [E('code', {}, '{{ time }}'), document.createTextNode(' — ' + _('time (14:32)'))]), + E('div', {}, [E('code', {}, '{{ datetime }}'), document.createTextNode(' — ' + _('date + time'))]), + E('div', {}, [E('code', {}, '{{ router }}'), document.createTextNode(' — ' + _('router hostname'))]), + E('div', {}, [E('code', {}, '{{ ssid }}'), document.createTextNode(' — ' + _('WiFi SSID'))]), + E('div', {}, [E('code', {}, '{{ signal }}'), document.createTextNode(' — ' + _('WiFi signal (dBm)'))]), + E('div', {}, [E('code', {}, '{{ freq }}'), document.createTextNode(' — ' + _('WiFi band (2.4/5GHz)'))]), + E('div', {}, [E('code', {}, '{{ iface }}'), document.createTextNode(' — ' + _('network interface'))]), + E('div', {}, [E('code', {}, '{{ clients }}'), document.createTextNode(' — ' + _('total connected clients'))]), + E('div', {}, [E('code', {}, '{{ uptime }}'), document.createTextNode(' — ' + _('router uptime'))]), + E('div', {}, [E('code', {}, '{{ wan_ip }}'), document.createTextNode(' — ' + _('WAN IP'))]), + E('div', {}, [E('code', {}, '{{ load }}'), document.createTextNode(' — ' + _('CPU load (1 min)'))]), + E('div', {}, [E('code', {}, '{{ conns }}'), document.createTextNode(' — ' + _('device connections'))]), + E('div', {'style':'margin-top:6px;color:var(--tc-muted);font-size:11px'}, [ + document.createTextNode(_('HTML:') + ' '), + E('code', {}, ''), document.createTextNode(' '), + E('code', {}, ''), document.createTextNode(' '), + E('code', {}, ''), document.createTextNode(' '), + E('code', {}, ''), + document.createTextNode('. \\n = ' + _('line break')) + ]) + ]); + + var templateToggle = E('span', { + 'style': 'font-size:12px;cursor:pointer;color:var(--tc-muted);user-select:none' + }, '▸ ' + _('Customize message')); + var templateBody = E('div', {'class':'tc-hidden','style':'margin-top:6px'}); + templateBody.appendChild(E('div', {'style':'display:flex;gap:14px;align-items:flex-start;flex-wrap:wrap'}, [ + E('div', {'style':'flex:1;min-width:200px'}, [templateArea]), + varRef + ])); + templateBody.appendChild(E('div', {'style':'margin-top:8px'}, [ + E('span', {'class':'tg-label','style':'display:block;margin-bottom:4px'}, _('Preview:')), + previewBubble + ])); + templateToggle.addEventListener('click', function() { + var show = templateBody.classList.contains('tc-hidden'); + templateBody.classList.toggle('tc-hidden'); + templateToggle.textContent = (show ? '▾ ' : '▸ ') + _('Customize message'); + }); + notifySection.appendChild(E('div', {'style':'margin-top:8px'}, [templateToggle, templateBody])); + + // ── Control section (conditionally visible) ── + controlSection.appendChild(E('div', {'class':'tg-divider'}, _('Control'))); + var btnInet = mkToggle('tm-tg-inet', _('Block Internet'), cfg.btn_block_inet, function() { updateKbd(); doSave(); }); + var btnWifi = mkToggle('tm-tg-wifi', _('Block WiFi'), cfg.btn_block_wifi, function() { updateKbd(); doSave(); }); + var btnLimit = mkToggle('tm-tg-limit', _('Limiter'), cfg.btn_limiter, function() { updateKbd(); doSave(); }); + var btnShape = mkToggle('tm-tg-shape', _('Shaper'), cfg.btn_shaper, function() { updateKbd(); doSave(); }); + controlSection.appendChild(E('div', {'class':'tg-row'}, [btnInet, btnWifi, btnLimit, btnShape])); + + // Dynamic keyboard preview + var kbdBubble = E('div', {'class':'tg-bubble tg-bubble--kbd'}); + var updateKbd = function() { + while (kbdBubble.firstChild) kbdBubble.removeChild(kbdBubble.firstChild); + var inetEl = controlSection.querySelector('#tm-tg-inet'); + var wifiEl = controlSection.querySelector('#tm-tg-wifi'); + var limitEl = controlSection.querySelector('#tm-tg-limit'); + var shapeEl = controlSection.querySelector('#tm-tg-shape'); + var inetOn = inetEl ? inetEl.checked : cfg.btn_block_inet; + var wifiOn = wifiEl ? wifiEl.checked : cfg.btn_block_wifi; + var limitOn = limitEl ? limitEl.checked : cfg.btn_limiter; + var shapeOn = shapeEl ? shapeEl.checked : cfg.btn_shaper; + if (!inetOn && !wifiOn && !limitOn && !shapeOn) { + kbdBubble.appendChild(E('div', {'style':'font-size:11px;color:var(--tc-faint);padding:4px'}, _('No action buttons enabled'))); + return; + } + var row1 = []; + if (inetOn) row1.push(E('span', {'class':'tg-kbd-btn'}, '⏸ Block Internet')); + if (wifiOn) row1.push(E('span', {'class':'tg-kbd-btn'}, '📵 Block WiFi')); + if (row1.length) kbdBubble.appendChild(E('div', {'class':'tg-kbd-row'}, row1)); + if (limitOn) { + var limitBtns = []; + RATE_PRESETS.forEach(function(p) { + if (p.v === '0' || p.v === 'custom') return; + limitBtns.push(E('span', {'class':'tg-kbd-btn'}, p.l.replace(' Mbit/s','M').replace(/\s/g,''))); + }); + for (var li = 0; li < limitBtns.length; li += 3) { + kbdBubble.appendChild(E('div', {'class':'tg-kbd-row'}, limitBtns.slice(li, li + 3))); + } + } + if (shapeOn) { + var shapeBtns = []; + RATE_PRESETS.forEach(function(p) { + if (p.v === '0' || p.v === 'custom') return; + shapeBtns.push(E('span', {'class':'tg-kbd-btn'}, '🔧 ' + p.l.replace(' Mbit/s','M').replace(/\s/g,''))); + }); + for (var si = 0; si < shapeBtns.length; si += 3) { + kbdBubble.appendChild(E('div', {'class':'tg-kbd-row'}, shapeBtns.slice(si, si + 3))); + } + } + kbdBubble.appendChild(E('div', {'class':'tg-kbd-row'}, [ + E('span', {'class':'tg-kbd-btn'}, '⬅️ Back') + ])); + }; + updateKbd(); + + controlSection.appendChild(E('div', {'style':'margin-top:8px'}, [ + E('span', {'class':'tg-label','style':'display:block;margin-bottom:4px'}, _('Inline keyboard preview:')), + kbdBubble + ])); + + // Commands + flow explanation + controlSection.appendChild(E('div', {'style':'margin-top:10px'}, [ + E('span', {'class':'tg-label','style':'display:block;margin-bottom:4px'}, _('Bot commands:')), + E('div', {'class':'tg-commands'}, [ + E('div', {}, [E('code', {}, '/devices'), document.createTextNode(' — ' + _('device list → tap device → action buttons'))]), + E('div', {}, [E('code', {}, '/status'), document.createTextNode(' — ' + _('all active blocks, limits, and shapes'))]), + E('div', {}, [E('code', {}, '/help'), document.createTextNode(' — ' + _('command list and bot mode'))]) + ]), + E('div', {'style':'font-size:10px;color:var(--tc-faint);margin-top:4px'}, + _('Flow: /devices → select device → inline keyboard with enabled actions above')) + ])); + + if (!controlMode) controlSection.classList.add('tc-hidden'); + section.appendChild(controlSection); + section.appendChild(notifySection); + setupDone = true; + }).catch(function(e) { + statusSpan.textContent = '✗ ' + e.message; + statusSpan.style.color = 'var(--tc-err)'; + }); + } + + // ── Assemble settings sections ───────────────────────────────────── + settingsBody.appendChild(tgSection.el); + + var loggingSection = mkCollapsible(_('Logging & Persistence'), null, false); + var loggingLoaded = false; + loggingSection.label.addEventListener('click', function() { + if (!loggingLoaded && !loggingSection.body.classList.contains('tc-hidden')) { + loggingLoaded = true; + loadLoggingUI(loggingSection.body); + } + }); + + function loadLoggingUI(container) { + var statusSpan = E('span', {'style':'font-size:12px;color:var(--tc-muted)'}, _('Loading…')); + container.appendChild(statusSpan); + + callLoggingGet().then(function(cfg) { + while (container.firstChild) container.removeChild(container.firstChild); + + var logStatus = E('span', {'class':'tg-save-status'}); + var logTimer = null; + var doLogSave = function() { + if (logTimer) clearTimeout(logTimer); + logTimer = setTimeout(function() { + logStatus.textContent = _('Saving…'); + logStatus.style.color = 'var(--tc-muted)'; + callLoggingSet( + container.querySelector('#tm-log-enabled').checked, + null, null, + container.querySelector('#tm-log-syslog').checked, + container.querySelector('#tm-log-blocks').checked, + container.querySelector('#tm-log-ratelimits').checked, + container.querySelector('#tm-log-shapes').checked, + container.querySelector('#tm-log-telegram').checked, + container.querySelector('#tm-log-config').checked, + container.querySelector('#tm-persist-rules').checked + ).then(function(res) { + logStatus.textContent = (res && res.ok) ? '✓' : '✗'; + logStatus.style.color = (res && res.ok) ? 'var(--tc-ok)' : 'var(--tc-err)'; + }).catch(function(e) { + logStatus.textContent = '✗'; + logStatus.style.color = 'var(--tc-err)'; + }); + }, 400); + }; + + var logEnabled = mkToggle('tm-log-enabled', _('Logging'), cfg.enabled, doLogSave); + var logSyslog = mkToggle('tm-log-syslog', _('Syslog'), cfg.syslog, doLogSave); + var persistRules = mkToggle('tm-persist-rules', _('Persist rules'), cfg.persist_rules, doLogSave); + + var logBlocks = mkToggle('tm-log-blocks', _('Blocks'), cfg.log_blocks, doLogSave); + var logRatelimits = mkToggle('tm-log-ratelimits', _('Ratelimits'), cfg.log_ratelimits, doLogSave); + var logShapes = mkToggle('tm-log-shapes', _('Shapes'), cfg.log_shapes, doLogSave); + var logTelegram = mkToggle('tm-log-telegram', _('Telegram'), cfg.log_telegram, doLogSave); + var logConfig = mkToggle('tm-log-config', _('Config'), cfg.log_config, doLogSave); + + container.appendChild(E('div', {'class':'tc-log-row'}, [logEnabled, logSyslog, persistRules, logStatus])); + container.appendChild(E('div', {'style':'margin-top:6px'}, [ + E('div', {'style':'font-size:11px;color:var(--tc-muted);margin-bottom:4px'}, _('Log categories')), + E('div', {'class':'tc-log-row'}, [logBlocks, logRatelimits, logShapes, logTelegram, logConfig]) + ])); + }).catch(function(e) { + statusSpan.textContent = '✗ ' + e.message; + statusSpan.style.color = 'var(--tc-err)'; + }); + } + settingsBody.appendChild(loggingSection.el); + + var newDevSection = mkCollapsible(_('New Device Defaults'), null, false); + var newDevLoaded = false; + newDevSection.label.addEventListener('click', function() { + if (!newDevLoaded && !newDevSection.body.classList.contains('tc-hidden')) { + newDevLoaded = true; + loadNewDeviceUI(newDevSection.body); + } + }); + + function loadNewDeviceUI(container) { + var statusSpan = E('span', {'style':'font-size:12px;color:var(--tc-muted)'}, _('Loading…')); + container.appendChild(statusSpan); + + callNewDeviceGet().then(function(cfg) { + while (container.firstChild) container.removeChild(container.firstChild); + + var saveStatus = E('span', {'class':'tg-save-status'}); + var hint = E('div', {'style':'font-size:11px;color:var(--tc-muted);margin-top:6px'}); + var modePick = null; + var saveTimer = null; + + // The baseline is what stops the feature limiting the whole + // LAN, so its size is shown rather than left implicit: an + // operator can tell a populated ledger from an empty one. + function renderHint(c) { + var kbit = parseInt(rateInput.value, 10); + if (enabledToggle.querySelector('input').checked && (!kbit || kbit <= 0)) { + hint.textContent = _('Set a rate above 0 — with no rate this does nothing.'); + hint.style.color = 'var(--tc-warn)'; + return; + } + hint.style.color = 'var(--tc-muted)'; + if (c && c.seeded) { + hint.textContent = _('Devices already known:') + ' ' + (c.seen_count || 0) + + ' — ' + _('these are never limited by this setting.'); + } else { + hint.textContent = _('No baseline recorded yet. It is taken from the current leases when you switch this on.'); + } + } + + var doSave = function() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(function() { + saveStatus.textContent = _('Saving…'); + saveStatus.style.color = 'var(--tc-muted)'; + var kbit = parseInt(rateInput.value, 10); + if (isNaN(kbit) || kbit < 0) kbit = 0; + callNewDeviceSet( + enabledToggle.querySelector('input').checked, + kbit, + modePick.getValue() + ).then(function(res) { + saveStatus.textContent = (res && res.ok) ? '✓' : '✗'; + saveStatus.style.color = (res && res.ok) ? 'var(--tc-ok)' : 'var(--tc-err)'; + if (res && res.ok) { + callNewDeviceGet().then(renderHint).catch(function() {}); + } + }).catch(function() { + saveStatus.textContent = '✗'; + saveStatus.style.color = 'var(--tc-err)'; + }); + }, 400); + }; + + var enabledToggle = mkToggle('tm-nd-enabled', _('Limit new devices'), cfg.enabled, function() { + renderHint(cfg); + doSave(); + }); + + var rateInput = E('input', { + 'type': 'number', + 'min': '0', + 'class': 'tg-input tg-input--chat', + 'value': cfg.limit_kbit || 0, + 'placeholder': _('kbit/s') + }); + rateInput.addEventListener('change', function() { + renderHint(cfg); + doSave(); + }); + + modePick = mkChipPick([ + { v: 'limiter', l: _('Limiter') }, + { v: 'shaper', l: _('Shaper') } + ], cfg.limit_mode || 'limiter', doSave); + + container.appendChild(E('div', {'class':'tc-log-row'}, [ + enabledToggle, mkLabel(_('Rate')), rateInput, mkLabel(_('kbit/s')), + modePick.el, saveStatus + ])); + container.appendChild(hint); + container.appendChild(E('div', {'style':'font-size:11px;color:var(--tc-muted);margin-top:4px'}, + _('Applied once, the first time a device appears on the network. A device that already has a limit is left alone.'))); + + renderHint(cfg); + }).catch(function(e) { + statusSpan.textContent = '✗ ' + e.message; + statusSpan.style.color = 'var(--tc-err)'; + }); + } + settingsBody.appendChild(newDevSection.el); + + var offloadSection = mkCollapsible(_('Flow Offload'), null, false); + var offloadLoaded = false; + offloadSection.label.addEventListener('click', function() { + if (!offloadLoaded && !offloadSection.body.classList.contains('tc-hidden')) { + offloadLoaded = true; + loadOffloadUI(offloadSection.body); + } + }); + + function loadOffloadUI(container) { + var statusSpan = E('span', {'style':'font-size:12px;color:var(--tc-muted)'}, _('Loading…')); + container.appendChild(statusSpan); + + callConfigGet().then(function(cfg) { + while (container.firstChild) container.removeChild(container.firstChild); + + var saveStatus = E('span', {'class':'tg-save-status'}); + var saveTimer = null; + var swCb, hwCb; + + function doSave() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(function() { + saveStatus.textContent = _('Applying…'); + saveStatus.style.color = 'var(--tc-muted)'; + callConfigSet(undefined, undefined, swCb.checked, hwCb.checked).then(function(res) { + saveStatus.textContent = (res && res.ok) ? '✓ ' + _('Applied — firewall reloading') : '✗'; + saveStatus.style.color = (res && res.ok) ? 'var(--tc-ok)' : 'var(--tc-err)'; + }).catch(function(e) { + saveStatus.textContent = '✗'; + saveStatus.style.color = 'var(--tc-err)'; + }); + }, 400); + } + + // Current mode badge + var modeLabels = { + 'none': ['⊘', _('No offload'), 'var(--tc-muted)'], + 'software': ['◑', _('Software offload'), 'var(--tc-speed)'], + 'software-counter': ['◑', _('Software offload (counter)'), 'var(--tc-speed)'], + 'hardware-counter': ['●', _('Hardware offload'), 'var(--tc-warn)'], + 'hardware': ['●', _('Hardware offload'), 'var(--tc-warn)'] + }; + var ml = modeLabels[cfg.offload_mode] || ['?', cfg.offload_mode, 'var(--tc-muted)']; + var modeBadge = E('div', {'style':'margin-bottom:10px;font-size:12px'}, [ + E('span', {'style':'color:'+ml[2]+';font-size:15px;margin-right:4px'}, ml[0]), + E('span', {'style':'color:var(--tc-muted)'}, _('Current mode:') + ' '), + E('b', {}, ml[1]) + ]); + + // SW toggle row + var swToggleEl = mkToggle('tc-offload-sw', _('Software flow offload'), cfg.sw, function() { + hwCb.disabled = !swCb.checked; + if (!swCb.checked) { hwCb.checked = false; } + doSave(); + }); + swCb = swToggleEl.querySelector('input'); + var swDesc = E('div', {'class':'tc-offload-desc'}, + _('Accelerates routing in the kernel via nftables flowtable. Speed monitoring and traffic shaping work normally.')); + + // HW toggle row + var hwToggleEl = mkToggle('tc-offload-hw', _('Hardware flow offload'), cfg.hw, doSave); + hwCb = hwToggleEl.querySelector('input'); + if (!cfg.sw) hwCb.disabled = true; + var hwDesc = E('div', {'class':'tc-offload-desc'}, + _('Offloads routing to the hardware engine (PPE/NPU). Requires software offload. ' + + '⚠ On many platforms (e.g. Mediatek Filogic) the driver does not report byte counters back to the kernel — real-time speed monitoring will show zero.')); + + // Qualcomm NSS acceleration (#81). This is NOT one of the + // flow-offload modes above and is not controlled by the two + // toggles below it: ECM pushes flow rules into the NSS + // firmware cores, so an accelerated flow never reaches the + // Linux stack at all and neither the nftables policer nor + // Linux HTB can act on it. The apply result says so on every + // attempt; this is here because the Flow Offload panel is + // where somebody asking "why is my limit doing nothing" looks. + if (cfg.nss === 'active') { + container.appendChild(E('div', { + 'style': 'margin-bottom:10px;padding:8px;border-left:3px solid var(--tc-warn);font-size:12px' + }, [ + E('b', {'style':'color:var(--tc-warn)'}, '⚠ ' + _('Qualcomm NSS acceleration is active')), + E('div', {'style':'margin-top:4px;color:var(--tc-muted)'}, + _('Rate limits and traffic shaping cannot be enforced while it is: accelerated flows bypass the Linux network stack, so rules are installed but have no effect. The toggles below do not control this — stop the ECM front end (front_end_ipv4_stop / front_end_ipv6_stop under /sys/kernel/debug/ecm) or set acceleration_engine in /etc/config/ecm, then re-apply the limit.')) + ])); + } + + container.appendChild(modeBadge); + container.appendChild(E('div', {'class':'tc-offload-row'}, [swToggleEl, saveStatus])); + container.appendChild(swDesc); + container.appendChild(E('div', {'class':'tc-offload-row tc-offload-row--hw'}, [hwToggleEl])); + container.appendChild(hwDesc); + }).catch(function(e) { + statusSpan.textContent = '✗ ' + (e.message || e); + statusSpan.style.color = 'var(--tc-err)'; + }); + } + settingsBody.appendChild(offloadSection.el); + + return E('div', {}, [ + E('h2', {}, _('Traffic Control Settings')), + E('div', { 'class': 'cbi-map-descr' }, + _('Package-wide settings. Controls that only affect what the Devices page shows — visible columns, poll interval, averaging window — stay on that page.')), + settingsBody + ]); + } +}); + diff --git a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js index 03d36416b..b3c0d9c68 100644 --- a/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js +++ b/luci-app-trafficctl/htdocs/luci-static/resources/view/trafficctl/status.js @@ -163,48 +163,6 @@ var callNameSet = rpc.declare({ params: ['ip', 'name'] }); -var callTelegramGet = rpc.declare({ - object: 'luci.trafficctl', - method: 'telegram_config_get' -}); - -var callTelegramSet = rpc.declare({ - object: 'luci.trafficctl', - method: 'telegram_config_set', - params: ['enabled', 'bot_token', 'chat_id', 'poll_interval', - 'notify_new_device', 'notify_known_device', 'control_enabled', 'notify_template', - 'btn_block_inet', 'btn_block_wifi', 'btn_limiter', 'btn_shaper'] -}); - -var callTelegramTest = rpc.declare({ - object: 'luci.trafficctl', - method: 'telegram_test', - params: ['bot_token', 'chat_id', 'message'] -}); - -var callLoggingGet = rpc.declare({ - object: 'luci.trafficctl', - method: 'logging_config_get' -}); - -var callLoggingSet = rpc.declare({ - object: 'luci.trafficctl', - method: 'logging_config_set', - params: ['enabled', 'log_file', 'max_lines', 'syslog', - 'log_blocks', 'log_ratelimits', 'log_shapes', 'log_telegram', 'log_config', 'persist_rules'] -}); - -var callNewDeviceGet = rpc.declare({ - object: 'luci.trafficctl', - method: 'newdevice_config_get' -}); - -var callNewDeviceSet = rpc.declare({ - object: 'luci.trafficctl', - method: 'newdevice_config_set', - params: ['enabled', 'limit_kbit', 'limit_mode'] -}); - // Global internet cut. cut_status is a write method even though it reads: // it reconciles the auto-revert deadline and revives a dead keeper, so that // the answer it gives about live nft state is one the UI can trust. @@ -3647,322 +3605,7 @@ return view.extend({ } // ── Telegram Bot section (lazy-loaded) ───────────────────────────── - var tgSection = mkCollapsible(_('Telegram Bot'), null, false); - var tgLoaded = false; - tgSection.label.addEventListener('click', function() { - if (!tgLoaded && !tgSection.body.classList.contains('tc-hidden')) { - tgLoaded = true; - loadTelegramUI(tgSection.body); - } - }); - - function loadTelegramUI(container) { - var statusSpan = E('span', {'style':'font-size:12px;margin-left:8px;color:var(--tc-muted)'}, _('Loading…')); - container.appendChild(statusSpan); - - callTelegramGet().then(function(cfg) { - while (container.firstChild) container.removeChild(container.firstChild); - - var section = E('div', {'class':'tg-section'}); - container.appendChild(section); - - // ── Auto-save with debounce ── - var saveTimer = null; - var setupDone = false; - var saveStatus = E('span', {'class':'tg-save-status'}); - var doSave = function() { - if (!setupDone) return; - if (saveTimer) clearTimeout(saveTimer); - saveTimer = setTimeout(function() { - saveStatus.textContent = _('Saving…'); - saveStatus.style.color = 'var(--tc-muted)'; - var tk = tokenInput.value; - if (tk === '' && cfg.bot_token_set) tk = '***'; - var inetEl = container.querySelector('#tm-tg-inet'); - var wifiEl = container.querySelector('#tm-tg-wifi'); - var limitEl = container.querySelector('#tm-tg-limit'); - var shapeEl = container.querySelector('#tm-tg-shape'); - callTelegramSet( - container.querySelector('#tm-tg-enabled').checked, - tk, - chatInput.value, - parseInt(cfg.poll_interval) || 3, - container.querySelector('#tm-tg-new').checked, - container.querySelector('#tm-tg-known').checked, - controlMode, - templateArea.value, - inetEl ? inetEl.checked : cfg.btn_block_inet, - wifiEl ? wifiEl.checked : cfg.btn_block_wifi, - limitEl ? limitEl.checked : cfg.btn_limiter, - shapeEl ? shapeEl.checked : cfg.btn_shaper - ).then(function(res) { - if (res && res.ok) { - saveStatus.textContent = '✓'; - saveStatus.style.color = 'var(--tc-ok)'; - cfg.bot_token_set = !!(tk && tk !== '***') || cfg.bot_token_set; - if (tk && tk !== '***') { - tokenInput.value = ''; - tokenInput.type = 'password'; - tokenInput.placeholder = '•••••••• ✓ ' + _('saved'); - } - } else { - saveStatus.textContent = '✗ ' + (res && res.msg || 'error'); - saveStatus.style.color = 'var(--tc-err)'; - } - }).catch(function(e) { - saveStatus.textContent = '✗ ' + e.message; - saveStatus.style.color = 'var(--tc-err)'; - }); - }, 600); - }; - - // ── Status dot ── - var dot = E('span', { - 'class': 'tg-status-dot ' + (cfg.bot_running ? 'tg-status-dot--on' : 'tg-status-dot--off'), - 'title': cfg.bot_running ? _('Bot is running') : _('Bot is stopped') - }); - - // ── Enabled toggle ── - var tgEnabled = mkToggle('tm-tg-enabled', _('Enabled'), cfg.enabled, doSave); - section.appendChild(E('div', {'class':'tg-row'}, [tgEnabled, dot, saveStatus])); - - // ── Token + Chat ID ── - var tokenInput = E('input', { - 'type': 'password', - 'class': 'tg-input tg-input--token', - 'value': '', - 'placeholder': cfg.bot_token_set ? '•••••••• ✓ ' + _('saved') : _('Paste bot token') - }); - tokenInput.addEventListener('change', doSave); - var eyeBtn = E('span', {'class':'tg-eye','title':_('Show/hide token')}, '👁'); - eyeBtn.addEventListener('click', function() { - tokenInput.type = tokenInput.type === 'password' ? 'text' : 'password'; - }); - var chatInput = E('input', { - 'type': 'text', - 'class': 'tg-input tg-input--chat', - 'value': cfg.chat_id || '', - 'placeholder': _('Chat ID') - }); - chatInput.addEventListener('change', doSave); - var testResult = E('span', {'style':'font-size:11px;margin-left:4px'}); - var testBtn = E('button', {'class':'tg-btn'}, _('Test')); - testBtn.addEventListener('click', function() { - testBtn.disabled = true; - testResult.textContent = _('Sending…'); - testResult.style.color = 'var(--tc-muted)'; - var tk = tokenInput.value || '***'; - callTelegramTest(tk, chatInput.value, templateArea.value || '').then(function(res) { - testResult.textContent = (res && res.ok) ? '✓ ' + (res.msg || 'OK') : '✗ ' + (res && res.msg || 'error'); - testResult.style.color = (res && res.ok) ? 'var(--tc-ok)' : 'var(--tc-err)'; - }).catch(function(e) { - testResult.textContent = '✗ ' + e.message; - testResult.style.color = 'var(--tc-err)'; - }).then(function() { testBtn.disabled = false; }); - }); - section.appendChild(E('div', {'class':'tg-row'}, [ - E('span', {'class':'tg-label'}, _('Token:')), tokenInput, eyeBtn, - E('span', {'style':'width:12px'}), - E('span', {'class':'tg-label'}, _('Chat ID:')), chatInput, - testBtn, testResult - ])); - - // ── Mode segmented control ── - var controlMode = cfg.control_enabled !== false; - var controlSection = E('div', {}); - var notifySection = E('div', {}); - function setMode(ctrl) { - controlMode = ctrl; - segControl.className = 'tg-segmented__item' + (ctrl ? ' tg-segmented__item--active' : ''); - segNotify.className = 'tg-segmented__item' + (!ctrl ? ' tg-segmented__item--active' : ''); - controlSection.classList.toggle('tc-hidden', !ctrl); - doSave(); - } - - var segNotify = document.createElement('div'); - segNotify.className = 'tg-segmented__item' + (!controlMode ? ' tg-segmented__item--active' : ''); - segNotify.textContent = '🔔 ' + _('Notifications only'); - segNotify.onclick = function() { setMode(false); }; - - var segControl = document.createElement('div'); - segControl.className = 'tg-segmented__item' + (controlMode ? ' tg-segmented__item--active' : ''); - segControl.textContent = '🎛 ' + _('Full control'); - segControl.onclick = function() { setMode(true); }; - - var segmented = E('div', {'class':'tg-segmented'}, [segNotify, segControl]); - - section.appendChild(E('div', {'class':'tg-row'}, [segmented])); - - // ── Notifications ── - notifySection.appendChild(E('div', {'class':'tg-divider'}, _('Notifications'))); - var notifyNew = mkToggle('tm-tg-new', _('New devices'), cfg.notify_new_device, doSave); - var notifyKnown = mkToggle('tm-tg-known', _('Known devices'), cfg.notify_known_device, doSave); - notifySection.appendChild(E('div', {'class':'tg-row'}, [notifyNew, notifyKnown])); - - // ── Custom template ── - var templateArea = E('textarea', { - 'class': 'tg-input tg-input--template', - 'placeholder': '🆕 New device\\n{{ name }} ({{ ip }})\\nMAC: {{ mac }}\\nLink: {{ link }}' - }, cfg.notify_template || ''); - templateArea.addEventListener('input', function() { renderPreview(); doSave(); }); - - var previewBubble = E('div', {'class':'tg-bubble'}); - var defaultTpl = '🆕 New device\n{{ name }} ({{ ip }})\nMAC: {{ mac }}\nLink: {{ link }}'; - var renderPreview = function() { - var tpl = templateArea.value || defaultTpl; - var txt = tpl - .replace(/\{\{\s*name\s*\}\}/g, 'MacBookPro') - .replace(/\{\{\s*ip\s*\}\}/g, '192.168.0.100') - .replace(/\{\{\s*mac\s*\}\}/g, 'aa:bb:cc:dd:ee:ff') - .replace(/\{\{\s*link\s*\}\}/g, '5G') - .replace(/\{\{\s*date\s*\}\}/g, new Date().toISOString().slice(0,10)) - .replace(/\{\{\s*time\s*\}\}/g, new Date().toTimeString().slice(0,5)) - .replace(/\{\{\s*datetime\s*\}\}/g, new Date().toISOString().slice(0,10) + ' ' + new Date().toTimeString().slice(0,5)) - .replace(/\{\{\s*router\s*\}\}/g, 'OpenWrt') - .replace(/\{\{\s*ssid\s*\}\}/g, 'MyNetwork_5G') - .replace(/\{\{\s*signal\s*\}\}/g, '-52') - .replace(/\{\{\s*freq\s*\}\}/g, '5GHz') - .replace(/\{\{\s*iface\s*\}\}/g, 'wlan1') - .replace(/\{\{\s*clients\s*\}\}/g, '12') - .replace(/\{\{\s*uptime\s*\}\}/g, '3d 5h') - .replace(/\{\{\s*wan_ip\s*\}\}/g, '85.192.48.1') - .replace(/\{\{\s*load\s*\}\}/g, '0.42') - .replace(/\{\{\s*conns\s*\}\}/g, '47'); - previewBubble.innerHTML = txt.replace(/\\n/g, '
').replace(/\n/g, '
'); - }; - renderPreview(); - - var varRef = E('div', {'style':'font-size:12px;line-height:1.7;color:currentColor'}, [ - E('div', {'style':'font-weight:600;margin-bottom:4px'}, _('Variables')), - E('div', {}, [E('code', {}, '{{ name }}'), document.createTextNode(' — ' + _('device hostname'))]), - E('div', {}, [E('code', {}, '{{ ip }}'), document.createTextNode(' — ' + _('IP address'))]), - E('div', {}, [E('code', {}, '{{ mac }}'), document.createTextNode(' — ' + _('MAC address'))]), - E('div', {}, [E('code', {}, '{{ link }}'), document.createTextNode(' — ' + _('connection type (5G, LAN)'))]), - E('div', {}, [E('code', {}, '{{ date }}'), document.createTextNode(' — ' + _('date (2026-05-26)'))]), - E('div', {}, [E('code', {}, '{{ time }}'), document.createTextNode(' — ' + _('time (14:32)'))]), - E('div', {}, [E('code', {}, '{{ datetime }}'), document.createTextNode(' — ' + _('date + time'))]), - E('div', {}, [E('code', {}, '{{ router }}'), document.createTextNode(' — ' + _('router hostname'))]), - E('div', {}, [E('code', {}, '{{ ssid }}'), document.createTextNode(' — ' + _('WiFi SSID'))]), - E('div', {}, [E('code', {}, '{{ signal }}'), document.createTextNode(' — ' + _('WiFi signal (dBm)'))]), - E('div', {}, [E('code', {}, '{{ freq }}'), document.createTextNode(' — ' + _('WiFi band (2.4/5GHz)'))]), - E('div', {}, [E('code', {}, '{{ iface }}'), document.createTextNode(' — ' + _('network interface'))]), - E('div', {}, [E('code', {}, '{{ clients }}'), document.createTextNode(' — ' + _('total connected clients'))]), - E('div', {}, [E('code', {}, '{{ uptime }}'), document.createTextNode(' — ' + _('router uptime'))]), - E('div', {}, [E('code', {}, '{{ wan_ip }}'), document.createTextNode(' — ' + _('WAN IP'))]), - E('div', {}, [E('code', {}, '{{ load }}'), document.createTextNode(' — ' + _('CPU load (1 min)'))]), - E('div', {}, [E('code', {}, '{{ conns }}'), document.createTextNode(' — ' + _('device connections'))]), - E('div', {'style':'margin-top:6px;color:var(--tc-muted);font-size:11px'}, [ - document.createTextNode(_('HTML:') + ' '), - E('code', {}, ''), document.createTextNode(' '), - E('code', {}, ''), document.createTextNode(' '), - E('code', {}, ''), document.createTextNode(' '), - E('code', {}, '
'), - document.createTextNode('. \\n = ' + _('line break')) - ]) - ]); - - var templateToggle = E('span', { - 'style': 'font-size:12px;cursor:pointer;color:var(--tc-muted);user-select:none' - }, '▸ ' + _('Customize message')); - var templateBody = E('div', {'class':'tc-hidden','style':'margin-top:6px'}); - templateBody.appendChild(E('div', {'style':'display:flex;gap:14px;align-items:flex-start;flex-wrap:wrap'}, [ - E('div', {'style':'flex:1;min-width:200px'}, [templateArea]), - varRef - ])); - templateBody.appendChild(E('div', {'style':'margin-top:8px'}, [ - E('span', {'class':'tg-label','style':'display:block;margin-bottom:4px'}, _('Preview:')), - previewBubble - ])); - templateToggle.addEventListener('click', function() { - var show = templateBody.classList.contains('tc-hidden'); - templateBody.classList.toggle('tc-hidden'); - templateToggle.textContent = (show ? '▾ ' : '▸ ') + _('Customize message'); - }); - notifySection.appendChild(E('div', {'style':'margin-top:8px'}, [templateToggle, templateBody])); - - // ── Control section (conditionally visible) ── - controlSection.appendChild(E('div', {'class':'tg-divider'}, _('Control'))); - var btnInet = mkToggle('tm-tg-inet', _('Block Internet'), cfg.btn_block_inet, function() { updateKbd(); doSave(); }); - var btnWifi = mkToggle('tm-tg-wifi', _('Block WiFi'), cfg.btn_block_wifi, function() { updateKbd(); doSave(); }); - var btnLimit = mkToggle('tm-tg-limit', _('Limiter'), cfg.btn_limiter, function() { updateKbd(); doSave(); }); - var btnShape = mkToggle('tm-tg-shape', _('Shaper'), cfg.btn_shaper, function() { updateKbd(); doSave(); }); - controlSection.appendChild(E('div', {'class':'tg-row'}, [btnInet, btnWifi, btnLimit, btnShape])); - - // Dynamic keyboard preview - var kbdBubble = E('div', {'class':'tg-bubble tg-bubble--kbd'}); - var updateKbd = function() { - while (kbdBubble.firstChild) kbdBubble.removeChild(kbdBubble.firstChild); - var inetEl = controlSection.querySelector('#tm-tg-inet'); - var wifiEl = controlSection.querySelector('#tm-tg-wifi'); - var limitEl = controlSection.querySelector('#tm-tg-limit'); - var shapeEl = controlSection.querySelector('#tm-tg-shape'); - var inetOn = inetEl ? inetEl.checked : cfg.btn_block_inet; - var wifiOn = wifiEl ? wifiEl.checked : cfg.btn_block_wifi; - var limitOn = limitEl ? limitEl.checked : cfg.btn_limiter; - var shapeOn = shapeEl ? shapeEl.checked : cfg.btn_shaper; - if (!inetOn && !wifiOn && !limitOn && !shapeOn) { - kbdBubble.appendChild(E('div', {'style':'font-size:11px;color:var(--tc-faint);padding:4px'}, _('No action buttons enabled'))); - return; - } - var row1 = []; - if (inetOn) row1.push(E('span', {'class':'tg-kbd-btn'}, '⏸ Block Internet')); - if (wifiOn) row1.push(E('span', {'class':'tg-kbd-btn'}, '📵 Block WiFi')); - if (row1.length) kbdBubble.appendChild(E('div', {'class':'tg-kbd-row'}, row1)); - if (limitOn) { - var limitBtns = []; - RATE_PRESETS.forEach(function(p) { - if (p.v === '0' || p.v === 'custom') return; - limitBtns.push(E('span', {'class':'tg-kbd-btn'}, p.l.replace(' Mbit/s','M').replace(/\s/g,''))); - }); - for (var li = 0; li < limitBtns.length; li += 3) { - kbdBubble.appendChild(E('div', {'class':'tg-kbd-row'}, limitBtns.slice(li, li + 3))); - } - } - if (shapeOn) { - var shapeBtns = []; - RATE_PRESETS.forEach(function(p) { - if (p.v === '0' || p.v === 'custom') return; - shapeBtns.push(E('span', {'class':'tg-kbd-btn'}, '🔧 ' + p.l.replace(' Mbit/s','M').replace(/\s/g,''))); - }); - for (var si = 0; si < shapeBtns.length; si += 3) { - kbdBubble.appendChild(E('div', {'class':'tg-kbd-row'}, shapeBtns.slice(si, si + 3))); - } - } - kbdBubble.appendChild(E('div', {'class':'tg-kbd-row'}, [ - E('span', {'class':'tg-kbd-btn'}, '⬅️ Back') - ])); - }; - updateKbd(); - - controlSection.appendChild(E('div', {'style':'margin-top:8px'}, [ - E('span', {'class':'tg-label','style':'display:block;margin-bottom:4px'}, _('Inline keyboard preview:')), - kbdBubble - ])); - - // Commands + flow explanation - controlSection.appendChild(E('div', {'style':'margin-top:10px'}, [ - E('span', {'class':'tg-label','style':'display:block;margin-bottom:4px'}, _('Bot commands:')), - E('div', {'class':'tg-commands'}, [ - E('div', {}, [E('code', {}, '/devices'), document.createTextNode(' — ' + _('device list → tap device → action buttons'))]), - E('div', {}, [E('code', {}, '/status'), document.createTextNode(' — ' + _('all active blocks, limits, and shapes'))]), - E('div', {}, [E('code', {}, '/help'), document.createTextNode(' — ' + _('command list and bot mode'))]) - ]), - E('div', {'style':'font-size:10px;color:var(--tc-faint);margin-top:4px'}, - _('Flow: /devices → select device → inline keyboard with enabled actions above')) - ])); - - if (!controlMode) controlSection.classList.add('tc-hidden'); - section.appendChild(controlSection); - section.appendChild(notifySection); - setupDone = true; - }).catch(function(e) { - statusSpan.textContent = '✗ ' + e.message; - statusSpan.style.color = 'var(--tc-err)'; - }); - } - - // ── Assemble settings sections ───────────────────────────────────── - settingsBody.appendChild(tgSection.el); + // Telegram Bot moved to the Settings tab (#90). var displaySection = mkCollapsible(_('Display'), E('div', {'class':'tc-settings-section-row'}, [ showStats, showConns, overviewCheck, extStatsCheck, rdnsCheck, activityCheck, @@ -3972,272 +3615,13 @@ return view.extend({ settingsBody.appendChild(displaySection.el); // ── Logging & Persistence section (lazy-loaded) ──────────────────── - var loggingSection = mkCollapsible(_('Logging & Persistence'), null, false); - var loggingLoaded = false; - loggingSection.label.addEventListener('click', function() { - if (!loggingLoaded && !loggingSection.body.classList.contains('tc-hidden')) { - loggingLoaded = true; - loadLoggingUI(loggingSection.body); - } - }); - - function loadLoggingUI(container) { - var statusSpan = E('span', {'style':'font-size:12px;color:var(--tc-muted)'}, _('Loading…')); - container.appendChild(statusSpan); - - callLoggingGet().then(function(cfg) { - while (container.firstChild) container.removeChild(container.firstChild); - - var logStatus = E('span', {'class':'tg-save-status'}); - var logTimer = null; - var doLogSave = function() { - if (logTimer) clearTimeout(logTimer); - logTimer = setTimeout(function() { - logStatus.textContent = _('Saving…'); - logStatus.style.color = 'var(--tc-muted)'; - callLoggingSet( - container.querySelector('#tm-log-enabled').checked, - null, null, - container.querySelector('#tm-log-syslog').checked, - container.querySelector('#tm-log-blocks').checked, - container.querySelector('#tm-log-ratelimits').checked, - container.querySelector('#tm-log-shapes').checked, - container.querySelector('#tm-log-telegram').checked, - container.querySelector('#tm-log-config').checked, - container.querySelector('#tm-persist-rules').checked - ).then(function(res) { - logStatus.textContent = (res && res.ok) ? '✓' : '✗'; - logStatus.style.color = (res && res.ok) ? 'var(--tc-ok)' : 'var(--tc-err)'; - }).catch(function(e) { - logStatus.textContent = '✗'; - logStatus.style.color = 'var(--tc-err)'; - }); - }, 400); - }; - - var logEnabled = mkToggle('tm-log-enabled', _('Logging'), cfg.enabled, doLogSave); - var logSyslog = mkToggle('tm-log-syslog', _('Syslog'), cfg.syslog, doLogSave); - var persistRules = mkToggle('tm-persist-rules', _('Persist rules'), cfg.persist_rules, doLogSave); - - var logBlocks = mkToggle('tm-log-blocks', _('Blocks'), cfg.log_blocks, doLogSave); - var logRatelimits = mkToggle('tm-log-ratelimits', _('Ratelimits'), cfg.log_ratelimits, doLogSave); - var logShapes = mkToggle('tm-log-shapes', _('Shapes'), cfg.log_shapes, doLogSave); - var logTelegram = mkToggle('tm-log-telegram', _('Telegram'), cfg.log_telegram, doLogSave); - var logConfig = mkToggle('tm-log-config', _('Config'), cfg.log_config, doLogSave); - - container.appendChild(E('div', {'class':'tc-log-row'}, [logEnabled, logSyslog, persistRules, logStatus])); - container.appendChild(E('div', {'style':'margin-top:6px'}, [ - E('div', {'style':'font-size:11px;color:var(--tc-muted);margin-bottom:4px'}, _('Log categories')), - E('div', {'class':'tc-log-row'}, [logBlocks, logRatelimits, logShapes, logTelegram, logConfig]) - ])); - }).catch(function(e) { - statusSpan.textContent = '✗ ' + e.message; - statusSpan.style.color = 'var(--tc-err)'; - }); - } - settingsBody.appendChild(loggingSection.el); + // Logging & Persistence moved to the Settings tab (#90). // ── New Device Defaults section (lazy-loaded) ────────────────────── - var newDevSection = mkCollapsible(_('New Device Defaults'), null, false); - var newDevLoaded = false; - newDevSection.label.addEventListener('click', function() { - if (!newDevLoaded && !newDevSection.body.classList.contains('tc-hidden')) { - newDevLoaded = true; - loadNewDeviceUI(newDevSection.body); - } - }); - - function loadNewDeviceUI(container) { - var statusSpan = E('span', {'style':'font-size:12px;color:var(--tc-muted)'}, _('Loading…')); - container.appendChild(statusSpan); - - callNewDeviceGet().then(function(cfg) { - while (container.firstChild) container.removeChild(container.firstChild); - - var saveStatus = E('span', {'class':'tg-save-status'}); - var hint = E('div', {'style':'font-size:11px;color:var(--tc-muted);margin-top:6px'}); - var modePick = null; - var saveTimer = null; - - // The baseline is what stops the feature limiting the whole - // LAN, so its size is shown rather than left implicit: an - // operator can tell a populated ledger from an empty one. - function renderHint(c) { - var kbit = parseInt(rateInput.value, 10); - if (enabledToggle.querySelector('input').checked && (!kbit || kbit <= 0)) { - hint.textContent = _('Set a rate above 0 — with no rate this does nothing.'); - hint.style.color = 'var(--tc-warn)'; - return; - } - hint.style.color = 'var(--tc-muted)'; - if (c && c.seeded) { - hint.textContent = _('Devices already known:') + ' ' + (c.seen_count || 0) + - ' — ' + _('these are never limited by this setting.'); - } else { - hint.textContent = _('No baseline recorded yet. It is taken from the current leases when you switch this on.'); - } - } - - var doSave = function() { - if (saveTimer) clearTimeout(saveTimer); - saveTimer = setTimeout(function() { - saveStatus.textContent = _('Saving…'); - saveStatus.style.color = 'var(--tc-muted)'; - var kbit = parseInt(rateInput.value, 10); - if (isNaN(kbit) || kbit < 0) kbit = 0; - callNewDeviceSet( - enabledToggle.querySelector('input').checked, - kbit, - modePick.getValue() - ).then(function(res) { - saveStatus.textContent = (res && res.ok) ? '✓' : '✗'; - saveStatus.style.color = (res && res.ok) ? 'var(--tc-ok)' : 'var(--tc-err)'; - if (res && res.ok) { - callNewDeviceGet().then(renderHint).catch(function() {}); - } - }).catch(function() { - saveStatus.textContent = '✗'; - saveStatus.style.color = 'var(--tc-err)'; - }); - }, 400); - }; - - var enabledToggle = mkToggle('tm-nd-enabled', _('Limit new devices'), cfg.enabled, function() { - renderHint(cfg); - doSave(); - }); - - var rateInput = E('input', { - 'type': 'number', - 'min': '0', - 'class': 'tg-input tg-input--chat', - 'value': cfg.limit_kbit || 0, - 'placeholder': _('kbit/s') - }); - rateInput.addEventListener('change', function() { - renderHint(cfg); - doSave(); - }); - - modePick = mkChipPick([ - { v: 'limiter', l: _('Limiter') }, - { v: 'shaper', l: _('Shaper') } - ], cfg.limit_mode || 'limiter', doSave); - - container.appendChild(E('div', {'class':'tc-log-row'}, [ - enabledToggle, mkLabel(_('Rate')), rateInput, mkLabel(_('kbit/s')), - modePick.el, saveStatus - ])); - container.appendChild(hint); - container.appendChild(E('div', {'style':'font-size:11px;color:var(--tc-muted);margin-top:4px'}, - _('Applied once, the first time a device appears on the network. A device that already has a limit is left alone.'))); - - renderHint(cfg); - }).catch(function(e) { - statusSpan.textContent = '✗ ' + e.message; - statusSpan.style.color = 'var(--tc-err)'; - }); - } - settingsBody.appendChild(newDevSection.el); + // New Device Defaults moved to the Settings tab (#90). // ── Flow Offload section (lazy-loaded) ───────────────────────────── - var offloadSection = mkCollapsible(_('Flow Offload'), null, false); - var offloadLoaded = false; - offloadSection.label.addEventListener('click', function() { - if (!offloadLoaded && !offloadSection.body.classList.contains('tc-hidden')) { - offloadLoaded = true; - loadOffloadUI(offloadSection.body); - } - }); - - function loadOffloadUI(container) { - var statusSpan = E('span', {'style':'font-size:12px;color:var(--tc-muted)'}, _('Loading…')); - container.appendChild(statusSpan); - - callConfigGet().then(function(cfg) { - while (container.firstChild) container.removeChild(container.firstChild); - - var saveStatus = E('span', {'class':'tg-save-status'}); - var saveTimer = null; - var swCb, hwCb; - - function doSave() { - if (saveTimer) clearTimeout(saveTimer); - saveTimer = setTimeout(function() { - saveStatus.textContent = _('Applying…'); - saveStatus.style.color = 'var(--tc-muted)'; - callConfigSet(undefined, undefined, swCb.checked, hwCb.checked).then(function(res) { - saveStatus.textContent = (res && res.ok) ? '✓ ' + _('Applied — firewall reloading') : '✗'; - saveStatus.style.color = (res && res.ok) ? 'var(--tc-ok)' : 'var(--tc-err)'; - }).catch(function(e) { - saveStatus.textContent = '✗'; - saveStatus.style.color = 'var(--tc-err)'; - }); - }, 400); - } - - // Current mode badge - var modeLabels = { - 'none': ['⊘', _('No offload'), 'var(--tc-muted)'], - 'software': ['◑', _('Software offload'), 'var(--tc-speed)'], - 'software-counter': ['◑', _('Software offload (counter)'), 'var(--tc-speed)'], - 'hardware-counter': ['●', _('Hardware offload'), 'var(--tc-warn)'], - 'hardware': ['●', _('Hardware offload'), 'var(--tc-warn)'] - }; - var ml = modeLabels[cfg.offload_mode] || ['?', cfg.offload_mode, 'var(--tc-muted)']; - var modeBadge = E('div', {'style':'margin-bottom:10px;font-size:12px'}, [ - E('span', {'style':'color:'+ml[2]+';font-size:15px;margin-right:4px'}, ml[0]), - E('span', {'style':'color:var(--tc-muted)'}, _('Current mode:') + ' '), - E('b', {}, ml[1]) - ]); - - // SW toggle row - var swToggleEl = mkToggle('tc-offload-sw', _('Software flow offload'), cfg.sw, function() { - hwCb.disabled = !swCb.checked; - if (!swCb.checked) { hwCb.checked = false; } - doSave(); - }); - swCb = swToggleEl.querySelector('input'); - var swDesc = E('div', {'class':'tc-offload-desc'}, - _('Accelerates routing in the kernel via nftables flowtable. Speed monitoring and traffic shaping work normally.')); - - // HW toggle row - var hwToggleEl = mkToggle('tc-offload-hw', _('Hardware flow offload'), cfg.hw, doSave); - hwCb = hwToggleEl.querySelector('input'); - if (!cfg.sw) hwCb.disabled = true; - var hwDesc = E('div', {'class':'tc-offload-desc'}, - _('Offloads routing to the hardware engine (PPE/NPU). Requires software offload. ' + - '⚠ On many platforms (e.g. Mediatek Filogic) the driver does not report byte counters back to the kernel — real-time speed monitoring will show zero.')); - - // Qualcomm NSS acceleration (#81). This is NOT one of the - // flow-offload modes above and is not controlled by the two - // toggles below it: ECM pushes flow rules into the NSS - // firmware cores, so an accelerated flow never reaches the - // Linux stack at all and neither the nftables policer nor - // Linux HTB can act on it. The apply result says so on every - // attempt; this is here because the Flow Offload panel is - // where somebody asking "why is my limit doing nothing" looks. - if (cfg.nss === 'active') { - container.appendChild(E('div', { - 'style': 'margin-bottom:10px;padding:8px;border-left:3px solid var(--tc-warn);font-size:12px' - }, [ - E('b', {'style':'color:var(--tc-warn)'}, '⚠ ' + _('Qualcomm NSS acceleration is active')), - E('div', {'style':'margin-top:4px;color:var(--tc-muted)'}, - _('Rate limits and traffic shaping cannot be enforced while it is: accelerated flows bypass the Linux network stack, so rules are installed but have no effect. The toggles below do not control this — stop the ECM front end (front_end_ipv4_stop / front_end_ipv6_stop under /sys/kernel/debug/ecm) or set acceleration_engine in /etc/config/ecm, then re-apply the limit.')) - ])); - } - - container.appendChild(modeBadge); - container.appendChild(E('div', {'class':'tc-offload-row'}, [swToggleEl, saveStatus])); - container.appendChild(swDesc); - container.appendChild(E('div', {'class':'tc-offload-row tc-offload-row--hw'}, [hwToggleEl])); - container.appendChild(hwDesc); - }).catch(function(e) { - statusSpan.textContent = '✗ ' + (e.message || e); - statusSpan.style.color = 'var(--tc-err)'; - }); - } - settingsBody.appendChild(offloadSection.el); + // Flow Offload moved to the Settings tab (#90). var connFiltersRow = E('div', {'class':'tc-conn-filters-row'}, [ E('span', {'data-tip':_('Filter connections by protocol')}, [mkLabel(_('Proto')+':'), protoPick.el]), @@ -4269,7 +3653,41 @@ return view.extend({ } updateTableSectionMode(); - var settingsPanel = E('div', {'class':'tc-settings-panel'}, [settingsToggle, settingsBody]); + // One-time pointer at the Settings tab, suggested by @the-e3n in #90: + // most of what used to be in this panel now lives there, and somebody + // who knew where to find it before should not have to go looking. + // Dismissal is remembered in localStorage — nothing on the router, and + // it never asks twice. + var settingsHint = null; + try { + if (localStorage.getItem('tc-settings-hint-seen') !== '1') { + var hintClose = E('span', { + 'style': 'cursor:pointer;margin-left:auto;padding:0 4px;opacity:.6', + 'title': _('Dismiss') + }, '×'); + settingsHint = E('div', { + 'class': 'tc-settings-hint', + 'style': 'display:flex;align-items:center;gap:6px;margin:6px 0;padding:6px 8px;' + + 'border-left:3px solid var(--tc-speed);font-size:12px' + }, [ + E('span', {}, 'ℹ'), + E('span', {}, _('Telegram, logging, new-device defaults and flow offload moved to the Settings tab.')), + hintClose + ]); + hintClose.addEventListener('click', function() { + settingsHint.parentNode.removeChild(settingsHint); + try { localStorage.setItem('tc-settings-hint-seen', '1'); } catch (e) { /* private mode */ } + }); + } + } catch (e) { + // localStorage can throw in private browsing. A missing hint is + // preferable to a broken page. + settingsHint = null; + } + + var settingsPanel = E('div', {'class':'tc-settings-panel'}, + (settingsHint ? [settingsHint, settingsToggle, settingsBody] + : [settingsToggle, settingsBody])); function loadActivityPanel(container) { container.className = 'tc-activity-panel'; @@ -4733,3 +4151,5 @@ return view.extend({ this._ifMeta = []; } }); + + diff --git a/luci-app-trafficctl/po/templates/luci-app-trafficctl.pot b/luci-app-trafficctl/po/templates/luci-app-trafficctl.pot index b2e11721b..c0f277159 100644 --- a/luci-app-trafficctl/po/templates/luci-app-trafficctl.pot +++ b/luci-app-trafficctl/po/templates/luci-app-trafficctl.pot @@ -703,6 +703,9 @@ msgstr "" msgid "Overview" msgstr "" +msgid "Package-wide settings. Controls that only affect what the Devices page shows — visible columns, poll interval, averaging window — stay on that page." +msgstr "" + msgid "Packets dropped" msgstr "" @@ -991,6 +994,9 @@ msgstr "" msgid "Telegram Bot" msgstr "" +msgid "Telegram, logging, new-device defaults and flow offload moved to the Settings tab." +msgstr "" + msgid "Test" msgstr "" @@ -1066,6 +1072,9 @@ msgstr "" msgid "Traffic Control" msgstr "" +msgid "Traffic Control Settings" +msgstr "" + msgid "Traffic History" msgstr "" diff --git a/luci-app-trafficctl/root/usr/share/luci/menu.d/luci-app-trafficctl.json b/luci-app-trafficctl/root/usr/share/luci/menu.d/luci-app-trafficctl.json index 4541b732a..6ed0ae40f 100644 --- a/luci-app-trafficctl/root/usr/share/luci/menu.d/luci-app-trafficctl.json +++ b/luci-app-trafficctl/root/usr/share/luci/menu.d/luci-app-trafficctl.json @@ -45,5 +45,16 @@ "depends": { "acl": [ "luci-app-trafficctl" ] } + }, + "admin/status/trafficctl/settings": { + "title": "Settings", + "order": 4, + "action": { + "type": "view", + "path": "trafficctl/settings" + }, + "depends": { + "acl": [ "luci-app-trafficctl" ] + } } } diff --git a/meow/Makefile b/meow/Makefile index b3c21bf3e..8a3467138 100644 --- a/meow/Makefile +++ b/meow/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=meow -PKG_VERSION:=0.25.0 -PKG_RELEASE:=14 +PKG_VERSION:=0.26.0 +PKG_RELEASE:=15 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/meow-rs/meow-rs/tar.gz/v$(PKG_VERSION)?