🎈 Sync 2026-10-21 17:11:15

This commit is contained in:
github-actions[bot] committed 2026-10-21 17:11:15 +08:00
1 parent f608112d08
commit 31748c050e
16 files changed
+900 -893

No files matched your search

+2 -2
View File
@@ -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)?
+3
View File
@@ -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'
+60 -11
View File
@@ -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"
@@ -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();
}
-12
View File
@@ -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 ""
-12
View File
@@ -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 穿透及反向代理规则执行情况。"
@@ -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
@@ -12,7 +12,7 @@
},
"write": {
"ubus": {
"luci.gately": [ "set_config", "service" ]
"luci.gately": [ "service" ]
},
"uci": [ "gately" ],
"file": {
@@ -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 <configdir>/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 <configdir>/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 };
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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 <yusdyr@gmail.com>
@@ -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 = '🆕 <b>New device</b>\n{{ name }} ({{ ip }})\nMAC: <code>{{ mac }}</code>\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, '<br>').replace(/\n/g, '<br>');
};
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', {}, '<b>'), document.createTextNode(' '),
E('code', {}, '<i>'), document.createTextNode(' '),
E('code', {}, '<code>'), document.createTextNode(' '),
E('code', {}, '<a href="">'),
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
]);
}
});
@@ -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 = '🆕 <b>New device</b>\n{{ name }} ({{ ip }})\nMAC: <code>{{ mac }}</code>\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, '<br>').replace(/\n/g, '<br>');
};
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', {}, '<b>'), document.createTextNode(' '),
E('code', {}, '<i>'), document.createTextNode(' '),
E('code', {}, '<code>'), document.createTextNode(' '),
E('code', {}, '<a href="">'),
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 = [];
}
});
@@ -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 ""
@@ -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" ]
}
}
}
+2 -2
View File
@@ -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)?