mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 18:44:03 +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
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=gately
|
PKG_NAME:=gately
|
||||||
PKG_VERSION:=0.5.3
|
PKG_VERSION:=0.5.4
|
||||||
PKG_RELEASE:=3
|
PKG_RELEASE:=4
|
||||||
|
|
||||||
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
||||||
PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)?
|
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 enabled '0'
|
||||||
option configdir '/etc/gately'
|
option configdir '/etc/gately'
|
||||||
option initial_password 'admin123456'
|
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
|
PROG=/usr/bin/gately
|
||||||
|
|
||||||
start_service() {
|
sync_gately_config() {
|
||||||
config_load gately
|
local configdir="$1"
|
||||||
|
local admin_port="$2"
|
||||||
local enabled configdir applydir initial_password
|
local admin_safe_url="$3"
|
||||||
config_get_bool enabled main enabled 0
|
local admin_internet="$4"
|
||||||
[ "$enabled" -eq 1 ] || return 0
|
local initial_password="$5"
|
||||||
|
|
||||||
config_get configdir main configdir '/etc/gately'
|
|
||||||
config_get applydir main applydir ''
|
|
||||||
config_get initial_password main initial_password 'admin123456'
|
|
||||||
|
|
||||||
mkdir -p "$configdir"
|
mkdir -p "$configdir"
|
||||||
chmod 700 "$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_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 "$applydir" ] && procd_append_param command -apply "$applydir"
|
||||||
[ -n "$initial_password" ] && procd_set_param env GATELY_INITIAL_PASSWORD="$initial_password"
|
[ -n "$initial_password" ] && procd_set_param env GATELY_INITIAL_PASSWORD="$initial_password"
|
||||||
|
|
||||||
|
|||||||
@@ -1,143 +1,45 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
'require view';
|
'require view';
|
||||||
'require rpc';
|
|
||||||
'require uci';
|
|
||||||
'require ui';
|
|
||||||
'require dom';
|
|
||||||
'require form';
|
'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({
|
return view.extend({
|
||||||
pendingTasks: [],
|
render: function() {
|
||||||
|
|
||||||
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 : {};
|
|
||||||
var m, s, o;
|
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.'));
|
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 = m.section(form.NamedSection, 'main', 'gately', _('Global Settings'));
|
||||||
s.anonymous = true;
|
s.anonymous = true;
|
||||||
s.addremove = false;
|
s.addremove = false;
|
||||||
|
|
||||||
// Enable service
|
|
||||||
o = s.option(form.Flag, 'enabled', _('Enable'));
|
o = s.option(form.Flag, 'enabled', _('Enable'));
|
||||||
o.rmempty = false;
|
o.rmempty = false;
|
||||||
|
|
||||||
// Config Directory (UCI)
|
|
||||||
o = s.option(form.Value, 'configdir', _('Config Directory Path'));
|
o = s.option(form.Value, 'configdir', _('Config Directory Path'));
|
||||||
o.rmempty = false;
|
o.rmempty = false;
|
||||||
|
o.default = '/etc/gately';
|
||||||
o.placeholder = '/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 = s.option(form.Value, 'initial_password', _('Initial Admin Password'));
|
||||||
o.password = true;
|
o.password = true;
|
||||||
o.rmempty = false;
|
o.rmempty = false;
|
||||||
o.default = 'admin123456';
|
o.default = 'admin123456';
|
||||||
o.placeholder = '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.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 = m.section(form.TypedSection, 'gately', _('Admin Panel Settings'));
|
||||||
s.anonymous = true;
|
s.anonymous = true;
|
||||||
s.addremove = false;
|
s.addremove = false;
|
||||||
|
|
||||||
// Admin Port
|
|
||||||
o = s.option(form.Value, 'admin_port', _('Admin Web Listen Port'));
|
o = s.option(form.Value, 'admin_port', _('Admin Web Listen Port'));
|
||||||
o.datatype = 'port';
|
o.datatype = 'port';
|
||||||
o.rmempty = false;
|
o.rmempty = false;
|
||||||
|
o.default = '16680';
|
||||||
o.placeholder = '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 = s.option(form.Value, 'admin_safe_url', _('Admin Safe URL'));
|
||||||
o.placeholder = '/secret-entry';
|
o.placeholder = '/secret-entry';
|
||||||
o.load = function(section_id) {
|
|
||||||
return baseConf.SafeURL || '';
|
|
||||||
};
|
|
||||||
o.validate = function(section_id, value) {
|
o.validate = function(section_id, value) {
|
||||||
if (!value) return true;
|
if (!value) return true;
|
||||||
var v = value.trim();
|
var v = value.trim();
|
||||||
@@ -150,25 +52,10 @@ return view.extend({
|
|||||||
}
|
}
|
||||||
return true;
|
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 = s.option(form.Flag, 'admin_internet', _('Allow Internet Access'));
|
||||||
o.rmempty = false;
|
o.rmempty = false;
|
||||||
o.load = function(section_id) {
|
o.default = '0';
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return m.render();
|
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."
|
msgid "Admin password (at least 8 characters, default: admin123456). Modifying this and clicking Save & Apply will update the admin password immediately."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Password must be at least 8 characters"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Admin Panel Settings"
|
msgid "Admin Panel Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -112,15 +109,6 @@ msgstr ""
|
|||||||
msgid "Allow Internet Access"
|
msgid "Allow Internet Access"
|
||||||
msgstr ""
|
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."
|
msgid "Real-time query of the Gately running logs to diagnose DDNS updates, STUN traversal, and reverse proxy rules."
|
||||||
msgstr ""
|
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."
|
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 密码。"
|
msgstr "管理员密码(至少 8 位字符,默认:admin123456)。修改此项并点击“保存并应用”将立即更新 admin 密码。"
|
||||||
|
|
||||||
msgid "Password must be at least 8 characters"
|
|
||||||
msgstr "密码长度至少需要 8 位字符"
|
|
||||||
|
|
||||||
msgid "Admin Panel Settings"
|
msgid "Admin Panel Settings"
|
||||||
msgstr "管理后台设置"
|
msgstr "管理后台设置"
|
||||||
|
|
||||||
@@ -112,15 +109,6 @@ msgstr "安全入口不能以 /api 或 /assets 开头,且不能包含空格、
|
|||||||
msgid "Allow Internet Access"
|
msgid "Allow Internet Access"
|
||||||
msgstr "允许外网访问管理后台"
|
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."
|
msgid "Real-time query of the Gately running logs to diagnose DDNS updates, STUN traversal, and reverse proxy rules."
|
||||||
msgstr "实时查询 Gately 运行日志,方便排查 DDNS 更新、STUN 穿透及反向代理规则执行情况。"
|
msgstr "实时查询 Gately 运行日志,方便排查 DDNS 更新、STUN 穿透及反向代理规则执行情况。"
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ uci -q batch <<-EOF >/dev/null
|
|||||||
commit gately
|
commit gately
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [ -z "$(uci -q get gately.main.initial_password)" ]; then
|
[ -z "$(uci -q get gately.main.configdir)" ] && uci -q set gately.main.configdir='/etc/gately'
|
||||||
uci -q set gately.main.initial_password='admin123456'
|
[ -z "$(uci -q get gately.main.initial_password)" ] && uci -q set gately.main.initial_password='admin123456'
|
||||||
uci -q commit gately
|
[ -z "$(uci -q get gately.main.admin_port)" ] && uci -q set gately.main.admin_port='16680'
|
||||||
fi
|
[ -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-indexcache* 2>/dev/null
|
||||||
rm -f /tmp/luci-modulecache/* 2>/dev/null
|
rm -f /tmp/luci-modulecache/* 2>/dev/null
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"write": {
|
"write": {
|
||||||
"ubus": {
|
"ubus": {
|
||||||
"luci.gately": [ "set_config", "service" ]
|
"luci.gately": [ "service" ]
|
||||||
},
|
},
|
||||||
"uci": [ "gately" ],
|
"uci": [ "gately" ],
|
||||||
"file": {
|
"file": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import { access, open, popen, readfile, writefile, unlink, mkdir, chmod } from 'fs';
|
import { access, popen, readfile } from 'fs';
|
||||||
import { cursor } from 'uci';
|
import { cursor } from 'uci';
|
||||||
|
|
||||||
const BIN_PATH = '/usr/bin/gately';
|
const BIN_PATH = '/usr/bin/gately';
|
||||||
@@ -14,10 +14,16 @@ function get_uci_config() {
|
|||||||
let enabled = c.get('gately', 'main', 'enabled') || '0';
|
let enabled = c.get('gately', 'main', 'enabled') || '0';
|
||||||
let configdir = c.get('gately', 'main', 'configdir') || '/etc/gately';
|
let configdir = c.get('gately', 'main', 'configdir') || '/etc/gately';
|
||||||
let initial_password = c.get('gately', 'main', 'initial_password') || 'admin123456';
|
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 {
|
return {
|
||||||
enabled: (enabled === '1'),
|
enabled: (enabled === '1'),
|
||||||
configdir: configdir,
|
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;
|
return arch;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gately stores global settings in <configdir>/gately.json (settingsName = "gately")
|
|
||||||
function read_gately_json(configdir) {
|
function read_gately_json(configdir) {
|
||||||
let path = configdir + '/gately.json';
|
let path = configdir + '/gately.json';
|
||||||
if (!access(path, 'r')) {
|
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) {
|
function read_account_json(configdir) {
|
||||||
let path = configdir + '/account.json';
|
let path = configdir + '/account.json';
|
||||||
if (!access(path, 'r')) {
|
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) {
|
function parse_port_from_listen(listen_str) {
|
||||||
if (!listen_str) return '16680';
|
if (!listen_str) return '16680';
|
||||||
let idx = rindex(listen_str, ':');
|
let idx = rindex(listen_str, ':');
|
||||||
@@ -118,19 +102,6 @@ function parse_port_from_listen(listen_str) {
|
|||||||
return 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 = {
|
const methods = {
|
||||||
get_status: {
|
get_status: {
|
||||||
call: function() {
|
call: function() {
|
||||||
@@ -152,13 +123,11 @@ const methods = {
|
|||||||
let settings = read_gately_json(uci_cfg.configdir);
|
let settings = read_gately_json(uci_cfg.configdir);
|
||||||
let account = read_account_json(uci_cfg.configdir);
|
let account = read_account_json(uci_cfg.configdir);
|
||||||
|
|
||||||
let effective_listen = settings.listen || ':16680';
|
let admin_port = uci_cfg.admin_port || parse_port_from_listen(settings.listen) || '16680';
|
||||||
let admin_port = parse_port_from_listen(effective_listen);
|
let safe_path = uci_cfg.admin_safe_url || settings.safePath || '';
|
||||||
let safe_path = settings.safePath || '';
|
|
||||||
if (length(safe_path) > 0 && substr(safe_path, 0, 1) !== '/') {
|
if (length(safe_path) > 0 && substr(safe_path, 0, 1) !== '/') {
|
||||||
safe_path = '/' + safe_path;
|
safe_path = '/' + safe_path;
|
||||||
}
|
}
|
||||||
let lan_only = (settings.lanOnly === true);
|
|
||||||
let admin_user = account.username || 'admin';
|
let admin_user = account.username || 'admin';
|
||||||
let admin_password = uci_cfg.initial_password || 'admin123456';
|
let admin_password = uci_cfg.initial_password || 'admin123456';
|
||||||
|
|
||||||
@@ -174,17 +143,7 @@ const methods = {
|
|||||||
Go: ver.go || '-'
|
Go: ver.go || '-'
|
||||||
}),
|
}),
|
||||||
adminPort: admin_port,
|
adminPort: admin_port,
|
||||||
adminListen: effective_listen,
|
adminSafeUrl: safe_path
|
||||||
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
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -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: {
|
service: {
|
||||||
args: { action: 'action' },
|
args: { action: 'action' },
|
||||||
call: function(req) {
|
call: function(req) {
|
||||||
let action = req.args?.action;
|
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') {
|
if (action === 'restart' || action === 'reload') {
|
||||||
let res = system(INIT_SCRIPT + ' stop >/dev/null 2>&1; sleep 1; ' + INIT_SCRIPT + ' start >/dev/null 2>&1');
|
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 };
|
return { result: (res === 0), ret: res };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=luci-app-open-box
|
PKG_NAME:=luci-app-open-box
|
||||||
PKG_VERSION:=0.1.343
|
PKG_VERSION:=0.1.344
|
||||||
PKG_RELEASE:=39
|
PKG_RELEASE:=40
|
||||||
|
|
||||||
PKG_MAINTAINER:=liandu2024
|
PKG_MAINTAINER:=liandu2024
|
||||||
PKG_LICENSE:=GPL-3.0-or-later
|
PKG_LICENSE:=GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=luci-app-trafficctl
|
PKG_NAME:=luci-app-trafficctl
|
||||||
PKG_VERSION:=1.23.1
|
PKG_VERSION:=1.24.0
|
||||||
PKG_RELEASE:=14
|
PKG_RELEASE:=15
|
||||||
|
|
||||||
PKG_LICENSE:=Apache-2.0
|
PKG_LICENSE:=Apache-2.0
|
||||||
PKG_MAINTAINER:=Denis Iusupov <yusdyr@gmail.com>
|
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']
|
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:
|
// 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
|
// 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.
|
// 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) ─────────────────────────────
|
// ── Telegram Bot section (lazy-loaded) ─────────────────────────────
|
||||||
var tgSection = mkCollapsible(_('Telegram Bot'), null, false);
|
// Telegram Bot moved to the Settings tab (#90).
|
||||||
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 displaySection = mkCollapsible(_('Display'), E('div', {'class':'tc-settings-section-row'}, [
|
var displaySection = mkCollapsible(_('Display'), E('div', {'class':'tc-settings-section-row'}, [
|
||||||
showStats, showConns, overviewCheck, extStatsCheck, rdnsCheck, activityCheck,
|
showStats, showConns, overviewCheck, extStatsCheck, rdnsCheck, activityCheck,
|
||||||
@@ -3972,272 +3615,13 @@ return view.extend({
|
|||||||
settingsBody.appendChild(displaySection.el);
|
settingsBody.appendChild(displaySection.el);
|
||||||
|
|
||||||
// ── Logging & Persistence section (lazy-loaded) ────────────────────
|
// ── Logging & Persistence section (lazy-loaded) ────────────────────
|
||||||
var loggingSection = mkCollapsible(_('Logging & Persistence'), null, false);
|
// Logging & Persistence moved to the Settings tab (#90).
|
||||||
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);
|
|
||||||
|
|
||||||
// ── New Device Defaults section (lazy-loaded) ──────────────────────
|
// ── New Device Defaults section (lazy-loaded) ──────────────────────
|
||||||
var newDevSection = mkCollapsible(_('New Device Defaults'), null, false);
|
// New Device Defaults moved to the Settings tab (#90).
|
||||||
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);
|
|
||||||
|
|
||||||
// ── Flow Offload section (lazy-loaded) ─────────────────────────────
|
// ── Flow Offload section (lazy-loaded) ─────────────────────────────
|
||||||
var offloadSection = mkCollapsible(_('Flow Offload'), null, false);
|
// Flow Offload moved to the Settings tab (#90).
|
||||||
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);
|
|
||||||
|
|
||||||
var connFiltersRow = E('div', {'class':'tc-conn-filters-row'}, [
|
var connFiltersRow = E('div', {'class':'tc-conn-filters-row'}, [
|
||||||
E('span', {'data-tip':_('Filter connections by protocol')}, [mkLabel(_('Proto')+':'), protoPick.el]),
|
E('span', {'data-tip':_('Filter connections by protocol')}, [mkLabel(_('Proto')+':'), protoPick.el]),
|
||||||
@@ -4269,7 +3653,41 @@ return view.extend({
|
|||||||
}
|
}
|
||||||
updateTableSectionMode();
|
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) {
|
function loadActivityPanel(container) {
|
||||||
container.className = 'tc-activity-panel';
|
container.className = 'tc-activity-panel';
|
||||||
@@ -4733,3 +4151,5 @@ return view.extend({
|
|||||||
this._ifMeta = [];
|
this._ifMeta = [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -703,6 +703,9 @@ msgstr ""
|
|||||||
msgid "Overview"
|
msgid "Overview"
|
||||||
msgstr ""
|
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"
|
msgid "Packets dropped"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -991,6 +994,9 @@ msgstr ""
|
|||||||
msgid "Telegram Bot"
|
msgid "Telegram Bot"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Telegram, logging, new-device defaults and flow offload moved to the Settings tab."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Test"
|
msgid "Test"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1066,6 +1072,9 @@ msgstr ""
|
|||||||
msgid "Traffic Control"
|
msgid "Traffic Control"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Traffic Control Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Traffic History"
|
msgid "Traffic History"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -45,5 +45,16 @@
|
|||||||
"depends": {
|
"depends": {
|
||||||
"acl": [ "luci-app-trafficctl" ]
|
"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
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=meow
|
PKG_NAME:=meow
|
||||||
PKG_VERSION:=0.25.0
|
PKG_VERSION:=0.26.0
|
||||||
PKG_RELEASE:=14
|
PKG_RELEASE:=15
|
||||||
|
|
||||||
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
||||||
PKG_SOURCE_URL:=https://codeload.github.com/meow-rs/meow-rs/tar.gz/v$(PKG_VERSION)?
|
PKG_SOURCE_URL:=https://codeload.github.com/meow-rs/meow-rs/tar.gz/v$(PKG_VERSION)?
|
||||||
|
|||||||
Reference in new issue
Block a user