mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-03 04:23:56 +08:00
💋 Sync 2026-10-21 01:19:43
This commit is contained in:
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
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Symlink
+1
@@ -0,0 +1 @@
|
||||
zh_Hans
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
|
||||
@@ -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*)")
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
Reference in new issue
Block a user