💋 Sync 2026-10-21 01:19:43

This commit is contained in:
github-actions[bot] committed 2026-10-21 01:19:43 +08:00
1 parent 0d656ea1c8
commit 2141016869
34 files changed
+2169 -817

No files matched your search

@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=gately
PKG_VERSION:=0.3.1
PKG_RELEASE:=2
PKG_VERSION:=0.5.2
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)?
File renamed without changes.
File renamed without changes.
+7 -7
View File
@@ -3,17 +3,17 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=honk
PKG_VERSION:=2026.10.15_pre1
PKG_RELEASE:=19
PKG_VERSION:=2026.10.21.1_pre1
PKG_RELEASE:=20
# Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49)
# because that is what the GitHub release asset name uses.
HONK_RELEASE_TAG:=debug.2026.10.15.native-api.1
HONK_RELEASE_TAG:=debug.2026.10.21.1
HONK_ASSET_TAG:=debug
# SHA256 of each release tarball, kept in sync by scripts/update_honk_version.sh.
HONK_HASH_X86_64:=b492c22fbf9347b741fe8904fbe1c6fb128e1f7b391b60f3925cec4cf14b5085
HONK_HASH_AARCH64:=ce25b0df9bfeb371a5340392df91606a8da6d209f1779d59dd1bd5abafe2c8a4
HONK_HASH_X86_64:=0714b035fb39efcdd9ab5f8b759ccd647847b3fe293ed51b4fafe11603834e3d
HONK_HASH_AARCH64:=d63a51bf922899ababf5bbddc9913a4a7a6df17fbbeb021a127b85bc740e0568
PKG_LICENSE:=AGPL-3.0-only
PKG_LICENSE_FILE:=LICENSE
@@ -33,7 +33,7 @@ HONK_SUFFIX:=-stock
HONK_ASSET:=honk-core-$(HONK_ASSET_TAG)-$(HONK_TARGET)$(HONK_SUFFIX).tar.gz
PKG_SOURCE:=$(HONK_ASSET)
PKG_SOURCE_URL:=https://github.com/Glassyiris/honk/releases/download/$(HONK_RELEASE_TAG)/
PKG_SOURCE_URL:=https://github.com/daeuniverse/honk/releases/download/$(HONK_RELEASE_TAG)/
PKG_HASH:=skip
include $(INCLUDE_DIR)/package.mk
@@ -43,7 +43,7 @@ define Package/honk
CATEGORY:=Network
SUBMENU:=Web Servers/Proxies
TITLE:=honk - eBPF transparent proxy engine (dae-compatible)
URL:=https://github.com/Glassyiris/honk
URL:=https://github.com/daeuniverse/honk
DEPENDS:=@(x86_64||aarch64) \
+ca-bundle +ip-full \
+kmod-sched-core +kmod-sched-bpf +kmod-veth +kmod-nft-queue \
File renamed without changes.
+1
View File
@@ -0,0 +1 @@
zh_Hans
+7 -2
View File
@@ -8,13 +8,14 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.10.16
PKG_RELEASE:=341
PKG_RELEASE:=342
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \
CONFIG_PACKAGE_$(PKG_NAME)_Nftables_Transparent_Proxy \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Geoview \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_SmartDNS \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Haproxy \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Hysteria \
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_NaiveProxy \
@@ -38,6 +39,7 @@ LUCI_DEPENDS:=+coreutils +coreutils-base64 +coreutils-nohup +coreutils-timeout +
+microsocks +resolveip +tcping +lyaml \
+ipt2socks \
+PACKAGE_$(PKG_NAME)_INCLUDE_Geoview:geoview \
+PACKAGE_$(PKG_NAME)_INCLUDE_SmartDNS:smartdns \
+PACKAGE_$(PKG_NAME)_INCLUDE_Haproxy:haproxy \
+PACKAGE_$(PKG_NAME)_INCLUDE_Hysteria:hysteria \
+PACKAGE_$(PKG_NAME)_INCLUDE_NaiveProxy:naiveproxy \
@@ -86,7 +88,10 @@ config PACKAGE_$(PKG_NAME)_Nftables_Transparent_Proxy
config PACKAGE_$(PKG_NAME)_INCLUDE_Geoview
bool "Include Geoview"
select PACKAGE_geoview
default y if aarch64||i386||x86_64
config PACKAGE_$(PKG_NAME)_INCLUDE_SmartDNS
bool "Include SmartDNS"
default y if aarch64||i386||x86_64
config PACKAGE_$(PKG_NAME)_INCLUDE_Haproxy
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.10.20
PKG_RELEASE:=147
PKG_RELEASE:=148
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -288,6 +288,25 @@ function UrlDecode(szText)
end) or nil
end
-- Calculate the MD5 hash of a file.
function md5_file(path)
if type(path) ~= "string" then return "" end
local quoted = "'" .. path:gsub("'", "'\\''") .. "'"
local out = sys.exec("md5sum " .. quoted)
if not out then return "" end
return out:sub(1, 32)
end
-- Calculate the MD5 hash of a string.
function md5_string(str)
if type(str) ~= "string" then return "" end
local path = trim(sys.exec("mktemp /tmp/md5.XXXXXX") or "")
if path == "" then return "" end
local md5 = fs.writefile(path, str) == #str and md5_file(path) or ""
fs.unlink(path)
return md5
end
-- Extract the domain name and port from the URL (no IP address).
function get_domain_port_from_url(url)
local scheme, domain, port = string.match(url, "^(https?)://([%w%.%-]+):?(%d*)")
+3
View File
@@ -2450,6 +2450,9 @@ msgstr "شروع اشتراک‌گیری: %s"
msgid "Subscription: [%s] No changes, no update required."
msgstr "اشتراک: [%s] بدون تغییر، نیازی به به‌روزرسانی نیست."
msgid "Refresh subscription [%s] account usage and expiration information."
msgstr "به‌روزرسانی اطلاعات میزان مصرف حساب و زمان انقضای اشتراک [%s]."
msgid "[%s] Subscription failed. This could be due to an invalid subscription address or a network issue. Please diagnose the problem! [%s]"
msgstr "[%s] اشتراک ناموفق بود. این می‌تواند به دلیل آدرس اشتراک نامعتبر یا مشکل شبکه باشد. لطفاً مشکل را بررسی کنید! [%s]"
+3
View File
@@ -2451,6 +2451,9 @@ msgstr "Начало подписки: %s"
msgid "Subscription: [%s] No changes, no update required."
msgstr "Подписка: [%s] изменений нет, обновление не требуется."
msgid "Refresh subscription [%s] account usage and expiration information."
msgstr "Обновление информации об использовании трафика и сроке действия учётной записи подписки [%s]."
msgid "[%s] Subscription failed. This could be due to an invalid subscription address or a network issue. Please diagnose the problem! [%s]"
msgstr "[%s] Ошибка подписки. Возможно, адрес подписки неверен или есть проблема с сетью. Пожалуйста, проведите диагностику! [%s]"
+3
View File
@@ -2436,6 +2436,9 @@ msgstr "开始订阅:%s"
msgid "Subscription: [%s] No changes, no update required."
msgstr "订阅:【%s】没有变化,无需更新。"
msgid "Refresh subscription [%s] account usage and expiration information."
msgstr "刷新订阅【%s】账户用量与到期时间信息。"
msgid "[%s] Subscription failed. This could be due to an invalid subscription address or a network issue. Please diagnose the problem! [%s]"
msgstr "【%s】订阅失败,可能是订阅地址无效,或是网络问题,请诊断![%s]"
+3
View File
@@ -2442,6 +2442,9 @@ msgstr "開始訂閱:%s"
msgid "Subscription: [%s] No changes, no update required."
msgstr "訂閱:【%s】沒有變化,無需更新。"
msgid "Refresh subscription [%s] account usage and expiration information."
msgstr "重新整理訂閱【%s】帳戶用量與到期時間資訊。"
msgid "[%s] Subscription failed. This could be due to an invalid subscription address or a network issue. Please diagnose the problem! [%s]"
msgstr "【%s】訂閱失敗,可能是訂閱地址無效,或是網絡問題,請診斷![%s]"
@@ -15,7 +15,22 @@ local base64Decode = api.base64Decode
local jsonParse, jsonStringify = api.jsonc.parse, api.jsonc.stringify
local UrlEncode, UrlDecode = api.UrlEncode, api.UrlDecode
local fs = api.fs
local log = api.log
local log_buffer
local function log(...)
if log_buffer then
table.insert(log_buffer, {...})
else
api.log(...)
end
end
local function flush_log_buffer()
local buffered = log_buffer
log_buffer = nil
for _, args in ipairs(buffered or {}) do
log(unpack(args))
end
end
local i18n = api.i18n
local uci, uci_get, uci_set, uci_del, uci_foreach, uci_save = api.uci, api.uci_get_c, api.uci_set_c, api.uci_del_c, api.uci_foreach_c, api.uci_save_c
@@ -2389,10 +2404,29 @@ local function select_node(nodes, config, parentConfig)
end
end
-- Write account information only when it changes; commit together with node updates.
local function update_subscribe_info()
local changed = {}
for cfgid, info in pairs(subscribe_info) do
for key, value in pairs(info) do
value = value and value ~= "" and value or nil
if uci_get(cfgid, key) ~= value then
if value then
uci_set(cfgid, key, value)
else
uci_del(cfgid, key)
end
changed[cfgid] = true
end
end
end
return changed
end
local function update_node(manual)
if next(nodeResult) == nil then
log(1, i18n.translatef("No node information updates are available."))
return
return false
end
local group = {}
@@ -2479,17 +2513,6 @@ local function update_node(manual)
end
end
end
-- Update subscription information
for cfgid, info in pairs(subscribe_info) do
for key, value in pairs(info) do
if value ~= "" then
uci_set(cfgid, key, value)
else
uci_del(cfgid, key)
end
end
end
if next(CONFIG) then
local nodes = {}
uci_foreach("nodes", function(node)
@@ -2514,9 +2537,7 @@ local function update_node(manual)
end
end
uci_save(true)
if manual ~= 1 then restart_required = true end
return true
end
local function parse_link(raw, add_mode, group, sub_cfg)
@@ -2619,7 +2640,7 @@ local function parse_link(raw, add_mode, group, sub_cfg)
)
end
end
if #node_list > 0 then
if add_mode ~= "2" and #node_list > 0 then
nodeResult[#nodeResult + 1] = {
remark = group,
list = node_list,
@@ -2627,10 +2648,12 @@ local function parse_link(raw, add_mode, group, sub_cfg)
}
end
log(2, i18n.translatef("Successfully resolved the [%s] node, number: %s", group, #node_list))
return node_list
else
if add_mode == "2" then
log(2, i18n.translatef("Get subscription content for [%s] is empty. This may be due to an invalid subscription address or a network problem. Please diagnose the issue!", group))
end
return {}
end
end
@@ -2680,13 +2703,24 @@ local execute = function()
local stdout = f:read("*all")
f:close()
local raw_data = api.trim(stdout)
-- Buffer parsing details until the parsed nodes are known to have changed.
log_buffer = {}
local parsed_nodes = parse_link(raw_data, "2", remark, value)
local old_md5 = value.md5 or ""
local new_md5 = luci.sys.exec("md5sum " .. tmp_file .. " 2>/dev/null | awk '{printf \"%s\", $1}'")
if not manual_sub and old_md5 == new_md5 then
local new_md5 = #parsed_nodes > 0 and api.md5_string(jsonStringify(parsed_nodes)) or ""
if not manual_sub and new_md5 ~= "" and old_md5 == new_md5 then
log_buffer = nil
log(1, i18n.translatef("Subscription: [%s] No changes, no update required.", remark))
else
parse_link(raw_data, "2", remark, value)
uci_set(cfgid, "md5", new_md5)
flush_log_buffer()
if #parsed_nodes > 0 then
nodeResult[#nodeResult + 1] = {
remark = remark,
list = parsed_nodes,
sub_cfg = value,
md5 = new_md5
}
end
end
else
fail_list[#fail_list + 1] = value
@@ -2704,13 +2738,28 @@ local execute = function()
log(1, i18n.translatef("[%s] Subscription failed. This could be due to an invalid subscription address or a network issue. Please diagnose the problem! [%s]", value.remark, tostring(value.http_code)))
end
end
update_node(0)
local nodes_changed = update_node(0)
local info_changed = update_subscribe_info()
-- Save nodes, references and their MD5 in the same commit.
for _, result in ipairs(nodeResult) do
uci_set(result.sub_cfg[".name"], "md5", result.md5)
end
if nodes_changed or next(info_changed) then
uci_save(true)
restart_required = nodes_changed
for _, value in ipairs(subscribe_list) do
if info_changed[value[".name"]] then
log(1, i18n.translatef("Refresh subscription [%s] account usage and expiration information.", value.remark or ""))
end
end
end
end
end
if arg[1] == "start" then
log(0, i18n.translate("Start subscribing..."))
xpcall(execute, function(e)
flush_log_buffer()
log(1, e)
if type(debug) == "table" and type(debug.traceback) == "function" then
log(1, debug.traceback())
@@ -2723,7 +2772,7 @@ elseif arg[1] == "add" then
local raw = f:read('*all')
f:close()
parse_link(raw, "1", arg[2])
update_node(1)
if update_node(1) then uci_save(true) end
luci.sys.call("rm -f /tmp/links.conf")
elseif arg[1] == "truncate" then
truncate_nodes(arg[2])
+1 -1
View File
@@ -11,7 +11,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-vantage
PKG_VERSION:=1.0.8
PKG_RELEASE:=14
PKG_RELEASE:=15
PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage dashboard
@@ -50,6 +50,7 @@
--vt-ctl: var(--v-ctl-h, 36px); /* every control: buttons, fields, chips */
--vt-r-ctl: var(--v-r, 6px); /* their corners, as the theme's controls */
--vt-shadow: var(--v-shadow, 0 1px 2px rgba(16, 20, 24, .05), 0 1px 1px rgba(16, 20, 24, .03));
--vt-shadow-pop: var(--v-shadow-pop, 0 12px 32px rgba(16, 20, 24, .16), 0 2px 6px rgba(16, 20, 24, .08));
/* derived: readable accent for text/strokes on surfaces (a lime or
vermilion accent is a fill colour, not a text colour) */
@@ -114,6 +115,7 @@ html[data-darkmode="true"] .vt-app {
--vt-info: var(--v-info, #6aa8ff);
--vt-info-soft: var(--v-info-soft, rgba(106, 168, 255, .14));
--vt-shadow: var(--v-shadow, 0 1px 0 rgba(255, 255, 255, .03) inset, 0 1px 2px rgba(0, 0, 0, .3));
--vt-shadow-pop: var(--v-shadow-pop, 0 16px 40px rgba(0, 0, 0, .55));
--vt-accent-text: color-mix(in oklab, var(--vt-accent) 72%, var(--vt-text));
--vt-accent-line: var(--vt-accent);
--vt-chart-1: var(--v-chart-1, #c8f031);
@@ -354,6 +356,28 @@ html[data-darkmode="true"] .vt-app {
background-position: calc(100% - 15px) 50%, calc(100% - 10px) 50%; background-size: 5px 5px, 5px 5px;
}
.vt-app .vt-select:hover { border-color: var(--vt-text-3); }
/* the open list: browsers that can style it (customizable select) draw
it like the theme's dropdowns; others keep their own */
@supports (appearance: base-select) {
.vt-app .vt-select, .vt-app .vt-select::picker(select) { appearance: base-select; }
/* sized by the chosen label here, not the longest: keep room for
the longest ("Mirrored, scale per direction") so the bar holds still */
.vt-app .vt-select { display: inline-flex; align-items: center; min-width: 16em; }
.vt-app .vt-select::picker-icon { display: none; }
.vt-app .vt-select::picker(select) {
margin: 4px 0; padding: 4px;
border: 1px solid var(--vt-line-2); border-radius: var(--vt-r-ctl);
background: var(--vt-surface); color: var(--vt-text); box-shadow: var(--vt-shadow-pop);
}
.vt-app .vt-select option {
min-height: 32px; padding: 0 10px; gap: 8px;
border-radius: var(--vt-r-sm); font-size: var(--vt-fs-base); cursor: pointer;
}
/* the option under the pointer or keyboard, as the theme's dropdowns mark it */
.vt-app .vt-select option:is(:hover, :focus, :focus-visible) { background: var(--vt-surface-3); box-shadow: inset 2px 0 0 var(--vt-accent-text); outline: none; }
.vt-app .vt-select option:checked { font-weight: 600; }
.vt-app .vt-select option::checkmark { color: var(--vt-accent-text); }
}
@container vt (max-width: 699px) {
.vt-app .vt-editbar { padding: 8px 8px 8px 12px; gap: 8px; }
.vt-app .vt-editbar-text { flex-basis: 0; }
@@ -1010,7 +1034,11 @@ h2, h3{
/* ---------------------------------------------------- 10. talkers, SSIDs */
.vt-app .vt-talkers { display: flex; flex-direction: column; gap: 2px; }
.vt-app .vt-talkers { display: flex; flex-direction: column; gap: 2px; position: relative; }
/* the rows a short list leaves free keep their room (one line per name,
so every row is as tall); the empty note sits over them */
.vt-app .vt-talk-slot { visibility: hidden; cursor: default; }
.vt-app .vt-talkers > .vt-empty-inline { position: absolute; top: 0; left: 0; right: 0; margin: 0; }
.vt-app .vt-talk-row, .vt-app .vt-ssid, .vt-app .vt-dlist-row {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 8px;
@@ -1021,7 +1049,7 @@ h2, h3{
.vt-app .vt-talk-rank { width: 14px; font-size: var(--vt-fs-sm); color: var(--vt-text-3); font-weight: 650; text-align: center; flex: none; }
.vt-app .vt-talk-main { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.vt-app .vt-talk-top { display: flex; justify-content: space-between; gap: 10px; }
.vt-app .vt-talk-name { font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.vt-app .vt-talk-name { font-weight: 600; line-height: 1.3; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vt-app .vt-talk-share { font-size: var(--vt-fs-sm); font-weight: 650; color: var(--vt-text-2); white-space: nowrap; }
.vt-app .vt-talk-sub b { color: var(--vt-text); font-weight: 650; }
.vt-app .vt-talk-bar { height: 5px; border-radius: 3px; background: var(--vt-surface-3); overflow: hidden; }
@@ -22,6 +22,7 @@ var POLL_S = 5;
var WINDOW_MS = 5 * 60 * 1000; /* throughput history shown */
var HIST_MAX = 90;
var GONE_KEEP_MS = 5 * 60 * 1000; /* disconnected stations stay listed */
var TALKERS = 3; /* top talkers shown; the tile keeps room for all */
var STORE_KEY = 'vantage.hist.v1';
var SVGNS = 'http://www.w3.org/2000/svg';
var HOSTAPD_OBJ = /^[A-Za-z0-9_.-]{1,64}$/;
@@ -651,7 +652,7 @@ return view.extend({
st.health = insight.health(m, { cpu: st.cpu, uplinkRate: st.uplinkRate });
st.summary = insight.summary(st.health);
st.radioInsights = insight.radioInsights(m.radios, st.radioRates);
st.talkers = insight.topTalkers(m.clients, st.staRates, 5);
st.talkers = insight.topTalkers(m.clients, st.staRates, TALKERS);
this.m = m;
this.lastRaw = raw;
this.resolveMore(m);
@@ -742,7 +743,7 @@ return view.extend({
this.markWired(m, null);
this.stickyGen(m);
m.clients.forEach(function(c) { c.exp = insight.experience(c); c.firstSeen = st.presence && st.presence.firstSeen[c.mac]; c.isNew = !!st.isNew[c.mac]; });
st.talkers = insight.topTalkers(m.clients, st.staRates, 5);
st.talkers = insight.topTalkers(m.clients, st.staRates, TALKERS);
st.health = insight.health(m, { cpu: st.cpu, uplinkRate: st.uplinkRate });
st.summary = insight.summary(st.health);
this.m = m;
@@ -2334,14 +2335,26 @@ return view.extend({
]);
},
/* always TALKERS rows tall: unused rows stay as hidden slots, so the
tile keeps its height as talkers come and go */
paintTalkers: function() {
var self = this, m = this.m, t = this.st.talkers || [];
var byMac = {}, slots = [];
m.clients.forEach(function(c) { byMac[c.mac] = c; });
for (var k = t.length; k < TALKERS; k++)
slots.push(E('div', { 'class': 'vt-talk-row vt-talk-slot', 'aria-hidden': 'true' }, [
E('span', { 'class': 'vt-talk-rank vt-tn' }, [ ' ' ]),
avatar({}, true),
E('span', { 'class': 'vt-talk-main' }, [
E('span', { 'class': 'vt-talk-top' }, [ E('span', { 'class': 'vt-talk-name' }, [ ' ' ]) ]),
E('span', { 'class': 'vt-talk-bar' }),
E('span', { 'class': 'vt-talk-sub vt-tn' }, [ E('b', {}, [ ' ' ]), E('span', { 'class': 'vt-talk-share' }, [ ' ' ]) ])
])
]));
if (!t.length) {
dom.content(this.el.talkers, [ E('p', { 'class': 'vt-empty-inline' }, [ m.clients.length ? _('Nobody is moving data right now.') : _('No clients connected.') ]) ]);
dom.content(this.el.talkers, [ E('p', { 'class': 'vt-empty-inline' }, [ m.clients.length ? _('Nobody is moving data right now.') : _('No clients connected.') ]) ].concat(slots));
return;
}
var byMac = {};
m.clients.forEach(function(c) { byMac[c.mac] = c; });
dom.content(this.el.talkers, t.map(function(x, i) {
var c = byMac[x.mac] || { mac: x.mac, name: x.name, icon: 'device' };
var fill = E('span', { 'class': 'vt-talk-fill' });
@@ -2356,7 +2369,7 @@ return view.extend({
E('b', {}, [ fmt.bits(x.total) ]), E('span', { 'class': 'vt-talk-share' }, [ fmt.pct(x.share * 100) ]), '↓ ' + fmt.bitsShort(x.down) + ' ↑ ' + fmt.bitsShort(x.up) ])
])
]);
}));
}).concat(slots));
},
/* ------------------------------------------------------------ SSIDs */
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -11,7 +11,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-theme-vantage
PKG_VERSION:=1.0.8
PKG_RELEASE:=19
PKG_RELEASE:=20
PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage theme
@@ -588,6 +588,14 @@ function setupCharts() {
if (/^admin-status-channel_analysis/.test(page) && window.MutationObserver) setupChannelAnalysis();
}
/* Status -> Firewall: the ruleset as a flow chart (vantage-theme.nftflow,
loaded on that page only) */
function setupNftFlow() {
const view = document.getElementById('view');
if (!view || !window.MutationObserver || document.body.getAttribute('data-page') !== 'admin-status-nftables') return;
L.require('vantage-theme.nftflow').then((m) => m.attach(view)).catch((e) => { if (window.console) console.warn('vantage: firewall flow', e); });
}
return baseclass.extend({
__init__() {
this.root = document.documentElement;
@@ -605,6 +613,7 @@ return baseclass.extend({
this.setupTooltips();
setupStockEnhancers();
setupCharts();
setupNftFlow();
this.setupKeys();
this.loadHost();
@@ -0,0 +1,480 @@
'use strict';
'require baseclass';
'require fs';
'require vantage-theme.nftmodel as nftmodel';
/*
* Vantage theme - Status -> Firewall as a flow chart.
*
* LuCI's view lists every chain as a table of rules. On top of it this
* draws (a Flow / Rules table switch picks one):
* - one chart of the way a packet goes: in through prerouting, then
* either input, this device and output, or forward, and out through
* postrouting; each stage names its chains and says whether it drops
* unmatched packets; ingress, bridge and egress hooks sit where a packet
* meets them; a second row for frames bridged between ports;
* - every base chain as a list of steps: the rule's name (fw4's comment),
* its conditions and what it does; a jump opens the chain it jumps to
* in place, so a packet's whole way reads top to bottom.
* The ruleset comes from the command LuCI's page runs (same ACL). The
* conditions are LuCI's own rendering of each rule, copied as text and a
* few formatting tags; LuCI read the ruleset separately, so a chain whose
* rows don't match its rules (a reload in between) shows no conditions.
* Rules as in menu-vantage.js: E() with array content only, LuCI's nodes
* stay where they are (hidden by a class in Flow mode).
* ------------------------------------------------------------------------ */
const MODE_KEY = 'vantage.nft.mode';
const MAX_DEPTH = 8;
const MAX_STEPS = 200; /* rules drawn per chain before "Show all" */
const CTX = 'Status -> Firewall flow chart';
function storageGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function storageSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function reducedMotion() { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); }
function hookName(h) {
switch (h) {
case 'prerouting': return _('Prerouting', CTX);
case 'input': return _('Input', CTX);
case 'forward': return _('Forward', CTX);
case 'output': return _('Output', CTX);
case 'postrouting': return _('Postrouting', CTX);
case 'ingress': return _('Ingress', CTX);
case 'egress': return _('Egress', CTX);
default: return h;
}
}
function stageName(s) {
switch (s.hook) {
case 'bridgeIn': case 'bridgeOut': return _('Bridge', CTX);
case 'bridged': return _('Bridged between ports', CTX);
default: return hookName(s.hook);
}
}
function familyName(f) {
return f === 'ip' ? 'IPv4' : f === 'ip6' ? 'IPv6' : f === 'inet' ? 'IPv4/IPv6' : f;
}
/* LuCI's table headings, so they read as in the Rules table */
function tableTitle(t) {
switch (t.family) {
case 'ip': return _('IPv4 traffic table "%h"').format(t.name);
case 'ip6': return _('IPv6 traffic table "%h"').format(t.name);
case 'inet': return _('IPv4/IPv6 traffic table "%h"').format(t.name);
case 'arp': return _('ARP traffic table "%h"').format(t.name);
case 'bridge': return _('Bridge traffic table "%h"').format(t.name);
case 'netdev': return _('Network device table "%h"').format(t.name);
default: return _('"%h" table "%h"', 'Yet unknown nftables table family ("family" table "name")').format(t.family, t.name);
}
}
/* LuCI's rows per chain key, in the ruleset's order: tables, chains and
rules are rendered in JSON order, one row per rule. A chain's rows are
used only when they agree with its rules: same count, same comments,
same jump targets (LuCI read the ruleset on its own; a reload between
the two reads must not pair a rule with another rule's conditions). */
function luciRows(root, m) {
const out = Object.create(null);
const tables = root.querySelectorAll(':scope > .nft-table');
m.tables.forEach((t, i) => {
const box = tables[i];
if (!box) return;
const cards = box.querySelectorAll('.nft-chain');
t.chains.forEach((k, j) => {
const c = m.chains[k], card = cards[j], h = card && card.querySelector(':scope > h4');
if (!h || h.id !== t.name + '.' + c.name) return;
const rows = Array.prototype.filter.call(card.querySelectorAll('.nft-rules .tr'), (tr) => !tr.classList.contains('table-titles'));
if (rows.length === c.rules.length && c.rules.every((r, n) => agrees(r, rows[n])))
out[k] = rows;
});
});
return out;
}
function agrees(r, tr) {
const tip = tr.querySelector('.nft-comment .cbi-tooltip');
if (!!tip !== !!r.comment || (tip && tip.textContent.indexOf(r.comment) < 0)) return false;
const v = r.verdict, want = v.target ? [ v.target ] : (v.map || []).filter((e) => e.target).map((e) => e.target);
const have = Array.prototype.map.call(tr.querySelectorAll('a[href^="#"]'), (a) => a.getAttribute('href').replace(/^#[^.]*\./, ''));
return want.every((t) => have.indexOf(t) >= 0);
}
/* a copy of LuCI's rendering as text and its formatting tags only: no
attribute but class (LuCI's tooltips take data-tooltip as markup, so it
stays behind), nothing that loads or runs */
function copyOf(node) {
if (node.nodeType === 3) return document.createTextNode(node.data);
if (node.nodeType !== 1) return null;
const kids = [];
node.childNodes.forEach((n) => { const c = copyOf(n); if (c) kids.push(c); });
const cls = node.getAttribute('class') || '';
switch (node.tagName) {
case 'VAR': return E('var', { 'class': cls }, kids);
case 'STRONG': return E('strong', { 'class': cls }, kids);
case 'EM': return E('em', { 'class': cls }, kids);
case 'B': return E('b', { 'class': cls }, kids);
case 'I': return E('i', { 'class': cls }, kids);
case 'SMALL': return E('small', { 'class': cls }, kids);
case 'DIV': return E('div', { 'class': cls }, kids);
case 'BR': return E('br', { 'class': cls }, []);
default: return E('span', { 'class': cls }, kids);
}
}
/* a rule's conditions as LuCI shows them, without its comment and counter;
vmap: the verdict chips say it all where LuCI can only show a placeholder
(LuCI 24.10: "{ vmap }", its fallback for an expression it can't read) */
function conditions(tr, vmap) {
const td = tr && tr.firstElementChild;
if (!td) return null;
const keep = Array.prototype.filter.call(td.childNodes, (n) => !(n.nodeType === 1 && n.matches('.nft-comment, .nft-counter'))
&& !(vmap && n.nodeType === 1 && n.matches('.ifacebadge[data-tooltip]') && /^\{\s*vmap\s*\}$/.test(n.textContent.trim())));
const c = E('div', { 'class': 'v-nft-cond' }, keep.map(copyOf).filter(Boolean));
return c.textContent.trim() ? c : null;
}
/* the actions LuCI shows besides the deciding one (log, a helper, a mark);
for NAT the deciding one too: it holds the address and port */
function sideActions(tr, parts, nat) {
const td = tr && tr.children[1];
if (!td || td.children.length !== parts.acts) return null;
const keep = Array.prototype.filter.call(td.children, (n, i) => nat || i !== parts.last).map(copyOf).filter(Boolean);
return keep.length ? E('div', { 'class': 'v-nft-cond v-nft-also' }, keep) : null;
}
function chip(kind, label, title) {
const el = E('span', { 'class': 'v-nft-verdict', 'data-kind': kind }, [ label ]);
if (title) el.setAttribute('title', title);
return el;
}
function verdictLabel(v) {
switch (v.kind) {
case 'accept': return _('Accept', CTX);
case 'drop': return _('Drop', CTX);
case 'reject': return _('Reject', CTX);
case 'queue': return _('Queue to a program', CTX);
case 'nat':
return v.nat === 'masquerade' ? _('Masquerade', CTX) : v.nat === 'snat' ? _('Rewrite source', CTX)
: v.nat === 'dnat' ? _('Rewrite destination', CTX) : _('Redirect', CTX);
default: return _('Next rule', CTX);
}
}
/* a chain with something to show: rules, or a policy that drops */
function live(c) { return c.rules.length > 0 || (c.base && c.policy === 'drop'); }
return baseclass.extend({
attach(view) {
const ready = () => {
const t = view.querySelector('.nft-table');
return t && t.parentNode;
};
const go = (root) => {
L.resolveDefault(fs.exec_direct('/usr/sbin/nft', [ '--terse', '--json', 'list', 'ruleset' ], 'json'), null).then((json) => {
if (!json || root.querySelector(':scope > .v-nft')) return;
const m = nftmodel.build(json);
if (!m.bases.length && !m.others.length) return;
this.draw(root, m);
}).catch((e) => { if (window.console) console.warn('vantage: firewall flow', e); });
};
const root = ready();
if (root) return go(root);
/* wait for LuCI's tables; give up once the view shows something
else ("No nftables ruleset loaded.") or after a minute */
const done = () => { mo.disconnect(); window.clearTimeout(timer); };
const mo = new MutationObserver(() => {
const r = ready();
if (r) { done(); go(r); }
else if (!view.querySelector('.spinning') && view.textContent.trim()) done();
});
const timer = window.setTimeout(done, 60000);
mo.observe(view, { childList: true, subtree: true });
},
draw(root, m) {
this.m = m;
this.rows = luciRows(root, m);
this.ids = Object.create(null);
Object.keys(m.chains).forEach((k, i) => { this.ids[k] = 'v-nft-c' + i; });
this.cards = Object.create(null);
const tabs = E('ul', { 'class': 'cbi-tabmenu v-nft-tabs', 'role': 'group', 'aria-label': _('View', CTX) });
const panel = E('div', { 'class': 'v-nft-panel' }, [
E('h3', {}, [ _('Packet path', CTX) ]),
this.chart(),
E('h3', {}, [ _('Chains', CTX) ]),
E('div', { 'class': 'v-nft-chains' }, this.cardsOf(m.bases))
]);
m.others.forEach((t) => {
panel.appendChild(E('h3', {}, [ tableTitle(t) ]));
panel.appendChild(E('div', { 'class': 'v-nft-chains' }, this.cardsOf(t.chains)));
});
if (m.unused.length) {
panel.appendChild(E('h3', {}, [ m.namedMaps ? _('Chains no rule jumps to directly', CTX) : _('Chains no rule jumps to', CTX) ]));
panel.appendChild(E('div', { 'class': 'v-nft-chains' }, this.cardsOf(m.unused, true)));
}
const box = E('div', { 'class': 'v-nft' }, [ tabs, panel ]);
const set = (mode) => {
root.classList.toggle('v-nft-flowmode', mode === 'flow');
Array.prototype.forEach.call(tabs.children, (li) => {
const on = li.getAttribute('data-mode') === mode;
li.className = on ? 'cbi-tab' : 'cbi-tab-disabled';
li.firstElementChild.setAttribute('aria-pressed', String(on));
});
if (mode === 'flow') this.fit();
};
/* LuCI's tab markup for the look; buttons for what they do */
[ [ 'flow', _('Flow', CTX) ], [ 'table', _('Rules table', CTX) ] ].forEach((t) => {
const pick = (ev) => { ev.preventDefault(); storageSet(MODE_KEY, t[0]); set(t[0]); };
tabs.appendChild(E('li', { 'data-mode': t[0], 'role': 'presentation' }, [
E('a', { 'href': '#', 'role': 'button', 'click': pick,
'keydown': (ev) => { if (ev.key === ' ' || ev.key === 'Spacebar') pick(ev); } }, [ t[1] ])
]));
});
root.insertBefore(box, root.firstChild);
set(storageGet(MODE_KEY) === 'table' ? 'table' : 'flow');
if (window.ResizeObserver) new ResizeObserver(() => this.fit()).observe(this.el);
},
/* the way through the hooks: in, a fork (to this device and back out,
or forwarded), out; below it the way of frames bridged between ports */
chart() {
const st = this.m.stages;
const arrow = () => E('span', { 'class': 'v-nft-arrow', 'aria-hidden': 'true' });
const pass = () => E('span', { 'class': 'v-nft-pass', 'aria-hidden': 'true' });
const end = (where) => E('span', { 'class': 'v-nft-endpoint', 'data-where': where }, [ where === 'device' ? _('This device', CTX) : _('Network', CTX) ]);
const row = [ end('net') ];
[ st.ingress, st.bridgeIn, st.prerouting ].filter(Boolean).forEach((s) => { row.push(arrow(), this.stage(s)); });
row.push(arrow(), E('div', { 'class': 'v-nft-fork' }, [
E('div', { 'class': 'v-nft-branch' }, [ this.stage(st.input), arrow(), end('device'), arrow(), this.stage(st.output), pass() ]),
E('div', { 'class': 'v-nft-branch' }, [ this.stage(st.forward), pass() ])
]), arrow(), this.stage(st.postrouting));
[ st.bridgeOut, st.egress ].filter(Boolean).forEach((s) => { row.push(arrow(), this.stage(s)); });
row.push(arrow(), end('net'));
const bridged = [ end('net') ];
[ st.ingress, st.bridged, st.egress ].filter(Boolean).forEach((s) => { bridged.push(arrow(), this.stage(s)); });
bridged.push(pass(), arrow(), end('net'));
const notes = [];
if (!st.bridged.chains.length)
notes.push(E('p', { 'class': 'v-nft-note' }, [ _('Frames bridged between ports of one bridge, such as Wi-Fi and LAN in br-lan on an access point, never reach the input, forward or output chains, and no bridge chain filters them here. (The kernel can hand them to the IP chains if br_netfilter is enabled; the ruleset doesn\'t show that.)', CTX) ]));
if (this.m.offload)
notes.push(E('p', { 'class': 'v-nft-note' }, [ _('Flow offloading is on: once a forwarded connection is established, its packets can skip prerouting, forward and postrouting, so these chains and their counters see only its first packets.', CTX) ]));
this.el = E('div', { 'class': 'v-nft-chart' }, [
E('div', { 'class': 'v-nft-chart-row' }, row),
E('div', { 'class': 'v-nft-chart-row' }, bridged)
].concat(notes));
return this.el;
},
/* the chart runs across while it fits, else down (again only when
its width changed: switching changes the height alone) */
fit() {
const el = this.el;
if (!el || !el.offsetWidth || el.offsetWidth === this.fitW) return;
this.fitW = el.offsetWidth;
el.classList.remove('v-nft-vertical');
const wide = Array.prototype.some.call(el.querySelectorAll(':scope > .v-nft-chart-row'), (r) => r.scrollWidth > r.clientWidth + 1);
el.classList.toggle('v-nft-vertical', wide);
},
/* a stage: its name, whether it drops, its chains with something to show */
stage(s) {
const m = this.m, list = s.chains.filter((k) => live(m.chains[k]));
const none = s.hook === 'bridged' && !s.chains.length;
const name = [ stageName(s) ];
if (s.drops === 'all')
name.push(E('small', {}, [ s.only ? _('drops unmatched %s', CTX).format(familyName(s.only)) : _('drops unmatched', CTX) ]));
else if (s.drops === 'some')
name.push(E('small', {}, [ _('rules drop some', CTX) ]));
else if (none)
name.push(E('small', {}, [ _('no bridge chains', CTX) ]));
/* a name twice at one hook (docker, mwan3): say whose */
const seen = Object.create(null);
s.chains.forEach((k) => { seen[m.chains[k].name] = (seen[m.chains[k].name] || 0) + 1; });
return E('div', { 'class': 'v-nft-stage', 'data-drops': none ? 'open' : s.drops, 'data-edge': String(nftmodel.HOOKS.indexOf(s.hook) < 0) }, [
E('div', { 'class': 'v-nft-stage-name' }, name),
E('div', { 'class': 'v-nft-stage-chains' }, list.length ? list.map((k) => {
const c = m.chains[k];
const b = E('button', { 'type': 'button', 'class': 'v-nft-link', 'click': () => this.show(k),
'title': _('%s: table %s (%s), priority %d', CTX).format(c.name, c.table, familyName(c.family), c.prio) }, [ c.name ]);
if (seen[c.name] > 1 || (nftmodel.HOOKS.indexOf(s.hook) < 0 && c.family !== 'netdev' && c.family !== 'bridge'))
b.appendChild(E('small', {}, [ c.table + ' · ' + familyName(c.family) ]));
else if (c.type === 'nat')
b.appendChild(E('small', {}, [ _('NAT', CTX) ]));
return b;
}) : none ? [] : [ E('span', { 'class': 'v-nft-none' }, [ _('no rules', CTX) ]) ])
]);
},
/* open a chain's card and bring it into view */
show(k) {
const card = this.cards[k];
if (!card) return;
card.open = true;
card.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' });
const s = card.querySelector('summary');
if (s) s.focus({ preventScroll: true });
},
/* a card per chain with something to show; the empty ones on one line */
cardsOf(keys, regular) {
const c = (k) => this.m.chains[k];
const out = keys.filter((k) => regular ? c(k).rules.length : live(c(k))).map((k) => this.card(k));
const empty = keys.filter((k) => regular ? !c(k).rules.length : !live(c(k)));
if (empty.length)
out.push(E('p', { 'class': 'v-nft-empty' }, [ _('No rules: %s', CTX).format(empty.map((k) => c(k).name).join(', ')) ]));
return out;
},
card(k) {
const m = this.m, c = m.chains[k];
const meta = [];
if (c.base) {
meta.push(hookName(c.hook), _('priority %d', CTX).format(c.prio));
if (c.family !== 'inet') meta.push(familyName(c.family));
if (c.type === 'nat') meta.push(_('NAT', CTX));
if (c.dev.length) meta.push(_('on %s', CTX).format(c.dev.join(', ')));
}
meta.push(N_(c.rules.length, '%d rule', '%d rules').format(c.rules.length));
/* other chains at the same hook (and family group) still see what
this one accepts */
if (c.base) {
const group = (f) => f === 'bridge' ? 'bridge' : f === 'netdev' ? 'netdev' : 'ip';
const more = Object.keys(m.chains).filter((o) => o !== k && m.chains[o].base && m.chains[o].hook === c.hook
&& group(m.chains[o].family) === group(c.family) && live(m.chains[o])).length;
if (more) meta.push(N_(more, '%d more chain at this hook', '%d more chains at this hook').format(more));
}
const card = E('details', { 'class': 'v-nft-card', 'id': this.ids[k], 'data-chain': c.name, 'data-family': c.family }, [
E('summary', {}, [ E('strong', {}, [ c.name ]), E('span', { 'class': 'v-nft-meta' }, [ meta.join(' · ') ]) ].concat(c.base ? [ this.policyChip(c) ] : []))
]);
/* the chains that drop what no rule accepts start open */
card.open = c.base && c.policy === 'drop';
let built = false;
const fill = () => { if (!built && card.open) { built = true; card.appendChild(this.flow(k, [ k ], [])); } };
card.addEventListener('toggle', fill);
fill();
this.cards[k] = card;
return card;
},
policyChip(c) {
if (c.policy === 'drop') return chip('drop', _('Drops unmatched', CTX));
if (c.type === 'filter') return chip('accept', _('Passes unmatched', CTX));
return chip('none', _('Leaves unmatched unchanged', CTX));
},
/* where a packet goes when it leaves the chain without a verdict:
back to the chain that last jumped (a goto doesn't come back), or,
with none, the base chain's policy */
away(path, stack) {
const up = stack.length ? this.m.chains[stack[stack.length - 1]] : null;
if (up) return chip('return', _('Back to %s', CTX).format(up.name));
const b = this.m.chains[path[0]];
if (!b.base) return chip('return', _('Back to the calling chain', CTX));
if (b.policy === 'drop') return chip('drop', _('Drop: %s policy', CTX).format(b.name));
if (b.type === 'filter') return chip('accept', _('Pass: %s policy', CTX).format(b.name));
return chip('none', _('Unchanged', CTX));
},
/* a chain's rules as steps. path: the chains opened above it (to stop
at a loop); stack: the chains to come back to (a jump adds its
chain, a goto doesn't) */
flow(k, path, stack) {
const c = this.m.chains[k], rows = this.rows[k];
const list = E('ol', { 'class': 'v-nft-flow' });
const step = (r) => {
const row = rows && rows[r.index];
const cond = r.parts.conds && row ? conditions(row, r.verdict.kind === 'vmap') : null;
const nat = r.verdict.kind === 'nat';
const also = row && r.verdict.kind !== 'vmap' && (nat || r.parts.acts > (r.parts.last >= 0 ? 1 : 0)) ? sideActions(row, r.parts, nat) : null;
/* a rule with conditions LuCI's rows can't supply never reads
as "Any packet" */
const label = r.comment || (cond ? '' : r.parts.conds ? _('Conditions in the rules table', CTX) : _('Any packet', 'nft match any traffic'));
const what = [ E('div', { 'class': 'v-nft-what-name', 'data-muted': String(!r.comment && !cond && !!r.parts.conds) }, [ label ]) ];
if (!r.comment && cond) what[0] = cond;
else if (cond) what.push(cond);
if (also) what.push(also);
const then = this.outcome(r.verdict, c, path, stack);
if (r.counter)
then.push(E('span', { 'class': 'v-nft-count', 'title': _('Traffic matched by rule: %.1000mPackets, %.1024mBytes', 'nft counter').format(r.counter.packets, r.counter.bytes) },
[ _('%s matched', CTX).format('%.1024mB'.format(r.counter.bytes)) ]));
return E('li', { 'class': 'v-nft-step' }, [
E('span', { 'class': 'v-nft-n' }, [ String(r.index + 1) ]),
E('div', { 'class': 'v-nft-what' }, what),
E('div', { 'class': 'v-nft-then' }, then)
]);
};
c.rules.slice(0, MAX_STEPS).forEach((r) => list.appendChild(step(r)));
if (c.rules.length > MAX_STEPS) {
const more = E('li', { 'class': 'v-nft-step v-nft-step-more' }, [
E('span', { 'class': 'v-nft-n', 'aria-hidden': 'true' }),
E('button', { 'type': 'button', 'class': 'v-nft-verdict v-nft-jump', 'click': () => {
c.rules.slice(MAX_STEPS).forEach((r) => list.insertBefore(step(r), more));
more.remove();
} }, [ N_(c.rules.length - MAX_STEPS, 'Show %d more rule', 'Show %d more rules').format(c.rules.length - MAX_STEPS) ])
]);
list.appendChild(more);
}
list.appendChild(this.end(path, stack));
return list;
},
/* what a rule does: a verdict, or the chain it jumps to (opens below) */
outcome(v, c, path, stack) {
const jump = (kind, target, key) => {
const tk = nftmodel.keyOf(c.family, c.table, target);
const label = (key != null ? key + ' → ' : '→ ') + target;
if (!this.m.chains[tk]) return chip(kind, label);
if (path.indexOf(tk) >= 0) return chip(kind, label + ' ' + _('(open above)', CTX));
if (path.length >= MAX_DEPTH) return chip(kind, label + ' ' + _('(too deep to open here)', CTX));
const b = E('button', { 'type': 'button', 'class': 'v-nft-verdict v-nft-jump', 'data-kind': kind, 'aria-expanded': 'false',
'title': kind === 'goto' ? _('Continues in %s and does not come back here', CTX).format(target) : _('Continues in %s, then comes back here', CTX).format(target) }, [ label ]);
let sub = null;
b.addEventListener('click', () => {
if (!sub) {
const back = kind === 'jump' ? stack.concat([ c.key ]) : stack;
sub = E('div', { 'class': 'v-nft-sub' }, [ this.flow(tk, path.concat([ tk ]), back) ]);
b.closest('.v-nft-step').appendChild(sub);
}
const open = b.getAttribute('aria-expanded') !== 'true';
sub.hidden = !open;
b.setAttribute('aria-expanded', String(open));
});
return b;
};
if (v.kind === 'return') return [ this.away(path, stack) ];
if (v.kind === 'jump' || v.kind === 'goto') return [ jump(v.kind, v.target) ];
if (v.kind === 'vmap' && v.named) return [ chip('none', _('Verdict map %s', CTX).format(v.named)) ];
if (v.kind === 'vmap') {
/* keys with the same verdict share a chip: "established, related → Accept" */
const out = [], same = Object.create(null);
v.map.forEach((e) => {
if (e.kind === 'jump' || e.kind === 'goto') return out.push(jump(e.kind, e.target, e.key));
const kind = e.kind === 'return' ? 'return' : e.kind;
if (same[kind]) return same[kind].push(e.key);
same[kind] = [ e.key ];
out.push(kind);
});
return out.map((x) => typeof x !== 'string' ? x
: chip(x === 'return' ? 'return' : x, same[x].join(', ') + ' → ' + (x === 'return' ? this.away(path, stack).textContent : verdictLabel({ kind: x }))));
}
return [ chip(v.kind, verdictLabel(v)) ];
},
/* where a packet goes when no rule of the chain decided */
end(path, stack) {
return E('li', { 'class': 'v-nft-step v-nft-step-end' }, [
E('span', { 'class': 'v-nft-n', 'aria-hidden': 'true' }),
E('div', { 'class': 'v-nft-what' }, [ _('No rule matched', CTX) ]),
E('div', { 'class': 'v-nft-then' }, [ this.away(path, stack) ])
]);
}
});
@@ -0,0 +1,255 @@
'use strict';
'require baseclass';
/*
* Vantage theme - the live nftables ruleset as a model for the flow view
* on Status -> Firewall (vantage-theme.nftflow draws it).
*
* Input: the JSON of `nft --terse --json list ruleset`, the command LuCI's
* own page runs. Output: tables, chains with their rules and what each
* rule finally does, and the stages a packet passes (the IP hooks, and
* the ingress, bridge and egress hooks around them) with their base
* chains in priority order. Pure functions, no DOM, unit tested in
* tests/nftflow.test.js.
*/
/* the IP families share the hooks; other families get their own list */
const IP = { ip: true, ip6: true, inet: true };
const HOOKS = [ 'prerouting', 'input', 'forward', 'output', 'postrouting' ];
/* every hook in the order a packet can meet it, to sort other families */
const ORDER = [ 'ingress', 'prerouting', 'input', 'forward', 'output', 'postrouting', 'egress' ];
/* where a packet meets the other hooks: ingress first (netdev, and inet
since Linux 5.10) and netdev egress last, on their device; bridge hooks
on a bridge port, prerouting and input before the IP stack, output and
postrouting after it; prerouting, forward and postrouting for frames
bridged from port to port, which never reach the IP hooks (unless the
kernel's br_netfilter hands them over, which the ruleset doesn't show) */
const EDGES = {
ingress: [ [ 'netdev', 'inet', 'ip', 'ip6' ], [ 'ingress' ] ],
bridgeIn: [ [ 'bridge' ], [ 'prerouting', 'input' ] ],
bridgeOut: [ [ 'bridge' ], [ 'output', 'postrouting' ] ],
egress: [ [ 'netdev' ], [ 'egress' ] ],
bridged: [ [ 'bridge' ], [ 'prerouting', 'forward', 'postrouting' ] ]
};
/* statements that end the rule (the last one wins); everything else
(counter, limit, log, mangle, ct helper, notrack, flow) lets the packet
go on to the next rule */
const NAT = { masquerade: true, snat: true, dnat: true, redirect: true };
/* what LuCI's view counts as an action (its isActionExpression); it shows
every other statement but a counter among the rule's conditions */
const ACTIONS = [ 'accept', 'notrack', 'reject', 'drop', 'jump', 'goto', 'continue', 'snat', 'dnat',
'redirect', 'mangle', 'masquerade', 'return', 'flow', 'ct helper', 'log' ];
/* nft prints "flow add @ft" as a bare string */
const FLOW_ADD = /^flow add @\S+$/;
function own(o, k) { return o != null && Object.prototype.hasOwnProperty.call(o, k); }
function str(v) { return typeof v === 'string' ? v : ''; }
function num(v) { return typeof v === 'number' && isFinite(v) ? v : null; }
function obj(v) { return v != null && typeof v === 'object'; }
function isAction(st) {
if (typeof st === 'string') return FLOW_ADD.test(st);
return obj(st) && ACTIONS.some((k) => own(st, k));
}
/* names can't hold a newline: no two chains share a key */
function keyOf(family, table, name) { return family + '\n' + table + '\n' + name; }
/* a verdict object ({ accept: null }, { jump: { target } }, ...) */
function verdict(v) {
if (!obj(v)) return null;
if (own(v, 'accept')) return { kind: 'accept' };
if (own(v, 'drop')) return { kind: 'drop' };
if (own(v, 'reject')) return { kind: 'reject' };
if (own(v, 'return')) return { kind: 'return' };
if (own(v, 'continue')) return { kind: 'continue' };
if (own(v, 'queue')) return { kind: 'queue' };
for (const k of [ 'jump', 'goto' ])
if (own(v, k) && v[k] && str(v[k].target)) return { kind: k, target: v[k].target };
return null;
}
/* a map key as text: strings and numbers; prefixes and ranges roughly */
function keyText(k) {
if (typeof k === 'string' || typeof k === 'number') return String(k);
if (obj(k)) {
if (own(k, 'prefix') && k.prefix) return str(k.prefix.addr) + '/' + String(num(k.prefix.len) != null ? k.prefix.len : '');
if (Array.isArray(k.range)) return k.range.map(keyText).join('-');
if (Array.isArray(k.concat)) return k.concat.map(keyText).join(' . ');
}
return '…';
}
const DROPS = { drop: true, reject: true };
return baseclass.extend({
HOOKS: HOOKS,
keyOf: keyOf,
/* fw4 marks its own comments with "!fw4: " */
comment(c) { return str(c).replace(/^!fw4:\s*/, '').trim(); },
/* what a rule's statements finally do:
{ kind: accept|drop|reject|return|continue|queue|jump|goto|nat|vmap|none,
target (jump, goto), nat (its statement), map (vmap: [{ key, kind,
target }]), named (a vmap looked up in a named map: its name) } */
ruleVerdict(expr) {
let out = { kind: 'none' };
(Array.isArray(expr) ? expr : []).forEach((st) => {
if (!obj(st)) return;
const v = verdict(st);
if (v) { out = v; return; }
for (const k in NAT) if (own(st, k)) { out = { kind: 'nat', nat: k }; return; }
if (!own(st, 'vmap') || !st.vmap) return;
const data = st.vmap.data;
if (typeof data === 'string') { out = { kind: 'vmap', map: [], named: data.replace(/^@/, '') }; return; }
const map = [];
(data && Array.isArray(data.set) ? data.set : []).forEach((e) => {
const d = Array.isArray(e) ? verdict(e[1]) : null;
if (d) map.push({ key: keyText(e[0]), kind: d.kind, target: d.target });
});
out = { kind: 'vmap', map: map };
});
return out;
},
/* matched packets and bytes, when the rule counts */
counter(expr) {
const c = (Array.isArray(expr) ? expr : []).find((st) => obj(st) && own(st, 'counter') && st.counter);
return c && num(c.counter.packets) != null ? { packets: c.counter.packets, bytes: num(c.counter.bytes) || 0 } : null;
},
/* how LuCI's view splits the statements: { conds, acts, last }; conds:
statements it shows as conditions (all but counters and actions, the
vmap's lookup included), acts: the actions in order, last: the index
among them of the one that decides (-1: none does) */
parts(expr) {
const out = { conds: 0, acts: 0, last: -1 };
(Array.isArray(expr) ? expr : []).forEach((st) => {
if (obj(st) && own(st, 'counter')) return;
if (obj(st) && own(st, 'vmap')) { out.conds++; return; }
if (!isAction(st)) { out.conds++; return; }
if (verdict(st) || Object.keys(NAT).some((k) => own(st, k))) out.last = out.acts;
out.acts++;
});
return out;
},
build(json) {
const items = json && Array.isArray(json.nftables) ? json.nftables : [];
const tables = [], chains = Object.create(null), byTable = Object.create(null);
let offload = false;
items.forEach((it) => {
if (obj(it) && obj(it.flowtable)) offload = true;
const t = it && it.table;
if (!obj(t) || !str(t.family) || !str(t.name)) return;
const tk = keyOf(t.family, t.name, '');
if (byTable[tk]) return;
byTable[tk] = { family: t.family, name: t.name, chains: [] };
tables.push(byTable[tk]);
});
items.forEach((it) => {
const c = it && it.chain;
if (!obj(c) || !str(c.family) || !str(c.table) || !str(c.name)) return;
const k = keyOf(c.family, c.table, c.name);
if (chains[k]) return;
const base = str(c.hook) !== '';
const dev = Array.isArray(c.dev) ? c.dev.filter((d) => typeof d === 'string') : str(c.dev) ? [ c.dev ] : [];
chains[k] = {
key: k, family: c.family, table: c.table, name: c.name,
type: str(c.type), hook: str(c.hook), prio: num(c.prio) || 0,
policy: base ? (str(c.policy) || 'accept') : '', base: base, dev: dev,
rules: [], callers: []
};
const tk = keyOf(c.family, c.table, '');
if (!byTable[tk]) { byTable[tk] = { family: c.family, name: c.table, chains: [] }; tables.push(byTable[tk]); }
byTable[tk].chains.push(k);
});
items.forEach((it) => {
const r = it && it.rule;
if (!obj(r) || !str(r.family) || !str(r.table) || !str(r.chain)) return;
const c = chains[keyOf(r.family, r.table, r.chain)];
if (!c) return;
const expr = Array.isArray(r.expr) ? r.expr : [];
if (expr.some((st) => (typeof st === 'string' && FLOW_ADD.test(st)) || (obj(st) && own(st, 'flow'))))
offload = true;
c.rules.push({
index: c.rules.length,
comment: this.comment(r.comment),
verdict: this.ruleVerdict(expr),
counter: this.counter(expr),
parts: this.parts(expr)
});
});
/* who jumps where: targets live in the caller's table */
const targets = (c, r) => (r.verdict.target ? [ r.verdict.target ] : (r.verdict.map || []).map((e) => e.target))
.map((t) => t && chains[keyOf(c.family, c.table, t)]).filter(Boolean);
Object.keys(chains).forEach((k) => {
const c = chains[k];
c.rules.forEach((r) => {
targets(c, r).forEach((to) => { if (to.callers.indexOf(k) < 0) to.callers.push(k); });
});
});
/* whether a rule of the chain, or of a chain it jumps or goes to,
drops or rejects: fw4 keeps most of its filtering in zone chains */
const memo = new Map();
const mayDrop = (k, seen) => {
if (memo.has(k)) return memo.get(k);
if (seen.has(k)) return false;
seen.add(k);
const c = chains[k];
const out = c.rules.some((r) => DROPS[r.verdict.kind] || (r.verdict.map || []).some((e) => DROPS[e.kind])
|| targets(c, r).some((to) => mayDrop(to.key, seen)));
memo.set(k, out);
return out;
};
Object.keys(chains).forEach((k) => { chains[k].mayDrop = mayDrop(k, new Set()); });
const byPrio = (a, b) => chains[a].prio - chains[b].prio || (chains[a].name < chains[b].name ? -1 : chains[a].name > chains[b].name ? 1 : 0);
const byHook = (a, b) => ORDER.indexOf(chains[a].hook) - ORDER.indexOf(chains[b].hook) || byPrio(a, b);
const of = (families, hooks) => Object.keys(chains).filter((k) => families.indexOf(chains[k].family) >= 0 && hooks.indexOf(chains[k].hook) >= 0).sort(byHook);
const stage = (hook, list) => {
/* unmatched packets: a filter chain that drops them stops them
here (for its family only); otherwise a rule may drop some */
const droppers = list.filter((k) => chains[k].type === 'filter' && chains[k].policy === 'drop');
const fams = droppers.map((k) => chains[k].family);
return {
hook: hook, chains: list,
drops: droppers.length ? 'all' : list.some((k) => chains[k].mayDrop) ? 'some' : 'none',
/* 'ip' or 'ip6' when only that family's chains drop unmatched */
only: droppers.length && fams.indexOf('inet') < 0 && fams.every((f) => f === fams[0]) && fams[0] !== 'bridge' && fams[0] !== 'netdev' ? fams[0] : ''
};
};
/* the IP hooks always; the others only where they have chains, but
for the bridged path, which the chart always shows: without a
bridge chain nothing in the ruleset filters it */
const stages = {};
HOOKS.forEach((h) => { stages[h] = stage(h, of([ 'ip', 'ip6', 'inet' ], [ h ])); });
Object.keys(EDGES).forEach((id) => {
const list = of(EDGES[id][0], EDGES[id][1]);
stages[id] = list.length || id === 'bridged' ? stage(id, list) : null;
});
/* the IP base chains in the order a packet meets them */
const bases = of([ 'ip', 'ip6', 'inet' ], ORDER);
/* other families (bridge, netdev, arp): their tables' base chains */
const others = tables.filter((t) => !IP[t.family])
.map((t) => ({ family: t.family, name: t.name, chains: t.chains.filter((k) => chains[k].base).sort(byHook) }))
.filter((t) => t.chains.length);
/* regular chains no rule jumps to (a named verdict map may still:
its contents aren't in the terse ruleset) */
const unused = Object.keys(chains).filter((k) => !chains[k].base && !chains[k].callers.length);
const namedMaps = Object.keys(chains).some((k) => chains[k].rules.some((r) => r.verdict.named));
return { tables: tables, chains: chains, stages: stages, bases: bases, others: others, unused: unused, namedMaps: namedMaps, offload: offload };
}
});
@@ -175,6 +175,7 @@ html[data-darkmode="true"] {
--v-r-lg: var(--r-lg);
--v-ctl-h: var(--ctl-h); /* every control: 36px, 44px on touch */
--v-shadow: 0 1px 2px rgba(20, 23, 26, .06), 0 1px 0 rgba(20, 23, 26, .03);
--v-shadow-pop: var(--shadow-pop); /* lists and menus that open over the page */
/* where sticky content may start: below the top bar (page tabs scroll) */
--v-stick-top: var(--stick-top);
@@ -2613,6 +2614,196 @@ select[id^="widget.cbid.firewall."] > option { color: var(--text); font-weight:
.nft-rules .td > div > span:first-child:not(.ifacebadge) { margin-right: 6px; color: var(--text-3); }
.nft-rules em { font-style: normal; color: var(--text-3); }
/* Status -> Firewall as a flow chart (vantage-theme/nftflow.js): a Flow /
Rules table switch over LuCI's view; Flow hides LuCI's tables (they stay
in the DOM). The chart: one row, in -> prerouting -> a fork (input, this
device, output | forward) -> postrouting -> out, the fork drawn as a
bracket each side, and a row for frames bridged between ports; a
stage's top edge is red where unmatched packets are dropped, amber where
some rules drop, grey on the bridged path when no bridge chain filters
it; ingress, bridge and egress stages dashed. When a row doesn't fit,
the script sets .v-nft-vertical: the rows run down and the fork's
branches stand side by side. Lines and arrows in --text-3 (they carry
the direction). The chains: a card each (details), its rules as
numbered steps on a rail, the verdict on the right; a jump opens its
chain beneath. */
.v-nft-flowmode > .nft-table { display: none; }
:not(.v-nft-flowmode) > .v-nft > .v-nft-panel { display: none; }
.v-nft-panel > h3 { margin: var(--sp-5) 0 var(--sp-3); font-size: var(--fs-md); }
.v-nft-panel > h3:first-child { margin-top: 0; }
.v-nft-chains { display: grid; gap: var(--sp-2); }
.v-nft-chart, .v-nft-card {
background: var(--s1);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--v-shadow);
}
.v-nft-chart { display: grid; gap: var(--sp-3); padding: 14px var(--card-px); }
.v-nft-chart-row { display: flex; align-items: center; min-width: 0; }
.v-nft-chart-row + .v-nft-chart-row { padding-top: var(--sp-3); border-top: 1px dashed var(--line); }
.v-nft-note { max-width: 90ch; margin: 0; color: var(--text-2); font-size: var(--fs-sm); }
.v-nft-endpoint {
flex: none;
padding: 4px 10px;
border-radius: 999px;
background: var(--s3); color: var(--text-2);
font-size: var(--fs-sm); font-weight: 600; white-space: nowrap;
}
.v-nft-endpoint[data-where="device"] { background: var(--accent-soft); color: var(--accent-text); }
.v-nft-arrow { position: relative; flex: 0 0 18px; height: 2px; margin-right: 6px; background: var(--text-3); }
.v-nft-arrow::after {
content: ""; position: absolute; right: -6px; top: -4px;
border: 5px solid transparent; border-left: 6px solid var(--text-3); border-right: 0;
}
/* a line that carries a branch on to the bracket */
.v-nft-pass { flex: 1 0 12px; height: 2px; background: var(--text-3); }
.v-nft-stage {
flex: none; max-width: 210px;
padding: 6px 10px 8px;
background: var(--s2);
border: 1px solid var(--line); border-top: 3px solid var(--ok);
border-radius: var(--r);
}
.v-nft-stage[data-drops="all"] { border-top-color: var(--err); }
.v-nft-stage[data-drops="some"] { border-top-color: var(--warn); }
.v-nft-stage[data-drops="open"] { border-top-color: var(--line-2); }
.v-nft-stage[data-edge="true"] { border-style: dashed; border-top-style: solid; border-color: var(--line-2); }
.v-nft-stage[data-edge="true"][data-drops="all"] { border-top-color: var(--err); }
.v-nft-stage[data-edge="true"][data-drops="some"] { border-top-color: var(--warn); }
.v-nft-stage[data-edge="true"][data-drops="none"] { border-top-color: var(--ok); }
.v-nft-stage-name { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0 6px; margin-bottom: 5px; font-size: var(--fs-sm); font-weight: 600; white-space: nowrap; }
.v-nft-stage-name small { font-size: var(--fs-micro); font-weight: 500; }
.v-nft-stage[data-drops="all"] .v-nft-stage-name small { color: var(--err-text); }
.v-nft-stage[data-drops="some"] .v-nft-stage-name small { color: var(--warn-text); }
.v-nft-stage[data-drops="open"] .v-nft-stage-name small { color: var(--text-2); }
.v-nft-stage-chains { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
.v-nft-none { color: var(--text-3); font-size: var(--fs-micro); }
.v-nft-link {
display: inline-flex; align-items: center; flex-wrap: wrap; gap: 0 4px;
min-height: 22px; max-width: 100%; margin: 0; padding: 1px 7px;
background: var(--s1); color: var(--text);
border: 1px solid var(--line-2); border-radius: var(--r-sm);
box-shadow: none; cursor: pointer;
font: 500 var(--fs-micro)/1.3 var(--mono); text-transform: none; text-align: left;
overflow-wrap: anywhere;
}
.v-nft-link:hover { border-color: var(--text-3); }
.v-nft-link small { color: var(--text-3); font: 600 var(--fs-micro)/1.3 var(--font); letter-spacing: .02em; }
/* the fork: two branches between brackets; each branch reaches both. The
brackets run between the centres of the two branches' lines */
.v-nft-fork { position: relative; display: grid; grid-auto-rows: 1fr; gap: 10px; flex: 1 1 auto; }
.v-nft-fork::before, .v-nft-fork::after { content: ""; position: absolute; top: calc(25% - 3.5px); bottom: calc(25% - 3.5px); width: 2px; background: var(--text-3); }
.v-nft-fork::before { left: -6px; }
.v-nft-fork::after { right: 0; }
.v-nft-branch { display: flex; align-items: center; min-width: 0; }
.v-nft-branch::before { content: ""; flex: 0 0 8px; height: 2px; margin-left: -6px; background: var(--text-3); }
.v-nft-branch > .v-nft-pass:last-child { margin-right: 0; }
.v-nft-vertical .v-nft-chart-row { flex-direction: column; align-items: stretch; }
.v-nft-vertical .v-nft-endpoint { align-self: center; }
.v-nft-vertical .v-nft-arrow { flex-basis: 16px; width: 2px; height: auto; margin: 0 auto 6px; }
.v-nft-vertical .v-nft-arrow::after { right: -4px; top: auto; bottom: -6px; border: 5px solid transparent; border-top: 6px solid var(--text-3); border-bottom: 0; }
/* one line a stage: its name, then its chains */
.v-nft-vertical .v-nft-stage { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; max-width: none; padding: 5px 10px 6px; }
.v-nft-vertical .v-nft-stage-name { margin: 0; }
.v-nft-vertical .v-nft-fork { grid-auto-rows: auto; grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 2px 0 0; }
.v-nft-vertical .v-nft-fork::before, .v-nft-vertical .v-nft-fork::after { left: calc(25% - 3.5px); right: calc(25% - 3.5px); width: auto; height: 2px; }
.v-nft-vertical .v-nft-fork::before { top: 0; bottom: auto; }
.v-nft-vertical .v-nft-fork::after { top: auto; bottom: 0; }
.v-nft-vertical .v-nft-branch { flex-direction: column; align-items: stretch; }
.v-nft-vertical .v-nft-branch::before { flex: 0 0 10px; width: 2px; height: auto; margin: 0 auto; }
.v-nft-vertical :is(.v-nft-pass, .v-nft-branch > .v-nft-pass:last-child) { flex: 1 0 10px; width: 2px; height: auto; margin: 0 auto; }
.v-nft-card > summary {
display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px;
min-height: 44px; padding: 8px var(--card-px);
list-style: none; cursor: pointer;
border-radius: var(--r-lg);
}
.v-nft-card > summary::-webkit-details-marker { display: none; }
.v-nft-card > summary::before {
content: ""; flex: none; width: 7px; height: 7px; margin: 0 4px 2px 0;
border-right: 2px solid var(--text-3); border-bottom: 2px solid var(--text-3);
transform: rotate(-45deg); transition: transform .15s;
}
.v-nft-card[open] > summary::before { transform: rotate(45deg); margin-bottom: 4px; }
.v-nft-card[open] > summary { border-bottom: 1px solid var(--line); border-radius: var(--r-lg) var(--r-lg) 0 0; }
.v-nft-card > summary > strong { font: 600 var(--fs-base) var(--mono); }
.v-nft-card > summary > .v-nft-verdict:last-child { margin-left: auto; }
.v-nft-meta { color: var(--text-3); font-size: var(--fs-sm); }
.v-nft-flow { position: relative; margin: 0; padding: 4px var(--card-px) 6px; list-style: none; }
/* the rail through the step numbers */
.v-nft-flow::before { content: ""; position: absolute; left: calc(var(--card-px) + 9px); top: 16px; bottom: 16px; width: 2px; background: var(--line); }
.v-nft-step {
position: relative;
display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; align-items: start; gap: 2px 12px;
padding: 6px 0;
}
.v-nft-step + .v-nft-step { border-top: 1px solid var(--line); }
.v-nft-n {
position: relative; z-index: 1;
width: 20px; height: 20px;
border-radius: 50%;
background: var(--s3); color: var(--text-2); box-shadow: 0 0 0 3px var(--s1);
font: 600 var(--fs-micro)/20px var(--mono); text-align: center;
}
.v-nft-what { min-width: 0; padding-top: 1px; }
.v-nft-what-name { font-weight: 600; overflow-wrap: anywhere; }
.v-nft-what-name[data-muted="true"] { color: var(--text-3); font-weight: 500; }
/* the chain's end: one quiet line */
.v-nft-step-end { padding: 4px 0 2px; }
.v-nft-step-end .v-nft-n { width: 10px; height: 10px; margin: 4px 5px; border-radius: 3px; background: var(--line-2); }
.v-nft-step-end .v-nft-what { color: var(--text-3); font-size: var(--fs-sm); }
.v-nft-step-end .v-nft-verdict { min-height: 20px; padding: 0 8px; font-size: var(--fs-micro); }
.v-nft-step-more { grid-template-columns: 20px auto; }
.v-nft-cond { margin-top: 2px; color: var(--text-2); font-size: var(--fs-sm); overflow-wrap: anywhere; }
.v-nft-what > .v-nft-cond:first-child { margin-top: 0; color: var(--text); font-size: inherit; }
.v-nft-cond .ifacebadge {
display: inline; margin: 0 10px 0 0; padding: 0;
background: none; border: 0; border-radius: 0;
font-size: inherit; white-space: normal; vertical-align: baseline;
}
.v-nft-cond .ifacebadge .ifacebadge { margin-right: 0; }
/* LuCI's view boxes a set's items at 200px; here they run on as text */
.v-nft-cond :is(.nft-set, .nft-list, .nft-set-items, .nft-list-items) { display: inline; max-width: none; overflow: visible; vertical-align: baseline; }
.v-nft-cond em { font-style: normal; color: var(--text-3); }
.v-nft-cond var { font-style: normal; }
.v-nft-also { color: var(--text-3); }
.v-nft-empty { margin: 0; padding: 0 var(--sp-1); color: var(--text-3); font-size: var(--fs-sm); }
.v-nft-count { color: var(--text-3); font: var(--fs-micro) var(--mono); white-space: nowrap; }
.v-nft-then { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
.v-nft-verdict {
display: inline-flex; align-items: center; gap: 4px;
min-height: 22px; margin: 0; padding: 1px 9px;
background: var(--s3); color: var(--text-2);
border: 1px solid transparent; border-radius: 999px;
box-shadow: none;
font: 600 var(--fs-sm)/1.3 var(--font); white-space: nowrap; text-transform: none;
}
.v-nft-verdict[data-kind="accept"] { background: var(--ok-soft); color: var(--ok-text); }
.v-nft-verdict:is([data-kind="drop"], [data-kind="reject"]) { background: var(--err-soft); color: var(--err-text); }
.v-nft-verdict[data-kind="nat"] { background: var(--info-soft); color: var(--info-text); }
.v-nft-jump { background: var(--s1); border-color: var(--line-2); color: var(--accent-text); font-family: var(--mono); font-weight: 500; cursor: pointer; }
.v-nft-jump::after {
content: ""; width: 5px; height: 5px; margin: 0 1px 2px 3px;
border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
transform: rotate(45deg); transition: transform .15s;
}
.v-nft-jump[aria-expanded="true"] { background: var(--accent-soft); border-color: var(--accent-line); }
.v-nft-jump[aria-expanded="true"]::after { transform: rotate(-135deg); margin-bottom: -2px; }
.v-nft-jump:hover { border-color: var(--text-3); }
/* the chain a rule jumps to, beneath the rule */
.v-nft-sub {
grid-column: 2 / -1;
margin: 4px 0 0;
background: var(--s2);
border: 1px solid var(--line); border-left: 3px solid var(--accent-line);
border-radius: var(--r);
}
.v-nft-sub > .v-nft-flow { padding: 2px 10px 4px; }
.v-nft-sub > .v-nft-flow::before { left: 19px; }
.v-nft-sub .v-nft-n { box-shadow: 0 0 0 3px var(--s2); }
/* Status -> System Log / Kernel Log: LuCI's filter rows (Not, box, label,
field) as wrapping rows; the gap replaces LuCI's inline margins */
#content_syslog > div:has(> :is(input, select)) {
@@ -2897,6 +3088,10 @@ select[id^="widget.cbid.firewall."] > option { color: var(--text); font-weight:
.table:has(.cbi-section-table-titles.named[data-title]) .cbi-section-table-row[data-title]:hover::before { background: none; }
/* nftables rule cards: LuCI's !important cell padding, on the card's edge */
#view .nft-rules .tr > .td, #view .nft-rules .tr > .td:is(:first-child, :last-child) { padding: 3px var(--card-px) !important; }
/* firewall flow: a step's verdict goes under its text */
.v-nft-step { grid-template-columns: 20px minmax(0, 1fr); }
.v-nft-then { grid-column: 2; align-items: flex-start; }
.v-nft-sub { grid-column: 1 / -1; }
/* log filters: each field on its own line under its label */
#content_syslog > div > :is(select, input:not([type="checkbox"])) { flex: 1 1 100%; }
#content_syslog > div > :is(select, input:not([type="checkbox"])) + label { margin-left: 0 !important; }
@@ -2960,6 +3155,7 @@ select[id^="widget.cbid.firewall."] > option { color: var(--text); font-weight:
.cbi-dropdown[open] > ul.dropdown { transition-duration: .01s; }
.cbi-progressbar > div { transition: none; }
.vt-crumb-switch > svg, .vt-rail-caret, .vt-rail-pin svg { transition: none; }
.v-nft-card > summary::before, .v-nft-jump::after { transition: none; }
}
@media print {
+321 -36
View File
@@ -1,13 +1,59 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1085
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:70
msgctxt "Yet unknown nftables table family (\"family\" table \"name\")"
msgid "\"%h\" table \"%h\""
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:353
msgid "%d more chain at this hook"
msgid_plural "%d more chains at this hook"
msgstr[0] ""
msgstr[1] ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:346
msgid "%d rule"
msgid_plural "%d rules"
msgstr[0] ""
msgstr[1] ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:408
msgid "%s matched"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:307
msgid "%s: table %s (%s), priority %d"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:436
msgid "(open above)"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:437
msgid "(too deep to open here)"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:67
msgid "ARP traffic table \"%h\""
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:156
msgid "Accept"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1094
msgid "Actions"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1081
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1082
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1083
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:400
msgctxt "nft match any traffic"
msgid "Any packet"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1090
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1091
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1092
msgid "Appearance"
msgstr ""
@@ -15,14 +61,34 @@ msgstr ""
msgid "Authorization Required"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1264
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1273
msgid "Auto"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:379
msgid "Back to %s"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:381
msgid "Back to the calling chain"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:69
msgid "Breadcrumb"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:51
msgid "Bridge"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:68
msgid "Bridge traffic table \"%h\""
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:52
msgid "Bridged between ports"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:278
msgid "Buffered"
msgstr ""
@@ -37,36 +103,99 @@ msgstr ""
msgid "Cached"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:209
msgid "Chains"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:217
msgid "Chains no rule jumps to"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:217
msgid "Chains no rule jumps to directly"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:359
msgid "Channel"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:736
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:745
msgid "Collapse navigation"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1265
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1274
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:81
msgid "Colour theme"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1106
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1115
msgid "Command palette"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1264
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:400
msgid "Conditions in the rules table"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:439
msgid "Continues in %s and does not come back here"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:439
msgid "Continues in %s, then comes back here"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1273
msgid "Dark"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:736
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:157
msgid "Drop"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:382
msgid "Drop: %s policy"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:369
msgid "Drops unmatched"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:44
msgid "Egress"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:745
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:54
msgid "Expand navigation"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1083
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:232
msgid "Flow"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:268
msgid ""
"Flow offloading is on: once a forwarded connection is established, its "
"packets can skip prerouting, forward and postrouting, so these chains and "
"their counters see only its first packets."
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1092
msgid "Follow system theme"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:40
msgid "Forward"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:266
msgid ""
"Frames bridged between ports of one bridge, such as Wi-Fi and LAN in br-lan "
"on an access point, never reach the input, forward or output chains, and no "
"bridge chain filters them here. (The kernel can hand them to the IP chains "
"if br_netfilter is enabled; the ruleset doesn't show that.)"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:290
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:304
msgid "Free"
@@ -76,21 +205,45 @@ msgstr ""
msgid "Home"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:64
msgid "IPv4 traffic table \"%h\""
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:66
msgid "IPv4/IPv6 traffic table \"%h\""
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:65
msgid "IPv6 traffic table \"%h\""
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:289
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:298
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:302
msgid "In use"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:43
msgid "Ingress"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:39
msgid "Input"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:48
msgid "Invalid username and/or password! Please try again."
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1102
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1111
msgid "Jump to a page or action…"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1264
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:371
msgid "Leaves unmatched unchanged"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1273
msgid "Light"
msgstr ""
@@ -99,7 +252,7 @@ msgstr ""
msgid "Log in"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1084
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1093
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:84
msgid "Log out"
msgstr ""
@@ -108,59 +261,142 @@ msgstr ""
msgid "Main navigation"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:161
msgid "Masquerade"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:273
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:288
msgid "Memory"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1493
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1510
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1502
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1519
msgid "More actions"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1177
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:311
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:343
msgid "NAT"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:250
msgid "Network"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:69
msgid "Network device table \"%h\""
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:163
msgid "Next rule"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1186
msgid "No matching pages"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1011
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:476
msgid "No rule matched"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:333
msgid "No rules: %s"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1020
msgid "Other pages in %s"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:41
msgid "Output"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:207
msgid "Packet path"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:90
msgid "Page tabs"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:383
msgid "Pass: %s policy"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:370
msgid "Passes unmatched"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:57
msgid "Password"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:885
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:42
msgid "Postrouting"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:38
msgid "Prerouting"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:894
msgid "Press"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1104
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:159
msgid "Queue to a program"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:162
msgid "Redirect"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:158
msgid "Reject"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1113
msgid "Results"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1102
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:162
msgid "Rewrite destination"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:161
msgid "Rewrite source"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:232
msgid "Rules table"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1111
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:72
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:74
msgid "Search pages"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1084
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1093
msgid "Session"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1647
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:422
msgid "Show %d more rule"
msgid_plural "Show %d more rules"
msgstr[0] ""
msgstr[1] ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1656
msgid "Show full description"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1636
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1645
msgid "Show full help"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1636
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1645
msgid "Show full help: %s"
msgstr ""
@@ -181,6 +417,10 @@ msgstr ""
msgid "Swap free"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:250
msgid "This device"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:290
msgid "Total"
msgstr ""
@@ -189,11 +429,20 @@ msgstr ""
msgid "Total Available"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1081
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:407
msgctxt "nft counter"
msgid "Traffic matched by rule: %.1000mPackets, %.1024mBytes"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:384
msgid "Unchanged"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1090
msgid "Use dark theme"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1082
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1091
msgid "Use light theme"
msgstr ""
@@ -205,6 +454,14 @@ msgstr ""
msgid "Username"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:455
msgid "Verdict map %s"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:205
msgid "View"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:352
msgid "Wireless"
msgstr ""
@@ -213,44 +470,72 @@ msgstr ""
msgid "available"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1112
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1121
msgid "close"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:294
msgid "drops unmatched"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:294
msgid "drops unmatched %s"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:340
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:343
msgid "free"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1179
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1188
msgid "items"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1110
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1119
msgid "navigate"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1111
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:298
msgid "no bridge chains"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:313
msgid "no rules"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:344
msgid "on %s"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1120
msgid "open"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:865
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:939
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:874
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:948
msgid "page"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:865
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:939
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:874
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:948
msgid "pages"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:341
msgid "priority %d"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:289
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:299
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:303
msgid "reclaimable"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:885
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:296
msgid "rules drop some"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:894
msgid "to search all pages"
msgstr ""