🏅 Sync 2026-08-13 09:29:33

This commit is contained in:
github-actions[bot]
2026-08-13 09:29:33 +08:00
parent 2a6de1e65f
commit c57dcedc98
40 changed files with 902 additions and 494 deletions
@@ -151,6 +151,16 @@ return L.view.extend({
o.value(0, _("In-kernel path manager"));
o.value(1, _("Userspace path manager"));
o.default = 0;
// LuCI's CBI removes an option instead of writing it whenever the
// submitted value equals .default and rmempty is left at its class
// default of true (see issue #4348 for the same pattern). That would
// silently delete network.globals.mptcp_pm_type on every Save & Apply
// of this page (not just when this field itself is touched), and the
// init script's own fallback for an absent value is "1" (userspace
// path manager) -- the opposite of this field's default -- which
// disables in-kernel fullmesh subflow creation entirely and breaks
// multi-WAN bonding down to the master WAN only (issue #4349).
o.rmempty = false;
o = s.option(form.ListValue, "mptcp_disable_initial_config", _("Initial MPTCP configuration"));
o.depends("mptcp_pm_type","1");
@@ -283,6 +293,13 @@ return L.view.extend({
o.value("backup", _("backup"));
//o.value("handover", _("handover"));
o.default = "off";
// Same rmempty/default collision as mptcp_pm_type above: without this,
// every interface currently set to "off" has network.<iface>.multipath
// silently deleted on each Save & Apply of this page. Currently harmless
// (the init script's own fallback for an absent value is also "off"),
// but keep the persisted value explicit so a future fallback change
// can't quietly flip behavior for these interfaces too.
o.rmempty = false;
o = s.option(form.Value, "multipath_weight", _("Weight"), _("Only used by *weight schedulers/path managers. Ignored if no weight scheduler is selected.") + '<br />' + _("A weight >100 make it more attractive, a weight <100 make it less attractive. Max 256"));
o.datatype = "uinteger";
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.8.10
PKG_RELEASE:=78
PKG_RELEASE:=79
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -20,19 +20,31 @@ function is_timehhmm(str) {
}
if (typeof cbi_validators !== 'undefined' && cbi_validators !== null) {
cbi_validators['base64'] = function() {
return isBase64(this);
}
cbi_validators['json'] = function() {
return is_json(this);
}
cbi_validators['timehhmm'] = function() {
return is_timehhmm(this);
}
cbi_validators['uuid'] = function() {
return isUUID(this);
}
} else {
L.require('validation').then(function(validation) {
validation.types['base64'] = function() {
return this.assert(isBase64(this.value), _('Must be Base64 text!'));
}
validation.types['json'] = function() {
return this.assert(is_json(this.value), _('Must be JSON text!'));
}
validation.types['timehhmm'] = function() {
return this.assert(is_timehhmm(this.value), _('valid time (hh:mm)'));
}
validation.types['uuid'] = function() {
return this.assert(isUUID(this.value), _('Must be UUID format!'));
}
});
}
@@ -8,6 +8,11 @@ function arraysEqual(a, b) {
return true;
}
function isBase64(str) {
const base64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/;
return base64Regex.test(str);
}
function decodeIfBase64(str) {
try {
let s = str.replace(/-/g, '+').replace(/_/g, '/');
@@ -24,6 +29,11 @@ function decodeIfBase64(str) {
return str;
}
function isUUID(str) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
}
function waitForElement(selector, callback) {
const el = document.querySelector(selector);
if (el) return callback(el);
@@ -56,12 +56,13 @@ function index()
--[[ Server ]]
entry({"admin", "services", appname, "server"}, cbi(appname .. "/server/index"), _("Server-Side"), 99).leaf = true
entry({"admin", "services", appname, "server_user"}, cbi(appname .. "/server/user")).leaf = true
entry({"admin", "services", appname, "server_config"}, cbi(appname .. "/server/server_config")).leaf = true
entry({"admin", "services", appname, "server_user_config"}, cbi(appname .. "/server/user_config")).leaf = true
--[[ API ]]
entry({"admin", "services", appname, "server_update_config"}, call("server_update_config")).leaf = true
entry({"admin", "services", appname, "server_user_status"}, call("server_user_status")).leaf = true
entry({"admin", "services", appname, "server_user_log"}, call("server_user_log")).leaf = true
entry({"admin", "services", appname, "server_status"}, call("server_status")).leaf = true
entry({"admin", "services", appname, "server_log"}, call("server_log")).leaf = true
entry({"admin", "services", appname, "server_get_log"}, call("server_get_log")).leaf = true
entry({"admin", "services", appname, "server_clear_log"}, call("server_clear_log")).leaf = true
entry({"admin", "services", appname, "link_add_node"}, call("link_add_node")).leaf = true
@@ -88,7 +89,6 @@ function index()
entry({"admin", "services", appname, "delete_select_nodes"}, call("delete_select_nodes")).leaf = true
entry({"admin", "services", appname, "reassign_group"}, call("reassign_group")).leaf = true
entry({"admin", "services", appname, "get_node"}, call("get_node")).leaf = true
entry({"admin", "services", appname, "save_node_order"}, call("save_node_order")).leaf = true
entry({"admin", "services", appname, "save_node_list_opt"}, call("save_node_list_opt")).leaf = true
entry({"admin", "services", appname, "update_rules"}, call("update_rules")).leaf = true
entry({"admin", "services", appname, "rollback_rules"}, call("rollback_rules")).leaf = true
@@ -100,7 +100,6 @@ function index()
entry({"admin", "services", appname, "get_shunt_rules"}, call("get_shunt_rules")).leaf = true
entry({"admin", "services", appname, "add_shunt_rule"}, call("add_shunt_rule")).leaf = true
entry({"admin", "services", appname, "delete_select_shunt_rules"}, call("delete_select_shunt_rules")).leaf = true
entry({"admin", "services", appname, "save_shunt_rule_order"}, call("save_shunt_rule_order")).leaf = true
--[[Components update]]
entry({"admin", "services", appname, "check_passwall2"}, call("app_check")).leaf = true
@@ -649,19 +648,6 @@ function get_node()
http_write_json(result)
end
function save_node_order()
local ids = http.formvalue("ids") or ""
local new_order = {}
for id in ids:gmatch("([^,]+)") do
new_order[#new_order + 1] = id
end
for idx, name in ipairs(new_order) do
luci.sys.call(string.format("uci -q reorder %s.%s=%d", appname, name, idx - 1))
end
api.sh_uci_commit(appname)
http_write_json({ status = "ok" })
end
function reassign_group()
local ids = http.formvalue("ids") or ""
local group = http.formvalue("group") or "default"
@@ -725,14 +711,14 @@ function server_update_config()
http_write_json_error()
end
function server_user_status()
function server_status()
local e = {}
e.index = http.formvalue("index")
e.status = luci.sys.call(string.format("/bin/busybox top -bn1 | grep -v 'grep' | grep '%s/bin/' | grep -i '%s' >/dev/null", appname .. "_server", http.formvalue("id"))) == 0
http_write_json(e)
end
function server_user_log()
function server_log()
local id = http.formvalue("id")
if nixio.fs.access("/tmp/etc/passwall2_server/" .. id .. ".log") then
local content = luci.sys.exec("cat /tmp/etc/passwall2_server/" .. id .. ".log")
@@ -1059,7 +1045,7 @@ function add_shunt_rule()
if add_name then
local has = uci:get(appname, uid)
if has then
http_write_json_error({ message = "This ID already exists." })
http_write_json_error({ message = i18n.translate("This ID already exists.") })
return
end
else
@@ -1099,16 +1085,3 @@ function delete_select_shunt_rules()
api.uci_save(uci, appname, true, true)
end
end
function save_shunt_rule_order()
local ids = http.formvalue("ids") or ""
local new_order = {}
for id in ids:gmatch("([^,]+)") do
new_order[#new_order + 1] = id
end
for idx, name in ipairs(new_order) do
luci.sys.call(string.format("uci -q reorder %s.%s=%d", appname, name, idx - 1))
end
api.sh_uci_commit(appname)
http_write_json({ status = "ok" })
end
@@ -1,7 +1,5 @@
local api = require "luci.passwall2.api"
local appname = api.appname
local datatypes = api.datatypes
local sys = api.sys
api.set_default_cbi()
@@ -30,6 +28,29 @@ o:value("https://connectivitycheck.platform.hicloud.com/generate_204", "HiCloud
o:value("https://wifi.vivo.com.cn/generate_204", "VIVO (CN)")
o.default = o.keylist[3]
m:append(Template(appname .. "/node_list/node_list"))
if true then
local o = Template(appname .. "/node_list/node_list")
o.map = m
o.api = api
m:append(o)
if luci.http.formvalue("cbi.submit") == "1" then
local group_order = {}
group_order = luci.http.formvaluetable("group.order")
if group_order then
for k, v in pairs(group_order) do
if v and v~= "" then
local new_order = {}
string.gsub(v, "[^" .. " " .. "]+", function(w)
new_order[#new_order + 1] = w
end)
for idx, name in ipairs(new_order) do
m.uci:reorder(appname, name, idx - 1)
end
end
end
end
end
end
return api.return_map(m)
@@ -88,6 +88,27 @@ end
s:append(Template(appname .. "/rule/rule_version"))
m:append(Template(appname .. "/rule/shunt_rule_list"))
if true then
local o = Template(appname .. "/rule/shunt_rule_list")
o.api = api
m:append(o)
if luci.http.formvalue("cbi.submit") == "1" then
local group_order = luci.http.formvaluetable("group.order")
if group_order then
for k, v in pairs(group_order) do
if v and v~= "" then
local new_order = {}
string.gsub(v, "[^" .. " " .. "]+", function(w)
new_order[#new_order + 1] = w
end)
for idx, name in ipairs(new_order) do
m.uci:reorder(appname, name, idx - 1)
end
end
end
end
end
end
return api.return_map(m)
@@ -13,38 +13,38 @@ t.addremove = false
e = t:option(Flag, "enable", translate("Enable"))
e.rmempty = false
local cfgname = "user"
t = m:section(TypedSection, cfgname, translate("Users Manager"))
t.anonymous = true
t.addremove = true
t.sortable = true
t.template = "cbi/tblsection"
t.extedit = api.url("server_user", "%s")
function t.create(e, t)
s_server = m:section(TypedSection, "server", translate("Servers Manager"))
s_server.anonymous = true
s_server.addremove = true
s_server.sortable = true
s_server.template = "cbi/tblsection"
s_server.extedit = api.url("server_config", "%s")
function s_server.create(self, section)
local uid = api.gen_random_char()
TypedSection.create(e, uid)
luci.http.redirect(e.extedit:format(uid))
TypedSection.create(self, uid)
luci.http.redirect(self.extedit:format(uid))
end
function t.remove(e, t)
e.map.proceed = true
e.map:del(t)
luci.http.redirect(api.url("server"))
function s_server.remove(self, section)
local o = m:get(section) or {}
if o[".type"] == self.sectiontype then
m:del(section)
end
end
e = t:option(Flag, "enable", translate("Enable"))
e = s_server:option(Flag, "enable", translate("Enable"))
e.width = "5%"
e.rmempty = false
e = t:option(DummyValue, "status", translate("Status"))
e = s_server:option(DummyValue, "status", translate("Status"))
e.rawhtml = true
e.cfgvalue = function(t, n)
return string.format('<font class="_users_status">%s</font>', translate("Collecting data..."))
return string.format('<font class="_servers_status">%s</font>', translate("Collecting data..."))
end
e = t:option(DummyValue, "remarks", translate("Remarks"))
e = s_server:option(DummyValue, "remarks", translate("Remarks"))
e.width = "15%"
e = t:option(DummyValue, "type", translate("Type"))
e = s_server:option(DummyValue, "type", translate("Type"))
e.width = "20%"
e.rawhtml = true
e.cfgvalue = function(t, n)
@@ -82,20 +82,74 @@ e.cfgvalue = function(t, n)
return str
end
e = t:option(DummyValue, "port", translate("Port"))
e = s_server:option(DummyValue, "port", translate("Port"))
e = t:option(Flag, "log", translate("Log"))
e = s_server:option(Flag, "log", translate("Log"))
e.default = "1"
e.rmempty = false
local sortable = Template(appname .. "/cbi/sortable")
sortable.api = api
sortable.appname = m.config
sortable.target_cfgname = cfgname
sortable.target_cfgname = s_server.sectiontype
m:append(sortable)
local server_list_status = Template(appname .. "/server/server_list_status")
server_list_status.api = api
server_list_status.appname = m.config
server_list_status.sectiontype = s_server.sectiontype
m:append(server_list_status)
s_user = m:section(TypedSection, "user", translate("Users Manager"))
s_user.anonymous = true
s_user.addremove = true
s_user.sortable = true
s_user.template = "cbi/tblsection"
s_user.extedit = api.url("server_user_config", "%s")
s_user.create = function(e, section)
local uid = api.gen_random_char()
TypedSection.create(e, uid)
luci.http.redirect(e.extedit:format(uid))
end
s_user.remove = function(self, section)
local o = m:get(section) or {}
if o[".type"] == self.sectiontype then
m.uci:foreach(m.config, "server", function(o)
if o.user and section == o.user then
m:set(o[".name"], "user", "")
end
local changed = false
local users = o.users or {}
for i = #users, 1, -1 do
if section == users[i] then
table.remove(users, i)
changed = true
end
end
if changed then
m:set(o[".name"], "users", users)
end
end)
m:del(section)
luci.http.redirect(api.url("server"))
end
end
e = s_user:option(DummyValue, "username", translate("Username"))
e.width = "25%"
e = s_user:option(DummyValue, "password", translate("Password"))
e.width = "25%"
e = s_user:option(DummyValue, "uuid", "UUID")
e.width = "25%"
local sortable = Template(appname .. "/cbi/sortable")
sortable.api = api
sortable.appname = m.config
sortable.target_cfgname = s_user.sectiontype
m:append(sortable)
m:append(Template(appname .. "/server/log"))
m:append(Template(appname .. "/server/users_list_status"))
return api.return_map(m)
@@ -20,7 +20,12 @@ m:append(header)
m:append(Template(appname .. "/cbi/nodes_listvalue_com"))
s = m:section(NamedSection, arg[1], "user", "")
user_list = {}
m.uci:foreach(m.config, "user", function(s)
user_list[#user_list + 1] = s
end)
s = m:section(NamedSection, arg[1], "server", "")
s.addremove = false
s.dynamic = false
@@ -26,6 +26,12 @@ o = s:option(Value, _n("port"), translate("Listen Port"))
o.datatype = "port"
o:depends({ [_n("custom")] = false })
o = s:option(DynamicList, _n("users"), translate("User"))
for i, v in ipairs(user_list) do
o:value(v[".name"], v.username)
end
o:depends({ [_n("custom")] = false })
o = s:option(Flag, _n("realms"), translate("Realms"))
o.default = "0"
o.rewrite_option = o.option
@@ -46,11 +52,6 @@ o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio
o.rewrite_option = o.option
o:depends({ [_n("realms")] = "1" })
o = s:option(Value, _n("auth_password"), translate("Auth Password"))
o.password = true
o.rewrite_option = o.option
o:depends({ [_n("custom")] = false })
o = s:option(ListValue, _n("obfs_type"), translate("Obfs Type"))
o:value("", translate("Disable"))
o:value("salamander")
@@ -48,27 +48,17 @@ o = s:option(Value, _n("port"), translate("Listen Port"))
o.datatype = "port"
o:depends({ [_n("custom")] = false })
o = s:option(Flag, _n("auth"), translate("Auth"))
o.validate = function(self, value, t)
if value and value == "1" then
local user_v = s.fields[_n("username")] and s.fields[_n("username")]:formvalue(t) or ""
local pass_v = s.fields[_n("password")] and s.fields[_n("password")]:formvalue(t) or ""
if user_v == "" or pass_v == "" then
return nil, translate("Username and Password must be used together!")
end
end
return value
o = s:option(DynamicList, _n("users"), translate("User"))
for i, v in ipairs(user_list) do
o:value(v[".name"], v.username)
end
o:depends({ [_n("protocol")] = "socks" })
o:depends({ [_n("protocol")] = "http" })
o = s:option(Value, _n("username"), translate("Username"))
o:depends({ [_n("auth")] = true })
o = s:option(Value, _n("password"), translate("Password"))
o.password = true
o:depends({ [_n("auth")] = true })
o:depends({ [_n("protocol")] = "socks" })
o:depends({ [_n("protocol")] = "shadowsocks" })
o:depends({ [_n("protocol")] = "vmess" })
o:depends({ [_n("protocol")] = "vless" })
o:depends({ [_n("protocol")] = "trojan" })
o:depends({ [_n("protocol")] = "hysteria2" })
o = s:option(ListValue, _n("d_protocol"), translate("Destination protocol"))
o:value("tcp", "TCP")
@@ -97,6 +87,9 @@ o.rewrite_option = "method"
for a, t in ipairs(x_ss_method_list) do o:value(t) end
o:depends({ [_n("protocol")] = "shadowsocks" })
o = s:option(Value, _n("ss_password"), translate("Password"))
o:depends({ [_n("protocol")] = "shadowsocks" })
o = s:option(ListValue, _n("ss_network"), translate("Transport"))
o.default = "tcp,udp"
o:value("tcp", "TCP")
@@ -109,14 +102,6 @@ o.default = "1"
o.rmempty = false
o:depends({ [_n("protocol")] = "socks" })
o = s:option(DynamicList, _n("uuid"), translate("ID") .. "/" .. translate("Password"))
for i = 1, 3 do
o:value(api.gen_uuid())
end
o:depends({ [_n("protocol")] = "vmess" })
o:depends({ [_n("protocol")] = "vless" })
o:depends({ [_n("protocol")] = "trojan" })
o = s:option(ListValue, _n("flow"), translate("flow"))
o.default = ""
o:value("", translate("Disable"))
@@ -141,10 +126,6 @@ o = s:option(DynamicList, _n("hysteria2_realm_stun"), translate("Realm STUN"))
o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" }
o:depends({ [_n("hysteria2_realms")] = "1" })
o = s:option(Value, _n("hysteria2_auth_password"), translate("Auth Password"))
o.password = true
o:depends({ [_n("protocol")] = "hysteria2"})
o = s:option(ListValue, _n("hysteria2_obfs_type"), translate("Obfs Type"))
o:value("", translate("Disable"))
o:value("salamander")
@@ -60,35 +60,27 @@ o = s:option(Value, _n("port"), translate("Listen Port"))
o.datatype = "port"
o:depends({ [_n("custom")] = false })
o = s:option(Flag, _n("auth"), translate("Auth"))
o.validate = function(self, value, t)
if value and value == "1" then
local user_v = s.fields[_n("username")] and s.fields[_n("username")]:formvalue(t) or ""
local pass_v = s.fields[_n("password")] and s.fields[_n("password")]:formvalue(t) or ""
if user_v == "" or pass_v == "" then
return nil, translate("Username and Password must be used together!")
end
end
return value
o = s:option(DynamicList, _n("users"), translate("User"))
for i, v in ipairs(user_list) do
o:value(v[".name"], v.username)
end
o:depends({ [_n("protocol")] = "mixed" })
o:depends({ [_n("protocol")] = "socks" })
o:depends({ [_n("protocol")] = "http" })
o = s:option(Value, _n("username"), translate("Username"))
o:depends({ [_n("auth")] = true })
o:depends({ [_n("protocol")] = "naive" })
o:depends({ [_n("protocol")] = "anytls" })
o = s:option(Value, _n("password"), translate("Password"))
o.password = true
o:depends({ [_n("auth")] = true })
o:depends({ [_n("protocol")] = "shadowsocks" })
o:depends({ [_n("protocol")] = "vmess" })
o:depends({ [_n("protocol")] = "vless" })
o:depends({ [_n("protocol")] = "trojan" })
o:depends({ [_n("protocol")] = "naive" })
o:depends({ [_n("protocol")] = "hysteria" })
o:depends({ [_n("protocol")] = "tuic" })
o:depends({ [_n("protocol")] = "hysteria2" })
o:depends({ [_n("protocol")] = "anytls" })
if singbox_tags:find("with_quic") then
o = s:option(Value, _n("hysteria_obfs"), translate("Obfs Password"))
o:depends({ [_n("protocol")] = "hysteria" })
o = s:option(Value, _n("hysteria_up_mbps"), translate("Max upload Mbps"))
o.default = "100"
o:depends({ [_n("protocol")] = "hysteria" })
@@ -97,20 +89,6 @@ if singbox_tags:find("with_quic") then
o.default = "100"
o:depends({ [_n("protocol")] = "hysteria" })
o = s:option(Value, _n("hysteria_obfs"), translate("Obfs Password"))
o:depends({ [_n("protocol")] = "hysteria" })
o = s:option(ListValue, _n("hysteria_auth_type"), translate("Auth Type"))
o:value("disable", translate("Disable"))
o:value("string", translate("STRING"))
o:value("base64", translate("BASE64"))
o:depends({ [_n("protocol")] = "hysteria" })
o = s:option(Value, _n("hysteria_auth_password"), translate("Auth Password"))
o.password = true
o:depends({ [_n("protocol")] = "hysteria", [_n("hysteria_auth_type")] = "string"})
o:depends({ [_n("protocol")] = "hysteria", [_n("hysteria_auth_type")] = "base64"})
o = s:option(Value, _n("hysteria_recv_window_conn"), translate("QUIC stream receive window"))
o:depends({ [_n("protocol")] = "hysteria" })
@@ -174,10 +152,6 @@ if singbox_tags:find("with_quic") then
o.default = { "stun.sip.us:3478", "stun.nextcloud.com:3478", "global.stun.twilio.com:3478" }
o:depends({ [_n("hysteria2_realms")] = "1" })
o = s:option(Value, _n("hysteria2_auth_password"), translate("Auth Password"))
o.password = true
o:depends({ [_n("protocol")] = "hysteria2"})
o = s:option(ListValue, _n("hysteria2_obfs_type"), translate("Obfs Type"))
o:value("", translate("Disable"))
o:value("salamander")
@@ -233,14 +207,8 @@ o.rewrite_option = "method"
for a, t in ipairs(ss_method_list) do o:value(t) end
o:depends({ [_n("protocol")] = "shadowsocks" })
o = s:option(DynamicList, _n("uuid"), translate("ID") .. "/" .. translate("Password"))
for i = 1, 3 do
o:value(api.gen_uuid())
end
o:depends({ [_n("protocol")] = "vmess" })
o:depends({ [_n("protocol")] = "vless" })
o:depends({ [_n("protocol")] = "trojan" })
o:depends({ [_n("protocol")] = "tuic" })
o = s:option(Value, _n("ss_password"), translate("Password"))
o:depends({ [_n("protocol")] = "shadowsocks" })
o = s:option(ListValue, _n("flow"), translate("flow"))
o.default = ""
@@ -32,8 +32,10 @@ o = s:option(Value, _n("port"), translate("Listen Port"))
o.datatype = "port"
o:depends({ [_n("custom")] = false })
o = s:option(Value, _n("password"), translate("Password"))
o.password = true
o = s:option(ListValue, _n("user"), translate("User"))
for i, v in ipairs(user_list) do
o:value(v[".name"], v.username)
end
o:depends({ [_n("custom")] = false })
o = s:option(ListValue, _n("method"), translate("Encrypt Method"))
@@ -45,8 +45,10 @@ o = s:option(Value, _n("port"), translate("Listen Port"))
o.datatype = "port"
o:depends({ [_n("custom")] = false })
o = s:option(Value, _n("password"), translate("Password"))
o.password = true
o = s:option(ListValue, _n("user"), translate("User"))
for i, v in ipairs(user_list) do
o:value(v[".name"], v.username)
end
o:depends({ [_n("custom")] = false })
o = s:option(ListValue, _n("method"), translate("Encrypt Method"))
@@ -0,0 +1,31 @@
api = require "luci.passwall2.api"
appname = api.appname
fs = api.fs
api.set_default_cbi()
m = Map("passwall2_server", translate("User Config"))
m.redirect = api.url("server")
api.set_apply_on_parse(m)
if not arg[1] or not m:get(arg[1]) then
luci.http.redirect(m.redirect)
end
s = m:section(NamedSection, arg[1], "user", "")
s.addremove = false
s.dynamic = false
o = s:option(Value, "username", translate("Username"))
o.datatype = "and(uciname,maxlength(24))"
o.rmempty = false
o = s:option(Value, "password", translate("Password"))
o.rmempty = false
o = s:option(Value, "uuid", "UUID")
o.datatype = "uuid"
o.default = api.gen_uuid()
o.rmempty = false
return api.return_map(m)
@@ -388,6 +388,12 @@ function is_timehhmm(str)
end
datatypes.timehhmm = is_timehhmm
function is_uuid(str)
local pattern = "^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$"
return string.match(str, pattern) ~= nil
end
datatypes.uuid = is_uuid
function is_normal_node(e)
if e and e.type and e.protocol and (e.protocol == "_balancing" or e.protocol == "_shunt" or e.protocol == "_iface" or e.protocol == "_urltest") then
return false
@@ -102,78 +102,78 @@ local function start()
nft_file:write('flush chain inet fw4 PSW2-SERVER\n')
nft_file:write('insert rule inet fw4 input position 0 jump PSW2-SERVER comment "PSW2-SERVER"\n')
end
uci:foreach(CONFIG, "user", function(user)
local id = user[".name"]
local enable = user.enable
uci:foreach(CONFIG, "server", function(server)
local id = server[".name"]
local enable = server.enable
if enable and tonumber(enable) == 1 then
local enable_log = user.log
local enable_log = server.log
local log_path = nil
if enable_log and enable_log == "1" then
log_path = CONFIG_PATH .. "/" .. id .. ".log"
else
log_path = nil
end
local remarks = user.remarks
local port = tonumber(user.port)
local remarks = server.remarks
local port = tonumber(server.port)
local bin
local config = {}
local config_file = CONFIG_PATH .. "/" .. id .. ".json"
local udp_forward = 1
local type = user.type or ""
local type = server.type or ""
if type == "SSR" then
if user.custom == "1" and user.config_str then
config = jsonc.parse(api.base64Decode(user.config_str))
if server.custom == "1" and server.config_str then
config = jsonc.parse(api.base64Decode(server.config_str))
else
config = require(require_dir .. "util_shadowsocks").gen_config_server(user)
config = require(require_dir .. "util_shadowsocks").gen_config_server(server)
end
local udp_param = ""
udp_forward = tonumber(user.udp_forward) or 1
udp_forward = tonumber(server.udp_forward) or 1
if udp_forward == 1 then
udp_param = "-u"
end
type = type:lower()
bin = ln_run("/usr/bin/" .. type .. "-server", type .. "-server", "-c " .. config_file .. " " .. udp_param, log_path)
elseif type == "SS-Rust" then
if user.custom == "1" and user.config_str then
config = jsonc.parse(api.base64Decode(user.config_str))
if server.custom == "1" and server.config_str then
config = jsonc.parse(api.base64Decode(server.config_str))
else
config = require(require_dir .. "util_shadowsocks").gen_config_server(user)
config = require(require_dir .. "util_shadowsocks").gen_config_server(server)
end
bin = ln_run("/usr/bin/ssserver", "ssserver", "-c " .. config_file, log_path)
elseif type == "Xray" then
if user.custom == "1" and user.config_str then
config = jsonc.parse(api.base64Decode(user.config_str))
if server.custom == "1" and server.config_str then
config = jsonc.parse(api.base64Decode(server.config_str))
if log_path then
if not config.log then
config.log = {}
end
config.log.loglevel = user.loglevel
config.log.loglevel = server.loglevel
end
else
config = require(require_dir .. "util_xray").gen_config_server(user)
config = require(require_dir .. "util_xray").gen_config_server(server)
end
bin = ln_run(api.get_app_path("xray"), "xray", "run -c " .. config_file, log_path)
elseif type == "sing-box" then
if user.custom == "1" and user.config_str then
config = jsonc.parse(api.base64Decode(user.config_str))
if server.custom == "1" and server.config_str then
config = jsonc.parse(api.base64Decode(server.config_str))
if log_path then
if not config.log then
config.log = {}
end
config.log.timestamp = true
config.log.disabled = false
config.log.level = user.loglevel
config.log.level = server.loglevel
config.log.output = log_path
end
else
config = require(require_dir .. "util_sing-box").gen_config_server(user)
config = require(require_dir .. "util_sing-box").gen_config_server(server)
end
bin = ln_run(api.get_app_path("sing-box"), "sing-box", "run -c " .. config_file, log_path)
elseif type == "Hysteria2" then
if user.custom == "1" and user.config_str then
config = jsonc.parse(api.base64Decode(user.config_str))
if server.custom == "1" and server.config_str then
config = jsonc.parse(api.base64Decode(server.config_str))
else
config = require(require_dir .. "util_hysteria2").gen_config_server(user)
config = require(require_dir .. "util_hysteria2").gen_config_server(server)
end
bin = ln_run(api.get_app_path("hysteria"), "hysteria", "-c " .. config_file .. " server", log_path)
end
@@ -191,7 +191,7 @@ local function start()
cmd(bin)
end
local bind_local = user.bind_local or 0
local bind_local = server.bind_local or 0
if bind_local and tonumber(bind_local) ~= 1 and port then
if nft_flag == "0" then
ipt(string.format('-A PSW2-SERVER -p tcp --dport %s -m comment --comment "%s" -j ACCEPT', port, remarks))
@@ -4,6 +4,20 @@ local uci = api.uci
local jsonc = api.jsonc
function gen_config_server(node)
local users = node.users or {}
local users = nil
if node.users and #node.users > 0 then
users = {}
for i, v in ipairs(node.users) do
local user = uci:get_all("passwall2_server", v) or {}
if user[".type"] == "user" then
users[user.username] = user.password
end
end
if not next(users) then
users = nil
end
end
local config = {
listen = (function()
if node.hysteria2_realms and node.hysteria2_realm_url then
@@ -25,10 +39,10 @@ function gen_config_server(node)
password = node.hysteria2_obfs_password
}
} or nil,
auth = {
type = "password",
password = node.hysteria2_auth_password
},
auth = users and {
type = "userpass",
userpass = users
} or nil,
bandwidth = (node.hysteria2_up_mbps or node.hysteria2_down_mbps) and {
up = node.hysteria2_up_mbps and node.hysteria2_up_mbps .. " mbps" or nil,
down = node.hysteria2_down_mbps and node.hysteria2_down_mbps .. " mbps" or nil
@@ -4,9 +4,14 @@ local uci = api.uci
local jsonc = api.jsonc
function gen_config_server(node)
local user = nil
if node.user then
user = uci:get_all("passwall2_server", node.user)
end
local config = {}
config.server_port = tonumber(node.port)
config.password = node.password
config.password = user and user.password or ""
config.timeout = tonumber(node.timeout)
config.fast_open = (node.tcp_fast_open and node.tcp_fast_open == "1") and true or false
config.method = node.method
@@ -789,39 +789,71 @@ function gen_config_server(node)
listen_port = tonumber(node.port),
}
local users = node.users or {}
local users = nil
if node.users and #node.users > 0 then
users = {}
for i, v in ipairs(node.users) do
local user = uci:get_all("passwall2_server", v) or {}
if user[".type"] == "user" then
local u = {}
if node.protocol == "mixed" or node.protocol == "socks" or node.protocol == "http" or node.protocol == "naive" then
u.username = user.username
u.password = user.password
end
if node.protocol == "shadowsocks" or node.protocol == "trojan" then
u.name = user.username
u.password = user.password
end
if node.protocol == "vmess" then
u.name = user.username
u.uuid = user.uuid
u.alterId = 0
end
if node.protocol == "vless" then
u.name = user.username
u.uuid = user.uuid
u.flow = node.flow
end
if node.protocol == "hysteria" then
u.name = user.username
u.auth_str = user.password
end
if node.protocol == "tuic" then
u.name = user.username
u.password = user.password
u.uuid = user.uuid
end
if node.protocol == "hysteria2" then
u.name = user.username
u.password = user.password
end
users[#users + 1] = u
end
end
if #users == 0 then
users = nil
end
end
local protocol_table = nil
if node.protocol == "mixed" then
protocol_table = {
users = (node.auth == "1") and {
{
username = node.username,
password = node.password
}
} or nil,
users = users,
set_system_proxy = false
}
end
if node.protocol == "socks" then
protocol_table = {
users = (node.auth == "1") and {
{
username = node.username,
password = node.password
}
} or nil
users = users
}
end
if node.protocol == "http" then
protocol_table = {
users = (node.auth == "1") and {
{
username = node.username,
password = node.password
}
} or nil,
users = users,
tls = (node.tls == "1") and tls or nil,
}
end
@@ -829,21 +861,14 @@ function gen_config_server(node)
if node.protocol == "shadowsocks" then
protocol_table = {
method = node.method,
password = node.password,
password = node.ss_password,
users = users,
multiplex = mux,
}
end
if node.protocol == "vmess" then
if node.uuid then
local users = {}
for i = 1, #node.uuid do
users[i] = {
name = node.uuid[i],
uuid = node.uuid[i],
alterId = 0,
}
end
if users then
protocol_table = {
users = users,
tls = (node.tls == "1") and tls or nil,
@@ -854,15 +879,7 @@ function gen_config_server(node)
end
if node.protocol == "vless" then
if node.uuid then
local users = {}
for i = 1, #node.uuid do
users[i] = {
name = node.uuid[i],
uuid = node.uuid[i],
flow = node.flow,
}
end
if users then
protocol_table = {
users = users,
tls = (node.tls == "1") and tls or nil,
@@ -873,14 +890,7 @@ function gen_config_server(node)
end
if node.protocol == "trojan" then
if node.uuid then
local users = {}
for i = 1, #node.uuid do
users[i] = {
name = node.uuid[i],
password = node.uuid[i],
}
end
if users then
protocol_table = {
users = users,
tls = (node.tls == "1") and tls or nil,
@@ -893,15 +903,12 @@ function gen_config_server(node)
end
if node.protocol == "naive" then
protocol_table = {
users = {
{
username = node.username,
password = node.password
}
},
tls = tls
}
if users then
protocol_table = {
users = users,
tls = tls
}
end
end
if node.protocol == "hysteria" then
@@ -911,13 +918,7 @@ function gen_config_server(node)
up_mbps = tonumber(node.hysteria_up_mbps),
down_mbps = tonumber(node.hysteria_down_mbps),
obfs = node.hysteria_obfs,
users = {
{
name = "user1",
auth = (node.hysteria_auth_type == "base64") and node.hysteria_auth_password or nil,
auth_str = (node.hysteria_auth_type == "string") and node.hysteria_auth_password or nil,
}
},
users = users,
recv_window_conn = node.hysteria_recv_window_conn and tonumber(node.hysteria_recv_window_conn) or nil, --1.14 to stream_receive_window
recv_window_client = node.hysteria_recv_window_client and tonumber(node.hysteria_recv_window_client) or nil, --1.14 to connection_receive_window
max_conn_client = node.hysteria_max_conn_client and tonumber(node.hysteria_max_conn_client) or nil, --1.14 to max_concurrent_streams
@@ -936,13 +937,7 @@ function gen_config_server(node)
return nil
end)() or nil
protocol_table = {
users = {
{
name = "user1",
uuid = node.uuid,
password = node.password
}
},
users = users,
congestion_control = node.tuic_congestion_control or "cubic",
zero_rtt_handshake = (node.tuic_zero_rtt_handshake == "1") and true or false,
heartbeat = (tonumber(node.tuic_heartbeat) or 3) .. "s",
@@ -972,12 +967,7 @@ function gen_config_server(node)
end
return o
end)(node.hysteria2_obfs_type),
users = {
{
name = "user1",
password = node.hysteria2_auth_password or nil,
}
},
users = users,
ignore_client_bandwidth = (node.hysteria2_ignore_client_bandwidth == "1") and true or false,
tls = tls,
realm = node.hysteria2_realms and (function()
@@ -997,15 +987,12 @@ function gen_config_server(node)
end
if node.protocol == "anytls" then
protocol_table = {
users = {
{
name = (node.username and node.username ~= "") and node.username or "sekai",
password = node.password
}
},
tls = tls,
}
if users then
protocol_table = {
users = users,
tls = tls,
}
end
end
if node.protocol == "direct" then
@@ -496,17 +496,46 @@ function gen_config_server(node)
{ protocol = "freedom", tag = "direct", settings = { finalRules = {{ action = "allow" }}}}, { protocol = "blackhole", tag = "blocked" }
}
if node.protocol == "vmess" or node.protocol == "vless" then
if node.uuid then
local users = {}
for i = 1, #node.uuid do
users[i] = {
id = node.uuid[i],
flow = (node.protocol == "vless"
and (node.tls == "1" or (node.decryption and node.decryption ~= "" and node.decryption ~= "none"))
and node.flow and node.flow ~= "") and node.flow or nil
}
local users = node.users or {}
local users = nil
if node.users and #node.users > 0 then
users = {}
for i, v in ipairs(node.users) do
local user = uci:get_all("passwall2_server", v) or {}
if user[".type"] == "user" then
local u = {}
if node.protocol == "socks" or node.protocol == "http" then
u.user = user.username
u.pass = user.password
end
if node.protocol == "shadowsocks" or node.protocol == "trojan" then
u.email = user.username
u.password = user.password
end
if node.protocol == "vmess" then
u.email = user.username
u.id = user.uuid
u.alterId = 0
end
if node.protocol == "vless" then
u.email = user.username
u.id = user.uuid
u.flow = node.flow
end
if node.protocol == "hysteria2" then
u.email = user.username
u.auth = user.password
end
users[#users + 1] = u
end
end
if #users == 0 then
users = nil
end
end
if node.protocol == "vmess" or node.protocol == "vless" then
if users then
settings = {
users = users,
decryption = (node.protocol == "vless") and ((node.decryption and node.decryption ~= "") and node.decryption or "none") or nil
@@ -515,40 +544,25 @@ function gen_config_server(node)
elseif node.protocol == "socks" then
settings = {
udp = ("1" == node.udp_forward) and true or false,
auth = ("1" == node.auth) and "password" or "noauth",
users = ("1" == node.auth) and {
{
user = node.username,
pass = node.password
}
} or nil
auth = users and "password" or "noauth",
users = users
}
elseif node.protocol == "http" then
settings = {
allowTransparent = false,
users = ("1" == node.auth) and {
{
user = node.username,
pass = node.password
}
} or nil
users = users
}
node.transport = "tcp"
node.tcp_guise = "none"
elseif node.protocol == "shadowsocks" then
settings = {
method = node.method,
password = node.password,
network = node.ss_network or "TCP,UDP"
password = node.ss_password,
users = users,
network = node.ss_network or "tcp,udp"
}
elseif node.protocol == "trojan" then
if node.uuid then
local users = {}
for i = 1, #node.uuid do
users[i] = {
password = node.uuid[i],
}
end
if users then
settings = {
users = users
}
@@ -556,9 +570,7 @@ function gen_config_server(node)
elseif node.protocol == "hysteria2" then
settings = {
version = 2,
users = node.hysteria2_auth_password and {
{ auth = node.hysteria2_auth_password }
}
users = users
}
elseif node.protocol == "dokodemo-door" then
settings = {
@@ -1,7 +1,7 @@
<%
local api = require "luci.passwall2.api"
local api = self.api
local appname = api.appname
local uci = api.uci
local map = self.map
local default_node_type = ""
local shunt_rule_list = {}
@@ -12,7 +12,7 @@ if node then
if (node_type == "Xray" or node_type == "sing-box") and node_protocol == "_shunt" then
default_node_type = node_protocol
local node_shunt_group = api.uci_get_type_id(node, "shunt_group")
uci:foreach(appname, "shunt_rules", function(e)
map.uci:foreach(appname, "shunt_rules", function(e)
if e[".name"] and e.remarks and e.group == node_shunt_group then
shunt_rule_list[#shunt_rule_list + 1] = e
end
@@ -517,15 +517,7 @@ table td, .table .td {
let firstDataRow = parent.querySelector("tr:not(.cbi-section-table-titles)");
if (firstDataRow && firstDataRow !== row) {
parent.insertBefore(row, firstDataRow);
let save_order_btn = document.getElementById("save_order_btn_" + group);
if (save_order_btn) {
const new_order = get_node_order(group);
if (!arraysEqual(new_order, origin_group_node_order[group])) {
save_order_btn.style.display = null;
} else {
save_order_btn.style.display = "none";
}
}
set_order(group);
}
break;
case "del_all":
@@ -669,48 +661,24 @@ table td, .table .td {
return ids;
}
function save_current_page_order(group) {
var table = document.getElementById("cbi-<%=appname%>-nodes-" + group + "-table");
function set_order(group) {
let table = document.getElementById("cbi-<%=appname%>-nodes-" + group + "-table");
if (!table) {
alert("<%:No table!%>");
return;
}
var rows = table.querySelectorAll("tr.cbi-section-table-row");
let rows = table.querySelectorAll("tr.cbi-section-table-row");
if (!rows || rows.length === 0) {
alert("<%:No nodes!%>");
return;
}
var btn = document.getElementById("save_order_btn_" + group);
if (btn) {
btn.style.display = "none";
btn.disabled = true;
}
var ids = [];
let ids = [];
rows.forEach(function(row) {
var id = row.id.replace("cbi-<%=appname%>-", "");
let id = row.id.replace("cbi-<%=appname%>-", "");
ids.push(id);
});
ajax.abortAll();
XHR.get('<%=api.url("save_node_order")%>', {
group: group,
ids: ids.join(",")
},
function(x, result) {
if (btn) {
btn.style.display = null;
btn.disabled = false;
}
if (x && x.status === 200) {
origin_group_node_order[group] = get_node_order(group);
alert("<%:Saved current page order successfully.%>");
if (btn) {
btn.style.display = "none";
}
} else {
alert("<%:Save failed!%>");
}
}
);
const input_order = document.getElementsByName(`group.order.${group}`)[0];
input_order.value = ids.join(" ");
}
function get_now_use_node() {
@@ -947,16 +915,7 @@ table td, .table .td {
forceFallback: false,
swapThreshold: 0.65,
onEnd: function (evt) {
//save_current_page_order(group); // Auto save
let save_order_btn = document.getElementById("save_order_btn_" + group);
if (save_order_btn) {
const new_order = get_node_order(group);
if (!arraysEqual(new_order, origin_group_node_order[group])) {
save_order_btn.style.display = null;
} else {
save_order_btn.style.display = "none";
}
}
set_order(group);
}
};
try {
@@ -1302,9 +1261,9 @@ table td, .table .td {
</tr>
{{node-tr}}
</table>
<input type="hidden" name="group.order.{{group}}">
<div class="cbi-section-create cbi-tblsection-create">
<input class="cbi-button cbi-button-add" type="button" value="<%:Add%>" onclick="node_action('add')">
<input class="cbi-button cbi-button-apply" style="display: none" type="button" id="save_order_btn_{{group}}" value="<%:Save Order%>" onclick="save_current_page_order('{{group}}')">
</div>
</fieldset>
</script>
@@ -1,10 +1,8 @@
<%
local api = require "luci.passwall2.api"
local api = self.api
local appname = api.appname
local uci = api.uci
-%>
<script src="<%=resource%>/view/<%=appname%>/Sortable.min.js"></script>
<style>
table th, .table .th {
text-align: center;
@@ -182,15 +180,7 @@ table td, .table .td {
let firstDataRow = parent.querySelector("tr:not(.cbi-section-table-titles)");
if (firstDataRow && firstDataRow !== row) {
parent.insertBefore(row, firstDataRow);
let save_order_btn = document.getElementById("save_order_btn_" + group);
if (save_order_btn) {
const new_order = get_shunt_rule_order(group);
if (!arraysEqual(new_order, origin_group_shunt_rule_order[group])) {
save_order_btn.style.display = null;
} else {
save_order_btn.style.display = "none";
}
}
set_order(group);
}
}
@@ -211,47 +201,24 @@ table td, .table .td {
return ids;
}
function save_current_page_order(group) {
var table = document.getElementById("cbi-<%=appname%>-shunt_rules-" + group + "-table");
function set_order(group) {
let table = document.getElementById("cbi-<%=appname%>-shunt_rules-" + group + "-table");
if (!table) {
alert("<%:No table!%>");
return;
}
var rows = table.querySelectorAll("tr.cbi-section-table-row");
let rows = table.querySelectorAll("tr.cbi-section-table-row");
if (!rows || rows.length === 0) {
alert("<%:No shunt_rules!%>");
return;
}
var btn = document.getElementById("save_order_btn_" + group);
if (btn) {
btn.style.display = "none";
btn.disabled = true;
}
var ids = [];
let ids = [];
rows.forEach(function(row) {
var id = row.id.replace("cbi-<%=appname%>-", "");
let id = row.id.replace("cbi-<%=appname%>-", "");
ids.push(id);
});
XHR.get('<%=api.url("save_shunt_rule_order")%>', {
group: group,
ids: ids.join(",")
},
function(x, result) {
if (btn) {
btn.style.display = null;
btn.disabled = false;
}
if (x && x.status === 200) {
origin_group_shunt_rule_order[group] = get_shunt_rule_order(group);
alert("<%:Saved current page order successfully.%>");
if (btn) {
btn.style.display = "none";
}
} else {
alert("<%:Save failed!%>");
}
}
);
const input_order = document.getElementsByName(`group.order.${group}`)[0];
input_order.value = ids.join(" ");
}
// List drag and rearrange
@@ -270,16 +237,7 @@ table td, .table .td {
forceFallback: false,
swapThreshold: 0.65,
onEnd: function (evt) {
//save_current_page_order(group); // Auto save
let save_order_btn = document.getElementById("save_order_btn_" + group);
if (save_order_btn) {
const new_order = get_shunt_rule_order(group);
if (!arraysEqual(new_order, origin_group_shunt_rule_order[group])) {
save_order_btn.style.display = null;
} else {
save_order_btn.style.display = "none";
}
}
set_order(group);
}
};
try {
@@ -335,10 +293,10 @@ table td, .table .td {
</tr>
{{shunt_rule-tr}}
</table>
<input type="hidden" name="group.order.{{group}}">
<div class="cbi-section-create cbi-tblsection-create">
<input type="text" id="add_name_{{group}}">
<input class="cbi-button cbi-button-add" type="button" value="<%:Add%>" onclick="toPage('add')">
<input class="cbi-button cbi-button-apply" style="display: none" type="button" id="save_order_btn_{{group}}" value="<%:Save Order%>" onclick="save_current_page_order('{{group}}')">
</div>
</fieldset>
</script>
@@ -4,7 +4,7 @@ local api = self.api
<script type="text/javascript">
//<![CDATA[
let node_id = "<%=self.section%>";
let node_config_url = '<%=api.url("server_user")%>/' + node_id;
let node_config_url = '<%=api.url("server_config")%>/' + node_id;
document.addEventListener("DOMContentLoaded", function () {
waitForElement('select[name*="<%=self.config%>"][name*="type"]', function(el) {
@@ -0,0 +1,38 @@
<%
local api = self.api
-%>
<script type="text/javascript">
//<![CDATA[
var _servers_status = document.getElementsByClassName('_servers_status');
for(var i = 0; i < _servers_status.length; i++) {
var id = _servers_status[i].parentElement.parentElement.parentElement.id;
id = id.substr(id.lastIndexOf("-") + 1);
XHR.get('<%=api.url("server_status")%>', {
index: i,
id: id
},
function(x, result) {
_servers_status[result.index].setAttribute("style","font-weight:bold;");
_servers_status[result.index].setAttribute("color",result.status ? "green":"red");
_servers_status[result.index].innerHTML = (result.status ? '✓' : 'X');
}
);
}
var edit_btn = document.getElementById("cbi-<%=self.appname%>-<%=self.sectiontype%>").getElementsByClassName("cbi-button cbi-button-edit");
for (var i = 0; i < edit_btn.length; i++) {
try {
var onclick_str = edit_btn[i].getAttribute("onclick");
var id = onclick_str.substring(onclick_str.lastIndexOf('/') + 1, onclick_str.length - 1);
var td = edit_btn[i].parentNode;
var new_div = "";
// Add `log` button
new_div += '<input class="btn cbi-button cbi-button-add" type="button" value="<%:Log%>" onclick="window.open(\'' + '<%=api.url("server_log")%>' + '?id=' + id + '\', \'_blank\')"/>&nbsp;&nbsp;';
td.innerHTML = new_div + td.innerHTML;
}
catch(err) {
console.error(err);
}
}
//]]>
</script>
@@ -1,38 +0,0 @@
<%
local api = require "luci.passwall2.api"
-%>
<script type="text/javascript">
//<![CDATA[
var _users_status = document.getElementsByClassName('_users_status');
for(var i = 0; i < _users_status.length; i++) {
var id = _users_status[i].parentElement.parentElement.parentElement.id;
id = id.substr(id.lastIndexOf("-") + 1);
XHR.get('<%=api.url("server_user_status")%>', {
index: i,
id: id
},
function(x, result) {
_users_status[result.index].setAttribute("style","font-weight:bold;");
_users_status[result.index].setAttribute("color",result.status ? "green":"red");
_users_status[result.index].innerHTML = (result.status ? '✓' : 'X');
}
);
}
var edit_btn = document.getElementById("cbi-passwall2_server-user").getElementsByClassName("cbi-button cbi-button-edit");
for (var i = 0; i < edit_btn.length; i++) {
try {
var onclick_str = edit_btn[i].getAttribute("onclick");
var id = onclick_str.substring(onclick_str.lastIndexOf('/') + 1, onclick_str.length - 1);
var td = edit_btn[i].parentNode;
var new_div = "";
// Add `log` button
new_div += '<input class="btn cbi-button cbi-button-add" type="button" value="<%:Log%>" onclick="window.open(\'' + '<%=api.url("server_user_log")%>' + '?id=' + id + '\', \'_blank\')"/>&nbsp;&nbsp;';
td.innerHTML = new_div + td.innerHTML;
}
catch(err) {
console.error(err);
}
}
//]]>
</script>
+15
View File
@@ -970,6 +970,9 @@ msgstr "گروه قوانین شنت"
msgid "Please note attention to the priority, the higher the order, the higher the priority."
msgstr "لطفاً به اولویت توجه کنید، هرچه ترتیب بالاتر باشد، اولویت بیشتر است."
msgid "This ID already exists."
msgstr "این شناسه از قبل وجود دارد."
msgid "Update..."
msgstr "به‌روزرسانی..."
@@ -1486,9 +1489,15 @@ msgstr "سمت سرور"
msgid "Server Config"
msgstr "پیکربندی سرور"
msgid "Servers Manager"
msgstr "مدیر سرورها"
msgid "Users Manager"
msgstr "مدیریت کاربران"
msgid "User Config"
msgstr "پیکربندی کاربر"
msgid "Logs"
msgstr "گزارش‌ها (Logs)"
@@ -1916,9 +1925,15 @@ msgstr "استفاده از پیکربندی سفارشی"
msgid "Custom Config"
msgstr "پیکربندی سفارشی"
msgid "Must be Base64 text!"
msgstr "متن باید Base64 باشد!"
msgid "Must be JSON text!"
msgstr "باید متن JSON باشد!"
msgid "Must be UUID format!"
msgstr "باید فرمت UUID باشه!"
msgid "Geo View"
msgstr "نمای Geo"
+15
View File
@@ -971,6 +971,9 @@ msgstr "Группа правил шунтирования"
msgid "Please note attention to the priority, the higher the order, the higher the priority."
msgstr "Обратите внимание: правила выше в списке имеют более высокий приоритет."
msgid "This ID already exists."
msgstr "Этот идентификатор уже существует."
msgid "Update..."
msgstr "Обновление..."
@@ -1487,9 +1490,15 @@ msgstr "Серверная часть"
msgid "Server Config"
msgstr "Конфигурация сервера"
msgid "Servers Manager"
msgstr "Диспетчер серверов"
msgid "Users Manager"
msgstr "Управление пользователями"
msgid "User Config"
msgstr "Пользовательская конфигурация"
msgid "Logs"
msgstr "Журнал"
@@ -1917,9 +1926,15 @@ msgstr "Использовать свою конфигурацию"
msgid "Custom Config"
msgstr "Пользовательская конфигурация"
msgid "Must be Base64 text!"
msgstr "Это должен быть текст в формате Base64!"
msgid "Must be JSON text!"
msgstr "Должен быть JSON-текст!"
msgid "Must be UUID format!"
msgstr "Обязательно в формате UUID!"
msgid "Geo View"
msgstr "Просмотр Geo"
+15
View File
@@ -962,6 +962,9 @@ msgstr "分流规则组"
msgid "Please note attention to the priority, the higher the order, the higher the priority."
msgstr "请注意优先级问题,排序越上面优先级越高。"
msgid "This ID already exists."
msgstr "该 ID 已存在。"
msgid "Update..."
msgstr "更新中"
@@ -1478,9 +1481,15 @@ msgstr "服务器端"
msgid "Server Config"
msgstr "服务器配置"
msgid "Servers Manager"
msgstr "服务器管理"
msgid "Users Manager"
msgstr "用户管理"
msgid "User Config"
msgstr "用户配置"
msgid "Logs"
msgstr "日志"
@@ -1902,9 +1911,15 @@ msgstr "使用自定义配置"
msgid "Custom Config"
msgstr "自定义配置"
msgid "Must be Base64 text!"
msgstr "必须是 Base64 内容!"
msgid "Must be JSON text!"
msgstr "必须是 JSON 文本内容!"
msgid "Must be UUID format!"
msgstr "必须是 UUID 格式!"
msgid "Geo View"
msgstr "Geo 查询"
+15
View File
@@ -962,6 +962,9 @@ msgstr "分流規則組"
msgid "Please note attention to the priority, the higher the order, the higher the priority."
msgstr "請注意優先級問題,排序越上面優先級越高。"
msgid "This ID already exists."
msgstr "此 ID 已存在。"
msgid "Update..."
msgstr "更新中"
@@ -1478,9 +1481,15 @@ msgstr "服務器端"
msgid "Server Config"
msgstr "服務器配置"
msgid "Servers Manager"
msgstr "服務器管理"
msgid "Users Manager"
msgstr "用戶管理"
msgid "User Config"
msgstr "用戶配置"
msgid "Logs"
msgstr "日誌"
@@ -1908,9 +1917,15 @@ msgstr "使用自定义配置"
msgid "Custom Config"
msgstr "自定义配置"
msgid "Must be Base64 text!"
msgstr "必須是 Base64 内容!"
msgid "Must be JSON text!"
msgstr "必須是 JSON 文本內容!"
msgid "Must be UUID format!"
msgstr "必須是 UUID 格式!"
msgid "Geo View"
msgstr "Geo 查詢"
+1 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=14
PKG_RELEASE:=15
endif
LUCI_TITLE:=Footstrap Theme
@@ -22,6 +22,10 @@
const _fitters = [];
let _rafPending = false;
let _ro = null, _mo = null;
/* one canvas for the whole document: wordFloor() measures text with it, and creating one per call
* is the expensive half of that measurement */
let _cx = null;
const SPACE_RE = /\s+/;
/* Run every fitter NOW, synchronously. A fitter must be idempotent this fires on every
* relevant mutation. */
@@ -119,6 +123,74 @@ return baseclass.extend({
return el.scrollWidth > this.roomFor(el) + 1; /* +1: sub-pixel rounding */
},
/* THE NARROWEST THIS TABLE CAN BE WITHOUT BREAKING A WORD THROUGH its own breakpoint, in px.
*
* Every other "does it fit" question here is asked of the browser. This one cannot be: it is
* exactly the table's min-content width, and `overflow-wrap: anywhere` (base/40-tables.css) makes
* the browser answer ONE CHARACTER per column. That is deliberate an unbreakable ICCID must not
* push a table nobody measures out of its card but it means a measured table can be starved to
* a ribbon of fragments and still report, truthfully, that it fits. Asking the engine the honest
* question by flipping `overflow-wrap` for one layout does not work either: Blink returns the same
* min-content for `normal`, `break-word` and `anywhere` on a table (measured 645px in all three,
* with the widest word alone needing 367), so the number simply is not available from layout.
*
* So compute it: per COLUMN, the width of the widest WORD any of its cells has to show, plus that
* cell's own side padding; summed across columns. A `nowrap`/`pre` column takes its whole text
* instead, because that is what it has committed to showing. Nothing here is a threshold the
* result is the table's content measured in the table's own fonts, so every table gets its own
* number and none was picked by anyone.
*
* Two approximations, both stated rather than hidden:
* - the FONT is sampled once per column, from the first data row. A column that changes face
* row by row would be measured in the first row's; no LuCI table does that, and the
* alternative is a `getComputedStyle` per cell, which is the expensive call here.
* - the widest word is picked by CHARACTER COUNT and only that one is measured, so a column of
* one font is one `measureText` per new maximum instead of one per cell. Within a single font
* that ranks `iiii` above `WWW`; it costs a few px on the floor and takes the walk over
* Processes (114 rows) from 6ms to about 1ms.
*
* A `colSpan` cell contributes its whole floor to one column, which over-states that column and
* under-states its neighbours by the same amount the sum, which is what the caller compares, is
* unaffected. */
wordFloor(t) {
const rows = t.querySelectorAll('.tr:not(.table-titles):not(.cbi-section-table-titles):not(.placeholder)');
if (!rows.length) return 0;
if (!_cx) _cx = document.createElement('canvas').getContext('2d');
const floors = [], longest = [];
let cols = null;
for (const row of rows) {
const cells = row.children;
if (!cols) {
cols = [];
for (const c of cells) {
const s = getComputedStyle(c);
cols.push({
font: `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`,
tracking: parseFloat(s.letterSpacing) || 0,
pad: parseFloat(s.paddingLeft) + parseFloat(s.paddingRight),
whole: (s.whiteSpace === 'nowrap' || s.whiteSpace === 'pre')
});
}
}
for (let i = 0; i < cells.length; i++) {
const text = (cells[i].textContent || '').trim();
if (!text) continue;
const col = cols[i] || cols[cols.length - 1];
let word = '';
if (col.whole) word = text;
else for (const w of text.split(SPACE_RE)) if (w.length > word.length) word = w;
if (!(word.length > (longest[i] || 0))) continue;
longest[i] = word.length;
_cx.font = col.font;
const need = _cx.measureText(word).width + (col.tracking * word.length) + col.pad;
if (!(floors[i] >= need)) floors[i] = need;
}
}
let sum = 0;
for (const f of floors) sum += (f || 0);
return sum;
},
/* How many LINE BOXES of text does `el` render? The fact behind "this column has been
* squeezed into a tower" a cell that has to break its own words is a column that has run
* out of width, which is a thing no viewport query can ask.
@@ -76,6 +76,63 @@ function clearViewIntervals() {
}
let _pollTimerWarned = false;
/* --- uci cache teardown for SPA nav ---
* `uci.load()` does not answer "is this config present?" it answers "which of these packages did
* THIS call fetch", skipping every package already in its document-scoped cache:
*
* for () if (!self.state.values[packages[i]]) { pkgs.push(); tasks.push() }
* return Promise.all(tasks).then(() => pkgs); // uci.js, luci-base
*
* Four shipped views read that return value as an existence check and abort on an empty array
* luci-app-banip and luci-app-adblock's overview, luci-app-travelmate's overview and stations:
*
* if (!result[3] || result[3].length === 0) { ui.addNotification(, _('No banIP config found!')); return; }
*
* On a full load the cache is empty, so the first visit always gets its package name back and the
* check passes. Under SPA the document survives, so the SECOND visit gets `[]` and the page renders
* as "no config found" reported against banip, where switching between its own tabs and coming
* back to Overview is the ordinary gesture, and unstickable by anything short of a reload.
*
* The apps' reading of `load()` is wrong, but the DIVERGENCE is ours: a cache that outlives the page
* that filled it is state a fresh load does not have, exactly like the poll queue and the view's
* intervals above. So drop it on navigation and let the incoming view fetch what it needs.
*
* `unload()` is upstream's own idiom for this, not a lever we found: `uci.save()` ends with
* `self.unload(pkgs); return self.load(pkgs)`. Pending local edits (creates/changes/deletes) go with
* it as they do on a full load, which throws away the whole document. Saved changes are already
* on the server and the Unsaved-changes banner reads them from there, so it is unaffected.
*
* Read through `window.L.uci` rather than a `'require uci'` pragma, deliberately: the class attaches
* itself to L's prototype when the FIRST requirer compiles it, so this sees the instance the pages
* actually use, and a router that required it would both bind it to its own prototypal L (the two-L
* trap, docs/spa-router.md) and pull uci.js onto pages that never touch uci. No instance means no
* cache to flush. */
function flushUciCache() {
const uci = window.L ? window.L.uci : null;
if (!uci || typeof uci.unload !== 'function') return;
/* `state.values` and `loaded` are private, so the same rule as L.Poll.timer applies: a shape we
* do not recognise is a reason to do nothing, once, loudly never to guess. The two hold
* different halves of the cache (a package whose load is still in flight is in `loaded` alone),
* and unload() clears both, so the names are taken from both. */
if (!uci.state || typeof uci.state.values !== 'object' || typeof uci.loaded !== 'object') {
if (!_uciCacheWarned) {
_uciCacheWarned = true;
console.error('footstrap: LuCI.uci keeps its cache somewhere this router does not know, so '
+ 'it is left alone. An app that reads uci.load()\'s return value as an existence check '
+ 'will report a missing config on the second SPA visit. fs-router.js needs updating for '
+ 'this luci-base.');
}
return;
}
const names = Object.keys(uci.state.values).concat(Object.keys(uci.loaded));
if (names.length) uci.unload(names);
/* `state.reorder` is the one half unload() does not clear, and it is deliberately left alone
* rather than reset by hand: with `values` gone, reorderSections() finds no sections to order,
* emits no call and clears the map itself on the next save. Writing to that field would be us
* editing another module's private state, for a difference nothing can observe. */
}
let _uciCacheWarned = false;
let _wired = false;
/* The pathname whose view is CURRENTLY rendered popstate compares against it to tell a real
* navigation from a mere fragment change (see there). Seeded from the served page. */
@@ -599,6 +656,9 @@ function navigate(pathname, push, kbd) {
/* kill the outgoing view's plain setInterval pollers too (podkop's log tailer) a full load
* would have. L.Poll's own tick survives. */
clearViewIntervals();
/* and drop uci's document-scoped config cache, which a full load would not have carried into the
* incoming page either (see flushUciCache) */
flushUciCache();
/* the outgoing page's links are about to become a detached tree — do not hold one of them */
_lastHovered = null;
/* run every registered navigation callback today the search palette's recent-pages record and
@@ -172,10 +172,40 @@ function enhance(sel) {
*
* `.table`, not `table.table` the SAME selector relevant() and STACKABLE use. Stock LuCI
* happens to emit only real <table>s, but a third-party luci-app-* may emit a <div class="table">
* (coverage rule, docs/conventions.md), which a tag qualifier would pass over so it could never card. */
* (coverage rule, docs/conventions.md), which a tag qualifier would pass over so it could never card.
*
* `.table` is LuCI's own class, and everything the theme knows how to do with a table hangs off it.
* A third-party app that emits a BARE `<table>` no LuCI classes at all therefore matched none of
* this: nothing tagged it, nothing measured it, nothing carded it, and the only thing that reached it
* was a phone-tier scrollbar (theme/90-responsive.css). Reported from a phone against a wifi-clients
* dashboard whose last column was simply cut off.
*
* So the second half of this selector claims those too. It is deliberately UNMEASURABLE on the dev
* stand a census of `#view table:not(.table):not(.cbi-section-table)` over all 196 menu pages,
* openclash / justclash / ssclash / dashboard / statistics included, found ZERO. That is the point:
* every table anyone here emits already carries the class, so this changes nothing that can be
* measured and covers the one shape that cannot be (docs/conventions.md: coverage is a contract). */
const FOREIGN_TABLE = '#view .table:not(.cbi-section-table):not(.fs-dt), ' +
'#view table:not(.table):not(.cbi-section-table):not(.fs-dt)';
/* The FOURTH header markup, and the one only a foreign table produces: `<table><tr><th>`, with no
* `<thead>` for the parser to imply and none of LuCI's class names. It is the exact shape the phone
* tier's scroll fallback was written against, so it has to be recognisable here or that table can
* still only ever scroll.
*
* "Every cell in the first row is a `<th>`" is the whole test, and it has to be EVERY: a data row
* whose first cell is a row header (`<th scope=row>`) would otherwise be read as the header row and
* every value below it captioned with a value. A table with no `<th>` at all a layout table, a
* matrix returns null and keeps today's behaviour, which is what the scroll fallback is for. */
function headerRow(t) {
const row = t.rows && t.rows[0];
if (!row || !row.cells.length) return null;
return [ ...row.cells ].every((c) => c.tagName === 'TH') ? row : null;
}
function tagDataTables() {
document.querySelectorAll('#view .table:not(.cbi-section-table):not(.fs-dt)').forEach((t) => {
/* THREE header markups, and each missing one cost a page. L.ui.Table emits
document.querySelectorAll(FOREIGN_TABLE).forEach((t) => {
/* FOUR header markups, and each missing one cost a page. L.ui.Table emits
* `.tr.table-titles`; the apk Software page emits `.tr.cbi-section-table-titles` (missing
* it is why the package list once needed a stacking block of its own); and a third-party
* table may simply use a real `<thead>` luci-mod-dashboard's device lists are
@@ -188,16 +218,66 @@ function tagDataTables() {
* to the `<thead>` the parser's implied row never happens, so a `tr` in the selector finds
* nothing. Read as "the header ROW-ISH element", which is what its children are cells of.
*
* ANY of the three = a data table; NONE = a key/value include (System, Memory), which must
* ANY of the four = a data table; NONE = a key/value include (System, Memory), which must
* never card. `thead` is the structural form of the same statement the two classes make, so
* it belongs in the same list rather than in a rule of its own. */
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead');
* it belongs in the same list rather than in a rule of its own; headerRow() is the fourth and
* cannot be, because "the first row is all `<th>`" is not a selector. */
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead') || headerRow(t);
if (!head) return;
t.classList.add('fs-dt');
/* `.table` as well as `.fs-dt`, and only ever ADDED: the theme's whole table vocabulary
* the frame, the cell padding, the card stack is written against `.table`, so a foreign
* table that has just been recognised as a data table has to join it or the tag buys nothing.
* A no-op on everything LuCI renders, which carries the class already. */
t.classList.add('table', 'fs-dt');
adoptMarkup(t, head);
labelCells(t, head);
});
}
/* ...AND THE ROWS AND CELLS INSIDE IT, or the claim is a trap.
*
* `.table` alone gets the frame and the padding, because those rules end at the table. Everything
* that makes the CARD is written one level down `.table.fs-stacked .tr { display: flex }`, the
* `.td[data-title]::before` label, the hidden header row and a bare foreign `<table>` carries none
* of those class names. So the fitter would measure it, decide it no longer fits, set `.fs-stacked`
* and change NOTHING: measured at 390px on a bare four-column table, the rows stayed `table-row`,
* the cells `table-cell` at 80px each, no label was generated and `#view .table.fs-dt.fs-stacked`
* sets `overflow: hidden`, so the columns were CLIPPED with no scrollbar to reach them. That is
* worse than the phone-tier scroll it replaced, which is the whole reason this exists.
*
* `.tr` / `.td` / `.th` are LuCI's own names for these roles (docs/third-party-apps.md: the shared
* zone), and the theme is already writing `.table` onto the same element this is that one act
* carried down to the rows, not a new liberty. Additive only, and cheap enough to re-run every fit
* pass: `classList.add` on an element that already has the class is the same `contains` check we
* would write to skip it, and these tables are POLLED, so fresh rows arrive bare.
*
* The HEADER also has to be recognisable as one, or the card shows it as a first row of column
* names: a `<thead>` becomes `.thead` and a plain first row of `<th>` becomes `.tr.table-titles`
* the two names theme/30-tables.css hides when stacked. */
function adoptMarkup(t, head) {
/* DECIDED ONCE, AT CLAIM TIME, and only for a table that speaks none of this vocabulary. Asking
* the question every pass instead would answer "already adopted" the moment we adopted it and
* these tables are polled, so the fresh rows that arrive bare afterwards would never be taken.
* Asking it at all is what keeps the theme's hands off LuCI's own markup: the apk Software list
* heads its table with `.tr.cbi-section-table-titles`, and blindly adding `table-titles` to that
* would be the theme rewriting a class LuCI chose. */
if (t._fsAdopt === undefined) t._fsAdopt = !t.querySelector('.tr, .thead');
if (!t._fsAdopt) return;
if (head.tagName === 'THEAD') head.classList.add('thead');
else head.classList.add('tr', 'table-titles');
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
for (const c of titleRow.children) c.classList.add('th');
/* `t.rows` covers a real <table> whether or not it has a <tbody> a table built with
* createElement has its <tr> directly under the <table> and `tbody tr` finds nothing. A
* `<div class="table">` has no `.rows` and is LuCI's own markup, which carries the classes. */
if (!t.rows) return;
for (const row of t.rows) {
if (head.contains(row) || row === head) continue;
row.classList.add('tr');
for (const cell of row.children) cell.classList.add(cell.tagName === 'TH' ? 'th' : 'td');
}
}
/* Give every cell the column heading it will show once the table cards.
*
* The card layout prints `attr(data-title)` above each value (theme/30-tables.css), and LuCI's own
@@ -212,7 +292,14 @@ function tagDataTables() {
* matters because these tables are POLLED: the rows are replaced wholesale every few seconds, and
* the fresh ones arrive without it. */
function labelCells(t, head) {
const titles = [ ...head.children ].map((c) => (c.textContent || '').trim());
/* A `<thead>` that was WRITTEN as markup nests a real `<tr>` the parser inserts one even where
* the author left it out while one built by E() holds the `<th>`s directly (see above). Reading
* `head.children` blind therefore captioned every cell of a parsed table with the header row's
* ENTIRE text: "HostAddressSignal" over the hostname, over the address and over the signal.
* Measured against a bare `<table><thead><tr><th>` on the stand, which is the shape a
* server-rendered or innerHTML-built foreign table has. */
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
const titles = [ ...titleRow.children ].map((c) => (c.textContent || '').trim());
if (!titles.some(Boolean)) return;
for (const row of t.querySelectorAll('.tr, tbody tr')) {
if (row === head) continue;
@@ -299,6 +386,30 @@ function idTower(t) {
return false;
}
/* ---- AND THE SAME RIBBON IN A COLUMN THAT IS NOT THE FIRST ----
*
* There is no second test for it, and that is the fix rather than an omission.
*
* `idTower` above is deliberately first-column-only, on the grounds that "a value column wrapping to
* a few lines is a value being shown, not a table falling apart". That held right up until the value
* was one unbreakable token: `overflow-wrap: anywhere` gave such a cell a min-content of ONE
* CHARACTER, so auto table layout was free to starve its column to one character, and `overflows()`
* then reported truthfully, uselessly that the table fit. Reported from a hardware router at
* 700-790px of window: the v4 lease table cards there (its `nowrap` columns give it a floor, so it
* really does overflow) while the v6 table beside it shredded the DUID, measured at 5 lines with
* 674px of room and 7 at 654px, against 1 line at 1160px.
*
* A first pass answered it with a line count, which is a number somebody picks. This asks the table
* instead: `fit.wordFloor()` returns the narrowest the table can be without breaking a word through
* per column, the widest WORD it must show, in that column's own font, summed. Past that width the
* browser has to cut through a value, and the card view is what shows values whole. So every table
* carries its own breakpoint, derived from its own content, and no threshold was chosen anywhere.
*
* On the reporting router, at 1190px of room: leases6 asks for 935, the associated-stations table
* for 966, the v4 leases for 645, Processes for 794, Connections for 550 and Startup for 381 so
* the two that were unreadable card at roughly a 1000px window, and the four that were fine keep
* being tables until the room they actually need runs out. */
function fitTables() {
document.querySelectorAll(STACKABLE).forEach((t) => {
const was = t.classList.contains('fs-stacked');
@@ -309,8 +420,8 @@ function fitTables() {
const room = fit.roomFor(t);
if (!(room > 0)) { if (was) t.classList.add('fs-stacked'); return; }
/* idTower last: it is the only one that walks the rows */
const stack = room < CRAMPED || fit.overflows(t) || idTower(t);
/* the two row walks last, cheapest first: idTower reads one column, wordFloor every cell */
const stack = room < CRAMPED || fit.overflows(t) || idTower(t) || fit.wordFloor(t) > room;
/* write only on a real change: the poll re-renders these tables once a second, and
* toggling the class off and on each tick would invalidate style for every row of
* Processes/Leases for nothing */
+47 -3
View File
@@ -83,14 +83,51 @@
.table .td { overflow-wrap: anywhere; }
.table .th { overflow-wrap: break-word; }
/* AND THE FIRST COLUMN IS A LABEL TOO, whichever element it happens to be made of.
*
* `anywhere` costs a min-content of one character (above), and auto table layout hands each
* column its share by MAX-content so a two-column key/value table whose value is one enormous
* token starves the key column down to that single character, and the header row above it with
* it. Measured against luci-app-dockerman's Environment table (issue #36), which prints
* `JSON.stringify()` of every field `docker info` returns: at 8 000 characters of value the key
* column fell from 95px to 55px and "Driver" started wrapping; on the reporter's own host it
* reached about one glyph, so the column header read "E N T R Y" straight down the page, on
* Firefox 153 and on Blink alike.
*
* `break-word` puts the floor back at the longest WORD the same trade `.th` makes one line up,
* for the same reason: the first column is the row's identity, and it is what you read the table
* by. fs-select.js makes the same judgement in the other direction (MAX_ID_LINES: past five
* lines the identity has stopped being readable and the table cards instead).
*
* A CARDED row is exempt and must be: `.table.fs-stacked .td` (theme/30-tables.css) restates
* `anywhere` from a later layer, so once every cell is on its own line there is no neighbouring
* column left to starve it and a long value may break wherever it likes. */
.table .tr > .td:first-child { overflow-wrap: break-word; }
.table .tr:first-child .th {
padding-top: var(--fs-space-2);
font-weight: var(--fs-weight-bold);
vertical-align: top;
}
/* the empty-table plate: three lines of leading plus the cell inset it sits in, so it keeps
* the table's own height when there is nothing to show. */
/* The empty-table plate: three lines of leading plus the cell inset it sits in, so it keeps
* the table's own height when there is nothing to show.
*
* IT IS OUT OF FLOW ON PURPOSE and stays that way: a single-cell row in a `display: table` spans
* only the FIRST column, so in flow the plate is a narrow box under column one and, worse, its
* sentence then becomes that column's max-content and WIDENS it (measured: the Container column
* of an empty section went 275px -> 502px when the cell was put back in flow). Absolute takes it
* out of the column sizing entirely and `inset-inline: 0` spans it across the table.
*
* WHAT IT MUST NOT DO IS PIN ITSELF TO THE BOTTOM. It used to say `bottom: 0`, which inside the
* `position: relative` .table means the bottom of the whole TABLE the same place as its own row
* only while the placeholder is the last row there is. `form.TableSection` renders its `footer`
* into a `<tfoot>` AFTER the `<tbody>`, so on every empty section that declares one the plate
* printed straight across the totals: measured on luci-app-dockerman's Containers, Images and
* Volumes tables (issue #36) as a 1190x35 overlap, the full width of the table, identically on
* Blink and on Gecko 153. With no `bottom` at all an absolutely positioned box keeps its STATIC
* position where the row actually is which is what was meant all along, and the reserved row
* height below is what holds that space open. */
.tr.placeholder {
height: calc(3em + var(--fs-space-4));
}
@@ -98,9 +135,16 @@
.tr.placeholder > .td {
position: absolute;
inset-inline: 0;
bottom: 0;
text-align: center;
line-height: 3em;
/* and the plate must be exactly as tall as the row reserved for it, or it hangs out of the
* table at whichever end it is anchored to. `--fs-space-4` is two `--fs-space-2` at every
* density, so this and the row height above are the same number written twice which is what
* lets the cell keep its own size while the four table flavours go on padding their ordinary
* cells differently (`--fs-space-2` here, `--fs-space-2-5` on a framed data table, and so on).
* Measured on Wireless, whose associated-stations table pads 10px a side: the plate came out
* 67px inside a 63px row and printed 4px BELOW the frame's bottom border. */
padding-block: var(--fs-space-2);
}
.tr.drag-over-above,
@@ -153,15 +153,20 @@
#view .table[id] .tr:last-child .td:last-child,
table.fs-dt .tr:last-child .td:last-child { border-bottom-right-radius: var(--fs-radius-lg); }
/* Empty table: L.ui.Table adds a single-cell .tr.placeholder ("There are no active leases").
* In a display:table it spans only the first column, so the bottom-corner rounding drew a tiny
* rounded box on the left. Block it to full width, carrying the frame's bottom corners. */
.cbi-section .table[id] .tr.placeholder,
#view .table[id] .tr.placeholder,
table.fs-dt .tr.placeholder { display: block; }
* Spanning it across the table is base's job (base/40-tables.css: absolute, `inset-inline: 0`),
* and the `display: block; width: 100%` that used to be restated here was a second way of saying
* the same thing it read as the fix while the real one was base's `inset-inline`. What is left
* is the FRAMED table's own flavour: the plate reads as prose, left-aligned like the rows it
* stands in for, and carries the frame's bottom corners so the rounding does not cut across it. */
.cbi-section .table[id] .tr.placeholder .td,
#view .table[id] .tr.placeholder .td,
table.fs-dt .tr.placeholder .td {
display: block; width: 100%; text-align: start;
/* the framed flavour pads its cells `--fs-space-2-5` a side (above), and that shorthand is in
* THIS layer, so it overrides base's placeholder padding and made the plate 4px taller than
* the row reserved for it measured on Wireless as 67px inside 63px, printing below the
* frame's bottom border. Put base's number back for this one cell. */
padding-block: var(--fs-space-2);
text-align: start;
color: var(--fs-dim); font-family: var(--fs-font-sans);
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
}
@@ -459,7 +464,11 @@
* only thing overflow:visible protected. `#view .table.fs-dt.fs-stacked` (1,3,0) outranks the
* frame's `#view .table[id]` (1,2,0) on specificity, not order. */
#view .table.fs-dt.fs-stacked { overflow: hidden; }
.table.fs-dt.fs-stacked .tr { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-3); }
/* `:not(.placeholder)` the plate is out of flow (base/40-tables.css) and its row exists only to
* reserve the height it needs. Padding is subtracted from that reservation (border-box), so the
* card's row inset left 41px of the 63px the plate is: measured on Wireless as 10px of text
* hanging below the frame once the table stacked. A row with no rows in it has nothing to inset. */
.table.fs-dt.fs-stacked .tr:not(.placeholder) { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-3); }
/* `.fs-stacked .td`, not `.table.fs-dt.fs-stacked .td` the same elements (the class is only
* ever written onto a .table.fs-dt), but at (0,2,0) instead of (0,4,0): low enough that the
* unguarded `.table .td.col-N` below, at (0,3,0), beats it on SPECIFICITY, not source order. */
@@ -490,4 +499,34 @@
/* general table cell padding — compact but readable (Startup/Processes/etc) */
.table .td { padding-top: var(--fs-space-2); padding-bottom: var(--fs-space-2); }
/* ---- THE LAST RESORT: a foreign <table> the card stack cannot claim ----
*
* fs-select.js now tags any `#view table` that HAS a header row `.tr.table-titles`,
* `.tr.cbi-section-table-titles`, a `<thead>`, or a first row made entirely of `<th>` as
* `.table.fs-dt`, so it is measured and cards like every other data table. What is left here is
* the table with no header at all: a log, a statistics matrix, a layout table. Those must NOT be
* carded even if they overflow a card prints `data-title` above each value, and a matrix has no
* titles to print, so stacking one produces a column of numbers with nothing saying what they are.
* Comparison is the whole point of that shape; it scrolls instead.
*
* `display: block` + `overflow-x: auto` on the table ITSELF, not on a wrapper: the theme cannot
* add markup around someone else's element, and the block box is what gives it a scroll container.
* The rows still lay out as a table a `display: block` <table> wraps its children in an
* anonymous table box so the columns keep lining up.
*
* UNGUARDED, and that is the change: this rule spent its life inside `@media (max-width: 767px)`
* (theme/90-responsive.css), where it was written after a phone report a wifi-clients dashboard
* whose last column was silently CUT by `.fs-main`'s `overflow-x: clip`, measured at 390px as a
* 388px table in a 356px card. But the viewport was never the fact that mattered: a narrow panel,
* a split window or a 960px tablet reaches the same wall with the query switched off, and the
* clip is just as silent there. Cost at a width where the table fits: nothing at all `overflow`
* only paints a scrollbar once there is something to scroll. */
#view table:not(.table):not(.cbi-section-table):not(.fs-dt) {
display: block;
max-width: 100%;
overflow-x: auto;
/* the scroll gesture stops at this table instead of dragging the page sideways */
overscroll-behavior-x: contain;
}
}
@@ -6,32 +6,11 @@
* or table shrink or wrap. Tablets (>=768px) were already clean.
* ================================================================== */
@media (max-width: 767px) {
/* A FOREIGN <table> one with none of LuCI's classes scrolls instead of being cut off.
*
* The theme cards a data table when it stops fitting (.fs-stacked, fs-select.js), but that
* only reaches `.table` markup carrying a header row: `.tr.table-titles` or
* `.tr.cbi-section-table-titles`. A third-party dashboard that emits a plain
* `<table><tr><th>` matches none of it, so nothing measured it, nothing carded it, and
* `.fs-main`'s `overflow-x: clip` silently CUT the right-hand columns reported on a phone
* against a wifi-clients dashboard whose last column was simply not there. Measured here at
* 390px: the table wanted 388px inside a 356px card and overflowed by 31px, with the card at
* `overflow-x: visible` so there was nothing to scroll.
*
* `display: block` + `overflow-x: auto` on the table itself, not a wrapper: the theme cannot
* add markup around someone else's element, and the block box is what gives it a scroll
* container. The rows still lay out as a table a `display: block` <table> wraps its own
* children in an anonymous table box so the columns keep lining up.
*
* Scoped away from every table the theme DOES understand: `.table` (LuCI's div-tables and
* the ones fs-select tags), `.cbi-section-table` (config forms, which card by container)
* and `.fs-dt`. Those have their own answer and must not get a scrollbar instead of it. */
#view table:not(.table):not(.cbi-section-table):not(.fs-dt) {
display: block;
max-width: 100%;
overflow-x: auto;
/* the scroll gesture stops at this table instead of dragging the page sideways */
overscroll-behavior-x: contain;
}
/* The foreign-<table> scroll fallback used to live here, guarded by this query. It does not
* any more: whether a table fits is a property of its CONTENT and its COLUMN, never of the
* viewport the same argument the fit engine is built on and a 400px-wide panel on a
* 1600px desktop needs it exactly as much as a phone does. It is unguarded now, in
* theme/30-tables.css, next to the card stack it is the fallback FOR. */
/* Must stay INSIDE this @media, and the reason is specificity within ONE layer: the 400px
* cap lives in theme/65-dropdown.css (absorbed there from base) on
+1 -1
View File
@@ -10,7 +10,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=mptcp
PKG_VERSION:=6.1
PKG_RELEASE:=9
PKG_RELEASE:=10
PKG_MAINTAINER:=Ycarus (Yannick Chabanois) <ycarus@zugaina.org>
PKG_BUILD_DIR := $(BUILD_DIR)/$(PKG_NAME)
+5 -1
View File
@@ -52,7 +52,11 @@ global_multipath_settings() {
config_get mptcp_rr_num_segments globals mptcp_rr_num_segments
config_get mptcp_version globals mptcp_version "0"
config_get mptcp_stale_loss_cnt globals mptcp_stale_loss_cnt "4"
config_get mptcp_pm_type globals mptcp_pm_type "1"
# Fallback matches the uci-defaults / LuCI form default (in-kernel PM):
# an absent value must NOT resolve to userspace PM, or a config save
# that drops this option (see issue #4349) silently disables in-kernel
# fullmesh subflow creation and breaks multi-WAN bonding.
config_get mptcp_pm_type globals mptcp_pm_type "0"
config_get mptcp_allow_join_initial_addr_port globals mptcp_allow_join_initial_addr_port "1"
config_get mptcp_close_timeout globals mptcp_close_timeout "60"
config_get mptcp_blackhole_timeout globals mptcp_blackhole_timeout "3600"
+2 -2
View File
@@ -8,8 +8,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=netdata
PKG_VERSION:=2.10.4
PKG_RELEASE:=14
PKG_VERSION:=2.11.0
PKG_RELEASE:=15
PKG_MAINTAINER:=Josef Schlehofer <pepe.schlehofer@gmail.com>, Daniel Engberg <daniel.engberg.lists@pyret.net>
PKG_LICENSE:=GPL-3.0-or-later