mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-04 06:43:56 +08:00
🎈 Sync 2026-10-21 17:11:15
This commit is contained in:
1 parent
f608112d08
commit
31748c050e
16 files changed
+900
-893
No files matched your search
+2
-2
@@ -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)?
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)?
|
||||
|
||||
Reference in new issue
Block a user