🍕 Sync 2026-08-16 23:43:42

This commit is contained in:
github-actions[bot] committed 2026-08-16 23:43:42 +08:00
1 parent cea9097c34
commit 4bf1fb0640
40 files changed
+2889 -144

No files matched your search

+1 -1
View File
@@ -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)
+60 -29
View File
@@ -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>
+9
View File
@@ -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
}
+1 -1
View File
@@ -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:**
+522
View File
@@ -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).
![Full reload vs the same-document router: timings above, the router's five stages below](https://raw.githubusercontent.com/eamonxg/assets/master/shared/architecture/same-document-router-architecture.png)
## 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) =>
+381
View File
@@ -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"));
});
+1
View File
@@ -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
+1 -1
View File
@@ -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`.
+2 -2
View File
@@ -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:=
+3
View File
@@ -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
+3
View File
@@ -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>