mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 13:15:13 +08:00
🍕 Sync 2026-08-16 23:43:42
This commit is contained in:
1 parent
cea9097c34
commit
4bf1fb0640
40 files changed
+2889
-144
No files matched your search
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-passwall
|
||||
PKG_VERSION:=26.8.12
|
||||
PKG_RELEASE:=223
|
||||
PKG_RELEASE:=224
|
||||
PKG_PO_VERSION:=$(PKG_VERSION)
|
||||
|
||||
PKG_CONFIG_DEPENDS:= \
|
||||
|
||||
@@ -124,6 +124,7 @@ function index()
|
||||
entry({"admin", "services", appname, "geo_view"}, call("geo_view")).leaf = true
|
||||
|
||||
entry({"admin", "services", appname, "fetch_certsha256"}, call("fetch_certsha256")).leaf = true
|
||||
entry({"admin", "services", appname, "gen_wireguard_key"}, call("gen_wireguard_key")).leaf = true
|
||||
end
|
||||
|
||||
local function http_write_json(content)
|
||||
@@ -247,7 +248,7 @@ function gen_client_config()
|
||||
end
|
||||
|
||||
function get_now_use_node()
|
||||
local path = "/tmp/etc/passwall/acl/default"
|
||||
local path = api.TMP_PATH .. "/acl/default"
|
||||
local e = {}
|
||||
local tcp_node = api.get_cache_var("ACL_GLOBAL_TCP_node")
|
||||
if tcp_node then
|
||||
@@ -263,7 +264,7 @@ end
|
||||
function get_redir_log()
|
||||
local name = http.formvalue("name")
|
||||
local proto = http.formvalue("proto"):upper()
|
||||
local path = "/tmp/etc/passwall/acl/" .. name
|
||||
local path = api.TMP_PATH .. "/acl/" .. name
|
||||
|
||||
local function alert(msg)
|
||||
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate(msg)))
|
||||
@@ -280,14 +281,14 @@ function get_redir_log()
|
||||
local acl_udp = uci_get(name, "udp_node") or "nil"
|
||||
local global_enabled = uci_get("@global[0]", "enabled") == "1"
|
||||
if proto == "TCP" and acl_tcp == global_tcp and global_enabled then
|
||||
path = "/tmp/etc/passwall/acl/default"
|
||||
path = api.TMP_PATH .. "/acl/default"
|
||||
if uci_get("@global[0]", "log_tcp") ~= "1" then
|
||||
alert("The access control node is the same as the global node. Please enable global logging.")
|
||||
return
|
||||
end
|
||||
end
|
||||
if proto == "UDP" and acl_udp == global_udp and global_enabled then
|
||||
path = "/tmp/etc/passwall/acl/default"
|
||||
path = api.TMP_PATH .. "/acl/default"
|
||||
if uci_get("@global[0]", "log_udp") ~= "1" then
|
||||
alert("The access control node is the same as the global node. Please enable global logging.")
|
||||
return
|
||||
@@ -309,7 +310,7 @@ end
|
||||
|
||||
function get_socks_log()
|
||||
local name = http.formvalue("name")
|
||||
local path = "/tmp/etc/passwall/" .. name .. ".log"
|
||||
local path = api.TMP_PATH .. "/" .. name .. ".log"
|
||||
if fs.access(path) then
|
||||
local content = luci.sys.exec("cat ".. path)
|
||||
content = content:gsub("\n", "<br />")
|
||||
@@ -321,12 +322,12 @@ end
|
||||
|
||||
function get_chinadns_log()
|
||||
local flag = http.formvalue("flag")
|
||||
local path = "/tmp/etc/passwall/acl/" .. flag .. "/chinadns_ng.log"
|
||||
local path = api.TMP_PATH .. "/acl/" .. flag .. "/chinadns_ng.log"
|
||||
if flag ~= "default" then
|
||||
local global_tcp = uci_get("@global[0]", "tcp_node") or "nil"
|
||||
local acl_tcp = uci_get(flag, "tcp_node") or "nil"
|
||||
if acl_tcp == global_tcp and uci_get("@global[0]", "enabled") == "1" then
|
||||
path = "/tmp/etc/passwall/acl/default/chinadns_ng.log"
|
||||
path = api.TMP_PATH .. "/acl/default/chinadns_ng.log"
|
||||
if uci_get("@global[0]", "log_chinadns_ng") ~= "1" then
|
||||
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate("The access control node is the same as the global node. Please enable global logging.")))
|
||||
return
|
||||
@@ -432,31 +433,31 @@ function index_status()
|
||||
local dns_shunt = uci_get("@global[0]", "dns_shunt") or "dnsmasq"
|
||||
if dns_shunt == "smartdns" then
|
||||
local port = api.get_cache_var("SMARTDNS_LOCAL_PORT") or 0
|
||||
e.dns_mode_status = (port ~= 0) and luci.sys.call(string.format("netstat -apn | grep ':%s ' >/dev/null", port)) == 0 or false
|
||||
e.dns_mode_status = (port ~= 0) and luci.sys.call("netstat -apn | grep ':%s ' >/dev/null" % port) == 0 or false
|
||||
elseif dns_shunt == "chinadns-ng" then
|
||||
e.dns_mode_status = luci.sys.call("/bin/busybox top -bn1 | grep -v 'grep' | grep '/tmp/etc/passwall/bin/' | grep 'default' | grep 'chinadns_ng' >/dev/null") == 0
|
||||
e.dns_mode_status = luci.sys.call("/bin/busybox top -bn1 | grep -v 'grep' | grep '%s/bin/' | grep 'default' | grep 'chinadns_ng' >/dev/null" % api.TMP_PATH) == 0
|
||||
else
|
||||
e.dns_mode_status = luci.sys.call("netstat -apn | grep ':15353 ' >/dev/null") == 0
|
||||
end
|
||||
|
||||
e.haproxy_status = "-1"
|
||||
if api.is_finded("haproxy") then
|
||||
e.haproxy_status = (luci.sys.call(string.format("/bin/busybox top -bn1 | grep -v grep | grep '%s/bin/' | grep haproxy >/dev/null", appname)) == 0) and "0" or "1"
|
||||
e.haproxy_status = (luci.sys.call("/bin/busybox top -bn1 | grep -v grep | grep '%s/bin/' | grep haproxy >/dev/null" % appname) == 0) and "0" or "1"
|
||||
end
|
||||
|
||||
e["tcp_node_status"] = luci.sys.call("/bin/busybox top -bn1 | grep -v 'grep' | grep '/tmp/etc/passwall/bin/' | grep 'default' | grep 'TCP' >/dev/null") == 0
|
||||
e["tcp_node_status"] = luci.sys.call("/bin/busybox top -bn1 | grep -v 'grep' | grep '%s/bin/' | grep 'default' | grep 'TCP' >/dev/null" % api.TMP_PATH) == 0
|
||||
|
||||
if (uci_get("@global[0]", "udp_node") or "nil") == "tcp" then
|
||||
e["udp_node_status"] = e["tcp_node_status"]
|
||||
else
|
||||
e["udp_node_status"] = luci.sys.call("/bin/busybox top -bn1 | grep -v 'grep' | grep '/tmp/etc/passwall/bin/' | grep 'default' | grep 'UDP' >/dev/null") == 0
|
||||
e["udp_node_status"] = luci.sys.call("/bin/busybox top -bn1 | grep -v 'grep' | grep '%s/bin/' | grep 'default' | grep 'UDP' >/dev/null" % api.TMP_PATH) == 0
|
||||
end
|
||||
http_write_json(e)
|
||||
end
|
||||
|
||||
function haproxy_status()
|
||||
local e = {}
|
||||
e["status"] = luci.sys.call(string.format("/bin/busybox top -bn1 | grep -v grep | grep '%s/bin/' | grep haproxy >/dev/null", appname)) == 0
|
||||
e["status"] = luci.sys.call("/bin/busybox top -bn1 | grep -v grep | grep '%s/bin/' | grep haproxy >/dev/null" % appname) == 0
|
||||
http_write_json(e)
|
||||
end
|
||||
|
||||
@@ -869,8 +870,9 @@ end
|
||||
|
||||
function server_log()
|
||||
local id = http.formvalue("id")
|
||||
if fs.access("/tmp/etc/passwall_server/" .. id .. ".log") then
|
||||
local content = luci.sys.exec("cat /tmp/etc/passwall_server/" .. id .. ".log")
|
||||
local f_file = api.S_TMP_PATH .. "/" .. id .. ".log"
|
||||
if nixio.fs.access(f_file) then
|
||||
local content = luci.sys.exec("cat " .. f_file)
|
||||
content = content:gsub("\n", "<br />")
|
||||
http.write(content)
|
||||
else
|
||||
@@ -1268,3 +1270,12 @@ function delete_select_shunt_rules()
|
||||
uci_save(true, true)
|
||||
end
|
||||
end
|
||||
|
||||
function gen_wireguard_key()
|
||||
local key = api.gen_wireguard_key()
|
||||
if key then
|
||||
http_write_json_ok(key)
|
||||
else
|
||||
http_write_json_error()
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@ local api = require "luci.passwall.api"
|
||||
api.set_default_cbi()
|
||||
|
||||
m = Map()
|
||||
m.redirect = api.url()
|
||||
|
||||
if not arg[1] or not m:get(arg[1]) then
|
||||
luci.http.redirect(m.redirect)
|
||||
|
||||
@@ -40,7 +40,8 @@ o:value("vmess", "Vmess")
|
||||
o:value("vless", "VLESS")
|
||||
o:value("shadowsocks", "Shadowsocks")
|
||||
o:value("trojan", "Trojan")
|
||||
o:value("hysteria2", translate("Hysteria2"))
|
||||
o:value("hysteria2", "Hysteria2")
|
||||
o:value("wireguard", "WireGuard")
|
||||
o:value("dokodemo-door", "dokodemo-door")
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
@@ -60,6 +61,7 @@ o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(ListValue, _n("d_protocol"), translate("Destination protocol"))
|
||||
o:value("tcp", "TCP")
|
||||
@@ -363,7 +365,6 @@ o:depends({ [_n("custom")] = false, [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("custom")] = false, [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("custom")] = false, [_n("protocol")] = "trojan" })
|
||||
o:depends({ [_n("custom")] = false, [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("custom")] = false, [_n("protocol")] = "wireguard" })
|
||||
o:depends({ [_n("custom")] = false, [_n("protocol")] = "hysteria2", [_n("hysteria2_realms")] = false })
|
||||
|
||||
o = s:option(TextValue, _n("finalmask"), "FinalMask JSON")
|
||||
@@ -417,6 +418,36 @@ o:depends({ [_n("fallback")] = true })
|
||||
o = s:option(DynamicList, _n("fallback_list"), "Fallback", translate("format: dest,path,xver"))
|
||||
o:depends({ [_n("fallback")] = true })
|
||||
|
||||
-- Not supported at present
|
||||
--[[
|
||||
o = s:option(Flag, _n("wireguard_system_interface"), translate("System interface"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
]]--
|
||||
|
||||
o = s:option(Value, _n("wireguard_mtu"), "MTU")
|
||||
o.default = "1420"
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
-- Not supported at present
|
||||
--[[
|
||||
o = s:option(DynamicList, _n("wireguard_local_address"), translate("Local Address"))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
]]--
|
||||
|
||||
o = s:option(Value, _n("wireguard_private_key"), translate("Private Key"))
|
||||
o.datatype = "base64"
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_public_key"), translate("Public Key"))
|
||||
o.datatype = "base64"
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(DummyValue, _n("gen_wireguard_key"))
|
||||
o.prefix = option_prefix
|
||||
o.template = m:template_path("/server/server_wireguard")
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Flag, _n("bind_local"), translate("Bind Local"), translate("When selected, it can only be accessed localhost."))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
@@ -53,6 +53,9 @@ if singbox_tags:find("with_quic") then
|
||||
o:value("hysteria2", "Hysteria2")
|
||||
end
|
||||
o:value("anytls", "AnyTLS")
|
||||
if singbox_tags:find("with_wireguard") then
|
||||
o:value("wireguard", "WireGuard")
|
||||
end
|
||||
o:value("direct", "Direct")
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
@@ -76,6 +79,7 @@ o:depends({ [_n("protocol")] = "hysteria" })
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
o:depends({ [_n("protocol")] = "anytls" })
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
if singbox_tags:find("with_quic") then
|
||||
o = s:option(Value, _n("hysteria_obfs"), translate("Obfs Password"))
|
||||
@@ -400,6 +404,32 @@ o = s:option(Value, _n("tcpbrutal_down_mbps"), translate("Max download Mbps"))
|
||||
o.default = "50"
|
||||
o:depends({ [_n("tcpbrutal")] = true })
|
||||
|
||||
if singbox_tags:find("with_wireguard") then
|
||||
o = s:option(Flag, _n("wireguard_system_interface"), translate("System interface"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_mtu"), "MTU")
|
||||
o.default = "1408"
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(DynamicList, _n("wireguard_local_address"), translate("Local Address"))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_private_key"), translate("Private Key"))
|
||||
o.datatype = "base64"
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_public_key"), translate("Public Key"))
|
||||
o.datatype = "base64"
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(DummyValue, _n("gen_wireguard_key"))
|
||||
o.prefix = option_prefix
|
||||
o.template = m:template_path("/server/server_wireguard")
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("bind_local"), translate("Bind Local"), translate("When selected, it can only be accessed localhost."))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
@@ -37,4 +37,18 @@ o.datatype = "uuid"
|
||||
o.default = api.gen_uuid()
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Value, "wireguard_public_key", "Wireguard " .. translate("Public Key"))
|
||||
o.datatype = "base64"
|
||||
|
||||
o = s:option(Value, "wireguard_private_key", "Wireguard " .. translate("Private Key"))
|
||||
o.datatype = "base64"
|
||||
|
||||
o = s:option(DummyValue, "gen_wireguard_key")
|
||||
o.template = m:template_path("/server/server_wireguard")
|
||||
|
||||
o = s:option(Value, "wireguard_pre_shared_key", "Wireguard " .. translate("Pre shared key"))
|
||||
o.datatype = "base64"
|
||||
|
||||
o = s:option(DynamicList, "allowed_ips", "Wireguard " .. translate("Allowed IPs"))
|
||||
|
||||
return api.return_map(m)
|
||||
@@ -19,10 +19,11 @@ OPENWRT_ARCH = nil
|
||||
DISTRIB_ARCH = nil
|
||||
OPENWRT_BOARD = nil
|
||||
|
||||
LOCK_PREFIX = "/tmp/lock/passwall"
|
||||
LOG_FILE = "/tmp/log/passwall.log"
|
||||
TMP_PATH = "/tmp/etc/passwall"
|
||||
LOCK_PREFIX = "/tmp/lock/" .. c_config
|
||||
LOG_FILE = "/tmp/log/" .. c_config .. ".log"
|
||||
TMP_PATH = "/tmp/etc/" .. c_config
|
||||
CACHE_PATH = TMP_PATH .. "_tmp"
|
||||
S_TMP_PATH = "/tmp/etc/" .. s_config
|
||||
TMP_IFACE_PATH = TMP_PATH .. "/iface"
|
||||
|
||||
function log(...)
|
||||
@@ -784,16 +785,16 @@ function clone(org)
|
||||
end
|
||||
|
||||
function get_bin_version_cache(file, cmd)
|
||||
sys.call("mkdir -p /tmp/etc/passwall_tmp")
|
||||
sys.call("mkdir -p " .. CACHE_PATH)
|
||||
if fs.access(file) then
|
||||
chmod_755(file)
|
||||
local md5 = sys.exec("echo -n $(md5sum " .. file .. " | awk '{print $1}')")
|
||||
if fs.access("/tmp/etc/passwall_tmp/" .. md5) then
|
||||
return sys.exec("echo -n $(cat /tmp/etc/passwall_tmp/%s)" % md5)
|
||||
if fs.access(CACHE_PATH .. "/" .. md5) then
|
||||
return sys.exec("echo -n $(cat %s)" % { CACHE_PATH .. "/" .. md5 })
|
||||
else
|
||||
local version = sys.exec(string.format("echo -n $(%s %s)", file, cmd))
|
||||
if version and version ~= "" then
|
||||
sys.call("echo '" .. version .. "' > " .. "/tmp/etc/passwall_tmp/" .. md5)
|
||||
sys.call("echo '%s' > %s" % { version, CACHE_PATH .. "/" .. md5})
|
||||
return version
|
||||
end
|
||||
end
|
||||
@@ -1410,29 +1411,24 @@ function set_default_cbi()
|
||||
if true then
|
||||
--Map
|
||||
local Map = cbi.Map
|
||||
|
||||
if not Map._passwall_default_cbi then
|
||||
Map._passwall_default_cbi = true
|
||||
local original_parse = Map.parse
|
||||
function Map.parse(self, ...)
|
||||
if is_js_luci() then
|
||||
apply_redirect(self)
|
||||
local old = self.on_after_save
|
||||
self.on_after_save = function(map)
|
||||
if old then old(map) end
|
||||
map:set("@global[0]", "timestamp", os.time())
|
||||
end
|
||||
end
|
||||
return original_parse(self, ...)
|
||||
end
|
||||
end
|
||||
|
||||
local original_init = Map.__init__
|
||||
local default_init = Map.__init__
|
||||
function Map.__init__(self, config, ...)
|
||||
if not config then config = c_config end
|
||||
original_init(self, config, ...)
|
||||
default_init(self, config, ...)
|
||||
self.api = require "luci.passwall.api"
|
||||
end
|
||||
if is_js_luci() == true then
|
||||
local default_parse = Map.parse
|
||||
function Map.parse(self, ...)
|
||||
apply_redirect(self)
|
||||
local old = self.on_after_save
|
||||
self.on_after_save = function(self)
|
||||
if old then old(self) end
|
||||
self:set("@global[0]", "timestamp", os.time())
|
||||
end
|
||||
return default_parse(self, ...)
|
||||
end
|
||||
end
|
||||
function Map.foreach(self, stype, func)
|
||||
self.uci:foreach(self.config, stype, func)
|
||||
end
|
||||
@@ -1470,9 +1466,9 @@ function set_default_cbi()
|
||||
if true then
|
||||
--TextValue
|
||||
local TextValue = cbi.TextValue
|
||||
local original_init = TextValue.__init__
|
||||
local default_init = TextValue.__init__
|
||||
function TextValue.__init__(self, ...)
|
||||
original_init(self, ...)
|
||||
default_init(self, ...)
|
||||
self.template = appname .. "/cbi/tvalue"
|
||||
end
|
||||
end
|
||||
@@ -1599,7 +1595,9 @@ function luci_types(id, m, s, type_name, option_prefix)
|
||||
local deps = s.fields[key].deps
|
||||
if #deps > 0 then
|
||||
for index, value in ipairs(deps) do
|
||||
deps[index]["type"] = type_name
|
||||
if not deps[index]['!reverse'] then
|
||||
deps[index]["type"] = type_name
|
||||
end
|
||||
end
|
||||
else
|
||||
s.fields[key]:depends({ type = type_name })
|
||||
@@ -2045,3 +2043,36 @@ function table_remove_duplicates(t)
|
||||
end
|
||||
return new_t
|
||||
end
|
||||
|
||||
function gen_wireguard_key()
|
||||
if sys.call("command -v wg >/dev/null") == 0 then
|
||||
local private_key = sys.exec('echo -n $(wg genkey)')
|
||||
local public_key = sys.exec('echo -n $(echo "%s" | wg pubkey)' % private_key)
|
||||
return {
|
||||
private_key = private_key,
|
||||
public_key = public_key
|
||||
}
|
||||
end
|
||||
local xray = finded_com("xray")
|
||||
if xray then
|
||||
local result = sys.exec(xray .. " wg | awk -F ': ' '{print $2}'")
|
||||
local s = split(result, "\n")
|
||||
local private_key = s[1]
|
||||
local public_key = s[2]
|
||||
return {
|
||||
private_key = private_key,
|
||||
public_key = public_key
|
||||
}
|
||||
end
|
||||
local sb = finded_com("sing-box")
|
||||
if sb then
|
||||
local result = sys.exec(sb .. " generate wg-keypair | awk '{print $2}'")
|
||||
local s = split(result, "\n")
|
||||
local private_key = s[1]
|
||||
local public_key = s[2]
|
||||
return {
|
||||
private_key = private_key,
|
||||
public_key = public_key
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,7 @@ local uci = api.uci
|
||||
local jsonc = api.jsonc
|
||||
|
||||
local CONFIG = api.s_config
|
||||
local CONFIG_PATH = "/tmp/etc/" .. CONFIG
|
||||
local CONFIG_PATH = api.S_TMP_PATH
|
||||
local NFT_INCLUDE_FILE = CONFIG_PATH .. "/" .. CONFIG .. ".nft"
|
||||
local LOG_APP_FILE = "/tmp/log/" .. CONFIG .. ".log"
|
||||
local TMP_BIN_PATH = CONFIG_PATH .. "/bin"
|
||||
@@ -53,7 +53,7 @@ local function ln_run(s, d, command, output)
|
||||
end
|
||||
|
||||
local function gen_include()
|
||||
cmd(string.format("echo '#!/bin/sh' > /tmp/etc/%s.include", CONFIG))
|
||||
cmd(string.format("echo '#!/bin/sh' > %s.include", CONFIG_PATH))
|
||||
local function extract_rules(n, a)
|
||||
local _ipt = ipt_bin
|
||||
if n == "6" then
|
||||
@@ -64,7 +64,7 @@ local function gen_include()
|
||||
result = result .. "COMMIT"
|
||||
return result
|
||||
end
|
||||
local f, err = io.open("/tmp/etc/" .. CONFIG .. ".include", "a")
|
||||
local f, err = io.open(CONFIG_PATH .. ".include", "a")
|
||||
if f and err == nil then
|
||||
if nft_flag == "0" then
|
||||
f:write(ipt_bin .. '-save -c | grep -v "PSW-SERVER" | ' .. ipt_bin .. '-restore -c' .. "\n")
|
||||
@@ -248,7 +248,7 @@ local function stop()
|
||||
cmd("nft flush chain inet fw4 PSW-SERVER 2>/dev/null")
|
||||
cmd("nft delete chain inet fw4 PSW-SERVER 2>/dev/null")
|
||||
end
|
||||
cmd(string.format("rm -rf %s %s /tmp/etc/%s.include", CONFIG_PATH, LOG_APP_FILE, CONFIG))
|
||||
cmd(string.format("rm -rf %s %s %s", CONFIG_PATH, LOG_APP_FILE, CONFIG_PATH .. ".include"))
|
||||
end
|
||||
|
||||
if action then
|
||||
|
||||
@@ -661,6 +661,8 @@ function gen_outbound(flag, node, tag, proxy_table)
|
||||
end
|
||||
|
||||
function gen_config_server(node)
|
||||
local endpoints = {}
|
||||
local inbounds = {}
|
||||
local outbounds = {
|
||||
{ type = "direct", tag = "direct" }
|
||||
}
|
||||
@@ -803,6 +805,12 @@ function gen_config_server(node)
|
||||
u.name = user.username
|
||||
u.password = user.password
|
||||
end
|
||||
if node.protocol == "wireguard" then
|
||||
u.public_key = user.wireguard_public_key
|
||||
u.pre_shared_key = user.wireguard_pre_shared_key
|
||||
u.allowed_ips = user.allowed_ips or {}
|
||||
u.persistent_keepalive_interval = 0
|
||||
end
|
||||
users[#users + 1] = u
|
||||
end
|
||||
end
|
||||
@@ -970,6 +978,18 @@ function gen_config_server(node)
|
||||
end
|
||||
end
|
||||
|
||||
if node.protocol == "wireguard" then
|
||||
inbound.listen = nil
|
||||
inbound.system = node.wireguard_system_interface == "1" and true or false
|
||||
inbound.name = "sbwg_" .. node[".name"]
|
||||
inbound.mtu = tonumber(node.wireguard_mtu or 1408)
|
||||
inbound.address = node.wireguard_local_address
|
||||
inbound.private_key = node.wireguard_private_key
|
||||
if users then
|
||||
inbound.peers = users
|
||||
end
|
||||
end
|
||||
|
||||
if node.protocol == "direct" then
|
||||
protocol_table = {
|
||||
network = (node.d_protocol ~= "TCP,UDP") and node.d_protocol or nil,
|
||||
@@ -984,6 +1004,13 @@ function gen_config_server(node)
|
||||
end
|
||||
end
|
||||
|
||||
if node.protocol == "wireguard" then
|
||||
inbound.listen = nil
|
||||
table.insert(endpoints, inbound)
|
||||
else
|
||||
table.insert(inbounds, inbound)
|
||||
end
|
||||
|
||||
local route = {
|
||||
rules = {
|
||||
{
|
||||
@@ -1038,7 +1065,8 @@ function gen_config_server(node)
|
||||
tag = "direct"
|
||||
}}
|
||||
},
|
||||
inbounds = { inbound },
|
||||
endpoints = endpoints,
|
||||
inbounds = inbounds,
|
||||
outbounds = outbounds,
|
||||
route = route
|
||||
}
|
||||
|
||||
@@ -515,6 +515,12 @@ function gen_config_server(node)
|
||||
u.email = user.username
|
||||
u.auth = user.password
|
||||
end
|
||||
if node.protocol == "wireguard" then
|
||||
u.publicKey = user.wireguard_public_key
|
||||
u.preSharedKey = user.wireguard_pre_shared_key
|
||||
u.keepAlive = 0
|
||||
u.allowedIPs = user.allowed_ips
|
||||
end
|
||||
users[#users + 1] = u
|
||||
end
|
||||
end
|
||||
@@ -567,6 +573,14 @@ function gen_config_server(node)
|
||||
address = node.d_address,
|
||||
port = tonumber(node.d_port)
|
||||
}
|
||||
elseif node.protocol == "wireguard" then
|
||||
settings = {
|
||||
secretKey = node.wireguard_private_key,
|
||||
--address = node.wireguard_local_address,
|
||||
--noKernelTun = node.wireguard_system_interface ~= "1" and true or false,
|
||||
mtu = tonumber(node.wireguard_mtu or 1420),
|
||||
peers = users
|
||||
}
|
||||
end
|
||||
|
||||
if node.fallback and node.fallback == "1" then
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<%
|
||||
local map = self.map
|
||||
local api = map.api
|
||||
local prefix = self.prefix
|
||||
-%>
|
||||
<div class="cbi-value" id="cbi-<%=self.config.."-"..section.."-"..self.option%>" data-index="<%=self.index%>" data-depends="<%=pcdata(self:deplist2json(section))%>">
|
||||
<label class="cbi-value-title"><%:Generate key%></label>
|
||||
<div class="cbi-value-field">
|
||||
<input class="btn cbi-button" type="button" onclick="gen_wireguard_key(this)" value="<%:Generate key%>" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function gen_wireguard_key() {
|
||||
XHR.get('<%=api.url("gen_wireguard_key")%>', {},
|
||||
function(x, data) {
|
||||
if (x && x.status == 200 && data.code == 1) {
|
||||
const private_key_dom = getOption("<%=self.config%>", "<%=section%>", "<%=prefix%>wireguard_private_key");
|
||||
private_key_dom.value = data.data.private_key;
|
||||
|
||||
const public_key_dom = getOption("<%=self.config%>", "<%=section%>", "<%=prefix%>wireguard_public_key");
|
||||
public_key_dom.value = data.data.public_key;
|
||||
} else {
|
||||
alert("<%:Error%>");
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -1490,9 +1490,18 @@ msgstr "私钥"
|
||||
msgid "Pre shared key"
|
||||
msgstr "额外的对称加密密钥"
|
||||
|
||||
msgid "Allowed IPs"
|
||||
msgstr "允许的 IP"
|
||||
|
||||
msgid "Generate key"
|
||||
msgstr "生成密钥"
|
||||
|
||||
msgid "Local Address"
|
||||
msgstr "本地地址"
|
||||
|
||||
msgid "System interface"
|
||||
msgstr "系统接口"
|
||||
|
||||
msgid "Decimal numbers separated by \",\" or Base64-encoded strings."
|
||||
msgstr "用“,”隔开的十进制数字或 Base64 编码字符串。"
|
||||
|
||||
|
||||
@@ -1868,7 +1868,7 @@ acl_app() {
|
||||
}
|
||||
|
||||
start() {
|
||||
busybox pgrep -f /tmp/etc/passwall/bin > /dev/null 2>&1 && {
|
||||
busybox pgrep -f ${TMP_PATH}/bin > /dev/null 2>&1 && {
|
||||
logger -t PSW-RESTART "Upgrade or overload residue is detected, and the subprocess is being called to perform complete cleaning..."
|
||||
(stop)
|
||||
sleep 2
|
||||
|
||||
@@ -71,10 +71,10 @@ test_node() {
|
||||
local curlx="socks5h://127.0.0.1:${_tmp_port}"
|
||||
local _proxy_status=$(test_url "${probe_url}" ${retry_num} ${connect_timeout} "-x $curlx" 1)
|
||||
# 结束 SS 插件进程
|
||||
local pid_file="/tmp/etc/${CONFIG}/test_node_${node_id}_plugin.pid"
|
||||
local pid_file="${TMP_PATH}/test_node_${node_id}_plugin.pid"
|
||||
[ -s "$pid_file" ] && kill -9 "$(head -n 1 "$pid_file")" >/dev/null 2>&1
|
||||
busybox pgrep -af "test_node_${node_id}" | awk '! /socks_auto_switch\.sh/{print $1}' | xargs kill -9 >/dev/null 2>&1
|
||||
rm -rf /tmp/etc/${CONFIG}/test_node_${node_id}*.*
|
||||
rm -rf ${TMP_PATH}/test_node_${node_id}*.*
|
||||
if [ "${_proxy_status}" -eq 200 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -64,10 +64,10 @@ url_test_node() {
|
||||
local probeUrl=$(config_t_get global_other url_test_url https://www.google.com/generate_204)
|
||||
result=$(curl --connect-timeout 3 --max-time 5 -o /dev/null -I -skL -w "%{http_code}:%{time_pretransfer}" -x ${curlx} "${probeUrl}")
|
||||
# 结束 SS 插件进程
|
||||
local pid_file="/tmp/etc/${CONFIG}/url_test_${node_id}_plugin.pid"
|
||||
local pid_file="${TMP_PATH}/url_test_${node_id}_plugin.pid"
|
||||
[ -s "$pid_file" ] && kill -9 "$(head -n 1 "$pid_file")" >/dev/null 2>&1
|
||||
busybox pgrep -af "url_test_${node_id}" | awk '! /test\.sh/{print $1}' | xargs kill -9 >/dev/null 2>&1
|
||||
rm -rf /tmp/etc/${CONFIG}/*url_test_${node_id}*.*
|
||||
rm -rf ${TMP_PATH}/*url_test_${node_id}*.*
|
||||
}
|
||||
echo $result
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-passwall2
|
||||
PKG_VERSION:=26.8.16
|
||||
PKG_RELEASE:=88
|
||||
PKG_RELEASE:=89
|
||||
PKG_PO_VERSION:=$(PKG_VERSION)
|
||||
|
||||
PKG_CONFIG_DEPENDS:= \
|
||||
|
||||
@@ -44,7 +44,7 @@ o = s:option(Value, "wireguard_private_key", "Wireguard " .. translate("Private
|
||||
o.datatype = "base64"
|
||||
|
||||
o = s:option(DummyValue, "gen_wireguard_key")
|
||||
o.template = m:template_path("/server/server_wireguard")
|
||||
o.template = m:template_path("/server/gen_wireguard_key")
|
||||
|
||||
o = s:option(Value, "wireguard_pre_shared_key", "Wireguard " .. translate("Pre shared key"))
|
||||
o.datatype = "base64"
|
||||
|
||||
@@ -11,11 +11,12 @@ address, so they stay local).
|
||||
|
||||
| Metric | Budget | Track | Source |
|
||||
|---|---|---|---|
|
||||
| main.css (identity/raw) | ≤ 190 KB | size | production build, 2026-07 (183,820 B) |
|
||||
| main.css (identity/raw) | ≤ 193 KB | size | production build, 2026-08 (192,201 B; +~2 KB custom-background mode, +~0.5 KB router progress bar / live region) |
|
||||
| login.css (identity/raw) | ≤ 12 KB | size | production build, 2026-07 (10,935 B, token-pruned) |
|
||||
| menu-aurora.js (identity/raw) | ≤ 20 KB | size | production build, 2026-07 (19,100 B) |
|
||||
| menu-aurora.js (identity/raw) | ≤ 21.5 KB | size | production build, 2026-08 (20,851 B; +1.3 KB for the router's `syncRoute`/`closeSurfaces` hooks) |
|
||||
| router-aurora.js (identity/raw, Navigation-API browsers only) | ≤ 15 KB | size | production build, 2026-08 (14,568 B: template shells fetched, per-render listener teardown, timeout-as-failure, expiry gate, readonly folding, node css, wildcard actions, progress bar, visibility gate, contract check) |
|
||||
| Default logo (identity/raw) | ≤ 16 KB | size | production build, 2026-07 (15,057 B) |
|
||||
| Core admin cold theme assets (identity/raw) | ≤ 250 KB | size | main CSS + menu JS + default font + logo, 2026-07 (241,557 B) |
|
||||
| Core admin cold theme assets (identity/raw) | ≤ 267 KB | size | main CSS + menu JS + router JS + default font + logo, 2026-08 (≈266.3 KB; the router is a one-time cost that removes per-click dispatcher work) |
|
||||
| Login cold theme assets, excluding configured background (identity/raw) | ≤ 55 KB | size | login CSS + default font + logo, 2026-07 (49,572 B) |
|
||||
| Blocking requests before first paint | ≤ 4 | count | current waterfall |
|
||||
| Repeat-visit asset requests | ≈ 0 | count | target state; package-built CSS/JS URLs are versioned, but long-lived cache headers still need live verification |
|
||||
@@ -42,6 +43,19 @@ Budget revisions require a new baseline entry under `../baselines/`.
|
||||
- login.css pruned to its reachable custom properties at build time (the
|
||||
shared token sheet is admin-sized; the login page consumes a fraction).
|
||||
|
||||
- Client-side router (`router-aurora.js`, `.dev/docs/router.md`,
|
||||
2026-08-16): view/alias/firstchild/overview navigations become
|
||||
same-document swaps on Navigation-API browsers, MPA elsewhere.
|
||||
Measured on RE-SS-01 over plain HTTP (`bench-router.mjs`, RUNS=10): click →
|
||||
view painted **241–544 ms → 49–251 ms warm, median −67 %**; walk of
|
||||
51/62 linked pages (mega-menu/sidebar), 42–43 served, **0 divergences** vs
|
||||
full loads incl. DOM shape; 65-navigation soak flat after the first lap
|
||||
once departed regions are cleared through `dom.content()` (the data-idref
|
||||
registry otherwise pins every departed subtree: 26k → 72k nodes before)
|
||||
and per-render window/document listeners are torn down; back traversal
|
||||
through alias/firstchild entries same-document; poison gate → full load →
|
||||
router again. Report in `../baselines/router-re-ss-01.md`.
|
||||
|
||||
### Pending
|
||||
| Item | Principle | Estimated gain |
|
||||
|---|---|---|
|
||||
|
||||
@@ -50,6 +50,38 @@ still print.
|
||||
**Output.** The whole report is Markdown: paste it straight into a PR
|
||||
description, or archive it (see below).
|
||||
|
||||
## `bench-router.mjs` — router verification and A/B (CDP)
|
||||
|
||||
The client-side router (`.dev/docs/router.md`) is verified against real
|
||||
full loads, never against expectation:
|
||||
|
||||
```bash
|
||||
curl -c jar.txt -d 'luci_username=root&luci_password=…' http://<device>/cgi-bin/luci/
|
||||
HOST=http://<device> COOKIE_NAME=sysauth_http COOKIE_VALUE=<from jar> \
|
||||
node ../.claude/skills/aurora-performance/scripts/bench-router.mjs <label>
|
||||
```
|
||||
|
||||
`ONLY=walk|timing|soak|back|poison` runs one scenario (`RUNS` defaults to and
|
||||
is floored at 10). `walk` visits every page
|
||||
the navigation model links to (menu + each page's tab strip) through the
|
||||
router, then full-loads the same URL and diffs title, `data-page`,
|
||||
`dispatchpath`, tab strip, active nav mark, footer presence and console
|
||||
errors — the report lists fallbacks (pages the router declined) and
|
||||
divergences separately, and 0 divergences is the merge gate. `timing` is
|
||||
click → view painted, median of `RUNS`, router warm/cold vs full load.
|
||||
`soak` samples heap, DOM nodes, listeners and the poll queue on the same
|
||||
page after each of 5 laps over 12 pages. `back` traverses a chain that
|
||||
deliberately interleaves alias/firstchild URLs (read from the menu tree)
|
||||
with view URLs and asserts each step stayed same-document with the right
|
||||
URL and `data-page`. `poison` injects a foreign `<style>` into `<head>` and
|
||||
asserts the next navigation is a full load and the one after is
|
||||
same-document again.
|
||||
|
||||
Trap: a navigation the router does not take is a real document load and
|
||||
tears down the CDP evaluation ("Inspected target navigated"); the harness
|
||||
treats that as a fallback, waits for the new document, and re-arms its
|
||||
same-document marker there.
|
||||
|
||||
## Measurement discipline
|
||||
|
||||
- **Median of ≥10 runs**, never a single sample — router-side variance
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* bench-router.mjs <label> — verifies and measures the theme's client-side
|
||||
* router (router-aurora.js) against real full loads on the device, over raw
|
||||
* CDP (headless Chrome, no npm deps, node >= 22).
|
||||
*
|
||||
* env: HOST (default http://192.168.1.1), COOKIE_NAME, COOKIE_VALUE,
|
||||
* CHROME_BIN, RUNS (default and minimum 10), ONLY (walk|timing|soak|back|poison|sheets|hygiene|nodecss|expiry),
|
||||
* MATCH (regex; walk only these pages), SETTLE (ms after the view settled before
|
||||
* a snapshot, default 300 — raise it for pages whose tables fill on the first poll)
|
||||
*
|
||||
* Scenarios:
|
||||
* walk every page the navigation model links to (menu + each page's
|
||||
* tab strip): same-document navigate, then a full load of the same
|
||||
* URL; compare title, data-page, dispatchpath, tab count, footer
|
||||
* presence, view child count. Reports divergences and fallbacks.
|
||||
* timing click → view painted, median of RUNS: router (warm and cold)
|
||||
* vs full load, same pages.
|
||||
* soak 60 navigations over the walked pages; heap, DOM nodes, poll queue
|
||||
* and view intervals sampled on the same page each lap.
|
||||
* back traverse back through a chain that deliberately includes alias
|
||||
* and firstchild URLs (read from the menu tree): same document,
|
||||
* correct URL/data-page each step.
|
||||
* poison a foreign <style> in <head> makes the next navigation a full
|
||||
* load, and the one after (fresh document) is same-document again.
|
||||
* sheets every walked view page that inserts its own <style>/<link> (found
|
||||
* on the walk): reached same-document, the navigation away is a
|
||||
* full load (poison gate), the one after is same-document again.
|
||||
* Skipped when the walk found no such page.
|
||||
* hygiene progress bar and live region exist; a hidden tab stops polling
|
||||
* and a visible one resumes it; the live region carries the title.
|
||||
* nodecss a page whose menu.d node declares `css`: its link is enabled on
|
||||
* arrival, disabled (not removed) after leaving, re-enabled without
|
||||
* a duplicate on return. Skipped when no such node is installed.
|
||||
* expiry the session is destroyed from inside the document (logout fetch)
|
||||
* and a poll fails: the next navigation must be a full load that
|
||||
* lands on the login form. Destroys the session — always runs last.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
const HOST = (process.env.HOST ?? "http://192.168.1.1").replace(/\/+$/, "");
|
||||
const LABEL = process.argv[2] ?? "run";
|
||||
// measuring.md: medians of at least 10 runs.
|
||||
const RUNS = Math.max(10, +(process.env.RUNS ?? 10) || 10);
|
||||
const ONLY = process.env.ONLY || null;
|
||||
const MATCH = process.env.MATCH ? new RegExp(process.env.MATCH) : null;
|
||||
const SETTLE = +(process.env.SETTLE ?? 300) || 300;
|
||||
const CHROME =
|
||||
process.env.CHROME_BIN ??
|
||||
(process.platform === "darwin"
|
||||
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
: "google-chrome");
|
||||
if (!process.env.COOKIE_NAME || !process.env.COOKIE_VALUE)
|
||||
throw new Error("COOKIE_NAME and COOKIE_VALUE are required");
|
||||
const COOKIE = {
|
||||
name: process.env.COOKIE_NAME,
|
||||
value: process.env.COOKIE_VALUE,
|
||||
domain: new URL(HOST).hostname,
|
||||
path: "/",
|
||||
};
|
||||
const START = `${HOST}/cgi-bin/luci/admin/status/overview`;
|
||||
|
||||
const median = (v) => {
|
||||
const s = [...v].sort((a, b) => a - b);
|
||||
const m = s.length >> 1;
|
||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||||
};
|
||||
|
||||
/* ---------- chrome + CDP ---------- */
|
||||
const profile = mkdtempSync(join(tmpdir(), "cdp-aurora-router-"));
|
||||
const chrome = spawn(
|
||||
CHROME,
|
||||
["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`,
|
||||
"--no-first-run", "--no-default-browser-check", "--ignore-certificate-errors",
|
||||
"--js-flags=--expose-gc", "about:blank"],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
let port = null;
|
||||
for (let i = 0; i < 100 && !port; i++) {
|
||||
await sleep(100);
|
||||
const f = join(profile, "DevToolsActivePort");
|
||||
if (existsSync(f)) port = +readFileSync(f, "utf8").split("\n")[0];
|
||||
}
|
||||
if (!port) throw new Error("chrome: no DevToolsActivePort");
|
||||
const { webSocketDebuggerUrl } = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
|
||||
const ws = new WebSocket(webSocketDebuggerUrl);
|
||||
await new Promise((res, rej) => {
|
||||
ws.addEventListener("open", res, { once: true });
|
||||
ws.addEventListener("error", () => rej(new Error("CDP connect failed")), { once: true });
|
||||
});
|
||||
let mid = 0;
|
||||
const pending = new Map();
|
||||
const handlers = new Set();
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && pending.has(m.id)) {
|
||||
const { res, rej } = pending.get(m.id);
|
||||
pending.delete(m.id);
|
||||
m.error ? rej(new Error(m.error.message)) : res(m.result);
|
||||
} else for (const h of handlers) h(m);
|
||||
});
|
||||
const send = (method, params = {}, sessionId) =>
|
||||
new Promise((res, rej) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return rej(new Error("CDP disconnected"));
|
||||
const id = ++mid;
|
||||
pending.set(id, { res, rej });
|
||||
ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
});
|
||||
const waitEvent = (method, sessionId, timeout = 25000) =>
|
||||
new Promise((res, rej) => {
|
||||
const timer = setTimeout(() => { handlers.delete(h); rej(new Error(`timeout ${method}`)); }, timeout);
|
||||
const h = (m) => {
|
||||
if (m.method === method && m.sessionId === sessionId) {
|
||||
clearTimeout(timer); handlers.delete(h); res(m.params);
|
||||
}
|
||||
};
|
||||
handlers.add(h);
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
let p = null;
|
||||
async function recover(reason) {
|
||||
process.stderr.write(`recover: ${reason}\n`);
|
||||
try { await send("Target.closeTarget", { targetId: p.targetId }); } catch {}
|
||||
p = await newPage();
|
||||
await fullLoad(p.sessionId, START);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
}
|
||||
async function newPage() {
|
||||
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
|
||||
const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
|
||||
await send("Page.enable", {}, sessionId);
|
||||
await send("Runtime.enable", {}, sessionId);
|
||||
await send("Log.enable", {}, sessionId);
|
||||
await send("Emulation.setDeviceMetricsOverride",
|
||||
{ width: 1280, height: 800, deviceScaleFactor: 1, mobile: false }, sessionId);
|
||||
handlers.add((m) => {
|
||||
if (m.sessionId !== sessionId) return;
|
||||
if (m.method === "Runtime.exceptionThrown")
|
||||
consoleErrors.push(m.params.exceptionDetails.exception?.description ?? m.params.exceptionDetails.text);
|
||||
if (m.method === "Runtime.consoleAPICalled" && m.params.type === "error")
|
||||
consoleErrors.push(m.params.args.map((a) => a.value ?? a.description).join(" "));
|
||||
if (m.method === "Log.entryAdded" && m.params.entry.level === "error"
|
||||
&& !/favicon|ERR_/.test(m.params.entry.text))
|
||||
consoleErrors.push(m.params.entry.text);
|
||||
});
|
||||
// View-paint clock for full loads: first non-spinner child of #view.
|
||||
await send("Page.addScriptToEvaluateOnNewDocument", { source: `
|
||||
window.__viewReady = null;
|
||||
new MutationObserver(() => {
|
||||
const v = document.getElementById('view');
|
||||
if (v && v.querySelector(':scope > :not(.spinning):not(script)') && window.__viewReady == null)
|
||||
window.__viewReady = performance.now();
|
||||
}).observe(document, { childList: true, subtree: true });` }, sessionId);
|
||||
return { targetId, sessionId };
|
||||
}
|
||||
async function evaljs(sessionId, expression, awaitPromise = false, timeout = 30000) {
|
||||
const r = await Promise.race([
|
||||
send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise }, sessionId),
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error("evaluate stalled (renderer busy?)")), timeout)),
|
||||
]);
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
}
|
||||
async function fullLoad(sessionId, url) {
|
||||
const load = waitEvent("Page.loadEventFired", sessionId);
|
||||
await send("Page.navigate", { url }, sessionId);
|
||||
await load;
|
||||
await waitViewSettled(sessionId);
|
||||
const login = await evaljs(sessionId, `!!document.querySelector('input[name="luci_username"]')`);
|
||||
if (login) throw new Error(`landed on the login form for ${url}`);
|
||||
}
|
||||
async function waitViewSettled(sessionId, timeout = 10000) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
const ok = await evaljs(sessionId, `(() => {
|
||||
const v = document.getElementById('view');
|
||||
return !v || !!v.querySelector(':scope > :not(.spinning):not(script)') ||
|
||||
(v.childElementCount === 0 && document.readyState === 'complete' && (window.L?.loaded ?? false));
|
||||
})()`);
|
||||
if (ok) return;
|
||||
await sleep(50);
|
||||
}
|
||||
}
|
||||
const SNAPSHOT = `(() => JSON.stringify({
|
||||
url: location.pathname,
|
||||
title: document.title,
|
||||
page: document.body.dataset.page,
|
||||
dispatch: (L.env.dispatchpath || []).join('/'),
|
||||
request: (L.env.requestpath || []).join('/'),
|
||||
tabs: document.querySelectorAll('#tabmenu a').length,
|
||||
activeTab: document.querySelector('#tabmenu li.active a')?.textContent ?? null,
|
||||
activeNav: [...document.querySelectorAll('#sidebar-list a.is-active-page, #mobile-nav-list a.is-active-page, .desktop-menu-canvas a.is-active-page, #topmenu a.is-active-page')].map(a => a.getAttribute('href'))[0] ?? null,
|
||||
footer: !!document.querySelector('#view .cbi-page-actions'),
|
||||
readonly: L.env.nodespec?.readonly === true,
|
||||
perm: L.hasViewPermission(),
|
||||
foreign: [...document.querySelectorAll('style, link[rel~="stylesheet"]')].filter(l => !document.getElementById('view')?.contains(l) && !l.hasAttribute('data-aurora-shell') && !l.hasAttribute('data-aurora-patch') && !l.hasAttribute('data-aurora-node-css')).map(l => l.tagName + (l.href ? ':' + l.href.replace(HOST, '') : '')),
|
||||
status: document.getElementById('aurora-nav-status')?.textContent ?? null,
|
||||
nodeCss: [...document.querySelectorAll('link[data-aurora-node-css]')].filter(l => !l.disabled).map(l => l.getAttribute('data-aurora-node-css')).sort().join(','),
|
||||
viewChildren: document.getElementById('view')?.childElementCount ?? -1,
|
||||
viewIds: document.querySelectorAll('[id="view"]').length,
|
||||
h1: document.querySelector('#view h2, #maincontent > h2')?.textContent ?? null,
|
||||
svgLines: document.querySelectorAll('#view svg line').length,
|
||||
// DOM shape of the rendered view: per tag+class, [elements, elements with text].
|
||||
shape: (() => { const m = {}; for (const el of document.querySelectorAll('#view *')) {
|
||||
const k = el.tagName + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\s+/).sort().join('.') : '');
|
||||
(m[k] ??= [0, 0]); m[k][0]++; if (el.textContent.trim()) m[k][1]++; } return m; })(),
|
||||
marker: window.__sameDocMarker ?? null,
|
||||
}))()`;
|
||||
async function snapshot(sessionId) { return JSON.parse(await evaljs(sessionId, SNAPSHOT)); }
|
||||
// A navigation the router does not take becomes a real document load, which
|
||||
// tears down the evaluation ("Inspected target navigated"): report it as a
|
||||
// fallback after the new document has loaded.
|
||||
async function spaNavigate(sessionId, url) {
|
||||
const load = waitEvent("Page.loadEventFired", sessionId, 25000);
|
||||
load.catch(() => {});
|
||||
try {
|
||||
const r = JSON.parse(await evaljs(sessionId, `(async () => {
|
||||
// A user hovers before clicking; the router prewarms (and, for template
|
||||
// nodes, fetches the shell) on that intent.
|
||||
const link = [...document.querySelectorAll('a[href]')].find(a => a.href === ${JSON.stringify(url)});
|
||||
if (link) { link.dispatchEvent(new Event('pointerover', { bubbles: true })); await new Promise(r => setTimeout(r, 400)); }
|
||||
const t0 = performance.now();
|
||||
let error = null;
|
||||
try {
|
||||
await Promise.race([navigation.navigate(${JSON.stringify(url)}).finished,
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error("router did not finish in 20 s")), 20000))]);
|
||||
} catch (e) { error = String(e); }
|
||||
return JSON.stringify({ ms: performance.now() - t0, error, sameDoc: window.__sameDocMarker === 1,
|
||||
state: { views: document.querySelectorAll('[id="view"]').length, page: document.body.dataset.page } });
|
||||
})()`, true));
|
||||
if (r.sameDoc) return r;
|
||||
await load; await waitViewSettled(sessionId);
|
||||
await evaljs(sessionId, "window.__sameDocMarker = 1");
|
||||
return r;
|
||||
} catch (e) {
|
||||
if (!/navigated or closed/.test(e.message)) throw e;
|
||||
await load; await waitViewSettled(sessionId);
|
||||
await evaljs(sessionId, "window.__sameDocMarker = 1");
|
||||
return { ms: null, error: null, sameDoc: false };
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- discover pages ---------- */
|
||||
await send("Storage.setCookies", { cookies: [COOKIE] });
|
||||
p = await newPage();
|
||||
await fullLoad(p.sessionId, START);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
const routerActive = await evaljs(p.sessionId,
|
||||
`!!window.navigation && performance.getEntriesByType('resource').some(e => /router-aurora\.js/.test(e.name))`);
|
||||
const menuLinks = JSON.parse(await evaljs(p.sessionId, `JSON.stringify([...new Set(
|
||||
[...document.querySelectorAll('#mobile-nav-list a[href], #sidebar-list a[href]')]
|
||||
.map(a => a.href).filter(h => /\\/cgi-bin\\/luci\\/admin\\//.test(h) && !/logout/.test(h)))])`));
|
||||
const pages = new Set(menuLinks);
|
||||
process.stderr.write(`router active: ${routerActive}, menu links: ${menuLinks.length}\n`);
|
||||
for (const url of menuLinks) {
|
||||
process.stderr.write(`discover ${url.replace(HOST, "")}\n`);
|
||||
const r = await spaNavigate(p.sessionId, url).catch((e) => ({ error: e.message }));
|
||||
process.stderr.write(` -> ${JSON.stringify(r)}\n`);
|
||||
if (/stalled/.test(r.error ?? "")) await recover(`discover ${url}`);
|
||||
if (r.error) continue;
|
||||
const tabs = JSON.parse(await evaljs(p.sessionId,
|
||||
`JSON.stringify([...document.querySelectorAll('#tabmenu a[href]')].map(a => a.href))`));
|
||||
for (const t of tabs) pages.add(t);
|
||||
}
|
||||
const PAGES = [...pages];
|
||||
process.stderr.write(`discovered ${PAGES.length} pages\n`);
|
||||
const out = { label: LABEL, host: HOST, routerActive, pages: PAGES.length };
|
||||
|
||||
/* ---------- walk ---------- */
|
||||
if (!ONLY || ONLY === "walk") {
|
||||
const divergences = [], fallbacks = [], ok = [], errors = [], injectors = [];
|
||||
for (const url of PAGES.filter((u) => !MATCH || MATCH.test(u))) {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
consoleErrors.length = 0;
|
||||
await fullLoad(p.sessionId, START);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
const nav = await spaNavigate(p.sessionId, url);
|
||||
if (!nav.sameDoc) { fallbacks.push(url.replace(HOST, "")); continue; }
|
||||
await waitViewSettled(p.sessionId);
|
||||
await sleep(SETTLE);
|
||||
const soft = await snapshot(p.sessionId);
|
||||
const softErrors = [...consoleErrors]; consoleErrors.length = 0;
|
||||
await fullLoad(p.sessionId, url);
|
||||
await sleep(SETTLE);
|
||||
const full = await snapshot(p.sessionId);
|
||||
// a page's own console errors (missing binaries, 404s) show on both paths
|
||||
const fullErrors = new Set(consoleErrors.map((e) => e.split("\n")[0]));
|
||||
const routerErrors = softErrors.filter((e) => !fullErrors.has(e.split("\n")[0]));
|
||||
const diffs = [];
|
||||
for (const k of ["url", "title", "page", "dispatch", "request", "tabs", "activeTab", "activeNav", "footer", "readonly", "perm", "nodeCss", "h1", "svgLines"])
|
||||
if (String(soft[k]) !== String(full[k])) diffs.push(`${k}: router=${soft[k]} full=${full[k]}`);
|
||||
if (soft.viewIds !== 1) diffs.push(`viewIds=${soft.viewIds}`);
|
||||
for (const k of new Set([...Object.keys(soft.shape), ...Object.keys(full.shape)])) {
|
||||
const a = soft.shape[k] ?? [0, 0], b = full.shape[k] ?? [0, 0];
|
||||
// tolerate small count drift (live tables), flag missing kinds and lost text
|
||||
if (Math.abs(a[0] - b[0]) > Math.max(2, b[0] * 0.25) || (b[1] > 0 && a[1] === 0 && b[0] <= 20 && a[0] > 0))
|
||||
diffs.push(`shape ${k}: router=${a} full=${b}`);
|
||||
}
|
||||
if (soft.viewChildren <= 0 && full.viewChildren > 0) diffs.push("view empty under router");
|
||||
if (soft.status !== soft.title) diffs.push(`live region: ${soft.status}`);
|
||||
if (full.foreign.length) injectors.push({ url: url.replace(HOST, ""), foreign: full.foreign });
|
||||
if (routerErrors.length) diffs.push(`console: ${routerErrors.slice(0, 2).join(" | ").slice(0, 200)}`);
|
||||
(diffs.length ? divergences : ok).push({ url: url.replace(HOST, ""), diffs });
|
||||
} catch (e) {
|
||||
errors.push({ url: url.replace(HOST, ""), error: e.message });
|
||||
if (/stalled/.test(e.message)) await recover(`walk ${url}`);
|
||||
}
|
||||
process.stderr.write(`walk ${url.replace(HOST, "")} ${Date.now() - t0} ms\n`);
|
||||
}
|
||||
out.walk = { ok: ok.length, fallbacks, divergences, errors, injectors };
|
||||
}
|
||||
|
||||
/* ---------- timing ---------- */
|
||||
if (!ONLY || ONLY === "timing") {
|
||||
const sample = PAGES.filter((u) => !/overview/.test(u)).slice(0, 8);
|
||||
const rows = [];
|
||||
for (const url of sample) {
|
||||
// full load: navigation start → first non-spinner content in #view
|
||||
const full = [];
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
await fullLoad(p.sessionId, i % 2 ? START : `${HOST}/cgi-bin/luci/admin/system/system`);
|
||||
await fullLoad(p.sessionId, url);
|
||||
const t = await evaljs(p.sessionId, "window.__viewReady");
|
||||
if (t != null) full.push(t);
|
||||
}
|
||||
// router, cold (fresh document, module never required) then warm
|
||||
await fullLoad(p.sessionId, START);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
const cold = await spaNavigate(p.sessionId, url);
|
||||
const warm = [];
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
await spaNavigate(p.sessionId, START);
|
||||
const r = await spaNavigate(p.sessionId, url);
|
||||
if (!r.error && r.sameDoc) warm.push(r.ms);
|
||||
}
|
||||
rows.push({ page: url.replace(`${HOST}/cgi-bin/luci`, ""),
|
||||
fullLoadMs: +median(full).toFixed(0), spaColdMs: +cold.ms.toFixed(0),
|
||||
spaWarmMs: +median(warm).toFixed(0), n: [full.length, warm.length] });
|
||||
}
|
||||
out.timing = rows;
|
||||
}
|
||||
|
||||
/* ---------- soak ---------- */
|
||||
if (!ONLY || ONLY === "soak") {
|
||||
await fullLoad(p.sessionId, START);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
const laps = 5, ring = PAGES.slice(0, 12);
|
||||
const samples = [];
|
||||
const measure = async () => {
|
||||
await evaljs(p.sessionId, "typeof gc === 'function' && gc()");
|
||||
await sleep(200);
|
||||
const m = await send("Performance.getMetrics", {}, p.sessionId).catch(() => null);
|
||||
const g = (n) => m?.metrics.find((x) => x.name === n)?.value ?? null;
|
||||
const inpage = JSON.parse(await evaljs(p.sessionId, `JSON.stringify({
|
||||
pollQueue: L.Poll.queue.length, viewIds: document.querySelectorAll('[id="view"]').length,
|
||||
sheets: document.querySelectorAll('style, link[rel~=stylesheet]').length })`));
|
||||
samples.push({ heapMB: +((g("JSHeapUsedSize") ?? 0) / 1048576).toFixed(2), nodes: g("Nodes"),
|
||||
listeners: g("JSEventListeners"), ...inpage });
|
||||
};
|
||||
await send("Performance.enable", {}, p.sessionId);
|
||||
await measure();
|
||||
for (let lap = 0; lap < laps; lap++) {
|
||||
for (const url of ring) { const r = await spaNavigate(p.sessionId, url); if (!r.sameDoc) break; }
|
||||
await spaNavigate(p.sessionId, START);
|
||||
await measure();
|
||||
}
|
||||
out.soak = { navigations: laps * (ring.length + 1), samples };
|
||||
}
|
||||
|
||||
/* ---------- back ---------- */
|
||||
if (!ONLY || ONLY === "back") {
|
||||
await fullLoad(p.sessionId, START);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
// Two alias/firstchild URLs (from the menu tree, resolved client-side by
|
||||
// the router) interleaved with two view URLs.
|
||||
const redirecting = JSON.parse(await evaljs(p.sessionId, `(async () => {
|
||||
const tree = await L.require('ui').then(ui => ui.menu.load());
|
||||
const out = [];
|
||||
(function walk(node, segs) {
|
||||
for (const [name, child] of Object.entries(node.children ?? {})) {
|
||||
const path = [...segs, name];
|
||||
const t = child.action?.type;
|
||||
if (child.satisfied && child.title && (t === 'alias' || t === 'firstchild') && path[0] === 'admin' && path.length >= 3)
|
||||
out.push(L.url(...path));
|
||||
walk(child, path);
|
||||
}
|
||||
})(tree, []);
|
||||
return JSON.stringify(out.map(u => new URL(u, location.href).href));
|
||||
})()`, true)).filter((u) => PAGES.includes(u) && /\/admin\/(status|network)\//.test(u)).slice(0, 2);
|
||||
const views = PAGES.filter((u) => !redirecting.includes(u) && u !== START).slice(0, 2);
|
||||
const chain = [redirecting[0], views[0], redirecting[1], views[1]].filter(Boolean);
|
||||
process.stderr.write(`back chain: ${chain.map((u) => u.replace(HOST, "")).join(" → ")}\n`);
|
||||
if (redirecting.length < 2) process.stderr.write("back: fewer than 2 alias/firstchild pages found\n");
|
||||
for (const url of chain) {
|
||||
const r = await spaNavigate(p.sessionId, url);
|
||||
process.stderr.write(` ${url.replace(HOST, "")} sameDoc=${r.sameDoc}\n`);
|
||||
}
|
||||
const steps = [];
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
let r;
|
||||
try {
|
||||
r = JSON.parse(await evaljs(p.sessionId, `(async () => {
|
||||
const t0 = performance.now();
|
||||
try { await navigation.back().finished; } catch (e) { return JSON.stringify({ error: String(e) }); }
|
||||
return JSON.stringify({ ms: performance.now() - t0, sameDoc: window.__sameDocMarker === 1,
|
||||
url: location.pathname, page: document.body.dataset.page });
|
||||
})()`, true));
|
||||
} catch (e) {
|
||||
if (!/navigated or closed/.test(e.message)) throw e;
|
||||
// cross-document traversal: a bfcache restore fires no load event, so
|
||||
// poll until the (restored or new) document answers
|
||||
for (let t = 0; t < 100; t++) {
|
||||
try { if (await evaljs(p.sessionId, "document.readyState") === "complete") break; } catch {}
|
||||
await sleep(100);
|
||||
}
|
||||
await waitViewSettled(p.sessionId);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
r = { sameDoc: false, url: await evaljs(p.sessionId, "location.pathname"), page: await evaljs(p.sessionId, "document.body.dataset.page") };
|
||||
}
|
||||
const expected = i > 0 ? chain[i - 1] : START;
|
||||
steps.push({ ...r, ok: r.sameDoc && `${HOST}${r.url}` === expected.split("?")[0] });
|
||||
}
|
||||
out.back = { chain: chain.map((u) => u.replace(HOST, "")), redirectingInChain: redirecting.length,
|
||||
steps: steps.length, allSameDocument: steps.every((s) => s.sameDoc),
|
||||
allCorrect: steps.every((s) => s.ok), detail: steps };
|
||||
}
|
||||
|
||||
/* ---------- poison gate ---------- */
|
||||
if (!ONLY || ONLY === "poison") {
|
||||
await fullLoad(p.sessionId, START);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
const [a, b] = PAGES.filter((u) => u !== START).slice(0, 2);
|
||||
const before = await spaNavigate(p.sessionId, a);
|
||||
// what a foreign view does: an unlayered <style> straight into <head>
|
||||
await evaljs(p.sessionId, `document.head.appendChild(Object.assign(document.createElement('style'),
|
||||
{ textContent: '.cbi-button-save{display:none!important}' })).id = 'poison'`);
|
||||
const poisoned = await spaNavigate(p.sessionId, b); // must be a full load
|
||||
const stillPoisoned = await evaljs(p.sessionId, "!!document.getElementById('poison')");
|
||||
const after = await spaNavigate(p.sessionId, a); // fresh document → router again
|
||||
out.poison = { beforeSameDoc: before.sameDoc, poisonedFullLoad: !poisoned.sameDoc,
|
||||
styleGoneAfterFullLoad: !stillPoisoned, afterSameDoc: after.sameDoc,
|
||||
ok: before.sameDoc && !poisoned.sameDoc && !stillPoisoned && after.sameDoc };
|
||||
}
|
||||
|
||||
/* ---------- foreign sheets ---------- */
|
||||
if (!ONLY || ONLY === "sheets") {
|
||||
const FOREIGN = `[...document.querySelectorAll('style, link[rel~="stylesheet"]')].filter(l => !document.getElementById('view')?.contains(l) && !l.hasAttribute('data-aurora-shell') && !l.hasAttribute('data-aurora-patch') && !l.hasAttribute('data-aurora-node-css')).length`;
|
||||
const injecting = out.walk?.injectors?.map((i) => HOST + i.url) ?? [];
|
||||
if (!injecting.length && ONLY === "sheets")
|
||||
for (const url of PAGES.filter((u) => u !== START)) {
|
||||
await fullLoad(p.sessionId, url);
|
||||
if (await evaljs(p.sessionId, FOREIGN)) injecting.push(url);
|
||||
if (injecting.length >= 3) break;
|
||||
}
|
||||
const cases = [];
|
||||
for (const a of injecting.slice(0, 3)) {
|
||||
const [b, c] = PAGES.filter((u) => u !== START && u !== a);
|
||||
await fullLoad(p.sessionId, START);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
const arrive = await spaNavigate(p.sessionId, a);
|
||||
// A page the router does not serve (Lua, call) is a full load either way.
|
||||
if (!arrive.sameDoc) { cases.push({ a: a.replace(HOST, ""), skipped: "not a view page" }); continue; }
|
||||
await waitViewSettled(p.sessionId); await sleep(300);
|
||||
const foreign = await evaljs(p.sessionId, FOREIGN);
|
||||
const leave = await spaNavigate(p.sessionId, b); // poison gate → full load
|
||||
const after = await spaNavigate(p.sessionId, c); // fresh document → router again
|
||||
// Landing on the page itself: its modules insert before the router boots;
|
||||
// the markers, not a snapshot, must still tell those sheets apart.
|
||||
await fullLoad(p.sessionId, a);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
const fromBoot = await spaNavigate(p.sessionId, b);
|
||||
cases.push({ a: a.replace(HOST, ""), foreign, arriveSameDoc: arrive.sameDoc, leaveFullLoad: !leave.sameDoc, afterSameDoc: after.sameDoc,
|
||||
leaveFromBootFullLoad: !fromBoot.sameDoc,
|
||||
ok: arrive.sameDoc && foreign > 0 && !leave.sameDoc && after.sameDoc && !fromBoot.sameDoc });
|
||||
}
|
||||
const tested = cases.filter((c) => !c.skipped);
|
||||
out.sheets = tested.length ? { cases, ok: tested.every((c) => c.ok) } : { skipped: "no walked view page inserts a stylesheet", cases };
|
||||
}
|
||||
|
||||
/* ---------- hygiene ---------- */
|
||||
if (!ONLY || ONLY === "hygiene") {
|
||||
await fullLoad(p.sessionId, START);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
const b = PAGES.find((u) => u !== START);
|
||||
const nav = await spaNavigate(p.sessionId, b);
|
||||
await waitViewSettled(p.sessionId); await sleep(300);
|
||||
// Back on Overview, which polls, so the visibility gate has a timer to stop.
|
||||
const home = await spaNavigate(p.sessionId, START);
|
||||
await waitViewSettled(p.sessionId); await sleep(1500);
|
||||
const r = JSON.parse(await evaljs(p.sessionId, `(async () => {
|
||||
const bar = !!document.getElementById('aurora-nav-progress');
|
||||
const status = document.getElementById('aurora-nav-status')?.textContent;
|
||||
const wasActive = L.Poll.active();
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
const hiddenActive = L.Poll.active();
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
const shownActive = L.Poll.active();
|
||||
return JSON.stringify({ bar, status, title: document.title, wasActive, hiddenActive, shownActive });
|
||||
})()`, true));
|
||||
out.hygiene = { ...r, sameDoc: nav.sameDoc && home.sameDoc,
|
||||
ok: nav.sameDoc && home.sameDoc && r.bar && r.status === r.title && r.wasActive && !r.hiddenActive && r.shownActive };
|
||||
}
|
||||
|
||||
/* ---------- menu.d node css ---------- */
|
||||
if (!ONLY || ONLY === "nodecss") {
|
||||
const LINKS = `JSON.stringify([...document.querySelectorAll('link[data-aurora-node-css]')].map(l => [l.getAttribute('data-aurora-node-css'), !l.disabled]))`;
|
||||
let styled = null;
|
||||
for (const url of PAGES.filter((u) => u !== START)) {
|
||||
await fullLoad(p.sessionId, url);
|
||||
if (JSON.parse(await evaljs(p.sessionId, LINKS)).length) { styled = url; break; }
|
||||
}
|
||||
if (!styled) out.nodecss = { skipped: "no installed menu.d node declares css" };
|
||||
else {
|
||||
const other = PAGES.find((u) => u !== START && u !== styled);
|
||||
await fullLoad(p.sessionId, START);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
const arrive = await spaNavigate(p.sessionId, styled);
|
||||
const onArrival = JSON.parse(await evaljs(p.sessionId, LINKS));
|
||||
const leave = await spaNavigate(p.sessionId, other);
|
||||
const afterLeave = JSON.parse(await evaljs(p.sessionId, LINKS));
|
||||
const back = await spaNavigate(p.sessionId, styled);
|
||||
const onReturn = JSON.parse(await evaljs(p.sessionId, LINKS));
|
||||
out.nodecss = { page: styled.replace(HOST, ""), onArrival, afterLeave, onReturn,
|
||||
ok: arrive.sameDoc && leave.sameDoc && back.sameDoc &&
|
||||
onArrival.length === 1 && onArrival[0][1] === true &&
|
||||
afterLeave.length === 1 && afterLeave[0][1] === false &&
|
||||
onReturn.length === 1 && onReturn[0][1] === true };
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- session expiry ---------- */
|
||||
if (!ONLY || ONLY === "expiry") {
|
||||
await fullLoad(p.sessionId, START);
|
||||
await evaljs(p.sessionId, "window.__sameDocMarker = 1");
|
||||
const [a, b] = PAGES.filter((u) => u !== START).slice(0, 2);
|
||||
const before = await spaNavigate(p.sessionId, a);
|
||||
// What a real expiry looks like from inside the document: the session is
|
||||
// gone server-side and the next RPC comes back -32002; luci-base probes
|
||||
// session.access, then shows its modal and stops polling.
|
||||
const seen = JSON.parse(await evaljs(p.sessionId, `(async () => {
|
||||
await fetch(L.url('admin/logout'), { credentials: 'same-origin' }).catch(() => {});
|
||||
const rpc = await L.require('rpc');
|
||||
await rpc.declare({ object: 'system', method: 'board' })().catch(() => {});
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
return JSON.stringify({ modal: !!document.querySelector('.modal'), pollActive: L.Poll.active() });
|
||||
})()`, true));
|
||||
const nav = await spaNavigate(p.sessionId, b); // must be a full load
|
||||
const login = await evaljs(p.sessionId, `!!document.querySelector('input[name="luci_username"]')`);
|
||||
out.expiry = { beforeSameDoc: before.sameDoc, modalShown: seen.modal, pollStopped: !seen.pollActive,
|
||||
expiredFullLoad: !nav.sameDoc, landedOnLogin: login,
|
||||
ok: before.sameDoc && seen.modal && !nav.sameDoc && login };
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
ws.close();
|
||||
if (chrome.exitCode == null) { const exited = once(chrome, "exit"); chrome.kill(); await Promise.race([exited, sleep(2000)]); }
|
||||
await sleep(300);
|
||||
try { rmSync(profile, { recursive: true, force: true }); } catch {}
|
||||
@@ -117,6 +117,8 @@ All rules use `@apply` with Tailwind utilities and CSS Nesting — no raw CSS pr
|
||||
|
||||
### On-Demand Third-Party Patches
|
||||
|
||||
> Page-scoped JS patches must expose `window.aurora.patches[stem] = { mount, unmount }` (and mount themselves once at eval) so the client-side router can drive them across same-document navigations — see `router.md`.
|
||||
|
||||
Some third-party LuCI apps ship markup that doesn't adapt to the theme and needs a narrow compatibility override. Instead of bundling every such patch into `main.css` (which would ship them to **every** page), each patch is a standalone CSS file loaded **only on the page it targets**.
|
||||
|
||||
**How it works:**
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
# The client-side router
|
||||
|
||||
How the theme turns a menu click into an in-document view swap instead of a
|
||||
full page load, where it deliberately does not, and the invariants a router
|
||||
inside LuCI has to keep. Source: `.dev/src/resource/router-aurora.js`,
|
||||
loaded from `footer.ut` next to `menu-aurora.js`. **No changes to luci-base
|
||||
or to any view** — the router is additive theme JS plus three small template
|
||||
hooks (a patch manifest, a `<footer>` boundary, and `data-aurora-*` markers
|
||||
on the stylesheets header.ut itself renders).
|
||||
|
||||

|
||||
|
||||
## Prior art
|
||||
|
||||
[luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap)
|
||||
solves the same problem, and reading it informed two pieces here: pausing
|
||||
`L.Poll` on a hidden tab, and folding a view's read-only state along its
|
||||
dispatch path (both below). The rest is independent, and one choice diverges
|
||||
deliberately. footstrap drives navigation through the **History API**
|
||||
(`pushState`/`popstate`) with its own scroll bookkeeping and a
|
||||
`prototype.render` guard to repair stale renders; this router is built on
|
||||
the **Navigation API** instead (see "Kernel"), which hands scroll, history
|
||||
and supersession to the browser and needs none of that — at the cost of
|
||||
running only on newer browsers, where the theme falls back to the plain MPA
|
||||
it already is. On top of that shared base this router also adds a
|
||||
session-expiry gate, reproduces `template` pages from the server's own shell
|
||||
rather than hand-porting them, and cross-fades the swap with a view
|
||||
transition — each its own section below.
|
||||
|
||||
## Why it pays, measured
|
||||
|
||||
One full-load navigation on an `ipq60xx` router (RE-SS-01) over plain
|
||||
HTTP, median of 10 over the 8 sample pages below, this branch (2026-08-16):
|
||||
|
||||
| stage | ms | note |
|
||||
|---|--:|---|
|
||||
| dispatcher renders the page HTML | 106 | TTFB; menu tree + ACL + template |
|
||||
| `admin/translations/<lang>` | 106→205 | a second dispatcher run, render-blocking, uncacheable |
|
||||
| DOMContentLoaded | 211 | right behind the translations |
|
||||
| view module + ubus calls + render | 211→313 | static assets are already 0-byte cache hits |
|
||||
|
||||
≈ 210 ms of the 313 is the router-side work of rebuilding a document whose
|
||||
content is rendered client-side anyway. A same-document swap keeps only the
|
||||
last row (view module from cache + data RPCs + render): the same 8 pages
|
||||
land at a median of 99 ms warm. Speculation-rules prefetch cannot reach
|
||||
that number on HTTP at all (secure-context API) and on HTTPS only hides the
|
||||
first row.
|
||||
|
||||
End to end, click → view painted, median of 10 on the same device
|
||||
(`bench-router.mjs timing`, RUNS=10, 2026-08-16):
|
||||
|
||||
| page | full load | router (warm) | faster |
|
||||
|---|--:|--:|--:|
|
||||
| status/routesj | 286 | 112 | 61 % |
|
||||
| status/nftables | 277 | 85 | 69 % |
|
||||
| status/logs | 309 | 124 | 60 % |
|
||||
| status/processes | 455 | 251 | 45 % |
|
||||
| status/channel_analysis | 412 | 63 | 85 % |
|
||||
| status/realtime | 241 | 49 | 80 % |
|
||||
| system/system | 544 | 194 | 64 % |
|
||||
| system/admin | 268 | 63 | 76 % |
|
||||
|
||||
Median **67 % faster** (45–85 % across the sample). Absolute numbers move
|
||||
with CPU, network and page; the ratio is the point — the same-document swap
|
||||
skips the router-side rebuild above and keeps only the view render.
|
||||
|
||||
## Why it is possible
|
||||
|
||||
For a `view` node the dispatcher renders `view.ut`: the theme header, then
|
||||
`<div id="view">` with an inline `L.require('ui').then(ui =>
|
||||
ui.instantiateView('<path>'))`, then the theme footer. The server decides
|
||||
*which* view; the client renders it. The router repeats what `view.ut` does
|
||||
without the reload: resolve the path against the menu tree the client
|
||||
already holds (`ui.menu.load()` serves it from `sessionStorage`), swap the
|
||||
content region, re-instantiate the view, and let the browser own the URL.
|
||||
|
||||
## Kernel: the Navigation API, and only that
|
||||
|
||||
`navigation.addEventListener('navigate', …)` + `event.intercept()`. One
|
||||
event covers link clicks, `location.assign`, back/forward traversals to
|
||||
same-document entries, and our own `navigation.navigate()`; the browser
|
||||
writes the URL and history entry, exposes `event.signal` for supersession,
|
||||
and (with `scroll: 'after-transition'`) restores scroll on traversal /
|
||||
scrolls to top on push, so the router carries no `pushState`/`popstate`
|
||||
code, no scroll bookkeeping, and no fragment-vs-navigation heuristics.
|
||||
|
||||
Why this API rather than the History API footstrap uses:
|
||||
|
||||
- **The browser owns URL, history and scroll.** `pushState` puts the
|
||||
router in charge of all three and of keeping them consistent with what
|
||||
it rendered; here it only ever renders.
|
||||
- **Supersession is built in.** A newer navigation aborts the older one's
|
||||
`event.signal`; the generation gate below is a check, not a state
|
||||
machine, and no `render` guard is needed to repair a stale paint.
|
||||
- **Every navigation kind arrives at one listener** — link click,
|
||||
`location.assign`, back/forward to a same-document entry, our own
|
||||
`navigation.navigate()` — so there is exactly one path to keep correct.
|
||||
- **The fallback is free.** Where the API is missing, the theme is the
|
||||
MPA it already was; nothing has to be polyfilled or feature-forked.
|
||||
|
||||
**Browsers without the API stay MPA.** Feature-detected at module eval:
|
||||
`window.navigation?.addEventListener` and `NavigateEvent.prototype.intercept`.
|
||||
Chrome/Edge 102+, Safari 26.2+, Firefox 147+ get the router; the theme's
|
||||
declared floor (Chrome 111 / Safari 16.4 / Firefox 128) keeps working as it
|
||||
does today. This is a deliberate trade: one code path, correct by
|
||||
construction, over a second history-API path that would double the surface
|
||||
of everything below.
|
||||
|
||||
## Compatibility
|
||||
|
||||
### Browsers — per platform feature
|
||||
|
||||
| Feature | Used for | Required? | Chrome / Edge | Safari | Firefox | Without it |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Navigation API (`navigation.addEventListener('navigate')`, `NavigateEvent.intercept()`, `event.destination/signal`, `navigation.navigate()/back()`) | the whole router | **yes — gate** | 105+ (2022) | 26.2+ (2026-01) | 147+ (2026-01) | `router-aurora.js` is not even loaded (`footer.ut` checks `window.navigation`); the theme is the plain MPA it was before |
|
||||
| `document.startViewTransition()` (same-document) | crossfade at the swap | no | 111+ | 18+ | 144+ | swap without animation; also off under `prefers-reduced-motion` |
|
||||
| `fetch(url, { priority: 'low' })` | hover module prewarm | no | 101+ | 17.2+ | 132+ | the option is ignored, the fetch still runs at default priority |
|
||||
| `MutationObserver`, `DOMParser`, `WeakSet`, `URL`, `Element.replaceWith`, `:scope`, `matchMedia`, optional chaining / `??=` | render completion, template shells, poison gate, staging | yes | ≥ 85 | ≥ 14 | ≥ 79 | all inside the theme's declared floor (Chrome 111 / Safari 16.4 / Firefox 128) |
|
||||
|
||||
So the router's effective floor is Chrome/Edge 105, Safari 26.2, Firefox 147;
|
||||
everything older keeps the theme's existing floor and behaviour. Verified
|
||||
live in Chrome 151 (headless, `bench-router.mjs`); Safari/Firefox by feature
|
||||
detection only — the gate is the same API surface, not a UA sniff.
|
||||
|
||||
### OpenWrt / LuCI
|
||||
|
||||
The theme already requires OpenWrt 23.05+ (ucode templates). The router
|
||||
touches only luci-base surfaces that exist unchanged in the `openwrt-23.05`,
|
||||
`openwrt-24.10`, `openwrt-25.12` and `master` branches of `openwrt/luci`
|
||||
(checked by source, 2026-08): `L.require` with instance caching and
|
||||
`prototype.constructor`, `L.view`, `L.dom.content` and the `data-idref`
|
||||
registry, `L.env.{scriptname, base_url, resource_version, media,
|
||||
requestpath, dispatchpath, pathinfo, nodespec}`, `L.hasSystemFeature`,
|
||||
`L.Poll.{queue, tick, timer, start, stop}` and the `poll-start/poll-stop`
|
||||
events, `ui.menu.load()`'s session-cached tree with `satisfied` /
|
||||
`firstchild_ineligible` / `wildcard` / `action.type` (`view`, `alias`,
|
||||
`firstchild`, `template`), `ui.instantiateView`, `ui.hideIndicator`,
|
||||
`ui.hideModal`, `uci.state.values` / `uci.unload()` / `uci.load()`,
|
||||
`network.js`'s uci-backed state, `Request.addInterceptor` /
|
||||
`rpc.addInterceptor` and the `-32002` → `session.access` probe in
|
||||
`setupDOM`, `dispatcher.uc`'s `ctx_append` acl folding and `node.css`
|
||||
(schema since 7c6d8ff, 2026-08 — older trees simply carry no `css`),
|
||||
`view.ut`'s `#view` + inline `instantiateView` shell, and `dispatcher.uc`'s `resolve_firstchild` /
|
||||
`node_weight` / alias re-dispatch semantics (ported verbatim). Live
|
||||
verification so far: OpenWrt SNAPSHOT (2026-08, ipq60xx) — 23.05/24.10 by
|
||||
inspection, not yet on device.
|
||||
|
||||
That list is also executable: `contract()` in `router-aurora.js` looks every
|
||||
one of those surfaces up at boot (`L.view`, `L.require`, `L.dom.content`,
|
||||
`L.env.{base_url,resource,media}`, `L.Request.addInterceptor`,
|
||||
`L.uci.{load,unload,state}`, `rpc.addInterceptor`,
|
||||
`poll.{queue,start,stop,active}`, `ui.menu.load`, `ui.hideModal`,
|
||||
`ui.hideIndicator`, `E`) and, if any is missing, logs which and does not
|
||||
activate — the theme is the MPA it was, not a broken router, on a luci-base
|
||||
that moved.
|
||||
|
||||
## What is intercepted
|
||||
|
||||
A `navigate` event is intercepted only when **all** hold:
|
||||
|
||||
- `event.canIntercept` (same-origin, not cross-document-only), not
|
||||
`hashChange`, no `downloadRequest`, no `formData`, `navigationType !== 'reload'`;
|
||||
- the destination path (minus `L.env.scriptname`) resolves in the menu tree
|
||||
to a **serviceable node** (below);
|
||||
- the document is not **poisoned** (below) and its session is not known to
|
||||
be **expired** (below);
|
||||
- the router **activated** in this document: it does so only when the page
|
||||
it booted on is itself serviceable. A `call`/`cbi`/`function` page carries
|
||||
scripts (legacy `XHR.poll`, inline timers) that only a document death
|
||||
retires; the first click away from one is always a full load.
|
||||
|
||||
Anything else falls through untouched: the browser performs the ordinary
|
||||
full navigation, i.e. exactly what the theme did before. Modifier-clicks and
|
||||
`target=_blank` never reach the event.
|
||||
|
||||
### Serviceable nodes
|
||||
|
||||
Resolved with a port of the dispatcher's own rules, not a paraphrase:
|
||||
|
||||
- `alias` → jump to `action.path` from the root and continue;
|
||||
- `firstchild` → the same `resolve_firstchild()` / `node_weight()` the
|
||||
dispatcher runs: candidates are `satisfied` children with a `title` and an
|
||||
object `action`; weight `min(order ?? 9999, 9999)` +10000 for
|
||||
`auth.login`; a `firstchild` candidate counts only if it resolves further;
|
||||
`firstchild_ineligible` excluded; ties keep key order. The ACL check is
|
||||
skipped because `/admin/menu` is already filtered for the session;
|
||||
- `wildcard` nodes accept trailing segments as request args, and — as the
|
||||
dispatcher does — run the node's `wildcardaction` (the `path/*` entry's
|
||||
own action) when args are present and `action` for the bare path;
|
||||
- a hop counter breaks cycles in a foreign `menu.d`.
|
||||
|
||||
Two tracks are kept, as a full load keeps them: **requested** segments →
|
||||
`L.env.requestpath`, `L.env.pathinfo`, `body[data-page]`; **resolved**
|
||||
segments → `L.env.dispatchpath`, `L.env.nodespec`, the menu highlight, the
|
||||
title. Pick a different child than the dispatcher would and a click opens
|
||||
one page while F5 opens another — that is why the resolver is a port.
|
||||
|
||||
| node | served |
|
||||
|---|---|
|
||||
| `view` | yes — `view.<path>` |
|
||||
| `alias`, `firstchild` | yes — resolved to a leaf, recursively |
|
||||
| `template` whose page is a view shell (Status → Overview) | yes — shell fetched once, see below |
|
||||
| Lua `template`, `call`, `function`, `cbi`, `rewrite` | no → full load |
|
||||
|
||||
`rewrite` is deliberately not resolved: it is not in the tree and a splice
|
||||
mistake opens the wrong page, which is worse than the reload it falls back to.
|
||||
|
||||
### Template nodes: the server's own shell, never a hand port
|
||||
|
||||
`admin/status/overview` is a `template` whose server side defines page
|
||||
globals (`progressbar`, `renderBox`, `renderBadge`), emits an `<h2>` and a
|
||||
`div.includes` (server-rendered Lua includes), and then instantiates
|
||||
`view.status.index`. A first version re-implemented those helpers in the
|
||||
router and drifted on the first real page (the network badges lost their
|
||||
labels: upstream's `renderBadge` takes extra `L.itemlist` arguments the port
|
||||
did not know about). So the router does not port anything: when a link to a
|
||||
template node is hovered or focused, its page is **fetched once per
|
||||
document** (the in-flight request is shared by every intent event that
|
||||
arrives before it resolves), parsed with `DOMParser`, and the content region between `#tabmenu` and
|
||||
`<footer>` is kept as the page's *shell* — every node cloned, `#view`
|
||||
replaced by an empty div, the inline `instantiateView('…')` script read for
|
||||
the class name, the remaining inline scripts (the helpers) replayed into
|
||||
global scope on staging. luci-base's own bootstrap (`luci.js` and
|
||||
`L = new LuCI(env)`) also lives in that region and is filtered out. If the
|
||||
document *is* that template (the session started on Overview), the shell is
|
||||
taken from the live region and no fetch happens. A template node is only
|
||||
intercepted once its shell is known — so a Lua template page (no
|
||||
`instantiateView` call) is remembered as unservable after one hover fetch
|
||||
and never enters the router's error path, and a template clicked without a
|
||||
prior hover is a plain full load that seeds the shell for the rest of the
|
||||
document. Its status include modules are singletons carrying
|
||||
`oneshot`/`hide` state that a full load would reset — verified against a
|
||||
real full load, not against expectation.
|
||||
|
||||
## The navigation procedure
|
||||
|
||||
`intercept({ handler, focusReset: 'manual', scroll: 'after-transition' })`,
|
||||
handler in order:
|
||||
|
||||
1. **Generation.** `gen = ++navGen`; every later DOM write is gated on it.
|
||||
`event.signal` aborts our own awaits, but it cannot cancel a LuCI XHR
|
||||
(`L.Request` never exposes its handle) or a `View.__init__` chain already
|
||||
running, so the generation is the correctness mechanism and the signal is
|
||||
hygiene.
|
||||
2. **Teardown of the departing document state**, i.e. what a document
|
||||
death would have done for free:
|
||||
- `Poll`: `queue.length = 0; stop(); start()` — three steps. The flush
|
||||
drops the old view's pollers; `stop()` drops the tick; `start()` on an
|
||||
empty queue re-arms `tick = 0` so the incoming view's `poll.add()`
|
||||
auto-starts and fires immediately instead of waiting up to `interval`
|
||||
seconds for the surviving tick to align. Upstream's `initDOM()` does
|
||||
the same `Poll.start()` on an empty queue before the first view.
|
||||
- `uci`: `unload()` every package present in `state.values` **or**
|
||||
`uci.loaded` (documents start with an empty cache; four shipped apps
|
||||
read `load()`'s return as an existence check and draw an error over
|
||||
the page when the cache answers `[]`; and `uci.loaded` keeps a
|
||||
package's request promise — a rejected one included — until
|
||||
`unload()`, so a failed load left there would be handed to every later
|
||||
view). Then, if `L.network` has been loaded,
|
||||
`load(['network','wireless','luci'])` is re-issued and **awaited** —
|
||||
and a rejection propagates to the hard-load fallback rather than
|
||||
leaving `network.js` on an empty config: `network.js` fills its `_state` once and
|
||||
from then on answers out of the uci cache (`getWifiDevices()` *is*
|
||||
`uci.sections('wireless','wifi-device')`), so dropping those without
|
||||
refilling hands every consumer an empty config for the rest of the
|
||||
document. Unsaved local edits die with the page as they would on a
|
||||
full load; saved changes live on the server and the Unsaved-changes
|
||||
indicator is unaffected.
|
||||
- bare `setInterval`s registered since the router booted are cleared
|
||||
(`setInterval` is hooked at module eval; the one interval `L.Poll`
|
||||
owns is preserved). `setTimeout` and rAF are **not** touched: the
|
||||
core keeps tooltips, notification timeouts and a request timeout on
|
||||
`setTimeout`, and there is no self-rescheduling timeout in any
|
||||
shipped view.
|
||||
- `window`/`document` listeners a view registered **while it rendered**
|
||||
are removed. Several shipped views add them per render (statistics
|
||||
graphs: an anonymous `resize` that later throws against detached DOM;
|
||||
nlbwmon: `tooltip-open`/`touchstart`; the core's own dropdown widgets:
|
||||
one `window` click/touchstart per instance), so they accumulate and
|
||||
act on pages that are gone. The hook records registrations inside the
|
||||
render window only. A **warm** render evaluates no module, so its
|
||||
registrations are per-render by construction and go on the next
|
||||
teardown. A **cold** render also runs the module's top level, whose
|
||||
registrations must survive (removing them is one-way — an editor's
|
||||
module-eval listeners never come back), so cold registrations are only
|
||||
credited to the class and released when a later warm render of the
|
||||
same class registers the same target/type, which proves them
|
||||
per-render.
|
||||
- `ui.hideModal()`, the theme's own surfaces (mega
|
||||
menu, mobile drawer, palette) close.
|
||||
- page-scoped patch CSS is disabled and its JS patch unmounted (below).
|
||||
3. **Environment.** `L.env.requestpath/dispatchpath/pathinfo/nodespec`,
|
||||
`body[data-page]`, `document.title`. An alias is re-dispatched
|
||||
server-side, so `requestpath` and `data-page` carry the alias target while
|
||||
`pathinfo` keeps the URL as requested; a `firstchild` keeps the requested
|
||||
path in both. The title suffix (` - hostname`) is read off the initial
|
||||
document, so it matches whatever the template emitted. `nodespec` drives
|
||||
`L.hasViewPermission()` and therefore the Save/Apply footer's readonly
|
||||
state — and its `readonly` is **folded down the dispatch path** the way
|
||||
`dispatcher.uc` does it: `ctx_append` collects every node's
|
||||
`depends.acl` and one `check_acl_depends()` over the union is writable as
|
||||
soon as *any* group is writable, so a page is readonly only when every
|
||||
acl-bearing node on its path is. The tree's per-node flag
|
||||
(`apply_tree_acls`) covers that node's own acl alone; handing the leaf
|
||||
node over as-is gave a read-only user a live Save & Apply on every page
|
||||
under a read-only group. The tree object is not mutated (`nodespec` is a
|
||||
copy). `data-page` keys `ui.tabs` session state and the theme's
|
||||
page-scoped CSS.
|
||||
4. **Chrome.** `menu-aurora.js` exposes `syncRoute()`: it re-marks
|
||||
`is-active-page`/`aria-current` from `L.env.dispatchpath` across every
|
||||
nav surface, expands the active sidebar/mobile group and collapses the
|
||||
rest, rebuilds the header crumb and re-renders `#tabmenu` for the new
|
||||
section. Menus are **not** rebuilt — the mega menu measures and binds on
|
||||
construction and the palette index is a flat array of the same model —
|
||||
only their state changes.
|
||||
5. **Staging.** A fresh `<div id="view" class="view-staging">` is inserted
|
||||
right after `#tabmenu`, i.e. **first in tree order** —
|
||||
`getElementById('view')` returns the first match, so everything LuCI's
|
||||
view chain writes goes into the staged element while the outgoing page
|
||||
stays on screen (dimmed, `.view-leaving`). The stage is invisible but
|
||||
**laid out** (`visibility:hidden; height:0; overflow:hidden`, never
|
||||
`display:none`): the realtime graphs size themselves from
|
||||
`#view.offsetWidth` inside `render()`, and a `display:none` stage handed
|
||||
them a 0-wide canvas. Nothing is removed yet.
|
||||
6. **Patches.** `header.ut` emits the installed on-demand patch stems as
|
||||
`body[data-patches]`; the router applies the same segment-prefix rule the
|
||||
template applies at render time: matching `patches/<stem>.css` links are
|
||||
ensured (`<link data-aurora-patch>`, enabled for the page on screen,
|
||||
`disabled` — not removed — for the rest, so a return costs nothing);
|
||||
matching `patches/<stem>.js` files are loaded once and their
|
||||
`window.aurora.patches[stem]` `{ mount, unmount }` pair is driven per
|
||||
visit (the list of stems to mount belongs to the navigation that computed
|
||||
it, so a superseded one mounts nothing later); URLs the router adds carry
|
||||
the same `?v=PKG_VERSION` luci.mk stamps on the template's own links,
|
||||
read from `body[data-asset-version]`, so they hit the same cache entry — a JS patch that registers nothing is simply executed once,
|
||||
MPA-style. A patch script mounts itself when it evaluates; if the user
|
||||
has navigated on before it arrives, its `load` handler checks whether
|
||||
the current page still wants that stem and unmounts it otherwise (a
|
||||
same-stem page reached meanwhile keeps it mounted).
|
||||
A menu.d node's own `css` (`header.ut` links `<resource>/<node.css>` for
|
||||
the dispatched node, marked `data-aurora-node-css`) is kept the same
|
||||
way: one `<link>` per stylesheet, enabled for the page whose resolved leaf
|
||||
declares it, `disabled` for every other page, never removed. Both
|
||||
attributes are exempt from the poison gate.
|
||||
7. **View.**
|
||||
- **cold** (`view.<path>` never required in this document):
|
||||
`window.L.require(className)` — the require *is* the render (LuCI
|
||||
instantiates on first require) and it must go through `window.L`, the
|
||||
runtime instance, never the prototypal `L` a module factory receives
|
||||
(`ui` hangs `itemlist`/`showModal` on `window.L`; a view required
|
||||
through the wrong `L` dies three modules down on `L.itemlist is not a
|
||||
function`, and because `require()` caches by name the binding is fixed
|
||||
by the *first* requirer);
|
||||
- **warm**: `require()` hands back the cached instance whose `__init__`
|
||||
already ran; LuCI's class system sets `prototype.constructor`, so
|
||||
`new instance.constructor()` runs a fresh `__init__` → `load()` →
|
||||
`render()` → `dom.content('#view')`, exactly what a full load starts
|
||||
from.
|
||||
- **completion** is observed, not assumed: a `MutationObserver` on the
|
||||
staged element resolves when a non-spinner child lands (or the spinner
|
||||
is removed for an empty render). Not completing within 15 s is a
|
||||
**failure**, not a completion: committing the spinner and releasing the
|
||||
serialization would let the still-running chain paint into a later
|
||||
navigation's `#view`, so the timeout rejects and the catch path
|
||||
hard-loads the destination. On completion —
|
||||
and only if this navigation is still the latest — the outgoing region
|
||||
(everything between `#tabmenu` and `<footer>` except the staged
|
||||
element) is removed and the staged view is unhidden inside
|
||||
`document.startViewTransition()` when available and reduced motion is
|
||||
off; the navigation's `finished` promise resolves after that swap.
|
||||
- **Renders are serialized.** Neither an in-flight LuCI XHR nor a running
|
||||
`View.__init__` chain can be cancelled (`L.Request` never exposes its
|
||||
handle), and every chain paints into *whichever* `#view` is first at
|
||||
paint time. So a navigation first awaits the previous one's completion
|
||||
(bounded by the same timeout) before it tears anything down or stages
|
||||
anything — the previous chain finishes into its own staged element,
|
||||
which is then discarded. Rapid A→B→C therefore never interleaves:
|
||||
B is skipped when C arrives before B ran (`event.signal` /
|
||||
generation), and C waits for whichever render is actually in flight.
|
||||
The document's initial LuCI-rendered view is tracked the same way, so a
|
||||
click during the first load cannot be painted over by it — and a first
|
||||
render that never completes rejects that wait, so the first navigation
|
||||
takes the hard-load fallback instead of staging next to a chain that
|
||||
may still paint. That first render also runs inside a render window
|
||||
(opened after the router's own listeners are registered), so the
|
||||
listeners it adds are credited to its class like a cold render's; the
|
||||
ones it registered before the router loaded are out of reach. The cost is
|
||||
that a click during a slow load waits for that load; the alternative —
|
||||
wrapping `prototype.render` per class and repairing stale cold renders
|
||||
by re-navigating — leaves a real window open and needs three mechanisms
|
||||
where one suffices.
|
||||
8. **Focus and announcement.** `#maincontent` (`tabindex=-1`) with
|
||||
`preventScroll`; the new `document.title` is written into
|
||||
`#aurora-nav-status` (`role=status`, `aria-live=polite`), since a
|
||||
same-document swap fires no load a screen reader would announce.
|
||||
9. **Progress.** A navigation that outlives 150 ms shows `#aurora-nav-progress`,
|
||||
a hairline at the top that creeps right while the view renders and
|
||||
completes on commit (`data-state` active → done); shorter ones stay
|
||||
silent, overlapping ones share the bar. Reduced motion drops its
|
||||
transitions, not the bar.
|
||||
10. Any exception → `console.error` (a silent fallback makes every router
|
||||
regression look like "the page is just slow") → `location.href =
|
||||
destination` — a hard full load, never a stuck page.
|
||||
|
||||
## The expiry gate
|
||||
|
||||
luci-base answers a dead session with `notifySessionExpiry()`: `Poll.stop()`
|
||||
plus a modal whose only button is a hard reload. A same-document swap would
|
||||
`hideModal()` and `Poll.start()` right through it and browse on, every page
|
||||
erroring in turn (measured: `bench-router.mjs expiry` against the previous
|
||||
router — `expiredFullLoad: false`). So the router listens for the same two
|
||||
signals luci-base acts on — a `403` with `X-LuCI-Login-Required: yes` on any
|
||||
`L.Request`, and the `session.access` probe luci-base fires after a
|
||||
`-32002` coming back denied or errored — and from then on intercepts nothing:
|
||||
the next click is a full load, which the dispatcher turns into the login
|
||||
page. A denied call on any other object is an ACL matter and is ignored.
|
||||
Nothing is reset: the flag dies with the document, as the session did. The
|
||||
same flag keeps the visibility gate (below) from restarting a poll the
|
||||
expiry stopped.
|
||||
|
||||
## Hidden tabs
|
||||
|
||||
luci-base keeps polling in a background tab. The router stops `Poll` on
|
||||
`visibilitychange` → hidden when it was active and starts it again on
|
||||
return — unless the user had paused it, or the session died meanwhile. On
|
||||
a weak router that is RPC work nobody is looking at.
|
||||
|
||||
## The poison gate
|
||||
|
||||
A `<style>`/`<link rel=stylesheet>` a view writes into `<head>` dies with
|
||||
the document on a full load and **survives** a same-document swap, painting
|
||||
every page after it (a shipped file manager hides Save/Reset on every config
|
||||
page with one unlayered `!important` rule). Removing it is not an option: a
|
||||
library that imports CSS at module eval never runs again, so deletion is
|
||||
one-way (an editor page came back as a black rectangle two million pixels
|
||||
tall). Hence a gate, not a sweep: before intercepting, any sheet outside
|
||||
`#view` that is not one of the theme's own — header.ut marks everything it
|
||||
renders (`data-aurora-shell` on `main.css`, the font, custom and token
|
||||
`<style>`s; `data-aurora-patch` on patches; `data-aurora-node-css` on the
|
||||
menu.d node css) — marks the document **poisoned** and the navigation is a
|
||||
full load — the fresh document carries no view CSS, so the router resumes
|
||||
immediately. The markers, not a boot snapshot, define "own": the boot page's
|
||||
modules load concurrently with the router, and a sheet they inserted before
|
||||
it snapshotted would have counted as the theme's for the rest of the
|
||||
document. Correctness over speed, never the other way.
|
||||
|
||||
An owner-based refinement (stamp each sheet with the inserting module off
|
||||
the call stack, enable it for pages whose dependency closure holds that
|
||||
module, `disabled` for the rest) was built, verified on the device and
|
||||
**removed again**: on this device one view page inserts its own CSS, the
|
||||
saving is one reload when leaving it, and the price was three monkeypatches
|
||||
plus an inline template script whose failure mode — a page silently missing
|
||||
a shared library's CSS — is worse than the reload it avoids. Revisit only
|
||||
with a real corpus of self-styling view pages.
|
||||
|
||||
## Module prewarm on hover
|
||||
|
||||
Entering (`pointerover`/`focusin`/`pointerdown`) a link to a serviceable
|
||||
node `fetch()`es its view module with `priority: 'low'` — not `require()`,
|
||||
which would render it. The URL is built byte-for-byte as `LuCI.require()`
|
||||
builds it (`<base_url>/<name with . → />.js?v=<resource_version>`) or it
|
||||
misses the HTTP cache. The walk is transitive: the fetched body is scanned
|
||||
for its leading `'require x'` string literals with a regex that is **not**
|
||||
line-anchored (shipped files are minified onto one line), and dotted names
|
||||
are warmed the same way; dotless names are either luci-base's file-less
|
||||
built-ins (`view`, `baseclass`, `dom`, `poll`, `request`, `session`) or flat
|
||||
libraries the chrome has already loaded, so they are declined outright.
|
||||
Deduplicated per class name; stops once a navigation to that link has
|
||||
committed. Cold navigations are the only place this shows; warm ones are
|
||||
already 0-byte cache hits.
|
||||
|
||||
## What is deliberately not done
|
||||
|
||||
- **No history-API path.** See "Kernel".
|
||||
- **No document prefetch while the router is active.** The
|
||||
`speculationrules` script is removed at boot when the router takes over —
|
||||
a hover prefetch of a document the router will never load is pure router
|
||||
CPU. Browsers without the Navigation API keep the rules and the MPA path.
|
||||
- **No `unload`/`beforeunload`**, ever (bfcache).
|
||||
- **No cancellation of in-flight XHR** — `L.Request` gives no handle; the
|
||||
generation gate makes it a waste, not a bug. Upstream-only.
|
||||
- **No sweeping of a view's global listeners or timeouts** — one-way
|
||||
deletions of module-eval registrations. If a per-render offender ever
|
||||
appears, the answer is a targeted teardown, not a global hook.
|
||||
- **`ui.changes.confirm/revert` and `awaitReconnect`** keep their hard
|
||||
`window.location` writes — a rollback/reboot boundary *should* be a fresh
|
||||
document.
|
||||
|
||||
## Verification matrix
|
||||
|
||||
- Unit (`.dev/tests/router.test.js`): resolver against a fixture tree
|
||||
(alias chain, nested firstchild, weights, ineligible, unsatisfied,
|
||||
wildcard args, cycle); URL → segments; patch prefix matching; pragma scan
|
||||
on a minified head; readonly folding; expiry signals; node css of the
|
||||
resolved leaf; the contract check.
|
||||
- Device (`.claude/skills/aurora-performance/scripts/bench-router.mjs`, CDP):
|
||||
1. full walk of every clickable node in each nav mode, each compared
|
||||
against a real full load of the same URL — `data-page`,
|
||||
`dispatchpath`, URL, title, tab count, footer presence, console clean;
|
||||
2. click → view painted, median of N, router vs full load, warm and cold;
|
||||
3. soak: 60 navigations over 12 pages, heap / DOM nodes / listeners /
|
||||
poll queue length flat after the first pass;
|
||||
4. back/forward chain through alias and firstchild URLs — no reload;
|
||||
5. poison gate: a foreign `<style>` in `<head>` makes the next
|
||||
navigation a full load, the one after is a same-document swap again;
|
||||
5b. sheets: the same, on every walked view page that really inserts its
|
||||
own sheets (found on the walk) instead of an injected one — reached
|
||||
same-document and landed on directly (its modules insert before the
|
||||
router boots), leaving is a full load either way;
|
||||
5c. hygiene: progress bar and live region present, live region carries the
|
||||
title, a hidden tab stops polling and a visible one resumes it;
|
||||
6. nodecss: a page whose menu.d node declares `css` — link enabled on
|
||||
arrival, disabled after leaving, re-enabled without a duplicate on
|
||||
return (skipped when no installed node declares one);
|
||||
7. expiry (last, destroys the session): logout fetched from inside the
|
||||
document, one failing RPC → luci-base's modal and `Poll.stop()`; the
|
||||
next navigation is a full load landing on the login form.
|
||||
The walk also compares `nodespec.readonly`, `L.hasViewPermission()`, the
|
||||
set of enabled node-css links and the live-region text against the full
|
||||
load, and reports which pages carry sheets that are not the theme's.
|
||||
- The perf skill's N1 is rewritten to describe this router and its
|
||||
boundaries; N2 keeps document prefetch for the non-router path.
|
||||
@@ -551,15 +551,47 @@ body[data-nav-type="sidebar"] {
|
||||
}
|
||||
}
|
||||
|
||||
/* Router progress (router.md): a hairline across the top that only
|
||||
appears once a navigation outlives its silent 150 ms, creeps towards
|
||||
the right while the view renders, and completes on commit. */
|
||||
#aurora-nav-progress {
|
||||
@apply from-progress-start to-progress-end pointer-events-none fixed inset-x-0 top-0 z-80 h-0.5 origin-left scale-x-0 bg-linear-to-r opacity-0;
|
||||
|
||||
&[data-state="active"] {
|
||||
@apply scale-x-90 opacity-100 transition-[scale,opacity] duration-[8s] ease-out;
|
||||
}
|
||||
|
||||
&[data-state="done"] {
|
||||
@apply scale-x-100 opacity-0 transition-[scale,opacity] duration-300 ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* Router announcement (router.md): the new page's title for screen
|
||||
readers after a same-document swap, which fires no load event. */
|
||||
#aurora-nav-status {
|
||||
@apply sr-only;
|
||||
}
|
||||
|
||||
#maincontent {
|
||||
/* Centred shells (top-bar navigation) clamp here and gutter the remainder.
|
||||
The sidebar shell overrides this — it caps the shell instead, see
|
||||
body[data-nav-type="sidebar"] above. */
|
||||
@apply mx-auto min-h-[calc(100vh-4rem)] w-23/24 max-w-(--content-width-centered) px-4 max-md:w-full max-md:px-3;
|
||||
|
||||
/* Router staging (router.md): the incoming region keeps its full
|
||||
width for layout-measuring renders but takes no space and no paint. */
|
||||
.view-staging {
|
||||
@apply invisible h-0 overflow-hidden;
|
||||
}
|
||||
|
||||
#view {
|
||||
@apply mx-0 w-full bg-transparent p-0 shadow-none empty:hidden md:p-0;
|
||||
|
||||
/* Outgoing page while the router renders the next one (router.md). */
|
||||
&.view-leaving {
|
||||
@apply cursor-progress opacity-60 transition-opacity duration-150;
|
||||
}
|
||||
|
||||
.cbi-title-section {
|
||||
@apply mb-6 leading-relaxed max-md:mx-2 max-md:mb-3;
|
||||
}
|
||||
|
||||
@@ -61,11 +61,14 @@ return baseclass.extend({
|
||||
};
|
||||
|
||||
const closeMobileNavigation = () => {
|
||||
if (!overlay.classList.contains("mobile-menu-open")) return;
|
||||
overlay.classList.remove("mobile-menu-open");
|
||||
document.body.classList.remove("mobile-navigation-open");
|
||||
document.body.style.overflow = "";
|
||||
this.resetNavigationGroups(mobileList);
|
||||
updateToggleState(false);
|
||||
};
|
||||
this.closeMobileNavigation = closeMobileNavigation;
|
||||
|
||||
const getNavigationExpanded = () => {
|
||||
if (isDesktopSidebar()) {
|
||||
@@ -195,20 +198,78 @@ return baseclass.extend({
|
||||
},
|
||||
|
||||
render(tree) {
|
||||
this.tree = tree;
|
||||
this.renderModeMenu(tree);
|
||||
this.renderTabs(tree);
|
||||
},
|
||||
|
||||
if (L.env.dispatchpath.length >= 3) {
|
||||
let node = tree;
|
||||
let url = "";
|
||||
renderTabs(tree) {
|
||||
if (L.env.dispatchpath.length < 3) return;
|
||||
|
||||
for (let i = 0; i < 3 && node; i++) {
|
||||
const segment = L.env.dispatchpath[i];
|
||||
node = node.children?.[segment];
|
||||
url += (url ? "/" : "") + segment;
|
||||
}
|
||||
let node = tree;
|
||||
let url = "";
|
||||
|
||||
if (node) this.renderTabMenu(node, url);
|
||||
for (let i = 0; i < 3 && node; i++) {
|
||||
const segment = L.env.dispatchpath[i];
|
||||
node = node.children?.[segment];
|
||||
url += (url ? "/" : "") + segment;
|
||||
}
|
||||
|
||||
if (node) this.renderTabMenu(node, url);
|
||||
},
|
||||
|
||||
// Re-marks every nav surface from L.env after a same-document navigation
|
||||
// (router-aurora.js). Menus keep their DOM and listeners; only state moves.
|
||||
syncRoute() {
|
||||
if (!this.tree) return;
|
||||
|
||||
const activeHref = new URL(
|
||||
L.url(...L.env.dispatchpath.slice(0, 3)),
|
||||
location.href,
|
||||
).pathname;
|
||||
|
||||
document
|
||||
.querySelectorAll(
|
||||
"#topmenu a, .desktop-menu-canvas a, #sidebar-list a, #mobile-nav-list a",
|
||||
)
|
||||
.forEach((a) => {
|
||||
const active =
|
||||
a.pathname === activeHref && !a.getAttribute("href").startsWith("#");
|
||||
a.classList.toggle("is-active-page", active);
|
||||
if (active) a.setAttribute("aria-current", "page");
|
||||
else a.removeAttribute("aria-current");
|
||||
});
|
||||
|
||||
document.querySelectorAll(".navigation-group").forEach((group) => {
|
||||
const active = !!group.querySelector("a.is-active-page");
|
||||
const toggle = group.querySelector(".navigation-group-toggle");
|
||||
group.classList.toggle("is-active-group", active);
|
||||
if (active) toggle?.setAttribute("aria-current", "location");
|
||||
else toggle?.removeAttribute("aria-current");
|
||||
});
|
||||
|
||||
for (const surface of [
|
||||
document.querySelector("#sidebar-list"),
|
||||
document.querySelector("#mobile-nav-list"),
|
||||
]) {
|
||||
this.resetNavigationGroups(surface);
|
||||
this.expandActiveNavigationGroup(surface);
|
||||
}
|
||||
|
||||
this.renderCrumb();
|
||||
|
||||
const tabs = document.querySelector("#tabmenu");
|
||||
if (tabs) {
|
||||
tabs.innerHTML = "";
|
||||
tabs.style.display = "none";
|
||||
}
|
||||
this.renderTabs(this.tree);
|
||||
},
|
||||
|
||||
closeSurfaces() {
|
||||
this.hideDesktopNav();
|
||||
if (this.paletteList) this.closePalette();
|
||||
this.closeMobileNavigation?.();
|
||||
},
|
||||
|
||||
renderTabMenu(tree, url, level = 0) {
|
||||
@@ -484,25 +545,13 @@ return baseclass.extend({
|
||||
renderSidebar(items) {
|
||||
const list = document.querySelector("#sidebar-list");
|
||||
const footer = document.querySelector("#sidebar-footer");
|
||||
const crumbEl = document.querySelector("#header-crumb");
|
||||
|
||||
if (list) list.innerHTML = "";
|
||||
if (footer) footer.innerHTML = "";
|
||||
if (crumbEl) crumbEl.innerHTML = "";
|
||||
|
||||
if (!list) return;
|
||||
|
||||
const crumb = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.isActiveGroup || item.isActivePage) {
|
||||
crumb.push(item.title);
|
||||
// Same-named group/page pairs ("System › System") collapse to one
|
||||
// level — the duplicate adds no information.
|
||||
if (item.activePage && item.activePage.title !== item.title)
|
||||
crumb.push(item.activePage.title);
|
||||
}
|
||||
|
||||
if (item.isLogout) {
|
||||
(footer || list).appendChild(
|
||||
E("a", { class: "nav-link", href: item.href }, [item.title]),
|
||||
@@ -514,10 +563,27 @@ return baseclass.extend({
|
||||
});
|
||||
|
||||
this.bindNavigationAccordion(list);
|
||||
this.renderCrumb();
|
||||
},
|
||||
|
||||
renderCrumb() {
|
||||
const crumbEl = document.querySelector("#header-crumb");
|
||||
const list = document.querySelector("#sidebar-list");
|
||||
if (!crumbEl || !list) return;
|
||||
|
||||
const crumb = [];
|
||||
const group = list.querySelector(".is-active-group");
|
||||
const page = list.querySelector(".is-active-page");
|
||||
if (group)
|
||||
crumb.push(group.querySelector(".nav-category-label")?.textContent);
|
||||
// Same-named group/page pairs ("System › System") collapse to one
|
||||
// level — the duplicate adds no information.
|
||||
if (page && page.textContent !== crumb[0]) crumb.push(page.textContent);
|
||||
|
||||
crumbEl.innerHTML = "";
|
||||
crumb.forEach((title, i) => {
|
||||
if (i) crumbEl?.appendChild(E("li", { class: "crumb-sep" }, ["/"]));
|
||||
crumbEl?.appendChild(
|
||||
if (i) crumbEl.appendChild(E("li", { class: "crumb-sep" }, ["/"]));
|
||||
crumbEl.appendChild(
|
||||
E("li", { class: i === crumb.length - 1 ? "current" : "" }, [title]),
|
||||
);
|
||||
});
|
||||
@@ -661,11 +727,9 @@ return baseclass.extend({
|
||||
});
|
||||
// Mobile-only exit (the full-screen takeover leaves no outside to tap
|
||||
// and touch devices have no Escape) — hidden on md+ via CSS.
|
||||
const cancel = E(
|
||||
"button",
|
||||
{ class: "cmdk-cancel", type: "button" },
|
||||
[_("Cancel")],
|
||||
);
|
||||
const cancel = E("button", { class: "cmdk-cancel", type: "button" }, [
|
||||
_("Cancel"),
|
||||
]);
|
||||
cancel.addEventListener("click", () => this.closePalette());
|
||||
const panel = E(
|
||||
"div",
|
||||
@@ -916,9 +980,7 @@ return baseclass.extend({
|
||||
},
|
||||
|
||||
movePaletteSelection(delta) {
|
||||
const rows = [
|
||||
...this.paletteList.querySelectorAll(".cmdk-row"),
|
||||
];
|
||||
const rows = [...this.paletteList.querySelectorAll(".cmdk-row")];
|
||||
if (!rows.length) return;
|
||||
|
||||
const current = rows.findIndex((row) =>
|
||||
|
||||
@@ -143,10 +143,10 @@
|
||||
|
||||
// Kernel-log page under either menu layout: admin-status-dmesg on
|
||||
// 23.05/24.10, admin-status-logs-dmesg on master's combined Logs menu.
|
||||
const page = document.body?.dataset?.page || "";
|
||||
const parseLine = /(^|-)dmesg(-|$)/.test(page)
|
||||
? parseDmesgLine
|
||||
: parseSyslogLine;
|
||||
// Read per mount: the router swaps pages without re-evaluating this file.
|
||||
let parseLine = parseSyslogLine;
|
||||
let timer = null;
|
||||
let arrival = null;
|
||||
|
||||
let viewer = null;
|
||||
let lastValue = null;
|
||||
@@ -248,26 +248,50 @@
|
||||
} else teardown(container);
|
||||
}
|
||||
|
||||
// Content updates: LuCI's poll rewrites .value, which fires nothing — a
|
||||
// cheap dirty-check covers it.
|
||||
setInterval(tick, 1000);
|
||||
|
||||
// First paint: the view (and its textarea) is inserted asynchronously by
|
||||
// LuCI after this script runs. Element insertion IS an observable childList
|
||||
// mutation (unlike .value writes), and the callback runs as a microtask
|
||||
// before the browser paints the inserted textarea — so the viewer takes
|
||||
// over without a flash of the raw log.
|
||||
if (document.getElementById("syslog")) {
|
||||
tick();
|
||||
} else {
|
||||
const arrival = new MutationObserver(function () {
|
||||
if (!document.getElementById("syslog")) return;
|
||||
arrival.disconnect();
|
||||
function mount() {
|
||||
if (timer) return;
|
||||
const page = document.body?.dataset?.page || "";
|
||||
parseLine = /(^|-)dmesg(-|$)/.test(page) ? parseDmesgLine : parseSyslogLine;
|
||||
lastValue = null;
|
||||
// Content updates: LuCI's poll rewrites .value, which fires nothing — a
|
||||
// cheap dirty-check covers it.
|
||||
timer = setInterval(tick, 1000);
|
||||
// First paint: the view (and its textarea) is inserted asynchronously by
|
||||
// LuCI after this script runs. Element insertion IS an observable childList
|
||||
// mutation (unlike .value writes), and the callback runs as a microtask
|
||||
// before the browser paints the inserted textarea — so the viewer takes
|
||||
// over without a flash of the raw log.
|
||||
if (document.getElementById("syslog")) {
|
||||
tick();
|
||||
});
|
||||
arrival.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
} else {
|
||||
arrival = new MutationObserver(function () {
|
||||
if (!document.getElementById("syslog")) return;
|
||||
arrival.disconnect();
|
||||
arrival = null;
|
||||
tick();
|
||||
});
|
||||
arrival.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
arrival?.disconnect();
|
||||
arrival = null;
|
||||
const container = document.getElementById("content_syslog");
|
||||
if (container) teardown(container);
|
||||
viewer = null;
|
||||
}
|
||||
|
||||
// Page-scoped lifecycle for the router (see .dev/docs/router.md);
|
||||
// a full load simply mounts once.
|
||||
((window.aurora ??= {}).patches ??= {})["admin-status-logs"] = {
|
||||
mount,
|
||||
unmount,
|
||||
};
|
||||
mount();
|
||||
})();
|
||||
@@ -0,0 +1,879 @@
|
||||
"use strict";
|
||||
"require baseclass";
|
||||
"require ui";
|
||||
"require poll";
|
||||
"require rpc";
|
||||
|
||||
// Same-document navigation for LuCI view pages. Design, boundaries and the
|
||||
// invariants each step keeps: .dev/docs/router.md.
|
||||
const RT = window.L;
|
||||
const RENDER_TIMEOUT = 15000;
|
||||
const PATCH_ATTR = "data-aurora-patch";
|
||||
const NODE_CSS_ATTR = "data-aurora-node-css";
|
||||
const SHELL_ATTR = "data-aurora-shell";
|
||||
// A navigation shorter than this stays silent; longer ones show the top bar.
|
||||
const PROGRESS_DELAY = 150;
|
||||
const INSTANTIATE = /instantiateView\(\s*['"]([^'"]+)['"]/;
|
||||
|
||||
const supported = () =>
|
||||
typeof navigation === "object" &&
|
||||
navigation !== null &&
|
||||
typeof navigation.addEventListener === "function" &&
|
||||
typeof NavigateEvent === "function" &&
|
||||
"intercept" in NavigateEvent.prototype;
|
||||
|
||||
const nodeWeight = (node) =>
|
||||
Math.min(node.order ?? 9999, 9999) + (node.auth?.login ? 10000 : 0);
|
||||
|
||||
// Port of dispatcher.uc resolve_firstchild(): the ACL check is skipped
|
||||
// because /admin/menu is already filtered for the session.
|
||||
function firstChild(node) {
|
||||
let candidate = null;
|
||||
|
||||
for (const [name, child] of Object.entries(node.children ?? {})) {
|
||||
if (!child.satisfied || !child.title || typeof child.action !== "object")
|
||||
continue;
|
||||
|
||||
if (child.action?.type === "firstchild") {
|
||||
if (candidate && nodeWeight(candidate.node) <= nodeWeight(child))
|
||||
continue;
|
||||
if (firstChild(child)) candidate = { name, node: child };
|
||||
} else if (!child.firstchild_ineligible) {
|
||||
if (!candidate || nodeWeight(candidate.node) > nodeWeight(child))
|
||||
candidate = { name, node: child };
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// segs (as requested) → { node, path (dispatch), args }, or null.
|
||||
function resolve(tree, segs) {
|
||||
let node = tree;
|
||||
let path = [];
|
||||
let args = [];
|
||||
let i = 0;
|
||||
|
||||
for (let hops = 0; hops < 32; hops++) {
|
||||
while (i < segs.length) {
|
||||
const child = node.children?.[segs[i]];
|
||||
|
||||
if (child && child.satisfied === false) return null;
|
||||
if (!child) {
|
||||
if (!node.wildcard) return null;
|
||||
args = segs.slice(i);
|
||||
i = segs.length;
|
||||
break;
|
||||
}
|
||||
|
||||
node = child;
|
||||
path.push(segs[i++]);
|
||||
}
|
||||
|
||||
const type = node.action?.type;
|
||||
|
||||
if (type === "alias") {
|
||||
segs = [
|
||||
...String(node.action.path ?? "")
|
||||
.split("/")
|
||||
.filter(Boolean),
|
||||
...args,
|
||||
];
|
||||
node = tree;
|
||||
path = [];
|
||||
args = [];
|
||||
i = 0;
|
||||
} else if (type === "firstchild") {
|
||||
const pick = firstChild(node);
|
||||
|
||||
if (!pick) return null;
|
||||
node = pick.node;
|
||||
path.push(pick.name);
|
||||
} else {
|
||||
return { node, path, args, request: segs };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// dispatcher.uc pushes every depends.acl along the dispatch path into one
|
||||
// check_acl_depends() call, which is writable as soon as any group is
|
||||
// writable; the tree's per-node readonly flag covers that node's acl alone.
|
||||
function readonlyAlong(tree, path) {
|
||||
const gated = [];
|
||||
let node = tree;
|
||||
|
||||
for (const name of path) {
|
||||
node = node?.children?.[name];
|
||||
if (node?.depends?.acl?.length) gated.push(node);
|
||||
}
|
||||
|
||||
return gated.length > 0 && gated.every((n) => n.readonly === true);
|
||||
}
|
||||
|
||||
// dispatcher.uc keeps a `path/*` entry's action apart as wildcardaction and
|
||||
// runs it only when request args are present; the bare path keeps action.
|
||||
const actionOf = (node, args) =>
|
||||
args.length && typeof node?.wildcardaction === "object"
|
||||
? node.wildcardaction
|
||||
: node?.action;
|
||||
|
||||
function viewClass(action) {
|
||||
if (action?.type === "view" && action.path)
|
||||
return `view.${action.path.replace(/\//g, ".")}`;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefixes = (segs) => {
|
||||
const out = [];
|
||||
let acc = null;
|
||||
|
||||
for (const seg of segs) out.push((acc = acc == null ? seg : `${acc}-${seg}`));
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
const PRAGMA_HEAD = /^(?:\s*["'](?:use strict|require\s[^"']+)["'];)+/;
|
||||
const PRAGMA = /["']require\s+([^\s"']+)/g;
|
||||
|
||||
function pragmaDeps(source) {
|
||||
const head = source.slice(0, 4096).match(PRAGMA_HEAD)?.[0] ?? "";
|
||||
|
||||
return [...head.matchAll(PRAGMA)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
// luci.mk appends ?v=PKG_VERSION to the theme's quoted asset URLs at package
|
||||
// time; header.ut exposes the same value so links the router adds match.
|
||||
const assetUrl = (path) => {
|
||||
const v = document.body.dataset.assetVersion;
|
||||
return v ? `${path}?v=${v}` : path;
|
||||
};
|
||||
|
||||
const moduleUrl = (name) =>
|
||||
`${RT.env.base_url}/${name.replace(/\./g, "/")}.js${
|
||||
RT.env.resource_version ? `?v=${RT.env.resource_version}` : ""
|
||||
}`;
|
||||
|
||||
return baseclass.extend({
|
||||
__init__() {
|
||||
if (!supported() || !RT?.env?.scriptname) return;
|
||||
const missing = this.contract();
|
||||
if (missing.length) {
|
||||
console.warn(
|
||||
`router-aurora: luci-base surface missing, staying MPA: ${missing.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.gen = 0;
|
||||
this.seen = new Set();
|
||||
this.warmed = new Set();
|
||||
this.inflight = Promise.resolve();
|
||||
this.intervals = new Set();
|
||||
// Only the sheets header.ut rendered are known; anything else present at
|
||||
// boot was injected by the page's own modules before the router ran.
|
||||
this.knownSheets = new WeakSet(
|
||||
this.sheets().filter(
|
||||
(el) =>
|
||||
el.hasAttribute(SHELL_ATTR) ||
|
||||
el.hasAttribute(PATCH_ATTR) ||
|
||||
el.hasAttribute(NODE_CSS_ATTR),
|
||||
),
|
||||
);
|
||||
this.hostname =
|
||||
document.querySelector(".brand")?.textContent?.trim() || document.title;
|
||||
this.hookIntervals();
|
||||
this.hookListeners();
|
||||
this.hookSession();
|
||||
this.hookVisibility();
|
||||
|
||||
Promise.all([ui.menu.load(), RT.require("menu-aurora")]).then(
|
||||
([tree, menu]) => {
|
||||
this.tree = tree;
|
||||
this.menu = menu;
|
||||
const current = this.initialRoute();
|
||||
if (!current) return;
|
||||
document.querySelector('script[type="speculationrules"]')?.remove();
|
||||
this.bar = document.body.appendChild(
|
||||
E("div", { id: "aurora-nav-progress", "aria-hidden": "true" }),
|
||||
);
|
||||
this.status = document.body.appendChild(
|
||||
E("div", {
|
||||
id: "aurora-nav-status",
|
||||
role: "status",
|
||||
"aria-live": "polite",
|
||||
"aria-atomic": "true",
|
||||
}),
|
||||
);
|
||||
navigation.addEventListener("navigate", (ev) => this.onNavigate(ev));
|
||||
document.addEventListener("pointerover", (ev) => this.onIntent(ev));
|
||||
document.addEventListener("pointerdown", (ev) => this.onIntent(ev));
|
||||
document.addEventListener("focusin", (ev) => this.onIntent(ev));
|
||||
this.watchInitialRender(current);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
// Every luci-base surface the router leans on, by name. A missing one means
|
||||
// upstream moved and the safe answer is the MPA the theme was before — with
|
||||
// a warning, not a broken page. Kept in step with router.md.
|
||||
contract() {
|
||||
const roots = { L: RT, rpc, poll, ui, window };
|
||||
const need = [
|
||||
"L.view",
|
||||
"L.require",
|
||||
"L.dom.content",
|
||||
"L.env.base_url",
|
||||
"L.env.resource",
|
||||
"L.env.media",
|
||||
"L.Request.addInterceptor",
|
||||
"L.uci.load",
|
||||
"L.uci.unload",
|
||||
"L.uci.state",
|
||||
"rpc.addInterceptor",
|
||||
"poll.queue",
|
||||
"poll.start",
|
||||
"poll.stop",
|
||||
"poll.active",
|
||||
"ui.menu.load",
|
||||
"ui.hideModal",
|
||||
"ui.hideIndicator",
|
||||
"window.E",
|
||||
];
|
||||
const lookup = (path) =>
|
||||
path.split(".").reduce((o, k) => (o == null ? o : o[k]), roots);
|
||||
|
||||
return need.filter((path) => lookup(path) == null);
|
||||
},
|
||||
|
||||
// A hidden tab keeps polling on a full load; on a weak router that is
|
||||
// RPC work nobody sees. Stopped while hidden, resumed on return unless the
|
||||
// user paused it or the session died meanwhile.
|
||||
hookVisibility() {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
this.pausedByVisibility = poll.active();
|
||||
if (this.pausedByVisibility) poll.stop();
|
||||
} else if (this.pausedByVisibility) {
|
||||
this.pausedByVisibility = false;
|
||||
if (!this.expired) poll.start();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
progressStart() {
|
||||
this.pending = (this.pending ?? 0) + 1;
|
||||
clearTimeout(this.progressTimer);
|
||||
this.progressTimer = setTimeout(
|
||||
() => (this.bar.dataset.state = "active"),
|
||||
PROGRESS_DELAY,
|
||||
);
|
||||
},
|
||||
|
||||
progressEnd() {
|
||||
if (--this.pending > 0) return;
|
||||
clearTimeout(this.progressTimer);
|
||||
if (this.bar.dataset.state !== "active") return;
|
||||
this.bar.dataset.state = "done";
|
||||
this.progressTimer = setTimeout(() => {
|
||||
if (this.bar.dataset.state === "done") delete this.bar.dataset.state;
|
||||
}, 300);
|
||||
},
|
||||
|
||||
sheets() {
|
||||
const view = document.getElementById("view");
|
||||
|
||||
return [
|
||||
...document.querySelectorAll('style, link[rel~="stylesheet"]'),
|
||||
].filter((el) => !view?.contains(el));
|
||||
},
|
||||
|
||||
poisoned() {
|
||||
return this.sheets().some(
|
||||
(el) =>
|
||||
!this.knownSheets.has(el) &&
|
||||
!el.hasAttribute(PATCH_ATTR) &&
|
||||
!el.hasAttribute(NODE_CSS_ATTR),
|
||||
);
|
||||
},
|
||||
|
||||
hookIntervals() {
|
||||
const set = window.setInterval;
|
||||
const clear = window.clearInterval;
|
||||
const ids = this.intervals;
|
||||
|
||||
window.setInterval = function (...args) {
|
||||
const id = set.apply(window, args);
|
||||
ids.add(id);
|
||||
return id;
|
||||
};
|
||||
window.clearInterval = function (id) {
|
||||
ids.delete(id);
|
||||
return clear.call(window, id);
|
||||
};
|
||||
this.nativeClearInterval = clear;
|
||||
},
|
||||
|
||||
// window/document listeners registered while a view renders. A warm render
|
||||
// evaluates no module, so everything it registers is per-render and is
|
||||
// removed on the next teardown. A cold render also evaluates the module,
|
||||
// whose top-level registrations must survive (removing them is one-way),
|
||||
// so its listeners are only credited to the class — and released the
|
||||
// moment a warm render of the same class registers the same target/type,
|
||||
// which proves them per-render too.
|
||||
hookListeners() {
|
||||
const self = this;
|
||||
for (const target of [window, document]) {
|
||||
const add = target.addEventListener;
|
||||
const remove = target.removeEventListener;
|
||||
target.addEventListener = function (type, fn, opts) {
|
||||
self.renderWindow?.push({ target, type, fn, opts });
|
||||
return add.call(this, type, fn, opts);
|
||||
};
|
||||
target.removeEventListener = function (type, fn, opts) {
|
||||
if (self.renderWindow)
|
||||
self.renderWindow = self.renderWindow.filter(
|
||||
(e) => e.fn !== fn || e.type !== type,
|
||||
);
|
||||
return remove.call(this, type, fn, opts);
|
||||
};
|
||||
}
|
||||
this.pageListeners = [];
|
||||
this.coldListeners = new Map();
|
||||
},
|
||||
|
||||
// luci-base answers an expired session with a modal and Poll.stop(); a
|
||||
// same-document swap would dismiss both and browse on. The signals are the
|
||||
// ones luci-base itself acts on: a 403 asking for login, or the
|
||||
// session.access probe it fires after any -32002 coming back denied.
|
||||
hookSession() {
|
||||
RT.Request?.addInterceptor?.((res) => {
|
||||
if (this.loginRequired(res)) this.expired = true;
|
||||
});
|
||||
rpc?.addInterceptor?.((msg, req) => {
|
||||
if (this.sessionGone(msg, req)) this.expired = true;
|
||||
});
|
||||
},
|
||||
|
||||
loginRequired(res) {
|
||||
return (
|
||||
res?.status === 403 && res.headers?.get("X-LuCI-Login-Required") === "yes"
|
||||
);
|
||||
},
|
||||
|
||||
sessionGone(msg, req) {
|
||||
if (req?.object !== "session" || req?.method !== "access" || !msg)
|
||||
return false;
|
||||
if (msg.error) return true;
|
||||
const data = Array.isArray(msg.result) ? msg.result[1] : msg.result;
|
||||
return data?.access === false;
|
||||
},
|
||||
|
||||
nodespec(r) {
|
||||
return { ...r.node, readonly: readonlyAlong(this.tree, r.path) };
|
||||
},
|
||||
|
||||
nodeCss(r) {
|
||||
return typeof r.node.css === "string" && r.node.css ? r.node.css : null;
|
||||
},
|
||||
|
||||
openRenderWindow() {
|
||||
this.renderWindow = [];
|
||||
},
|
||||
|
||||
closeRenderWindow(className, cold) {
|
||||
const entries = this.renderWindow ?? [];
|
||||
this.renderWindow = null;
|
||||
if (cold) {
|
||||
this.coldListeners.set(className, entries);
|
||||
this.pageListeners = [];
|
||||
return;
|
||||
}
|
||||
const stale = this.coldListeners.get(className) ?? [];
|
||||
for (const e of entries)
|
||||
for (const c of stale.filter(
|
||||
(c) => c.target === e.target && c.type === e.type,
|
||||
))
|
||||
c.target.removeEventListener(c.type, c.fn, c.opts);
|
||||
this.coldListeners.set(
|
||||
className,
|
||||
stale.filter(
|
||||
(c) => !entries.some((e) => e.target === c.target && e.type === c.type),
|
||||
),
|
||||
);
|
||||
this.pageListeners = entries;
|
||||
},
|
||||
|
||||
clearViewListeners() {
|
||||
for (const e of this.pageListeners)
|
||||
e.target.removeEventListener(e.type, e.fn, e.opts);
|
||||
this.pageListeners = [];
|
||||
},
|
||||
|
||||
clearViewIntervals() {
|
||||
for (const id of this.intervals) {
|
||||
if (id === poll.timer) continue;
|
||||
this.nativeClearInterval.call(window, id);
|
||||
this.intervals.delete(id);
|
||||
}
|
||||
},
|
||||
|
||||
segsFromURL(url) {
|
||||
const u = new URL(url, window.location.href);
|
||||
const base = RT.env.scriptname;
|
||||
|
||||
if (u.origin !== window.location.origin || !u.pathname.startsWith(base))
|
||||
return null;
|
||||
|
||||
return u.pathname
|
||||
.slice(base.length)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(decodeURIComponent);
|
||||
},
|
||||
|
||||
// A view node is served directly. A template node is served once its
|
||||
// server-rendered page is known to be a view shell (its region contains an
|
||||
// instantiateView call): the shell is fetched on hover/focus (onIntent) or
|
||||
// seeded from the document itself, so the decision here stays synchronous
|
||||
// and a Lua template page never costs a wasted fetch or an error path.
|
||||
route(url, { intent = false } = {}) {
|
||||
const segs = this.segsFromURL(url);
|
||||
const resolved = segs && this.resolve(segs);
|
||||
if (!resolved) return null;
|
||||
const action = actionOf(resolved.node, resolved.args);
|
||||
const className = viewClass(action);
|
||||
const path = action?.path;
|
||||
const template =
|
||||
action?.type === "template" &&
|
||||
(intent ? !this.unservable?.has(path) : this.templates?.has(path));
|
||||
|
||||
return className || template
|
||||
? { segs, className, template: template && path, url, ...resolved }
|
||||
: null;
|
||||
},
|
||||
|
||||
// Fetch a template page once per document and keep the content region the
|
||||
// server rendered — the page's own helper scripts, <h2>, Lua includes —
|
||||
// instead of re-implementing them by hand. Not a view shell → remembered
|
||||
// as unservable so it is not fetched again this document.
|
||||
template(r) {
|
||||
this.templates ??= new Map();
|
||||
if (this.templates.has(r.template))
|
||||
return Promise.resolve(this.templates.get(r.template));
|
||||
// One request per template per document, however many intent events
|
||||
// (pointerover, pointerdown, focusin) arrive before it resolves.
|
||||
this.templateLoads ??= new Map();
|
||||
if (!this.templateLoads.has(r.template))
|
||||
this.templateLoads.set(
|
||||
r.template,
|
||||
fetch(r.url, { credentials: "same-origin" })
|
||||
.then((res) => res.text())
|
||||
.then((html) => {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const main = doc.getElementById("maincontent");
|
||||
const start = main?.querySelector("#tabmenu");
|
||||
const end = main?.querySelector(":scope > footer");
|
||||
const nodes = [];
|
||||
|
||||
for (let n = start?.nextSibling; n && n !== end; n = n.nextSibling)
|
||||
nodes.push(n);
|
||||
|
||||
return this.rememberShell(r.template, nodes);
|
||||
})
|
||||
.finally(() => this.templateLoads.delete(r.template)),
|
||||
);
|
||||
|
||||
return this.templateLoads.get(r.template);
|
||||
},
|
||||
|
||||
// Region nodes → { className, nodes, scripts }: inline scripts are split
|
||||
// into the instantiateView call (which names the class) and the helpers,
|
||||
// #view is replaced by an empty shell, everything else is cloned.
|
||||
rememberShell(path, nodes) {
|
||||
const shell = { className: null, nodes: [], scripts: [] };
|
||||
|
||||
for (const n of nodes) {
|
||||
if (n.nodeName === "SCRIPT") {
|
||||
// luci-base's own bootstrap (luci.js + `L = new LuCI(env)`) sits in
|
||||
// this region too; only the template's inline helpers are replayed.
|
||||
const m = n.textContent.match(INSTANTIATE);
|
||||
if (m) shell.className = `view.${m[1].replace(/\//g, ".")}`;
|
||||
else if (
|
||||
!n.src &&
|
||||
n.textContent.trim() &&
|
||||
!/new LuCI\(/.test(n.textContent)
|
||||
)
|
||||
shell.scripts.push(n.textContent);
|
||||
} else if (n.nodeType === 1 && n.id === "view") {
|
||||
shell.nodes.push(E("div", { id: "view" }));
|
||||
} else if (n.nodeType === 1 && n.querySelector("#view")) {
|
||||
shell.className = null;
|
||||
break;
|
||||
} else shell.nodes.push(document.importNode(n, true));
|
||||
}
|
||||
|
||||
if (shell.className) this.templates.set(path, shell);
|
||||
else (this.unservable ??= new Set()).add(path);
|
||||
|
||||
return shell.className ? shell : null;
|
||||
},
|
||||
|
||||
resolve(segs) {
|
||||
return resolve(this.tree, segs);
|
||||
},
|
||||
|
||||
moduleDeps(source) {
|
||||
return pragmaDeps(source);
|
||||
},
|
||||
|
||||
patchPrefixes(segs) {
|
||||
return prefixes(segs);
|
||||
},
|
||||
|
||||
// The document's first view is LuCI's; wait for it like any other render
|
||||
// so a click during its load cannot be painted over by it. A document the
|
||||
// router could not have rendered (call/cbi/function pages) carries scripts
|
||||
// only a document death retires, so the router never takes over from one.
|
||||
initialRoute() {
|
||||
const current = this.route(window.location.href, { intent: true });
|
||||
|
||||
if (!current) return null;
|
||||
if (current.template) {
|
||||
// The document IS the rendered template: keep its region as the shell
|
||||
// so a later visit needs no fetch.
|
||||
this.templates ??= new Map();
|
||||
const shell = this.rememberShell(current.template, this.region().nodes);
|
||||
if (!shell) return null;
|
||||
current.className = shell.className;
|
||||
}
|
||||
this.seen.add(current.className);
|
||||
const title = current.node.title ? _(current.node.title) : "";
|
||||
this.titleTail = document.title.startsWith(title)
|
||||
? document.title.slice(title.length)
|
||||
: ` - ${this.hostname}`;
|
||||
return current;
|
||||
},
|
||||
|
||||
// A first render that never completes is a failure like any other: the
|
||||
// first navigation's `await previous` rejects into the hard-load fallback
|
||||
// instead of staging next to a chain that may still paint. Its listeners
|
||||
// are credited to the class as a cold render's, released by the first
|
||||
// warm render of the same class — as far as the router can see them: the
|
||||
// ones registered before it loaded are out of reach.
|
||||
watchInitialRender(current) {
|
||||
const view = document.getElementById("view");
|
||||
if (!view) return;
|
||||
this.openRenderWindow();
|
||||
this.inflight = this.rendered(view).finally(() =>
|
||||
this.closeRenderWindow(current.className, true),
|
||||
);
|
||||
this.inflight.catch(() => {});
|
||||
},
|
||||
|
||||
rendered(view) {
|
||||
const done = (v) =>
|
||||
v.querySelector(":scope > :not(.spinning):not(script)") ||
|
||||
(v.childElementCount === 0 && v.dataset.auroraStarted);
|
||||
if (done(view)) return Promise.resolve();
|
||||
|
||||
// A render that never completes is a failure, not a completion: committing
|
||||
// a spinner and releasing the serialization would let the still-running
|
||||
// chain paint into a later navigation's #view.
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => finish(reject, new Error("view did not render in time")),
|
||||
RENDER_TIMEOUT,
|
||||
);
|
||||
const observer = new MutationObserver(() => {
|
||||
if (done(view)) finish(resolve);
|
||||
});
|
||||
function finish(settle, value) {
|
||||
clearTimeout(timer);
|
||||
observer.disconnect();
|
||||
settle(value);
|
||||
}
|
||||
view.dataset.auroraStarted = "1";
|
||||
observer.observe(view, { childList: true });
|
||||
});
|
||||
},
|
||||
|
||||
onIntent(ev) {
|
||||
const a = ev.target?.closest?.("a[href]");
|
||||
if (!a || a.target || a.hasAttribute("download")) return;
|
||||
|
||||
const r = this.route(a.href, { intent: true });
|
||||
if (!r) return;
|
||||
if (r.template)
|
||||
this.template(r)
|
||||
.then((tpl) => tpl && this.warm(tpl.className))
|
||||
.catch(() => {});
|
||||
else if (!this.seen.has(r.className)) this.warm(r.className);
|
||||
},
|
||||
|
||||
warm(name) {
|
||||
if (this.warmed.has(name)) return;
|
||||
this.warmed.add(name);
|
||||
fetch(moduleUrl(name), { priority: "low", credentials: "same-origin" })
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.then((src) => {
|
||||
for (const dep of pragmaDeps(src))
|
||||
if (dep.includes(".")) this.warm(dep);
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
onNavigate(ev) {
|
||||
if (this.bypass) {
|
||||
this.bypass = false;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!ev.canIntercept ||
|
||||
ev.hashChange ||
|
||||
ev.downloadRequest !== null ||
|
||||
ev.formData ||
|
||||
ev.navigationType === "reload"
|
||||
)
|
||||
return;
|
||||
|
||||
const r = this.route(ev.destination.url);
|
||||
if (!r || this.expired || this.poisoned()) return;
|
||||
|
||||
ev.intercept({
|
||||
focusReset: "manual",
|
||||
scroll: "after-transition",
|
||||
handler: () => this.navigate(r, ev),
|
||||
});
|
||||
},
|
||||
|
||||
async navigate(r, ev) {
|
||||
const gen = ++this.gen;
|
||||
const previous = this.inflight;
|
||||
let release;
|
||||
this.inflight = new Promise((res) => (release = res));
|
||||
this.progressStart();
|
||||
|
||||
try {
|
||||
await previous;
|
||||
if (gen !== this.gen || ev.signal.aborted) return;
|
||||
|
||||
const tpl = r.template ? await this.template(r) : null;
|
||||
if (tpl) r.className = tpl.className;
|
||||
if (gen !== this.gen || ev.signal.aborted) return;
|
||||
|
||||
this.teardown();
|
||||
await this.flushUci();
|
||||
if (gen !== this.gen || ev.signal.aborted) return;
|
||||
|
||||
this.setEnvironment(r);
|
||||
this.menu.syncRoute();
|
||||
const patches = this.applyPatches(r.request);
|
||||
this.applyNodeCss(this.nodeCss(r));
|
||||
|
||||
const view = this.stage(tpl);
|
||||
const done = this.rendered(view);
|
||||
const cold = !this.seen.has(r.className);
|
||||
this.seen.add(r.className);
|
||||
|
||||
this.openRenderWindow();
|
||||
try {
|
||||
const instance = await RT.require(r.className);
|
||||
if (!(instance instanceof RT.view))
|
||||
throw new TypeError(`${r.className} is not a LuCI.view`);
|
||||
if (!cold) new instance.constructor();
|
||||
await done;
|
||||
} finally {
|
||||
this.closeRenderWindow(r.className, cold);
|
||||
}
|
||||
if (gen !== this.gen) return;
|
||||
await this.commit(view);
|
||||
this.mountPatches(patches);
|
||||
this.status.textContent = document.title;
|
||||
document.getElementById("maincontent")?.focus({ preventScroll: true });
|
||||
} catch (err) {
|
||||
console.error("router-aurora:", err);
|
||||
if (gen !== this.gen) return;
|
||||
this.bypass = true;
|
||||
window.location.href = ev.destination.url;
|
||||
await new Promise(() => {});
|
||||
} finally {
|
||||
this.progressEnd();
|
||||
release();
|
||||
}
|
||||
},
|
||||
|
||||
teardown() {
|
||||
poll.queue.length = 0;
|
||||
poll.stop();
|
||||
poll.start();
|
||||
ui.hideIndicator("poll-status");
|
||||
this.clearViewIntervals();
|
||||
this.clearViewListeners();
|
||||
ui.hideModal();
|
||||
this.menu.closeSurfaces();
|
||||
this.unmountPatches();
|
||||
},
|
||||
|
||||
// Documents start with an empty uci cache; network.js answers out of it
|
||||
// for the whole document, so its packages are refilled and awaited.
|
||||
async flushUci() {
|
||||
const uci = RT.uci;
|
||||
if (!uci?.state) return;
|
||||
|
||||
// uci.loaded keeps a package's request promise — a rejected one too —
|
||||
// until unload(); flush it as well or every later view inherits the
|
||||
// rejection.
|
||||
const loaded = Object.keys({ ...uci.state.values, ...uci.loaded });
|
||||
if (loaded.length) uci.unload(loaded);
|
||||
|
||||
if (RT.network) {
|
||||
const pkgs = ["network", "luci"];
|
||||
if (RT.hasSystemFeature?.("wifi")) pkgs.push("wireless");
|
||||
// A failed refill propagates: the catch in navigate() hard-loads the
|
||||
// destination rather than leaving network.js on an empty config.
|
||||
await uci.load(pkgs);
|
||||
}
|
||||
},
|
||||
|
||||
// An alias is re-dispatched server-side, so requestpath and data-page carry
|
||||
// the alias target while pathinfo keeps the URL as requested.
|
||||
setEnvironment(r) {
|
||||
RT.env.requestpath = r.request;
|
||||
RT.env.dispatchpath = r.path;
|
||||
RT.env.pathinfo = `/${r.segs.join("/")}`;
|
||||
RT.env.nodespec = this.nodespec(r);
|
||||
document.body.dataset.page = r.request.join("-");
|
||||
document.title = r.node.title
|
||||
? `${_(r.node.title)}${this.titleTail}`
|
||||
: this.hostname;
|
||||
},
|
||||
|
||||
region() {
|
||||
const main = document.getElementById("maincontent");
|
||||
const start = document.getElementById("tabmenu");
|
||||
const end = main.querySelector(":scope > footer");
|
||||
const nodes = [];
|
||||
|
||||
for (let n = start ? start.nextSibling : main.firstChild; n && n !== end; )
|
||||
(nodes.push(n), (n = n.nextSibling));
|
||||
|
||||
return { main, start, end, nodes };
|
||||
},
|
||||
|
||||
// The incoming view renders into an invisible but laid-out #view placed
|
||||
// first in tree order (getElementById returns the first), so the outgoing
|
||||
// page stays on screen until the new one is ready. Laid out, not
|
||||
// display:none: views size their graphs from #view.offsetWidth in render().
|
||||
stage(tpl) {
|
||||
const { main, start, end } = this.region();
|
||||
const wrapper = E("div", { class: "view-staging" });
|
||||
|
||||
for (const old of main.querySelectorAll(":scope > #view"))
|
||||
old.classList.add("view-leaving");
|
||||
if (tpl) {
|
||||
for (const n of tpl.nodes) wrapper.appendChild(n.cloneNode(true));
|
||||
// Inline scripts of a parsed document never run on adoption; re-create
|
||||
// them so the template's helpers land in global scope as on a full load.
|
||||
for (const text of tpl.scripts)
|
||||
wrapper.appendChild(E("script", {}, text));
|
||||
} else wrapper.appendChild(E("div", { id: "view" }));
|
||||
main.insertBefore(wrapper, start ? start.nextSibling : (end ?? null));
|
||||
|
||||
return wrapper.querySelector("#view");
|
||||
},
|
||||
|
||||
commit(view) {
|
||||
const wrapper = view.parentNode;
|
||||
const swap = () => {
|
||||
for (const n of this.region().nodes) {
|
||||
if (n === wrapper) continue;
|
||||
// dom.content() drops the data-idref registry entries that would
|
||||
// otherwise keep the departed subtree (and its class instances) alive.
|
||||
if (n.nodeType === 1) RT.dom.content(n, null);
|
||||
n.remove();
|
||||
}
|
||||
wrapper.replaceWith(...wrapper.childNodes);
|
||||
};
|
||||
const reduce = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
if (document.startViewTransition && !reduce)
|
||||
return document.startViewTransition(swap).updateCallbackDone;
|
||||
swap();
|
||||
},
|
||||
|
||||
// menu.d `css` is linked by header.ut for the dispatched node; the same
|
||||
// link is kept per page here — disabled, not removed, when leaving.
|
||||
applyNodeCss(css) {
|
||||
for (const link of document.querySelectorAll(`link[${NODE_CSS_ATTR}]`))
|
||||
link.disabled = link.getAttribute(NODE_CSS_ATTR) !== css;
|
||||
if (css && !document.querySelector(`link[${NODE_CSS_ATTR}="${css}"]`))
|
||||
document.head.appendChild(
|
||||
E("link", {
|
||||
rel: "stylesheet",
|
||||
href: `${RT.env.resource}/${css}`,
|
||||
[NODE_CSS_ATTR]: css,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
installed() {
|
||||
return (document.body.dataset.patches ?? "").split(/\s+/).filter(Boolean);
|
||||
},
|
||||
|
||||
applyPatches(segs) {
|
||||
const want = new Set(prefixes(segs));
|
||||
const media = RT.env.media;
|
||||
const pending = [];
|
||||
|
||||
for (const file of this.installed()) {
|
||||
const stem = file.replace(/\.(css|js)$/, "");
|
||||
const needed = want.has(stem);
|
||||
|
||||
if (file.endsWith(".css")) {
|
||||
let link = document.querySelector(`link[${PATCH_ATTR}="${stem}"]`);
|
||||
if (!link && needed) {
|
||||
link = E("link", {
|
||||
rel: "stylesheet",
|
||||
href: assetUrl(`${media}/patches/${stem}.css`),
|
||||
[PATCH_ATTR]: stem,
|
||||
});
|
||||
document.head.appendChild(link);
|
||||
} else if (link) link.disabled = !needed;
|
||||
} else if (needed) pending.push(stem);
|
||||
}
|
||||
|
||||
return pending;
|
||||
},
|
||||
|
||||
mountPatches(pending) {
|
||||
const registry = window.aurora?.patches ?? {};
|
||||
this.live = new Set(pending);
|
||||
|
||||
for (const stem of pending) {
|
||||
const script = document.querySelector(`script[${PATCH_ATTR}="${stem}"]`);
|
||||
if (!script) {
|
||||
const el = E("script", {
|
||||
src: assetUrl(`${RT.env.media}/patches/${stem}.js`),
|
||||
[PATCH_ATTR]: stem,
|
||||
});
|
||||
// The patch mounts itself on evaluation; if the user has already
|
||||
// navigated to a page without it by then, that mount is stale.
|
||||
el.addEventListener("load", () => {
|
||||
if (!this.live.has(stem)) window.aurora?.patches?.[stem]?.unmount?.();
|
||||
});
|
||||
document.head.appendChild(el);
|
||||
} else registry[stem]?.mount?.();
|
||||
}
|
||||
},
|
||||
|
||||
unmountPatches() {
|
||||
this.live = new Set();
|
||||
for (const stem in window.aurora?.patches ?? {})
|
||||
window.aurora.patches[stem].unmount?.();
|
||||
},
|
||||
});
|
||||
@@ -13,17 +13,27 @@ test("production assets stay within raw-transfer budgets", () => {
|
||||
const main = bytes("aurora/main.css");
|
||||
const login = bytes("aurora/login.css");
|
||||
const menu = bytes("resources/menu-aurora.js");
|
||||
const router = bytes("resources/router-aurora.js");
|
||||
const font = bytes("aurora/fonts/lato-v24-latin-regular.woff2");
|
||||
const logo = bytes("aurora/images/logo.svg");
|
||||
|
||||
// 192K: the custom-background mode (scheme D surfaces + shared page-bg
|
||||
// layer) added ~1.4 KB of rules; the total-transfer budget below is
|
||||
// unchanged and still binds.
|
||||
assert.ok(main <= 192_000, "main.css exceeds 192 KB");
|
||||
// layer) added ~1.4 KB of rules; 193K: the router's progress bar and
|
||||
// live region. The total-transfer budget below is unchanged and still
|
||||
// binds.
|
||||
assert.ok(main <= 193_000, "main.css exceeds 193 KB");
|
||||
assert.ok(login <= 12_000, "login.css exceeds 12 KB");
|
||||
assert.ok(menu <= 20_000, "menu-aurora.js exceeds 20 KB");
|
||||
assert.ok(menu <= 21_500, "menu-aurora.js exceeds 21.5 KB");
|
||||
// 15K: the expiry gate, readonly folding, menu.d node css, wildcard
|
||||
// actions, progress bar, visibility gate and contract check added ~3 KB
|
||||
// over the first cut; the total-transfer budget below moved by the same
|
||||
// amount. Mirrored in the perf skill's aurora-budgets.md.
|
||||
assert.ok(router <= 15_000, "router-aurora.js exceeds 15 KB");
|
||||
assert.ok(logo <= 16_000, "logo.svg exceeds 16 KB");
|
||||
assert.ok(main + menu + font + logo <= 250_000, "admin assets exceed 250 KB");
|
||||
assert.ok(
|
||||
main + menu + router + font + logo <= 267_000,
|
||||
"admin assets exceed 267 KB",
|
||||
);
|
||||
assert.ok(login + font + logo <= 55_000, "login assets exceed 55 KB");
|
||||
});
|
||||
|
||||
|
||||
@@ -200,6 +200,14 @@ class FakeElement {
|
||||
assert.equal(value, "");
|
||||
this.children = [];
|
||||
}
|
||||
|
||||
get textContent() {
|
||||
return this.children
|
||||
.map((child) =>
|
||||
child instanceof FakeElement ? child.textContent : String(child),
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
const E = (tagName, attributes, children) =>
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(
|
||||
resolve(import.meta.dirname, "../src/resource/router-aurora.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// The module returns a class from its top level (LuCI factory shape); the
|
||||
// factory is built without a Navigation API so __init__ stays inert.
|
||||
const loadRouter = ({ tree, scriptname = "/cgi-bin/luci" } = {}) => {
|
||||
const window = {
|
||||
location: new URL("https://r/cgi-bin/luci/admin/status/overview"),
|
||||
L: {
|
||||
env: {
|
||||
scriptname,
|
||||
base_url: "/luci-static/resources",
|
||||
resource_version: "1",
|
||||
},
|
||||
},
|
||||
};
|
||||
const document = { querySelector: () => null, querySelectorAll: () => [] };
|
||||
const router = new Function(
|
||||
"window",
|
||||
"document",
|
||||
"L",
|
||||
"baseclass",
|
||||
"ui",
|
||||
"poll",
|
||||
"rpc",
|
||||
source,
|
||||
)(window, document, window.L, { extend: (value) => value }, {}, {}, {});
|
||||
|
||||
router.tree = tree;
|
||||
return router;
|
||||
};
|
||||
|
||||
const view = (path, extra = {}) => ({
|
||||
title: path,
|
||||
satisfied: true,
|
||||
action: { type: "view", path },
|
||||
...extra,
|
||||
});
|
||||
|
||||
const tree = {
|
||||
action: { type: "firstchild" },
|
||||
satisfied: true,
|
||||
children: {
|
||||
admin: {
|
||||
title: "Admin",
|
||||
satisfied: true,
|
||||
action: { type: "firstchild" },
|
||||
children: {
|
||||
status: {
|
||||
title: "Status",
|
||||
satisfied: true,
|
||||
order: 10,
|
||||
action: { type: "firstchild" },
|
||||
children: {
|
||||
overview: {
|
||||
title: "Overview",
|
||||
satisfied: true,
|
||||
order: 1,
|
||||
action: { type: "template", path: "admin_status/index" },
|
||||
},
|
||||
logs: {
|
||||
title: "Logs",
|
||||
satisfied: true,
|
||||
order: 5,
|
||||
action: { type: "firstchild" },
|
||||
children: {
|
||||
syslog: view("status/logs/syslog", { order: 1 }),
|
||||
dmesg: view("status/logs/dmesg", { order: 2 }),
|
||||
},
|
||||
},
|
||||
realtime: {
|
||||
title: "Realtime",
|
||||
satisfied: true,
|
||||
action: { type: "alias", path: "admin/status/logs" },
|
||||
},
|
||||
unreachable: {
|
||||
title: "Loop",
|
||||
satisfied: true,
|
||||
action: { type: "alias", path: "admin/status/unreachable" },
|
||||
},
|
||||
hidden: view("status/hidden", { title: null, order: 0 }),
|
||||
},
|
||||
},
|
||||
system: {
|
||||
title: "System",
|
||||
satisfied: true,
|
||||
order: 20,
|
||||
action: { type: "firstchild" },
|
||||
children: {
|
||||
reboot: view("system/reboot", {
|
||||
order: 1,
|
||||
firstchild_ineligible: true,
|
||||
}),
|
||||
system: view("system/system", { order: 2, auth: { login: true } }),
|
||||
admin: view("system/admin", { order: 3 }),
|
||||
flash: view("system/flash", { order: 4, satisfied: false }),
|
||||
wild: view("system/wild", { order: 9, wildcard: true }),
|
||||
detail: view("system/detail", {
|
||||
order: 10,
|
||||
wildcard: true,
|
||||
wildcardaction: { type: "view", path: "system/detail-item" },
|
||||
}),
|
||||
legacy: {
|
||||
title: "Legacy",
|
||||
satisfied: true,
|
||||
action: { type: "call", path: "legacy" },
|
||||
},
|
||||
},
|
||||
},
|
||||
logout: {
|
||||
title: "Logout",
|
||||
satisfied: true,
|
||||
action: { type: "function", name: "logout" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test("a view node resolves to its class with request and dispatch tracks", () => {
|
||||
const router = loadRouter({ tree });
|
||||
const r = router.route("https://r/cgi-bin/luci/admin/system/admin");
|
||||
|
||||
assert.equal(r.className, "view.system.admin");
|
||||
assert.deepEqual(r.segs, ["admin", "system", "admin"]);
|
||||
assert.deepEqual(r.path, ["admin", "system", "admin"]);
|
||||
assert.deepEqual(r.args, []);
|
||||
});
|
||||
|
||||
test("firstchild follows the dispatcher's weights and eligibility rules", () => {
|
||||
const router = loadRouter({ tree });
|
||||
// reboot is ineligible, system carries auth.login (+10000), so admin wins.
|
||||
const r = router.route("https://r/cgi-bin/luci/admin/system");
|
||||
|
||||
assert.equal(r.className, "view.system.admin");
|
||||
assert.deepEqual(r.segs, ["admin", "system"]);
|
||||
assert.deepEqual(r.request, ["admin", "system"]);
|
||||
assert.deepEqual(r.path, ["admin", "system", "admin"]);
|
||||
});
|
||||
|
||||
test("nested firstchild resolves through the whole descent", () => {
|
||||
const router = loadRouter({ tree });
|
||||
// status (order 10) beats system (20); inside status the overview template
|
||||
// (order 1) is the lightest eligible child — a template node, routed only
|
||||
// once its page is known to be a view shell (hover fetch or seeded).
|
||||
assert.equal(router.route("https://r/cgi-bin/luci/"), null);
|
||||
const r = router.route("https://r/cgi-bin/luci/", { intent: true });
|
||||
assert.equal(r.className, null);
|
||||
assert.equal(r.template, "admin_status/index");
|
||||
assert.deepEqual(r.path, ["admin", "status", "overview"]);
|
||||
router.templates = new Map([["admin_status/index", {}]]);
|
||||
assert.equal(
|
||||
router.route("https://r/cgi-bin/luci/").template,
|
||||
"admin_status/index",
|
||||
);
|
||||
});
|
||||
|
||||
test("firstchild ignores untitled children", () => {
|
||||
const router = loadRouter({ tree });
|
||||
const r = router.route("https://r/cgi-bin/luci/admin/status/logs");
|
||||
|
||||
assert.equal(r.className, "view.status.logs.syslog");
|
||||
});
|
||||
|
||||
test("alias restarts from the root and keeps request segments intact", () => {
|
||||
const router = loadRouter({ tree });
|
||||
const r = router.route("https://r/cgi-bin/luci/admin/status/realtime");
|
||||
|
||||
assert.equal(r.className, "view.status.logs.syslog");
|
||||
assert.deepEqual(r.segs, ["admin", "status", "realtime"]);
|
||||
assert.deepEqual(r.request, ["admin", "status", "logs"]);
|
||||
assert.deepEqual(r.path, ["admin", "status", "logs", "syslog"]);
|
||||
});
|
||||
|
||||
test("an alias cycle resolves to nothing instead of hanging", () => {
|
||||
const router = loadRouter({ tree });
|
||||
|
||||
assert.equal(
|
||||
router.route("https://r/cgi-bin/luci/admin/status/unreachable"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("wildcard nodes carry trailing segments as request args", () => {
|
||||
const router = loadRouter({ tree });
|
||||
const r = router.route("https://r/cgi-bin/luci/admin/system/wild/eth0/x");
|
||||
|
||||
assert.equal(r.className, "view.system.wild");
|
||||
assert.deepEqual(r.path, ["admin", "system", "wild"]);
|
||||
assert.deepEqual(r.args, ["eth0", "x"]);
|
||||
assert.deepEqual(r.segs, ["admin", "system", "wild", "eth0", "x"]);
|
||||
});
|
||||
|
||||
test("a wildcard node's wildcardaction serves the path with args, action the bare path", () => {
|
||||
const router = loadRouter({ tree });
|
||||
|
||||
assert.equal(
|
||||
router.route("https://r/cgi-bin/luci/admin/system/detail").className,
|
||||
"view.system.detail",
|
||||
);
|
||||
assert.equal(
|
||||
router.route("https://r/cgi-bin/luci/admin/system/detail/c1").className,
|
||||
"view.system.detail-item",
|
||||
);
|
||||
});
|
||||
|
||||
test("unsatisfied, non-view, unknown and foreign URLs are not routed", () => {
|
||||
const router = loadRouter({ tree });
|
||||
|
||||
for (const url of [
|
||||
"https://r/cgi-bin/luci/admin/system/flash",
|
||||
"https://r/cgi-bin/luci/admin/system/legacy",
|
||||
"https://r/cgi-bin/luci/admin/logout",
|
||||
"https://r/cgi-bin/luci/admin/nowhere",
|
||||
"https://r/cgi-bin/luci/admin/system/admin/extra",
|
||||
"https://r/other/admin/system/admin",
|
||||
"https://elsewhere/cgi-bin/luci/admin/system/admin",
|
||||
])
|
||||
assert.equal(router.route(url), null, url);
|
||||
});
|
||||
|
||||
test("patch prefixes follow the header's segment-boundary rule", () => {
|
||||
const router = loadRouter({ tree });
|
||||
|
||||
assert.deepEqual(router.patchPrefixes(["admin", "status", "logs", "dmesg"]), [
|
||||
"admin",
|
||||
"admin-status",
|
||||
"admin-status-logs",
|
||||
"admin-status-logs-dmesg",
|
||||
]);
|
||||
});
|
||||
|
||||
test("module dependencies are read from a minified one-line head", () => {
|
||||
const router = loadRouter({ tree });
|
||||
const minified =
|
||||
`"use strict";"require view";"require dom";"require tools.widgets as widgets";` +
|
||||
`"require network";return view.extend({"require fake":1})`;
|
||||
|
||||
assert.deepEqual(router.moduleDeps(minified), [
|
||||
"view",
|
||||
"dom",
|
||||
"tools.widgets",
|
||||
"network",
|
||||
]);
|
||||
assert.deepEqual(router.moduleDeps("(function(){'require x'})()"), []);
|
||||
});
|
||||
|
||||
// dispatcher.uc folds every depends.acl along the dispatch path into one
|
||||
// check_acl_depends() call, which is writable as soon as any group is
|
||||
// writable — so a page is readonly only when every acl-bearing node on its
|
||||
// path is readonly. The menu tree's per-node flag covers that node alone.
|
||||
test("nodespec readonly is folded down the dispatch path like the dispatcher", () => {
|
||||
const acl = (readonly) => ({
|
||||
depends: { acl: ["luci-app-x"] },
|
||||
...(readonly ? { readonly: true } : {}),
|
||||
});
|
||||
const t = {
|
||||
action: { type: "firstchild" },
|
||||
satisfied: true,
|
||||
children: {
|
||||
admin: {
|
||||
title: "Admin",
|
||||
satisfied: true,
|
||||
action: { type: "firstchild" },
|
||||
children: {
|
||||
ro: {
|
||||
title: "RO",
|
||||
satisfied: true,
|
||||
action: { type: "firstchild" },
|
||||
...acl(true),
|
||||
children: {
|
||||
leaf: view("ro/leaf"),
|
||||
writable: view("ro/writable", acl(false)),
|
||||
alsoro: view("ro/alsoro", acl(true)),
|
||||
},
|
||||
},
|
||||
plain: {
|
||||
title: "Plain",
|
||||
satisfied: true,
|
||||
action: { type: "firstchild" },
|
||||
children: { leaf: view("plain/leaf") },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const router = loadRouter({ tree: t });
|
||||
const spec = (segs) => router.nodespec(router.resolve(segs));
|
||||
|
||||
assert.equal(spec(["admin", "ro", "leaf"]).readonly, true);
|
||||
assert.equal(spec(["admin", "ro", "alsoro"]).readonly, true);
|
||||
assert.equal(spec(["admin", "ro", "writable"]).readonly, false);
|
||||
assert.equal(spec(["admin", "plain", "leaf"]).readonly, false);
|
||||
assert.equal(spec(["admin", "ro"]).readonly, true);
|
||||
// The tree's own node object is not mutated.
|
||||
assert.equal(t.children.admin.children.ro.children.leaf.readonly, undefined);
|
||||
assert.equal(spec(["admin", "ro", "leaf"]).action.path, "ro/leaf");
|
||||
});
|
||||
|
||||
test("session expiry is recognised from the same signals luci-base uses", () => {
|
||||
const router = loadRouter({ tree });
|
||||
const res = (status, required) => ({
|
||||
status,
|
||||
headers: { get: (h) => (h === "X-LuCI-Login-Required" ? required : null) },
|
||||
});
|
||||
const probe = { object: "session", method: "access" };
|
||||
|
||||
assert.equal(router.loginRequired(res(403, "yes")), true);
|
||||
assert.equal(router.loginRequired(res(403, null)), false);
|
||||
assert.equal(router.loginRequired(res(200, "yes")), false);
|
||||
|
||||
assert.equal(router.sessionGone({ error: { code: -32002 } }, probe), true);
|
||||
assert.equal(
|
||||
router.sessionGone({ result: [0, { access: false }] }, probe),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
router.sessionGone({ result: [0, { access: true }] }, probe),
|
||||
false,
|
||||
);
|
||||
// A denied call on any other object is an ACL matter, not an expiry.
|
||||
assert.equal(
|
||||
router.sessionGone(
|
||||
{ error: { code: -32002 } },
|
||||
{ object: "uci", method: "get" },
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(router.sessionGone(null, probe), false);
|
||||
});
|
||||
|
||||
test("a menu.d node css is served for the resolved leaf only", () => {
|
||||
const t = {
|
||||
action: { type: "firstchild" },
|
||||
satisfied: true,
|
||||
children: {
|
||||
admin: {
|
||||
title: "Admin",
|
||||
satisfied: true,
|
||||
action: { type: "firstchild" },
|
||||
children: {
|
||||
styled: view("styled", { css: "view/styled/styled.css" }),
|
||||
group: {
|
||||
title: "Group",
|
||||
satisfied: true,
|
||||
action: { type: "firstchild" },
|
||||
css: "group.css",
|
||||
children: { leaf: view("group/leaf") },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const router = loadRouter({ tree: t });
|
||||
|
||||
assert.equal(
|
||||
router.nodeCss(router.resolve(["admin", "styled"])),
|
||||
"view/styled/styled.css",
|
||||
);
|
||||
assert.equal(router.nodeCss(router.resolve(["admin", "group"])), null);
|
||||
assert.equal(
|
||||
router.nodeCss(router.resolve(["admin", "group", "leaf"])),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("the contract names every missing luci-base surface", () => {
|
||||
const router = loadRouter({ tree });
|
||||
const missing = router.contract();
|
||||
|
||||
assert.ok(missing.includes("L.view"));
|
||||
assert.ok(missing.includes("poll.queue"));
|
||||
assert.ok(!missing.includes("L.env.base_url"));
|
||||
});
|
||||
@@ -266,6 +266,7 @@ function createLocalServePlugin(): Plugin {
|
||||
"/luci-static/resources/view/aurora/sysauth.js":
|
||||
"src/resource/view/aurora/sysauth.js",
|
||||
"/luci-static/resources/menu-aurora.js": "src/resource/menu-aurora.js",
|
||||
"/luci-static/resources/router-aurora.js": "src/resource/router-aurora.js",
|
||||
};
|
||||
|
||||
// Any theme CSS (entries, partials, patches) or served JS change must force
|
||||
|
||||
@@ -30,7 +30,7 @@ No linter CLI. Formatting uses Prettier with format-on-save (`.vscode/settings.j
|
||||
|
||||
**Design tokens**: `_tokens.css` 已随 @eamonxg/luci-theme-tokens 上移——main.css/login.css 直接 @import 包内 dist/aurora/tokens.css。改配色/推导请到 luci-theme-tokens 仓库(aurora/defaults.js、aurora/spec.js),发版后在此 bump 依赖重新构建。
|
||||
|
||||
**JavaScript**: LuCI `E()` DOM API (not React/Vue). Minified but not bundled.
|
||||
**JavaScript**: LuCI `E()` DOM API (not React/Vue). Minified but not bundled. `router-aurora.js` turns view-page navigation into same-document swaps on Navigation-API browsers (MPA elsewhere) — read `.dev/docs/router.md` before touching navigation, teardown, or page-scoped patches, and verify with `bench-router.mjs`.
|
||||
|
||||
**Dark mode**: `@custom-variant dark` keyed on `[data-darkmode=true]`, switching logic in `header.ut`.
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
|
||||
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
|
||||
LUCI_DEPENDS:=+luci-base
|
||||
|
||||
PKG_VERSION:=1.2.0
|
||||
PKG_RELEASE:=66
|
||||
PKG_VERSION:=1.2.1
|
||||
PKG_RELEASE:=67
|
||||
PKG_LICENSE:=Apache-2.0
|
||||
|
||||
LUCI_MINIFY_CSS:=
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
## Features
|
||||
|
||||
- **Modern**: Modern, content-first UI design with a clean layout and elegant animations.
|
||||
- **Fast, silky navigation**: On supported browsers, switching pages only updates the content instead of reloading the whole page, so moving between pages feels silky and seamless — and loads much faster, about 67% quicker per page switch at the median in our measurements (see the [router doc](.dev/docs/router.md#why-it-pays-measured)). This no-reload page-switching took cues from [luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap).
|
||||
- **Mobile-friendly**: Optimized for mobile interactions and display, supporting both smartphones and tablets.
|
||||
- **Theme Switcher**: Built-in theme switcher with seamless switching between Auto (system), Light, and Dark modes.
|
||||
- **Command Palette (⌘K)**: Search and jump to any page from the header.
|
||||
@@ -53,6 +54,7 @@
|
||||
- **Chrome/Edge 111+** _(released March 2023)_
|
||||
- **Safari 16.4+** _(released March 2023)_
|
||||
- **Firefox 128+** _(released July 2024)_
|
||||
- _Optional enhancement, not a requirement:_ same-document navigation (no full page reload between view pages) uses the [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) where available — Chrome/Edge 105+, Safari 26.2+, Firefox 147+. Browsers without it keep classic full-page navigation automatically, with no loss of function.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -152,6 +154,7 @@ Thanks goes to these wonderful people:
|
||||
[Apache 2.0](LICENSE). Thanks to:
|
||||
|
||||
- [luci-theme-bootstrap](https://github.com/openwrt/luci/tree/master/themes/luci-theme-bootstrap)
|
||||
- [luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap) — a LuCI theme with its own client-side router. Aurora's same-document navigation borrows some of its ideas and implements them on the Navigation API instead — see the [router doc](.dev/docs/router.md)
|
||||
- [Vite](https://vitejs.dev/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Tabler Icons](https://tabler.io/icons) — the interface icon set
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
## 特性
|
||||
|
||||
- **现代化**:内容优先的现代化 UI 设计,布局整洁,动画优雅。
|
||||
- **快且丝滑的导航体验**:在支持的浏览器上,切换页面时只更新内容、不整页刷新,切换起来丝滑流畅,加载速度大幅提升——实测页面切换中位数快约 67%(详见[路由文档](.dev/docs/router.md#why-it-pays-measured))。这套无刷新切换的思路参考了 [luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap)。
|
||||
- **移动端友好**:针对移动端的交互和显示进行了优化,适配手机和平板设备。
|
||||
- **主题切换**:内置主题切换器,支持在自动(跟随系统)、浅色和深色模式之间无缝切换。
|
||||
- **命令面板(⌘K)**:在顶栏一键搜索并跳转到任意页面。
|
||||
@@ -53,6 +54,7 @@
|
||||
- **Chrome/Edge 111+** _(2023 年 3 月发布)_
|
||||
- **Safari 16.4+** _(2023 年 3 月发布)_
|
||||
- **Firefox 128+** _(2024 年 7 月发布)_
|
||||
- _可选增强,非必需:_ 页面间的同文档导航(点菜单不整页刷新)在支持的浏览器上使用 [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API)——Chrome/Edge 105+、Safari 26.2+、Firefox 147+;不支持的浏览器自动保持传统整页跳转,功能不受影响。
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -152,6 +154,7 @@ Aurora 使用 **Vite** 与现代前端工具链构建,并尝试将 AI 融入
|
||||
[Apache 2.0](LICENSE)。致谢:
|
||||
|
||||
- [luci-theme-bootstrap](https://github.com/openwrt/luci/tree/master/themes/luci-theme-bootstrap)
|
||||
- [luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap) — 一个自带客户端路由的 LuCI 主题。Aurora 的同文档导航借鉴了它的部分思路,并改用 Navigation API 实现——见[路由文档](.dev/docs/router.md)
|
||||
- [Vite](https://vitejs.dev/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Tabler Icons](https://tabler.io/icons) — 界面图标集
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-content:"";--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-border-style:solid;--tw-duration:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-border-spacing-x:0;--tw-border-spacing-y:0}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:var(--spacing);--container-xs:20rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1Line truncated
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-content:"";--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-border-style:solid;--tw-duration:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-border-spacing-x:0;--tw-border-spacing-y:0}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:var(--spacing);--container-xs:20rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1Line truncated
|
||||
@@ -1 +1 @@
|
||||
"use strict";!function(){const e=new Set(["kern","user","mail","daemon","auth","syslog","lpr","news","uucp","cron","authpriv","ftp","ntp","security","console","local0","local1","local2","local3","local4","local5","local6","local7","unknown"]),t=new Set(["emerg","alert","crit","err","warn","notice","info","debug"]),n={emerg:"crit",alert:"crit",crit:"crit",err:"err",warn:"warn",notice:"notice",info:"info",debug:"debug"};function o(e){const t=e.match(/^([A-Za-z0-9_.\/-]+(?:\[\d+\])?):\s+([\s\S]*)$/);return t?{tag:t[1],msg:t[2]}:{tag:"",msg:e}}function s(n){let s=n.match(/^\[(.+?)\]\s+(\w+)\.(\w+):\s+([\s\S]*)$/);return s&&e.has(s[2])&&t.has(s[3])?{time:`[${s[1]}]`,fac:s[2],sev:s[3],sevTok:`${s[2]}.${s[3]}:`,...o(s[4])}:(s=n.match(/^(\w{3} \w{3} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4}) (\w+)\.(\w+) ([\s\S]*)$/),s&&e.has(s[2])&&t.has(s[3])?{time:s[1],fac:s[2],sev:s[3],sevTok:`${s[2]}.${s[3]}`,...o(s[4])}:null)}function c(e){const t=e.match(/^(\[\s*\d+\.\d+\])\s?([\s\S]*)$/);return t?{time:t[1],fac:"",sev:"",sevTok:"",tag:"",msg:t[2]}:null}function l(e,t){const n=(e?e.split(/\n/):[]).map(e=>({line:e,parsed:t(e)})),o=n.reduce((e,t)=>e+(t.parsed?1:0),0);return{rows:n,ratio:n.length?o/n.length:1}}if("undefined"!=typeof module&&module.exports&&(module.exports={parseSyslogLine:s,parseDmesgLine:c,splitTag:o,evaluate:l,PARSE_GATE:.8}),"undefined"==typeof document)return;const a=document.body?.dataset?.page||"",d=/(^|-)dmesg(-|$)/.test(a)?c:s;let r=null,i=null;function u(e,t,n){const o=document.createElement("span");return o.className=e,o.textContent=t,n&&(o.title=n),o}function m(e){const t=document.createElement("div");if(!e.parsed)return t.className="log-row",t.textContent=e.line,t;const o=e.parsed;return t.className="log-row"+(o.sev?` lv-${n[o.sev]}`:""),t.appendChild(u("log-time",o.time)),o.sevTok&&(t.appendChild(document.createTextNode(" ")),t.appendChild(u("log-sev",o.sevTok))),o.tag&&(t.appendChild(document.createTextNode(" ")),t.appendChild(u("log-tag",o.tag+":"))),t.appendChild(document.createTextNode(" ")),t.appendChild(u("log-msg",o.msg)),t}function g(){const e=document.getElementById("syslog"),t=document.getElementById("content_syslog");if(!e||!t)return;const n=e.value;if(n===i)return;if(r&&function(e){const t=window.getSelection&&window.getSelection();return!(!t||t.isCollapsed)&&(e.contains(t.anchorNode)||e.contains(t.focusNode))}(r))return;i=n;const{rows:o,ratio:s}=l(n,d);if(s>=.8){const n=r&&window.innerHeight+window.scrollY>=document.documentElement.scrollHeight-40;!function(e,t,n){r||(r=document.createElement("div"),r.className="syslog-view",r.tabIndex=0,r.addEventListener("keydown",function(e){if((e.ctrlKey||e.metaKey)&&"a"===e.key.toLowerCase()){e.preventDefault();const t=window.getSelection();t.removeAllRanges();const n=document.createRange();n.selectNodeContents(r),t.addRange(n)}}),e.insertAdjacentElement("afterend",r),t.classList.add("log-enhanced"));const o=document.createDocumentFragment();for(const e of n)o.appendChild(m(e));r.replaceChildren(o)}(e,t,o),n&&window.scrollTo(0,document.documentElement.scrollHeight)}else!function(e){r&&r.remove(),r=null,e.classList.remove("log-enhanced")}(t)}if(setInterval(g,1e3),document.getElementById("syslog"))g();else{const e=new MutationObserver(function(){document.getElementById("syslog")&&(e.disconnect(),g())});e.observe(document.documentElement,{childList:!0,subtree:!0})}}();
|
||||
"use strict";!function(){const e=new Set(["kern","user","mail","daemon","auth","syslog","lpr","news","uucp","cron","authpriv","ftp","ntp","security","console","local0","local1","local2","local3","local4","local5","local6","local7","unknown"]),n=new Set(["emerg","alert","crit","err","warn","notice","info","debug"]),t={emerg:"crit",alert:"crit",crit:"crit",err:"err",warn:"warn",notice:"notice",info:"info",debug:"debug"};function o(e){const n=e.match(/^([A-Za-z0-9_.\/-]+(?:\[\d+\])?):\s+([\s\S]*)$/);return n?{tag:n[1],msg:n[2]}:{tag:"",msg:e}}function l(t){let l=t.match(/^\[(.+?)\]\s+(\w+)\.(\w+):\s+([\s\S]*)$/);return l&&e.has(l[2])&&n.has(l[3])?{time:`[${l[1]}]`,fac:l[2],sev:l[3],sevTok:`${l[2]}.${l[3]}:`,...o(l[4])}:(l=t.match(/^(\w{3} \w{3} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4}) (\w+)\.(\w+) ([\s\S]*)$/),l&&e.has(l[2])&&n.has(l[3])?{time:l[1],fac:l[2],sev:l[3],sevTok:`${l[2]}.${l[3]}`,...o(l[4])}:null)}function s(e){const n=e.match(/^(\[\s*\d+\.\d+\])\s?([\s\S]*)$/);return n?{time:n[1],fac:"",sev:"",sevTok:"",tag:"",msg:n[2]}:null}function c(e,n){const t=(e?e.split(/\n/):[]).map(e=>({line:e,parsed:n(e)})),o=t.reduce((e,n)=>e+(n.parsed?1:0),0);return{rows:t,ratio:t.length?o/t.length:1}}if("undefined"!=typeof module&&module.exports&&(module.exports={parseSyslogLine:l,parseDmesgLine:s,splitTag:o,evaluate:c,PARSE_GATE:.8}),"undefined"==typeof document)return;let a=l,d=null,r=null,i=null,u=null;function m(e,n,t){const o=document.createElement("span");return o.className=e,o.textContent=n,t&&(o.title=t),o}function g(e){const n=document.createElement("div");if(!e.parsed)return n.className="log-row",n.textContent=e.line,n;const o=e.parsed;return n.className="log-row"+(o.sev?` lv-${t[o.sev]}`:""),n.appendChild(m("log-time",o.time)),o.sevTok&&(n.appendChild(document.createTextNode(" ")),n.appendChild(m("log-sev",o.sevTok))),o.tag&&(n.appendChild(document.createTextNode(" ")),n.appendChild(m("log-tag",o.tag+":"))),n.appendChild(document.createTextNode(" ")),n.appendChild(m("log-msg",o.msg)),n}function p(e){i&&i.remove(),i=null,e.classList.remove("log-enhanced")}function f(){const e=document.getElementById("syslog"),n=document.getElementById("content_syslog");if(!e||!n)return;const t=e.value;if(t===u)return;if(i&&function(e){const n=window.getSelection&&window.getSelection();return!(!n||n.isCollapsed)&&(e.contains(n.anchorNode)||e.contains(n.focusNode))}(i))return;u=t;const{rows:o,ratio:l}=c(t,a);if(l>=.8){const t=i&&window.innerHeight+window.scrollY>=document.documentElement.scrollHeight-40;!function(e,n,t){i||(i=document.createElement("div"),i.className="syslog-view",i.tabIndex=0,i.addEventListener("keydown",function(e){if((e.ctrlKey||e.metaKey)&&"a"===e.key.toLowerCase()){e.preventDefault();const n=window.getSelection();n.removeAllRanges();const t=document.createRange();t.selectNodeContents(i),n.addRange(t)}}),e.insertAdjacentElement("afterend",i),n.classList.add("log-enhanced"));const o=document.createDocumentFragment();for(const e of t)o.appendChild(g(e));i.replaceChildren(o)}(e,n,o),t&&window.scrollTo(0,document.documentElement.scrollHeight)}else p(n)}function w(){if(d)return;const e=document.body?.dataset?.page||"";a=/(^|-)dmesg(-|$)/.test(e)?s:l,u=null,d=setInterval(f,1e3),document.getElementById("syslog")?f():(r=new MutationObserver(function(){document.getElementById("syslog")&&(r.disconnect(),r=null,f())}),r.observe(document.documentElement,{childList:!0,subtree:!0}))}((window.aurora??={}).patches??={})["admin-status-logs"]={mount:w,unmount:function(){clearInterval(d),d=null,r?.disconnect(),r=null;const e=document.getElementById("content_syslog");e&&p(e),i=null}},w()}();
|
||||
@@ -1 +1 @@
|
||||
"use strict";"require baseclass";"require ui";return baseclass.extend({__init__(){ui.menu.load().then(e=>this.render(e)),this.initNavigationControls(),this.initUciIndicator()},initUciIndicator(){const e=ui.changes?.setIndicator;e&&(ui.changes.setIndicator=function(t){e.call(this,t),document.querySelector('[data-indicator="uci-changes"]')?.setAttribute("data-count",t||0)})},initNavigationControls(){const e=document.querySelector("#mobile-menu-overlay"),t=document.querySelector("#navigation-toggle");if(!t||!e)return;const a=e.querySelector("#mobile-nav-list"),i=window.matchMedia("(min-width: 768px)"),n="aurora.sidebarCollapsed";let s;const o=()=>i.matches&&"sidebar"===document.body.dataset.navType,l=e=>{const a=o();t.classList.toggle("is-expanded",e&&!i.matches),t.setAttribute("aria-expanded",e?"true":"false"),t.setAttribute("aria-controls",a?"sidebar-panel":"mobile-menu-overlay");const n=e&&!a?_("Close"):_("Navigation");t.setAttribute("title",n),t.setAttribute("aria-label",n)},r=()=>{e.classList.remove("mobile-menu-open"),document.body.classList.remove("mobile-navigation-open"),document.body.style.overflow="",this.resetNavigationGroups(a)},c=t=>{if(o()){r();const e=!t,a=document.body;return a.classList.remove("sidebar-anim-open","sidebar-anim-close"),a.classList.add(e?"sidebar-anim-close":"sidebar-anim-open"),clearTimeout(s),s=setTimeout(()=>{a.classList.remove("sidebar-anim-open","sidebar-anim-close")},300),a.classList.toggle("sidebar-collapsed",e),localStorage.setItem(n,e),void l(t)}if(i.matches)return r(),void l(!1);e.classList.toggle("mobile-menu-open",t),document.body.classList.toggle("mobile-navigation-open",t),document.body.style.overflow=t?"hidden":"",t?this.expandActiveNavigationGroup(a):this.resetNavigationGroups(a),l(t)},d=()=>{if(r(),o()){const e="true"===localStorage.getItem(n);return document.body.classList.toggle("sidebar-collapsed",e),void l(!e)}l(!1)};t.addEventListener("click",t=>{t.stopPropagation(),c(!(o()?!document.body.classList.contains("sidebar-collapsed"):!i.matches&&e.classList.contains("mobile-menu-open")))}),e.addEventListener("click",t=>{t.target===e&&c(!1)}),document.addEventListener("keydown",t=>{"Escape"===t.key&&e.classList.contains("mobile-menu-open")&&c(!1)}),i.addEventListener("change",d),d(),document.addEventListener("click",t=>{const a=t.target?.closest?.(".navigation-direct, .navigation-sublink, .mobile-nav-logout");a&&e.contains(a)&&c(!1)})},renderMobileMenu(e){const t=document.querySelector("#mobile-nav-list"),a=document.querySelector("#mobile-nav-footer-action");t&&(t.innerHTML=""),a&&(a.innerHTML=""),t&&(e.forEach(e=>{e.isLogout?a&&a.appendChild(E("a",{class:"mobile-nav-logout",href:e.href},[e.title])):t.appendChild(this.renderNavigationItem(e,"mobile"))}),this.bindNavigationAccordion(t))},render(e){if(this.renderModeMenu(e),L.env.dispatchpath.length>=3){let t=e,a="";for(let e=0;e<3&&t;e++){const i=L.env.dispatchpath[e];t=t.children?.[i],a+=(a?"/":"")+i}t&&this.renderTabMenu(t,a)}},renderTabMenu(e,t,a=0){const i=document.querySelector("#tabmenu"),n=E("ul",{class:"tabs"}),s=ui.menu.getChildren(e);let o=null;return s.forEach(e=>{const i=L.env.dispatchpath[3+a]===e.name;n.appendChild(E("li",{class:`tabmenu-item-${e.name}${i?" active":""}`},[E("a",{href:L.url(t,e.name)},[_(e.title)])])),i&&(o=e)}),n.children.length?(i.appendChild(n),i.style.display="",o&&this.renderTabMenu(o,`${t}/${o.name}`,a+1),n):E([])},renderMainMenu(e,t,a=0,i=null){const n=a?E("ul",{class:"desktop-nav-list"}):document.querySelector("#topmenu"),s=ui.menu.getChildren(e);if(a>1)return E([]);if(0===a){const e=document.body?.dataset?.navType||"mega-menu";if("sidebar"===e)return this.renderSidebar(i||[]),n||E([]);if(!n||!s.length)return E([]);"mega-menu"===e?this.initMegaMenu(s,t,n):this.initDropdown(s,t,n)}else{if(!s.length)return E([]);s.forEach(a=>{const i=this.isActivePath(e.name,a.name),s={class:i?"is-active-page":"",href:L.url(t,a.name)};i&&(s["aria-current"]="page"),n.appendChild(E("li",{},[E("a",s,[_(a.title)])]))})}return n.style.display="",n},isActivePath:(e,t)=>L.env.dispatchpath[1]===e&&(null==t||L.env.dispatchpath[2]===t),buildNavigationModel(e,t){return e.filter(e=>e?.name).map(e=>{const a=ui.menu.getChildren(e).filter(e=>e?.name).map(a=>({name:a.name,title:_(a.title),href:L.url(t,e.name,a.name),isActivePage:this.isActivePath(e.name,a.name)})),i=a.length>0,n=this.isActivePath(e.name);return{name:e.name,title:_(e.title),href:L.url(t,e.name),hasChildren:i,isLogout:"logout"===e.name,isActiveGroup:i&&n,isActivePage:!i&&n,activePage:a.find(e=>e.isActivePage)||null,pages:a}})},navigationSubmenuId:(e,t)=>`${e}-submenu-${encodeURIComponent(String(t))}`,renderNavigationItem(e,t){const a="mobile"===t,i=a?"mobile-nav-item":"",n=a?"navigation-direct mobile-nav-link":"navigation-direct nav-link";if(!e.hasChildren){const t={class:`${n}${e.isActivePage?" is-active-page":""}`,href:e.href};return e.isActivePage&&(t["aria-current"]="page"),t["data-section"]=e.name,E("li",{class:i},[E("a",t,[E("span",{class:Line truncated
|
||||
"use strict";"require baseclass";"require ui";return baseclass.extend({__init__(){ui.menu.load().then(e=>this.render(e)),this.initNavigationControls(),this.initUciIndicator()},initUciIndicator(){const e=ui.changes?.setIndicator;e&&(ui.changes.setIndicator=function(t){e.call(this,t),document.querySelector('[data-indicator="uci-changes"]')?.setAttribute("data-count",t||0)})},initNavigationControls(){const e=document.querySelector("#mobile-menu-overlay"),t=document.querySelector("#navigation-toggle");if(!t||!e)return;const a=e.querySelector("#mobile-nav-list"),i=window.matchMedia("(min-width: 768px)"),n="aurora.sidebarCollapsed";let s;const o=()=>i.matches&&"sidebar"===document.body.dataset.navType,r=e=>{const a=o();t.classList.toggle("is-expanded",e&&!i.matches),t.setAttribute("aria-expanded",e?"true":"false"),t.setAttribute("aria-controls",a?"sidebar-panel":"mobile-menu-overlay");const n=e&&!a?_("Close"):_("Navigation");t.setAttribute("title",n),t.setAttribute("aria-label",n)},l=()=>{e.classList.contains("mobile-menu-open")&&(e.classList.remove("mobile-menu-open"),document.body.classList.remove("mobile-navigation-open"),document.body.style.overflow="",this.resetNavigationGroups(a),r(!1))};this.closeMobileNavigation=l;const c=t=>{if(o()){l();const e=!t,a=document.body;return a.classList.remove("sidebar-anim-open","sidebar-anim-close"),a.classList.add(e?"sidebar-anim-close":"sidebar-anim-open"),clearTimeout(s),s=setTimeout(()=>{a.classList.remove("sidebar-anim-open","sidebar-anim-close")},300),a.classList.toggle("sidebar-collapsed",e),localStorage.setItem(n,e),void r(t)}if(i.matches)return l(),void r(!1);e.classList.toggle("mobile-menu-open",t),document.body.classList.toggle("mobile-navigation-open",t),document.body.style.overflow=t?"hidden":"",t?this.expandActiveNavigationGroup(a):this.resetNavigationGroups(a),r(t)},d=()=>{if(l(),o()){const e="true"===localStorage.getItem(n);return document.body.classList.toggle("sidebar-collapsed",e),void r(!e)}r(!1)};t.addEventListener("click",t=>{t.stopPropagation(),c(!(o()?!document.body.classList.contains("sidebar-collapsed"):!i.matches&&e.classList.contains("mobile-menu-open")))}),e.addEventListener("click",t=>{t.target===e&&c(!1)}),document.addEventListener("keydown",t=>{"Escape"===t.key&&e.classList.contains("mobile-menu-open")&&c(!1)}),i.addEventListener("change",d),d(),document.addEventListener("click",t=>{const a=t.target?.closest?.(".navigation-direct, .navigation-sublink, .mobile-nav-logout");a&&e.contains(a)&&c(!1)})},renderMobileMenu(e){const t=document.querySelector("#mobile-nav-list"),a=document.querySelector("#mobile-nav-footer-action");t&&(t.innerHTML=""),a&&(a.innerHTML=""),t&&(e.forEach(e=>{e.isLogout?a&&a.appendChild(E("a",{class:"mobile-nav-logout",href:e.href},[e.title])):t.appendChild(this.renderNavigationItem(e,"mobile"))}),this.bindNavigationAccordion(t))},render(e){this.tree=e,this.renderModeMenu(e),this.renderTabs(e)},renderTabs(e){if(L.env.dispatchpath.length<3)return;let t=e,a="";for(let e=0;e<3&&t;e++){const i=L.env.dispatchpath[e];t=t.children?.[i],a+=(a?"/":"")+i}t&&this.renderTabMenu(t,a)},syncRoute(){if(!this.tree)return;const e=new URL(L.url(...L.env.dispatchpath.slice(0,3)),location.href).pathname;document.querySelectorAll("#topmenu a, .desktop-menu-canvas a, #sidebar-list a, #mobile-nav-list a").forEach(t=>{const a=t.pathname===e&&!t.getAttribute("href").startsWith("#");t.classList.toggle("is-active-page",a),a?t.setAttribute("aria-current","page"):t.removeAttribute("aria-current")}),document.querySelectorAll(".navigation-group").forEach(e=>{const t=!!e.querySelector("a.is-active-page"),a=e.querySelector(".navigation-group-toggle");e.classList.toggle("is-active-group",t),t?a?.setAttribute("aria-current","location"):a?.removeAttribute("aria-current")});for(const e of[document.querySelector("#sidebar-list"),document.querySelector("#mobile-nav-list")])this.resetNavigationGroups(e),this.expandActiveNavigationGroup(e);this.renderCrumb();const t=document.querySelector("#tabmenu");t&&(t.innerHTML="",t.style.display="none"),this.renderTabs(this.tree)},closeSurfaces(){this.hideDesktopNav(),this.paletteList&&this.closePalette(),this.closeMobileNavigation?.()},renderTabMenu(e,t,a=0){const i=document.querySelector("#tabmenu"),n=E("ul",{class:"tabs"}),s=ui.menu.getChildren(e);let o=null;return s.forEach(e=>{const i=L.env.dispatchpath[3+a]===e.name;n.appendChild(E("li",{class:`tabmenu-item-${e.name}${i?" active":""}`},[E("a",{href:L.url(t,e.name)},[_(e.title)])])),i&&(o=e)}),n.children.length?(i.appendChild(n),i.style.display="",o&&this.renderTabMenu(o,`${t}/${o.name}`,a+1),n):E([])},renderMainMenu(e,t,a=0,i=null){const n=a?E("ul",{class:"desktop-nav-list"}):document.querySelector("#topmenu"),s=ui.menu.getChildren(e);if(a>1)return E([]);if(0===a){const e=document.body?.dataset?.navType||"mega-menu";if("sidebar"===e)return this.renderSidebar(i||[]),n||E([]);if(!n||!s.length)return E([]);"mega-menu"===e?this.initMegaMenu(s,t,n):this.initDropdown(s,t,n)}Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
"use strict";"require baseclass";"require ui";"require poll";"require rpc";const RT=window.L,RENDER_TIMEOUT=15e3,PATCH_ATTR="data-aurora-patch",NODE_CSS_ATTR="data-aurora-node-css",SHELL_ATTR="data-aurora-shell",PROGRESS_DELAY=150,INSTANTIATE=/instantiateView\(\s*['"]([^'"]+)['"]/,supported=()=>"object"==typeof navigation&&null!==navigation&&"function"==typeof navigation.addEventListener&&"function"==typeof NavigateEvent&&"intercept"in NavigateEvent.prototype,nodeWeight=e=>Math.min(e.order??9999,9999)+(e.auth?.login?1e4:0);function firstChild(e){let t=null;for(const[s,n]of Object.entries(e.children??{}))if(n.satisfied&&n.title&&"object"==typeof n.action)if("firstchild"===n.action?.type){if(t&&nodeWeight(t.node)<=nodeWeight(n))continue;firstChild(n)&&(t={name:s,node:n})}else n.firstchild_ineligible||(!t||nodeWeight(t.node)>nodeWeight(n))&&(t={name:s,node:n});return t}function resolve(e,t){let s=e,n=[],i=[],r=0;for(let o=0;o<32;o++){for(;r<t.length;){const e=s.children?.[t[r]];if(e&&!1===e.satisfied)return null;if(!e){if(!s.wildcard)return null;i=t.slice(r),r=t.length;break}s=e,n.push(t[r++])}const o=s.action?.type;if("alias"===o)t=[...String(s.action.path??"").split("/").filter(Boolean),...i],s=e,n=[],i=[],r=0;else{if("firstchild"!==o)return{node:s,path:n,args:i,request:t};{const e=firstChild(s);if(!e)return null;s=e.node,n.push(e.name)}}}return null}function readonlyAlong(e,t){const s=[];let n=e;for(const e of t)n=n?.children?.[e],n?.depends?.acl?.length&&s.push(n);return s.length>0&&s.every(e=>!0===e.readonly)}const actionOf=(e,t)=>t.length&&"object"==typeof e?.wildcardaction?e.wildcardaction:e?.action;function viewClass(e){return"view"===e?.type&&e.path?`view.${e.path.replace(/\//g,".")}`:null}const prefixes=e=>{const t=[];let s=null;for(const n of e)t.push(s=null==s?n:`${s}-${n}`);return t},PRAGMA_HEAD=/^(?:\s*["'](?:use strict|require\s[^"']+)["'];)+/,PRAGMA=/["']require\s+([^\s"']+)/g;function pragmaDeps(e){return[...(e.slice(0,4096).match(PRAGMA_HEAD)?.[0]??"").matchAll(PRAGMA)].map(e=>e[1])}const assetUrl=e=>{const t=document.body.dataset.assetVersion;return t?`${e}?v=${t}`:e},moduleUrl=e=>`${RT.env.base_url}/${e.replace(/\./g,"/")}.js${RT.env.resource_version?`?v=${RT.env.resource_version}`:""}`;return baseclass.extend({__init__(){if(!supported()||!RT?.env?.scriptname)return;const e=this.contract();e.length?console.warn(`router-aurora: luci-base surface missing, staying MPA: ${e.join(", ")}`):(this.gen=0,this.seen=new Set,this.warmed=new Set,this.inflight=Promise.resolve(),this.intervals=new Set,this.knownSheets=new WeakSet(this.sheets().filter(e=>e.hasAttribute(SHELL_ATTR)||e.hasAttribute(PATCH_ATTR)||e.hasAttribute(NODE_CSS_ATTR))),this.hostname=document.querySelector(".brand")?.textContent?.trim()||document.title,this.hookIntervals(),this.hookListeners(),this.hookSession(),this.hookVisibility(),Promise.all([ui.menu.load(),RT.require("menu-aurora")]).then(([e,t])=>{this.tree=e,this.menu=t;const s=this.initialRoute();s&&(document.querySelector('script[type="speculationrules"]')?.remove(),this.bar=document.body.appendChild(E("div",{id:"aurora-nav-progress","aria-hidden":"true"})),this.status=document.body.appendChild(E("div",{id:"aurora-nav-status",role:"status","aria-live":"polite","aria-atomic":"true"})),navigation.addEventListener("navigate",e=>this.onNavigate(e)),document.addEventListener("pointerover",e=>this.onIntent(e)),document.addEventListener("pointerdown",e=>this.onIntent(e)),document.addEventListener("focusin",e=>this.onIntent(e)),this.watchInitialRender(s))}))},contract(){const e={L:RT,rpc:rpc,poll:poll,ui:ui,window:window};return["L.view","L.require","L.dom.content","L.env.base_url","L.env.resource","L.env.media","L.Request.addInterceptor","L.uci.load","L.uci.unload","L.uci.state","rpc.addInterceptor","poll.queue","poll.start","poll.stop","poll.active","ui.menu.load","ui.hideModal","ui.hideIndicator","window.E"].filter(t=>null==(t=>t.split(".").reduce((e,t)=>null==e?e:e[t],e))(t))},hookVisibility(){document.addEventListener("visibilitychange",()=>{document.hidden?(this.pausedByVisibility=poll.active(),this.pausedByVisibility&&poll.stop()):this.pausedByVisibility&&(this.pausedByVisibility=!1,this.expired||poll.start())})},progressStart(){this.pending=(this.pending??0)+1,clearTimeout(this.progressTimer),this.progressTimer=setTimeout(()=>this.bar.dataset.state="active",150)},progressEnd(){--this.pending>0||(clearTimeout(this.progressTimer),"active"===this.bar.dataset.state&&(this.bar.dataset.state="done",this.progressTimer=setTimeout(()=>{"done"===this.bar.dataset.state&&delete this.bar.dataset.state},300)))},sheets(){const e=document.getElementById("view");return[...document.querySelectorAll('style, link[rel~="stylesheet"]')].filter(t=>!e?.contains(t))},poisoned(){return this.sheets().some(e=>!this.knownSheets.has(e)&&!e.hasAttribute(PATCH_ATTR)&&!e.hasAttribute(NODE_CSS_ATTR))},hookIntervals(){const e=window.setInterval,t=window.clearInterval,s=this.intervals;window.setInterval=functioLine truncated
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script>L.require('menu-aurora')</script>
|
||||
<script>L.require('menu-aurora').then(() => { if (window.navigation) L.require('router-aurora'); })</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -22,19 +22,26 @@
|
||||
const page_segs = length(ctx?.request_path) ? ctx.request_path : (ctx?.path || []);
|
||||
const page = join('-', page_segs);
|
||||
// On-demand patches — see "On-Demand Third-Party Patches" in DEVELOPMENT.md.
|
||||
let patch_files = [], patch_scripts = [];
|
||||
let patch_files = [], patch_scripts = [], patch_all = [];
|
||||
if (!blank_page) {
|
||||
let seg_prefixes = {}, acc = null;
|
||||
for (let seg in page_segs)
|
||||
seg_prefixes[acc = (acc == null ? seg : `${acc}-${seg}`)] = true;
|
||||
for (let f in lsdir(`/www${media}/patches`) ?? []) {
|
||||
if (substr(f, -4) == '.css' && exists(seg_prefixes, substr(f, 0, -4)))
|
||||
push(patch_files, substr(f, 0, -4));
|
||||
else if (substr(f, -3) == '.js' && exists(seg_prefixes, substr(f, 0, -3)))
|
||||
push(patch_scripts, substr(f, 0, -3));
|
||||
if (substr(f, -4) == '.css') {
|
||||
push(patch_all, f);
|
||||
if (exists(seg_prefixes, substr(f, 0, -4)))
|
||||
push(patch_files, substr(f, 0, -4));
|
||||
}
|
||||
else if (substr(f, -3) == '.js') {
|
||||
push(patch_all, f);
|
||||
if (exists(seg_prefixes, substr(f, 0, -3)))
|
||||
push(patch_scripts, substr(f, 0, -3));
|
||||
}
|
||||
}
|
||||
sort(patch_files);
|
||||
sort(patch_scripts);
|
||||
sort(patch_all);
|
||||
}
|
||||
// Both background tokens store a url('...') wrapper; preload wants the
|
||||
// bare URL, and a data: URI (an inline LQIP-sized value) needs no preload.
|
||||
@@ -93,7 +100,7 @@
|
||||
<html lang="{{ dispatcher.lang }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ striptags(dispatched?.title ? `${_(dispatched.title)} - ` : '') }}{{ hostname }}</title>
|
||||
<title>{{ dispatched?.title ? `${striptags(_(dispatched.title))} - ` : '' }}{{ hostname }}</title>
|
||||
{% if (blank_page): %}
|
||||
<script>
|
||||
(function () {
|
||||
@@ -173,19 +180,19 @@
|
||||
{% if (_mainBgUrl): %}
|
||||
<link rel="preload" as="image" href="{{ entityencode(_mainBgUrl, true) }}">
|
||||
{% endif %}
|
||||
<link rel="stylesheet" href="{{ media }}/main.css">
|
||||
<link rel="stylesheet" href="{{ media }}/main.css" data-aurora-shell>
|
||||
{% for (let patch in patch_files): %}
|
||||
<link rel="stylesheet" href="{{ media }}/patches/{{ patch }}.css">
|
||||
<link rel="stylesheet" href="{{ media }}/patches/{{ patch }}.css" data-aurora-patch="{{ patch }}">
|
||||
{% endfor %}
|
||||
{% for (let patch in patch_scripts): %}
|
||||
<script defer src="{{ media }}/patches/{{ patch }}.js"></script>
|
||||
<script defer src="{{ media }}/patches/{{ patch }}.js" data-aurora-patch="{{ patch }}"></script>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if (font_preload): %}
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="{{ font_preload }}">
|
||||
{% endif %}
|
||||
{% if (font_css): %}
|
||||
<style>{{ font_css }}</style>
|
||||
<style data-aurora-shell>{{ font_css }}</style>
|
||||
{% endif %}
|
||||
<link rel="icon" href="{{ media }}/images/{{ logo_svg }}?v={{ icon_cache_version }}" sizes="any" type="image/svg+xml">
|
||||
{% if (favicon_png): %}
|
||||
@@ -200,13 +207,13 @@
|
||||
<meta name="apple-mobile-web-app-title" content="{{ striptags(boardinfo.hostname ?? 'Aurora') }}">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
{% if (dispatched?.css): %}
|
||||
<link rel="stylesheet" href="{{ resource }}/{{ dispatched.css }}">
|
||||
<link rel="stylesheet" href="{{ resource }}/{{ dispatched.css }}" data-aurora-node-css="{{ entityencode(dispatched.css, true) }}">
|
||||
{% endif %}
|
||||
{% if (css): %}
|
||||
<style title="text/css">{{ css }}</style>
|
||||
<style title="text/css" data-aurora-shell>{{ css }}</style>
|
||||
{% endif %}
|
||||
{% if (token_css_light || token_css_dark): %}
|
||||
<style>
|
||||
<style data-aurora-shell>
|
||||
:root { {{ token_css_light }} }
|
||||
[data-darkmode="true"] { {{ token_css_dark }} }
|
||||
</style>
|
||||
@@ -217,7 +224,7 @@
|
||||
{% endif %}
|
||||
</head>
|
||||
|
||||
<body class="lang_{{ dispatcher.lang }}" data-page="{{ entityencode(page, true) }}" data-nav-type="{{ nav_type }}"{% if (blank_page ? tokens.struct_login_bg : tokens.struct_main_bg): %} data-bg{% endif %}>
|
||||
<body class="lang_{{ dispatcher.lang }}" data-page="{{ entityencode(page, true) }}" data-nav-type="{{ nav_type }}" data-asset-version="{# PKG_VERSION #}"{% if (length(patch_all)): %} data-patches="{{ entityencode(join(' ', patch_all), true) }}"{% endif %}{% if (blank_page ? tokens.struct_login_bg : tokens.struct_main_bg): %} data-bg{% endif %}>
|
||||
{% if (!blank_page): %}
|
||||
{% if (tokens.struct_main_bg): %}
|
||||
<div class="page-bg" aria-hidden="true"></div>
|
||||
@@ -384,7 +391,7 @@
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<div id="maincontent">
|
||||
<div id="maincontent" tabindex="-1">
|
||||
{% if (getuid() == 0 && getspnam('root')?.pwdp === ''): %}
|
||||
<div class="alert-message warning">
|
||||
<h4>{{ _('No password set!') }}</h4>
|
||||
|
||||
Reference in new issue
Block a user