🐶 Sync 2026-11-08 23:51:11

This commit is contained in:
github-actions[bot] committed 2026-11-08 23:51:11 +08:00
1 parent 5a5196e2d6
commit 534c8d6ab2
73 files changed
+12872 -2580

No files matched your search

Binary file not shown.
+2 -2
View File
@@ -6,13 +6,13 @@ PKG_UPSTREAM_TAG:=v$(PKG_UPSTREAM_VERSION)
PKG_SHORT_SHA:=3f27ef3
PKG_VERSION:=2026.11.04.$(PKG_UPSTREAM_VERSION)
PKG_RELEASE:=26
PKG_RELEASE:=27
PKG_SOURCE:=$(PKG_NAME)-stable-$(PKG_UPSTREAM_TAG)-$(PKG_SHORT_SHA).tar.gz
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-stable-$(PKG_UPSTREAM_TAG)-$(PKG_SHORT_SHA)
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR)
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src
PKG_SOURCE_VERSION:=7cfb805e17bdb39e47de36296a41660d2027e954
PKG_SOURCE_VERSION:=205f65fc18f4dd0579b421a2271e9d46ba8cb6f3
PKG_HASH:=skip
PKG_BUILD_VERSION:=$(PKG_UPSTREAM_TAG)
+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=gately
PKG_VERSION:=0.6.5.1
PKG_RELEASE:=28
PKG_VERSION:=0.6.5.2
PKG_RELEASE:=29
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)?
+3 -3
View File
@@ -5,11 +5,11 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=honk
PKG_VERSION:=2026.11.07~73b1077
PKG_RELEASE:=32
PKG_VERSION:=2026.11.08~62b1390
PKG_RELEASE:=33
HONK_SRC_URL:=https://github.com/daeuniverse/honk.git
HONK_SRC_VERSION:=73b1077bf9cdb639899f0447994cf8980abc0752
HONK_SRC_VERSION:=62b1390401d9b5eb00dfaddad3e11db4a2539fdf
PKG_LICENSE:=AGPL-3.0-only
PKG_LICENSE_FILES:=LICENSE
+2 -2
View File
@@ -11,8 +11,8 @@ PKG_ARCH_ISTOREENHANCE:=$(ARCH)
PKG_NAME:=istoreenhance
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=0.8.8
PKG_RELEASE:=15
PKG_SOURCE_DATE:=0.9.0
PKG_RELEASE:=16
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
ARCH_HEXCODE=8664
+2 -2
View File
@@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk
PKG_ARCH_KS_CLI:=$(ARCH)
PKG_NAME:=ks-cli
PKG_VERSION:=0.8.9
PKG_RELEASE:=6
PKG_VERSION:=0.9.0
PKG_RELEASE:=7
PKG_SOURCE:=ks-cli-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/kspeeder/docker_kspeeder/releases/download/v$(PKG_VERSION)/
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for KSpeeder
LUCI_DEPENDS:=+istoreenhance +luci-lib-linkeaseauth +linkease-app-entry
LUCI_PKGARCH:=all
PKG_VERSION:=0.8.8-r1
PKG_RELEASE:=9
PKG_VERSION:=0.9.0-r1
PKG_RELEASE:=10
include $(TOPDIR)/feeds/luci/luci.mk
+2 -2
View File
@@ -7,8 +7,8 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=KaiPlus
PKG_VERSION:=1.0.9-r9
PKG_RELEASE:=15
PKG_VERSION:=1.0.11-r1
PKG_RELEASE:=16
LUCI_DEPENDS:=+kaiplus +luci-compat +luci-lib-taskd +luci-lib-linkeaseauth +linkease-app-entry
LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-open-box
PKG_VERSION:=0.1.414
PKG_RELEASE:=69
PKG_VERSION:=0.1.420
PKG_RELEASE:=70
PKG_MAINTAINER:=liandu2024
PKG_LICENSE:=GPL-3.0-or-later
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.11.3
PKG_RELEASE:=370
PKG_RELEASE:=371
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -336,6 +336,17 @@ local com = self.com
font-weight: normal;
opacity: .7;
}
/* Keep action controls out of theme help-text folding. */
#app_version .app-update-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem;
}
#app_version .app-update-actions > span {
min-width: 0;
overflow-wrap: anywhere;
}
</style>
<div class="cbi-value">
@@ -343,13 +354,13 @@ local com = self.com
<span class="app-version-sub"><%=api.get_version()%></span>
</label>
<div class="cbi-value-field">
<!--div class="cbi-value-description"-->
<div class="app-update-actions">
<input class="btn cbi-button cbi-button-apply" type="button" id="<%=api.appname%>-check_btn" data-update-app="<%=api.appname%>"
onclick="onBtnClick(this, appname);" value="<%:Check update%>" />
<input class="btn cbi-button cbi-button-apply" type="button" id="_<%=api.appname%>-force_btn" data-update-app="<%=api.appname%>"
onclick="doUpdate(this, appname);" value="<%:Force update%>" style="display:none" />
<span id="<%=api.appname%>-check_btn-detail"></span>
<!--/div-->
</div>
</div>
</div>
@@ -362,13 +373,13 @@ local com = self.com
<span class="app-version-sub"><span class="com_version" key="<%=k%>"><%=translate("Null")%></span></span>
</label>
<div class="cbi-value-field">
<!--div class="cbi-value-description"-->
<div class="app-update-actions">
<input class="btn cbi-button cbi-button-apply" type="button" id="_<%=k%>-check_btn" data-update-app="<%=k%>"
onclick="onBtnClick(this,'<%=k%>');" value="<%:Check update%>" />
<input class="btn cbi-button cbi-button-apply" type="button" id="_<%=k%>-force_btn" data-update-app="<%=k%>"
onclick="doUpdate(this,'<%=k%>');" value="<%:Force update%>" style="display:none"/>
<span id="_<%=k%>-check_btn-detail"></span>
<!--/div-->
</div>
</div>
</div>
<%end%>
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.11.3
PKG_RELEASE:=172
PKG_RELEASE:=173
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -344,6 +344,17 @@ local com = self.com
font-weight: normal;
opacity: .7;
}
/* Keep action controls out of theme help-text folding. */
#app_version .app-update-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem;
}
#app_version .app-update-actions > span {
min-width: 0;
overflow-wrap: anywhere;
}
</style>
<div class="cbi-value">
@@ -351,7 +362,7 @@ local com = self.com
<span class="app-version-sub"><%=api.get_version()%></span>
</label>
<div class="cbi-value-field">
<div class="cbi-value-description">
<div class="app-update-actions">
<input class="btn cbi-button cbi-button-apply" type="button" id="<%=api.appname%>-check_btn" data-update-app="<%=api.appname%>"
onclick="onBtnClick(this, appname);" value="<%:Check update%>" />
<input class="btn cbi-button cbi-button-apply" type="button" id="_<%=api.appname%>-force_btn" data-update-app="<%=api.appname%>"
@@ -370,7 +381,7 @@ local com = self.com
<span class="app-version-sub"><span class="com_version" key="<%=k%>"><%=translate("Null")%></span></span>
</label>
<div class="cbi-value-field">
<div class="cbi-value-description">
<div class="app-update-actions">
<input class="btn cbi-button cbi-button-apply" type="button" id="_<%=k%>-check_btn" data-update-app="<%=k%>"
onclick="onBtnClick(this,'<%=k%>');" value="<%:Check update%>" />
<input class="btn cbi-button cbi-button-apply" type="button" id="_<%=k%>-force_btn" data-update-app="<%=k%>"
+1 -1
View File
@@ -4,7 +4,7 @@ LUCI_TITLE:=luci-app-ssr-plus
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-ssr-plus
PKG_VERSION:=198
PKG_RELEASE:=101
PKG_RELEASE:=102
PKG_CONFIG_DEPENDS:= \
CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \
@@ -20,8 +20,7 @@ local CLASH_RULES_DIR = "/etc/ssrplus/clash"
local SUPPORTED_COMPONENTS = {
mainprogram = true,
xray = true,
mihomo = true,
naiveproxy = true
mihomo = true
}
local SUPPORTED_GEO_COMPONENTS = {
{ name = "country_mmdb", require = nil },
@@ -12,10 +12,6 @@
mihomo: {
label: '<%:Mihomo%>',
help: '<%:The online upgrade downloads and installs the matching Mihomo IPK or APK package for this system from fw876/helloworld releases.%>'
},
naiveproxy: {
label: '<%:NaiveProxy%>',
help: '<%:The online upgrade downloads and installs the matching NaiveProxy package for this system from fw876/helloworld releases.%>'
}
};
@@ -53,7 +49,6 @@
'Extract failed': '<%:Extract failed%>',
'xray binary not found in archive': '<%:xray binary not found in archive%>',
'mihomo binary not found in archive': '<%:mihomo binary not found in archive%>',
'naive binary not found in archive': '<%:naive binary not found in archive%>',
'Install failed': '<%:Install failed%>',
'Installed binary failed to run': '<%:Installed binary failed to run%>',
'Unsupported package manager': '<%:Unsupported package manager%>',
@@ -468,7 +463,6 @@
<option value="mainprogram"><%:SSR Plus+ Main Program%></option>
<option value="xray"><%:Xray-core%></option>
<option value="mihomo"><%:Mihomo%></option>
<option value="naiveproxy"><%:NaiveProxy%></option>
</select>
</div>
</div>
@@ -2677,12 +2677,6 @@ msgid ""
"package for this system from fw876/helloworld releases."
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:18
msgid ""
"The online upgrade downloads and installs the matching NaiveProxy package "
"for this system from fw876/helloworld releases."
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:816
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:871
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:89
@@ -3265,10 +3259,6 @@ msgstr ""
msgid "mihomo binary not found in archive"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:56
msgid "naive binary not found in archive"
msgstr ""
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:911
msgid "native UDP characteristics"
msgstr ""
-11
View File
@@ -2701,13 +2701,6 @@ msgid ""
msgstr ""
"在线升级会从 fw876/helloworld 发布页下载并安装适配当前系统的 Mihomo IPK 或 APK 软件包。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:18
msgid ""
"The online upgrade downloads and installs the matching NaiveProxy package "
"for this system from fw876/helloworld releases."
msgstr ""
"在线升级会从 fw876/helloworld 发布页下载并安装适配当前系统的 NaiveProxy 软件包。"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:816
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_main_panel.htm:871
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/clash_panel.htm:89
@@ -3292,10 +3285,6 @@ msgstr "mKCP 伪装(混淆)类型"
msgid "mihomo binary not found in archive"
msgstr "压缩包中未找到 mihomo 二进制文件"
#: applications/luci-app-ssr-plus/luasrc/view/shadowsocksr/component.htm:56
msgid "naive binary not found in archive"
msgstr "压缩包中未找到 naive 二进制文件"
#: applications/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua:911
msgid "native UDP characteristics"
msgstr "原生 UDP 特性"
@@ -103,17 +103,6 @@ version_gt() {
[ "$first" = "$left" ]
}
naiveproxy_versions_equal() {
local left right
left="$(trim_version "${1:-}")"
right="$(trim_version "${2:-}")"
[ -n "$left" ] || return 1
[ -n "$right" ] || return 1
[ "$left" = "$right" ] && return 0
[ "${left%%-*}" = "${right%%-*}" ]
}
get_openwrt_arch() {
local arch
@@ -163,22 +152,6 @@ find_mihomo_binary() {
return 1
}
find_naiveproxy_binary() {
if command -v naive >/dev/null 2>&1; then
command -v naive
return 0
fi
for path in /usr/bin/naive /usr/libexec/naive /etc/ssrplus/bin/naive; do
if [ -x "$path" ]; then
printf '%s' "$path"
return 0
fi
done
return 1
}
get_xray_current_version() {
if [ ! -x "$XRAY_BINARY" ]; then
return 1
@@ -211,29 +184,6 @@ get_mihomo_current_version() {
return 0
}
get_naiveproxy_current_version() {
local pm version binary
pm="$(detect_package_manager 2>/dev/null || true)"
case "$pm" in
apk)
version="$(apk list -I naiveproxy 2>/dev/null | sed -n 's/^naiveproxy-\([^[:space:]]*\).*$/\1/p' | sed -n '1p')"
;;
opkg)
version="$(opkg status naiveproxy 2>/dev/null | sed -n 's/^Version:[[:space:]]*//p' | sed -n '1p')"
;;
esac
if [ -n "${version:-}" ]; then
printf '%s' "$version"
return 0
fi
binary="$(find_naiveproxy_binary)" || return 1
"$binary" --version 2>&1 | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?' | sed -n '1p'
return 0
}
require_cmd() {
command -v "$1" >/dev/null 2>&1
}
@@ -1130,71 +1080,6 @@ get_mihomo_latest_info() {
return 0
}
select_naiveproxy_asset() {
local asset_list="$1"
local pm="$2"
local arch="$3"
local candidate
case "$pm" in
apk)
candidate="$(printf '%s\n' "$asset_list" | grep -E '^naiveproxy-[^[:space:]]+\.apk$' | grep -F "_${arch}.apk" | sed -n '1p')"
;;
opkg)
candidate="$(printf '%s\n' "$asset_list" | grep -E '^naiveproxy_[^[:space:]]+\.ipk$' | grep -F "_${arch}.ipk" | sed -n '1p')"
;;
*)
return 1
;;
esac
[ -n "$candidate" ] || return 1
printf '%s' "$candidate"
}
naiveproxy_asset_version() {
local asset="$1"
local arch="$2"
local version
case "$asset" in
naiveproxy_*_"$arch".ipk)
version="${asset#naiveproxy_}"
version="${version%_${arch}.ipk}"
;;
naiveproxy-*_"$arch".apk)
version="${asset#naiveproxy-}"
version="${version%_${arch}.apk}"
;;
*)
return 1
;;
esac
[ -n "$version" ] || return 1
printf '%s' "$version"
}
get_naiveproxy_latest_info() {
local pm arch tag version asset asset_list release_html url
pm="$(detect_package_manager)" || return 2
arch="$(get_openwrt_arch)"
tag="$(get_helloworld_latest_tag)" || return 3
release_html="$(fetch_text "https://github.com/fw876/helloworld/releases/expanded_assets/$tag")" || return 3
asset_list="$(printf '%s\n' "$release_html" | sed -n 's#.*href="/fw876/helloworld/releases/download/[^/]*/\([^"]*\)".*#\1#p')"
asset="$(select_naiveproxy_asset "$asset_list" "$pm" "$arch")" || return 4
version="$(naiveproxy_asset_version "$asset" "$arch")" || return 4
url="$(mirror_wrap_url "https://github.com/fw876/helloworld/releases/download/$tag/$asset")"
log_kv package_manager "$pm"
log_kv arch "$arch"
log_kv asset "$asset"
log_kv latest_version "$version"
log_kv download_url "$url"
return 0
}
get_mainprogram_current_version() {
local pm="$1"
local version
@@ -1522,54 +1407,6 @@ mihomo_info() {
log_kv error ''
}
naiveproxy_info() {
local pm current installed latest_output latest_rc latest_version arch asset can_upgrade
installed=0
current=""
pm="$(detect_package_manager 2>/dev/null || true)"
arch="$(get_openwrt_arch)"
if current="$(get_naiveproxy_current_version)" && [ -n "$current" ]; then
installed=1
fi
latest_output="$(get_naiveproxy_latest_info 2>/dev/null)"
latest_rc=$?
log_kv component naiveproxy
log_kv installed "$installed"
log_kv current_version "$current"
log_kv package_manager "$pm"
log_kv arch "$arch"
log_kv asset ''
if [ $latest_rc -ne 0 ]; then
log_kv can_upgrade 0
case "$latest_rc" in
2) log_kv error 'unsupported_package_manager' ;;
3) log_kv error 'fetch_failed' ;;
4) log_kv error 'asset_not_found' ;;
*) log_kv error 'unknown_error' ;;
esac
return 0
fi
latest_version="$(printf '%s\n' "$latest_output" | sed -n 's/^latest_version=//p' | sed -n '1p')"
pm="$(printf '%s\n' "$latest_output" | sed -n 's/^package_manager=//p' | sed -n '1p')"
arch="$(printf '%s\n' "$latest_output" | sed -n 's/^arch=//p' | sed -n '1p')"
asset="$(printf '%s\n' "$latest_output" | sed -n 's/^asset=//p' | sed -n '1p')"
can_upgrade=0
if [ -z "$current" ]; then
can_upgrade=1
elif ! naiveproxy_versions_equal "$latest_version" "$current" && version_gt "$latest_version" "$current"; then
can_upgrade=1
fi
printf '%s\n' "$latest_output" | sed '/^download_url=/d'
log_kv can_upgrade "$can_upgrade"
log_kv error ''
}
xray_local_info() {
local pm current installed arch
@@ -1614,28 +1451,6 @@ mihomo_local_info() {
log_kv error ''
}
naiveproxy_local_info() {
local pm current installed arch
installed=0
current=""
pm="$(detect_package_manager 2>/dev/null || true)"
arch="$(get_openwrt_arch)"
if current="$(get_naiveproxy_current_version)" && [ -n "$current" ]; then
installed=1
fi
log_kv component naiveproxy
log_kv installed "$installed"
log_kv current_version "$current"
log_kv latest_version ''
log_kv package_manager "$pm"
log_kv arch "$arch"
log_kv asset ''
log_kv can_upgrade 0
log_kv error ''
}
xray_upgrade() {
local latest_output latest_rc latest_version download_url package_manager asset tmp_dir package_file current_before current_after
@@ -1784,80 +1599,6 @@ mihomo_upgrade() {
return 0
}
naiveproxy_upgrade() {
local latest_output latest_rc latest_version download_url package_manager asset tmp_dir package_file current_before current_after
latest_output="$(get_naiveproxy_latest_info 2>/dev/null)"
latest_rc=$?
if [ $latest_rc -ne 0 ]; then
log_kv success 0
case "$latest_rc" in
2) log_kv message 'Unsupported package manager' ;;
3) log_kv message 'Failed to fetch release metadata' ;;
4) log_kv message 'Matching release asset not found' ;;
*) log_kv message 'Unknown error' ;;
esac
return 0
fi
latest_version="$(printf '%s\n' "$latest_output" | sed -n 's/^latest_version=//p' | sed -n '1p')"
download_url="$(printf '%s\n' "$latest_output" | sed -n 's/^download_url=//p' | sed -n '1p')"
package_manager="$(printf '%s\n' "$latest_output" | sed -n 's/^package_manager=//p' | sed -n '1p')"
asset="$(printf '%s\n' "$latest_output" | sed -n 's/^asset=//p' | sed -n '1p')"
current_before="$(get_naiveproxy_current_version 2>/dev/null || true)"
if [ -n "$current_before" ] && ! version_gt "$latest_version" "$current_before"; then
log_kv success 1
log_kv previous_version "$current_before"
log_kv current_version "$current_before"
log_kv latest_version "$latest_version"
log_kv message 'Already up to date'
return 0
fi
tmp_dir="$(mktemp -d /tmp/ssrplus-naiveproxy.XXXXXX)"
if [ -z "$tmp_dir" ] || [ ! -d "$tmp_dir" ]; then
log_kv success 0
log_kv message 'Failed to create temp directory'
return 0
fi
package_file="$tmp_dir/$asset"
trap "rm -rf '$tmp_dir'" EXIT INT TERM
if ! download_file "$download_url" "$package_file"; then
log_kv success 0
log_kv message 'Download failed'
return 0
fi
if [ ! -s "$package_file" ] || ! mainprogram_install_package "$package_manager" "$package_file"; then
log_kv success 0
log_kv message 'Install failed'
return 0
fi
current_after="$(get_naiveproxy_current_version 2>/dev/null || true)"
if [ -z "$current_after" ]; then
log_kv success 0
log_kv message 'Installed package check failed'
return 0
fi
if [ -x /etc/init.d/shadowsocksr ]; then
/etc/init.d/shadowsocksr restart >/dev/null 2>&1 || true
fi
log_kv success 1
log_kv previous_version "$current_before"
log_kv current_version "$current_after"
log_kv latest_version "$latest_version"
log_kv package_manager "$package_manager"
log_kv arch "$(get_openwrt_arch)"
log_kv asset "$asset"
log_kv message 'Upgrade completed'
return 0
}
# ==================== 主程序入口 ====================
case "${1:-}" in
# 主程序相关
@@ -1890,16 +1631,6 @@ case "${1:-}" in
mihomo_upgrade)
mihomo_upgrade
;;
# Naiveproxy 相关
naiveproxy_info)
naiveproxy_info
;;
naiveproxy_local_info)
naiveproxy_local_info
;;
naiveproxy_upgrade)
naiveproxy_upgrade
;;
# Country MMDB 相关
country_mmdb_info)
country_mmdb_info
@@ -1942,7 +1673,7 @@ case "${1:-}" in
;;
*)
log_kv success 0
log_kv message 'Usage: update_components.sh mainprogram_info|mainprogram_local_info|mainprogram_upgrade|xray_info|xray_local_info|xray_upgrade|mihomo_info|mihomo_local_info|mihomo_upgrade|naiveproxy_info|naiveproxy_local_info|naiveproxy_upgrade|country_mmdb_info|country_mmdb_local_info|country_mmdb_upgrade|geosite_info|geosite_local_info|geosite_upgrade|v2ray_geoip_info|v2ray_geoip_local_info|v2ray_geoip_upgrade|v2ray_geosite_info|v2ray_geosite_local_info|v2ray_geosite_upgrade'
log_kv message 'Usage: update_components.sh mainprogram_info|mainprogram_local_info|mainprogram_upgrade|xray_info|xray_local_info|xray_upgrade|mihomo_info|mihomo_local_info|mihomo_upgrade|country_mmdb_info|country_mmdb_local_info|country_mmdb_upgrade|geosite_info|geosite_local_info|geosite_upgrade|v2ray_geoip_info|v2ray_geoip_local_info|v2ray_geoip_upgrade|v2ray_geosite_info|v2ray_geosite_local_info|v2ray_geosite_upgrade'
return 1 2>/dev/null || exit 1
;;
esac
+1
View File
@@ -1,3 +1,4 @@
.deploy/target.env
.deploy/.ssh/
.private
/artifacts/
+5 -2
View File
@@ -9,8 +9,11 @@ LUCI_TITLE:=LuCI for Tailscale (NG)
LUCI_DEPENDS:=+tailscale
LUCI_PKGARCH:=all
PKG_VERSION:=0.1.1
PKG_RELEASE:=3
PKG_VERSION:=0.1.2
# match the i18n pkg version to the main pkg version
PKG_RELEASE:=4
PKG_PO_VERSION:=$(PKG_VERSION)-r$(PKG_RELEASE)
include $(TOPDIR)/feeds/luci/luci.mk
+5
View File
@@ -101,9 +101,14 @@ Download the latest `.apk` package from [Releases](https://github.com/vad-b/luci
apk add --allow-untrusted --upgrade /tmp/luci-app-tailscale-ng_*.apk
```
Simplified Chinese translation is also available as a separate `luci-i18n-tailscale-ng-zh-cn` package in both `.apk` and `.ipk` formats in [Releases](https://github.com/vad-b/luci-app-tailscale-ng/releases).
For local development builds and testing, see [Local package builds](docs/local-build.md).
## Credits
- [asvow/luci-app-tailscale](https://github.com/asvow/luci-app-tailscale) - the project that inspired this work
- [GFander](https://github.com/GFander) - Simplified Chinese translation
## License
+65
View File
@@ -0,0 +1,65 @@
# Local package builds
Build locally with the same script used by GitHub Actions. No push or release
is needed.
## Prepare
Use Ubuntu 24.04 x86_64, including under WSL2, as a regular user. Run the
commands below in the Linux shell. Install dependencies once:
```sh
sudo apt-get update
sudo apt-get install -y git build-essential flex bison gawk gcc-multilib g++-multilib gettext libncurses-dev libssl-dev python3-setuptools rsync unzip zlib1g-dev file wget curl zstd
```
## Build
Commit the changes to test locally: the script builds the current commit and
requires a clean working tree, including untracked files. From the repository
root, build APK packages for OpenWrt 25.12:
```sh
JOBS=2 bash scripts/build-packages.sh apk
```
For IPK packages targeting OpenWrt 24.10, use:
```sh
JOBS=2 bash scripts/build-packages.sh ipk
```
Outputs go to `artifacts/apk/` or `artifacts/ipk/`: the application package,
Chinese translation, `SHA256SUMS` and `build-info.txt` with the source commit
and SDK details. SDK versions and checksums are fixed in the build script.
The build host must be x86_64; the two output packages are architecture
independent. Obtain native dependencies such as Tailscale from the router's
own feeds.
## Check and test
For an APK build, verify the outputs and source commit:
```sh
cd artifacts/apk
sha256sum --check SHA256SUMS
cat build-info.txt
cd ../..
```
Back up the router's Tailscale configuration and authorization state before
testing. Transfer both packages and install them using the
[manual installation instructions](../README.md#manual-installation).
Local APK packages are unsigned and require `--allow-untrusted`.
Check installed versions, preserved settings and login, service operation,
and the translated LuCI page. If Software hides the translation package,
select **Display LuCI translation packages → all**.
## Cache
The first build is slower; later builds reuse `~/.cache/luci-app-tailscale-ng`.
Keep this cache on a Linux filesystem at a path without spaces. Delete that
directory for a fresh SDK, or `artifacts/` to discard generated packages.
Optional overrides are `BUILD_CACHE_DIR`, `OUTPUT_DIR` and `JOBS`.
@@ -0,0 +1,314 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:199
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:618
msgid "-- None --"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:587
msgid "Accept DNS"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:588
msgid "Accept DNS configuration from the control server."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:601
msgid "Accept Subnet Routes"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:602
msgid "Accept subnet routes that other nodes advertise."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:648
msgid "Additional Flags"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:596
msgid "Advertise Exit Node"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:606
msgid "Advertised Routes"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:164
msgid "Are you sure you want to log out and clean the local state?"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:522
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:528
msgid "Authentication"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:534
msgid "Base URL of login (control) server."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:283
msgid "Checking login..."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:368
msgid "Checking status..."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:355
msgid "Click here to log in"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:131
msgid "Command exited with code"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:585
msgid "DNS"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:552
msgid ""
"Disconnect from login server, expire current login, and flush local state "
"cache."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:173
msgid "Error:"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:151
msgid "Error: unable to load status data."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:616
msgid "Exit Node"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:646
msgid "Extra"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:650
msgid "Extra flags in format --flag=value. See %s for available options."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:575
msgid "Firewall Mode"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:560
msgid "General"
msgstr ""
#: root/usr/share/rpcd/acl.d/luci-app-tailscale-ng.json:3
msgid "Grant access to Tailscale NG configuration"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:562
msgid "Listen Port"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:387
msgid "Loading..."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:279
msgid "Logged in"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:281
msgid "Logged out"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:630
msgid "Logging"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:167
msgid "Logging out and cleaning state..."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:634
msgid "Logging program activities."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:640
msgid "Logging program errors and exceptions."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:533
msgid "Login Server"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:530
msgid "Login Status"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:549
msgid "Logout"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:546
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:550
msgid "Logout and Clean"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:363
msgid "Manage in Admin Console"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:514
msgid "N/A"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:391
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:707
msgid "No output available. Service may not be running."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:270
msgid "Not running"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:597
msgid "Offer to be an exit node for outbound internet traffic."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:571
msgid "Path to state file. WARNING: Changing this requires reauthentication."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:538
msgid "Pre-authentication Key"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:313
msgid "Restart"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:315
msgid "Restarting Tailscale service..."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:594
msgid "Routing"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:273
msgid "Running"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:617
msgid "Select an online machine to use as an exit node."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:577
msgid "Select the firewall backend to use."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:366
msgid "Service not running"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:539
msgid "Set the machine authorization key."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:523
msgid "Settings"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:326
msgid "Start"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:504
msgid "Start Tailscale service on boot."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:328
msgid "Starting Tailscale service..."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:569
msgid "State File"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:524
msgid "Status"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:638
msgid "StdErr Logging"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:632
msgid "StdOut Logging"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:319
msgid "Stop"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:321
msgid "Stopping Tailscale service..."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:607
msgid ""
"Subnets to advertise to other Tailscale nodes, e.g. <code>192.168.1.0/24</"
"code>."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:681
msgid "Tailscale DNS Status"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:503
msgid "Tailscale Enabled"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:663
msgid "Tailscale IP"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:669
msgid "Tailscale Interface"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:492
#: root/usr/share/luci/menu.d/luci-app-tailscale-ng.json:3
msgid "Tailscale NG"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:675
msgid "Tailscale Netcheck"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:508
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:657
msgid "Tailscale Status"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:512
msgid "Tailscale Version"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:651
msgid "Tailscale documentation"
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:493
msgid "Tailscale is a cross-platform and easy to use virtual LAN."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:564
msgid "UDP port to listen on for WireGuard and peer-to-peer traffic."
msgstr ""
#: htdocs/luci-static/resources/view/tailscale-ng/index.js:540
msgid ""
"Use a non-reusable auth key and disable key expiry for trusted machines via "
"the provider admin console."
msgstr ""
@@ -3,16 +3,16 @@
# LuCI for Tailscale (NG) - Simplified Chinese (简体中文) translation
#
# Extracted with: build/i18n-scan.pl from the openwrt/luci repository
# Language: zh_CN / zh-cn
# Language: zh_Hans (Simplified Chinese)
msgid ""
msgstr ""
"Project-Id-Version: luci-app-tailscale-ng 0.1.1\n"
"Project-Id-Version: luci-app-tailscale-ng 0.1.2\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-15 00:00+0000\n"
"PO-Revision-Date: 2026-08-15 00:00+0000\n"
"Last-Translator: \n"
"Language-Team: Chinese (Simplified) <zh-cn>\n"
"Language: zh_CN\n"
"Language-Team: Chinese (Simplified)\n"
"Language: zh_Hans\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-only
set -euo pipefail
# Use Linux tools only, including when invoked from WSL with Windows PATH entries.
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export LC_ALL=C
format="${1:-apk}"
case "$format" in
apk)
sdk_version=25.12.6
sdk_file=openwrt-sdk-25.12.6-x86-64_gcc-14.3.0_musl.Linux-x86_64.tar.zst
sdk_url="https://downloads.openwrt.org/releases/$sdk_version/targets/x86/64/$sdk_file"
sdk_sha256=1864152619092e781fa36a401960c8e9ae42d6a364c44ee5f0b197ca5f3c0fcc
;;
ipk)
sdk_version=24.10.4
sdk_file=openwrt-sdk-24.10.4-x86-64_gcc-13.3.0_musl.Linux-x86_64.tar.zst
sdk_url="https://archive.openwrt.org/releases/$sdk_version/targets/x86/64/$sdk_file"
sdk_sha256=229e871f734a2cee5ce3ad6a3e98d3836b0899bfdeaea4d9c2c5cc7b1fce1407
;;
*) printf 'Usage: %s [apk|ipk]\n' "$0" >&2; exit 1 ;;
esac
source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cache_dir="${BUILD_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/luci-app-tailscale-ng}"
output_dir="${OUTPUT_DIR:-$source_dir/artifacts/$format}"
jobs="${JOBS:-$(nproc)}"
if [[ "$(id -u)" == 0 ]]; then
printf 'Run the SDK build as a regular Linux user.\n' >&2
exit 1
fi
if [[ -n "$(git -C "$source_dir" status --porcelain)" ]]; then
printf 'Commit the source changes first; the build uses an exact Git snapshot.\n' >&2
exit 1
fi
source_commit="$(git -C "$source_dir" rev-parse HEAD)"
mkdir -p "$cache_dir/downloads" "$output_dir"
cache_dir="$(realpath "$cache_dir")"
output_dir="$(realpath "$output_dir")"
sdk_dir="$cache_dir/sdk-$sdk_version"
archive="$cache_dir/downloads/$sdk_file"
if [[ "$sdk_dir" == *[[:space:]]* ]]; then
printf 'The SDK cache path must not contain whitespace.\n' >&2
exit 1
fi
if [[ ! -f "$archive" ]]; then
curl --fail --location --retry 3 "$sdk_url" --output "$archive.part"
mv "$archive.part" "$archive"
fi
printf '%s %s\n' "$sdk_sha256" "$archive" | sha256sum --check
if [[ ! -f "$sdk_dir/.sdk-ready" ]]; then
mkdir -p "$sdk_dir"
tar --zstd -xf "$archive" -C "$sdk_dir" --strip-components=1
touch "$sdk_dir/.sdk-ready"
fi
# Export only committed source: no deployment credentials or private notes.
package_dir="$sdk_dir/package/luci-app-tailscale-ng"
rm -rf -- "$package_dir"
mkdir -p "$package_dir"
git -C "$source_dir" archive "$source_commit" | tar -xf - -C "$package_dir"
package_version="$(sed -n 's/^PKG_VERSION:=//p' "$package_dir/Makefile")"
package_release="$(sed -n 's/^PKG_RELEASE:=//p' "$package_dir/Makefile")"
cd "$sdk_dir"
# Keep the release SDK's pinned feed revisions instead of tracking moving branches.
if [[ ! -f .feeds-ready ]]; then
./scripts/feeds update -a
touch .feeds-ready
fi
./scripts/feeds uninstall -a
./scripts/feeds install tailscale luci-base
cat > .config <<EOF
CONFIG_ALL_NONSHARED=n
CONFIG_ALL_KMODS=n
CONFIG_ALL=n
CONFIG_AUTOREMOVE=n
CONFIG_SIGNED_PACKAGES=n
CONFIG_PACKAGE_luci-app-tailscale-ng=m
CONFIG_LUCI_LANG_zh_Hans=y
CONFIG_PACKAGE_luci-i18n-tailscale-ng-zh-cn=m
EOF
make defconfig
grep -qx 'CONFIG_PACKAGE_luci-i18n-tailscale-ng-zh-cn=m' .config
make package/luci-app-tailscale-ng/clean
make -j"$jobs" package/luci-app-tailscale-ng/compile V=s
# Require both outputs; a successful app build alone does not deliver a translation.
find "$output_dir" -maxdepth 1 -type f \( \
-name "luci-app-tailscale-ng[-_]*.$format" -o \
-name "luci-i18n-tailscale-ng-zh-cn[-_]*.$format" \) -delete
for package in luci-app-tailscale-ng luci-i18n-tailscale-ng-zh-cn; do
mapfile -t files < <(find bin/packages -type f -name "${package}[-_]${package_version}-r${package_release}[._]*${format}")
if [[ "${#files[@]}" != 1 ]]; then
printf 'Expected one %s package, found %s.\n' "$package" "${#files[@]}" >&2
exit 1
fi
cp "${files[0]}" "$output_dir/"
done
{
printf 'source_commit=%s\n' "$source_commit"
printf 'sdk_version=%s\nsdk_sha256=%s\nformat=%s\n' "$sdk_version" "$sdk_sha256" "$format"
cat feeds.conf.default
} > "$output_dir/build-info.txt"
(cd "$output_dir" && sha256sum -- *."$format" > SHA256SUMS)
printf '\nPackages saved to %s\n' "$output_dir"
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-trafficctl
PKG_VERSION:=1.26.4
PKG_RELEASE:=22
PKG_VERSION:=1.28.0
PKG_RELEASE:=23
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Denis Iusupov <yusdyr@gmail.com>
@@ -172,8 +172,9 @@ else
# The download rule is listed first; the upload one is the "_ul" name.
RL_DUMP=$(iptables -t mangle -L FORWARD -nv 2>/dev/null | grep "rl_ratelimit" \
| awk -v ip="$IP" '{ for (i = 1; i <= NF; i++) if ($i == ip || $i == ip "/32") { print; next } }')
RATE_LIM=$(echo "$RL_DUMP" | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//')
RATE_LIM_UP=$(echo "$RL_DUMP" | grep -E 'rl_ratelimit.*_ul' | grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//')
RATE_LIM=$(echo "$RL_DUMP" | awk "$TCTL_NFT_RATE_AWK"'{ r = tctl_ipt_first_rate(); if (r >= 0) { print r; exit } }')
RATE_LIM_UP=$(echo "$RL_DUMP" | grep -E 'rl_ratelimit.*_ul' \
| awk "$TCTL_NFT_RATE_AWK"'{ r = tctl_ipt_first_rate(); if (r >= 0) { print r; exit } }')
fi
[ -z "$RATE_LIM" ] && RATE_LIM=0
[ -z "$RATE_LIM_UP" ] && RATE_LIM_UP=0
@@ -279,12 +279,14 @@ tctl_ratelimit_add() {
TCTL_RL_UPLOAD6_OK=0
# shellcheck disable=SC2034
TCTL_RL_DOWNLOAD6_OK=0
# shellcheck disable=SC2046 # deliberate split into the option words
iptables -t mangle -A FORWARD -d "$ip" -m hashlimit \
--hashlimit-above "${rate_kbit}kbit/sec" --hashlimit-burst "${rate_kbit}kbit" \
$(tctl_ipt_limit_args "$rate_kbit") \
--hashlimit-mode dstip --hashlimit-name "rl_${comment}" \
-j DROP -m comment --comment "$comment" 2>/dev/null
# shellcheck disable=SC2046 # deliberate split into the option words
iptables -t mangle -A FORWARD -s "$ip" -m hashlimit \
--hashlimit-above "${rate_kbit_up}kbit/sec" --hashlimit-burst "${rate_kbit_up}kbit" \
$(tctl_ipt_limit_args "$rate_kbit_up") \
--hashlimit-mode srcip --hashlimit-name "rl_${comment}_ul" \
-j DROP -m comment --comment "${comment}_ul" 2>/dev/null
fi
@@ -382,8 +384,50 @@ function tctl_rate_kbit(n, u, b) {
# Rounded here, so a reader printing %d agrees with one printing %.0f.
return int(b * 8 / 1000 + 0.5)
}
# An iptables hashlimit rate as listed, e.g. "624992b/s" or "1213mb/s".
# iptables prints bytes in 1024 steps too. A packet rate ("5000/sec") is not a
# ceiling in kbit/s and reads 0.
function tctl_ipt_rate_kbit(t, n, u) {
if (!match(t, /^[0-9]+/)) return 0
n = substr(t, 1, RLENGTH); u = substr(t, RLENGTH + 1)
if (u ~ /^b\//) return tctl_rate_kbit(n, "bytes")
if (u ~ /^kb\//) return tctl_rate_kbit(n, "kbytes")
if (u ~ /^mb\//) return tctl_rate_kbit(n, "mbytes")
if (u ~ /^gb\//) return tctl_rate_kbit(n, "gbytes")
return 0
}
# The ceiling of the hashlimit rule on the current line: "above X" in an
# "iptables -L" listing, "--hashlimit-above X" in "iptables -S". -1 if none.
function tctl_ipt_first_rate( i) {
for (i = 1; i < NF; i++)
if ($i == "above" || $i == "--hashlimit-above") return tctl_ipt_rate_kbit($(i+1))
return -1
}
'
# hashlimit arguments for a kbit/s ceiling, in BYTES.
#
# "${kbit}kbit/sec" is not a byte rate to hashlimit: it reads the number as
# PACKETS per second (iptables -S lists it back as "5000/sec"), and the
# "${kbit}kbit" burst written next to it is rejected outright ("bad value for
# option --hashlimit-burst", iptables 1.8.7 and 1.8.10). The 2>/dev/null after
# the rule hid that, so on fw3 no limit was ever installed.
#
# Bytes per second, SI like the nft side (tctl_rate_bytes). The burst is two
# seconds of the rate: hashlimit refuses a byte burst below about one second
# of it ("burst cannot be smaller than 125172064b" at 1 Gbit/s), and it is a
# 32-bit field, hence the cap, which puts the highest ceiling fw3 can
# express near 30 Gbit/s (10 Gbit/s is accepted, 40 is not). Verified on iptables-legacy 1.8.7 with iperf3
# through a forwarding netns: 5, 50 and 1000 Mbit/s held at 4.9-4.97, 49-50.6
# and 990-1003 Mbit/s.
tctl_ipt_limit_args() {
local b burst
b=$(tctl_rate_bytes "$1")
burst=$(( b * 2 ))
[ "$burst" -gt 4000000000 ] && burst=4000000000
echo "--hashlimit-above ${b}b/s --hashlimit-burst ${burst}b"
}
tctl_ratelimit_list() {
if [ "$TCTL_FW" = "nft" ]; then
nft list table netdev tm_ratelimit 2>/dev/null
@@ -115,6 +115,8 @@ BEGIN {
print "# TYPE trafficctl_device_blocked gauge"
print "# HELP trafficctl_device_limit_kbit Applied throttle in kbit/s (0 = none)."
print "# TYPE trafficctl_device_limit_kbit gauge"
print "# HELP trafficctl_device_limit_up_kbit Applied upload throttle in kbit/s (0 = none); equals the download one unless split."
print "# TYPE trafficctl_device_limit_up_kbit gauge"
print "# HELP trafficctl_device_wifi_blocked Whether the device is denied on WiFi."
print "# TYPE trafficctl_device_wifi_blocked gauge"
print "# HELP trafficctl_device_conntrack_bytes Bytes currently accounted in conntrack (falls as flows expire)."
@@ -132,6 +134,10 @@ BEGIN {
printf "trafficctl_device_wifi_blocked{ip=\"%s\"} %d\n", ip, (index($0, "\"wifi_blocked\":true") ? 1 : 0)
printf "trafficctl_device_limit_kbit{ip=\"%s\",mode=\"limiter\"} %d\n", ip, num($0, "rate_limit_kbit")
printf "trafficctl_device_limit_kbit{ip=\"%s\",mode=\"shaper\"} %d\n", ip, num($0, "shape_kbit")
# Its own metric rather than a direction label on the one above: a new
# label would start new series for every existing dashboard and alert.
printf "trafficctl_device_limit_up_kbit{ip=\"%s\",mode=\"limiter\"} %d\n", ip, num($0, "rate_limit_kbit_up")
printf "trafficctl_device_limit_up_kbit{ip=\"%s\",mode=\"shaper\"} %d\n", ip, num($0, "shape_kbit_up")
# Conntrack view: current in-flight totals and the protocol split.
printf "trafficctl_device_conntrack_bytes{ip=\"%s\",proto=\"all\"} %.0f\n", ip, num($0, "total")
printf "trafficctl_device_conntrack_bytes{ip=\"%s\",proto=\"tcp\"} %.0f\n", ip, num($0, "tcp")
@@ -154,13 +154,15 @@ pfw_add_limit() {
fi
else
if [ "$scope" = "input" ]; then
# shellcheck disable=SC2046 # deliberate split into the option words
iptables -I INPUT -p "$proto" --dport "$(iptables_dport "$port")" -m hashlimit \
--hashlimit-above "${kbit}kbit/sec" --hashlimit-burst "$kbit" \
$(tctl_ipt_limit_args "$kbit") \
--hashlimit-mode dstport --hashlimit-name "$(hashlimit_name "$proto" "$port")" \
-j DROP -m comment --comment "$cmt" 2>/dev/null
else
# shellcheck disable=SC2046 # deliberate split into the option words
iptables -I FORWARD -d "$ip" -p "$proto" --dport "$(iptables_dport "$port")" -m hashlimit \
--hashlimit-above "${kbit}kbit/sec" --hashlimit-burst "$kbit" \
$(tctl_ipt_limit_args "$kbit") \
--hashlimit-mode dstip --hashlimit-name "$(hashlimit_name "$proto" "$port")" \
-j DROP -m comment --comment "$cmt" 2>/dev/null
fi
@@ -372,7 +374,7 @@ do_list() {
st=$(printf '%s' "$CTL_DUMP" | grep "$(pfw_comment limit "$p" "$cmt_ip" "$port")" | \
awk "$TCTL_NFT_RATE_AWK"'{ for (i = 1; i < NF - 1; i++) if ($i == "over") { printf "%.0f\n", tctl_rate_kbit($(i+1), $(i+2)); exit } }')
[ -z "$st" ] && st=$(printf '%s' "$CTL_DUMP" | grep "$(pfw_comment limit "$p" "$cmt_ip" "$port")" | \
grep -oE 'hashlimit-above [0-9]+' | awk '{print $2; exit}')
awk "$TCTL_NFT_RATE_AWK"'{ r = tctl_ipt_first_rate(); if (r >= 0) { print r; exit } }')
[ -n "$st" ] && limit_kbit="$st"
fi
done
@@ -65,7 +65,7 @@ else
# hashlimit is keyed on dstip whatever mode was asked for, so on this path
# every bucket is per-address — "each" is the honest answer, not the
# requested mode. Documented in docs/API.md: shared needs nftables.
iptables -t mangle -L FORWARD -nvx 2>/dev/null | grep "rl_ratelimit" | awk '
iptables -t mangle -L FORWARD -nvx 2>/dev/null | grep "rl_ratelimit" | awk "$TCTL_NFT_RATE_AWK"'
{
packets = $1
bytes = $2
@@ -77,14 +77,9 @@ else
}
if (i == 9) ip = $i
}
# extract rate from hashlimit-above
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+kbit/) {
gsub(/kbit.*/, "", $i)
rate = $i
break
}
}
# the "above X" ceiling, in iptables units (see tctl_ipt_rate_kbit)
rate = tctl_ipt_first_rate()
if (rate < 0) rate = 0
if (ip != "" && ip != "0.0.0.0/0") {
if (!(ip in seen)) { seen[ip] = 1; order[++n] = ip }
rate_of[ip] = rate
@@ -343,7 +343,7 @@ lookup_rate_limit() {
# way; the upload one is the only "_ul" name.
r=$(echo "$RL_DUMP" | grep -E "$pat" \
| awk -v ip="$ip" '{ for (i = 1; i <= NF; i++) if ($i == ip || $i == ip "/32") { print; next } }' \
| grep -oE '[0-9]+kbit' | head -1 | sed 's/kbit//')
| awk "$TCTL_NFT_RATE_AWK"'{ r = tctl_ipt_first_rate(); if (r >= 0) { print r; exit } }')
fi
echo "${r:-0}"
}
@@ -450,13 +450,34 @@ build_device_keyboard() {
rm -f "$tmpkb"
}
# A ceiling as "8000 kbit/s", or "8000↓ / 2000↑ kbit/s" when upload has its
# own (#66). An upload figure of 0 or equal to download is not a split.
tg_ceiling() {
if [ "${2:-0}" -gt 0 ] 2>/dev/null && [ "$2" != "$1" ]; then
printf '%s↓ / %s↑ kbit/s' "$1" "$2"
else
printf '%s kbit/s' "$1"
fi
}
# The same, compact, for the /status flags: "8000k" or "8000/2000k".
tg_ceiling_short() {
if [ "${2:-0}" -gt 0 ] 2>/dev/null && [ "$2" != "$1" ]; then
printf '%s/%sk' "$1" "$2"
else
printf '%sk' "$1"
fi
}
build_action_keyboard() {
local ip="$1" devices="$2"
local blocked wifi_blocked rl_kbit shape_kbit
local blocked wifi_blocked rl_kbit shape_kbit rl_up shape_up
blocked=$(get_device_field "$devices" "$ip" "blocked")
wifi_blocked=$(get_device_field "$devices" "$ip" "wifi_blocked")
rl_kbit=$(get_device_field "$devices" "$ip" "rate_limit_kbit")
shape_kbit=$(get_device_field "$devices" "$ip" "shape_kbit")
rl_up=$(get_device_field "$devices" "$ip" "rate_limit_kbit_up")
shape_up=$(get_device_field "$devices" "$ip" "shape_kbit_up")
conn_type=$(get_device_field "$devices" "$ip" "conn_type")
local kb='{"inline_keyboard":['
@@ -487,7 +508,7 @@ build_action_keyboard() {
# limiter
if [ "$TG_BTN_LIMIT" = "1" ]; then
if [ "${rl_kbit:-0}" -gt 0 ] 2>/dev/null; then
rows="${rows}[{\"text\":\"⚡ Limit: ${rl_kbit} kbit/s — Remove\",\"callback_data\":\"act:unlimit:${ip}\"}],"
rows="${rows}[{\"text\":\"⚡ Limit: $(tg_ceiling "$rl_kbit" "$rl_up") — Remove\",\"callback_data\":\"act:unlimit:${ip}\"}],"
else
rows="${rows}[{\"text\":\"⚡ 1M\",\"callback_data\":\"act:limit:${ip}:1000\"},{\"text\":\"⚡ 2M\",\"callback_data\":\"act:limit:${ip}:2000\"},{\"text\":\"⚡ 5M\",\"callback_data\":\"act:limit:${ip}:5000\"}],"
rows="${rows}[{\"text\":\"⚡ 10M\",\"callback_data\":\"act:limit:${ip}:10000\"},{\"text\":\"⚡ 25M\",\"callback_data\":\"act:limit:${ip}:25000\"},{\"text\":\"⚡ 50M\",\"callback_data\":\"act:limit:${ip}:50000\"},{\"text\":\"⚡ 100M\",\"callback_data\":\"act:limit:${ip}:100000\"}],"
@@ -497,7 +518,7 @@ build_action_keyboard() {
# shaper
if [ "$TG_BTN_SHAPE" = "1" ]; then
if [ "${shape_kbit:-0}" -gt 0 ] 2>/dev/null; then
rows="${rows}[{\"text\":\"🔧 Shape: ${shape_kbit} kbit/s — Remove\",\"callback_data\":\"act:unshape:${ip}\"}],"
rows="${rows}[{\"text\":\"🔧 Shape: $(tg_ceiling "$shape_kbit" "$shape_up") — Remove\",\"callback_data\":\"act:unshape:${ip}\"}],"
else
rows="${rows}[{\"text\":\"🔧 1M\",\"callback_data\":\"act:shape:${ip}:1000\"},{\"text\":\"🔧 2M\",\"callback_data\":\"act:shape:${ip}:2000\"},{\"text\":\"🔧 5M\",\"callback_data\":\"act:shape:${ip}:5000\"}],"
rows="${rows}[{\"text\":\"🔧 10M\",\"callback_data\":\"act:shape:${ip}:10000\"},{\"text\":\"🔧 25M\",\"callback_data\":\"act:shape:${ip}:25000\"},{\"text\":\"🔧 50M\",\"callback_data\":\"act:shape:${ip}:50000\"},{\"text\":\"🔧 100M\",\"callback_data\":\"act:shape:${ip}:100000\"}],"
@@ -556,7 +577,7 @@ handle_devices_edit() {
}
handle_status() {
local devices ip name rl_kbit shape_kbit blocked wifi_blocked
local devices ip name rl_kbit shape_kbit rl_up shape_up blocked wifi_blocked
local result=""
devices=$(get_devices)
[ -z "$devices" ] || [ "$devices" = "[]" ] && { tg_send "No active devices"; return; }
@@ -566,13 +587,15 @@ handle_status() {
wifi_blocked=$(get_device_field "$devices" "$ip" "wifi_blocked")
rl_kbit=$(get_device_field "$devices" "$ip" "rate_limit_kbit")
shape_kbit=$(get_device_field "$devices" "$ip" "shape_kbit")
rl_up=$(get_device_field "$devices" "$ip" "rate_limit_kbit_up")
shape_up=$(get_device_field "$devices" "$ip" "shape_kbit_up")
name=$(get_device_field "$devices" "$ip" "name")
local flags=""
[ "$blocked" = "true" ] || [ "$blocked" = "1" ] && flags="${flags} 🚫inet"
[ "$wifi_blocked" = "true" ] || [ "$wifi_blocked" = "1" ] && flags="${flags} 📵wifi"
[ "${rl_kbit:-0}" -gt 0 ] 2>/dev/null && flags="${flags} ⚡${rl_kbit}k"
[ "${shape_kbit:-0}" -gt 0 ] 2>/dev/null && flags="${flags} 🔧${shape_kbit}k"
[ "${rl_kbit:-0}" -gt 0 ] 2>/dev/null && flags="${flags} ⚡$(tg_ceiling_short "$rl_kbit" "$rl_up")"
[ "${shape_kbit:-0}" -gt 0 ] 2>/dev/null && flags="${flags} 🔧$(tg_ceiling_short "$shape_kbit" "$shape_up")"
[ -n "$flags" ] && result="${result}${name:-?} (${ip}):${flags}\n"
done
@@ -616,17 +639,19 @@ handle_callback() {
invalidate_cache
devices=$(get_devices)
name=$(get_device_field "$devices" "$ip" "name")
local blocked wifi_blocked rl_kbit shape_kbit
local blocked wifi_blocked rl_kbit shape_kbit rl_up shape_up
blocked=$(get_device_field "$devices" "$ip" "blocked")
wifi_blocked=$(get_device_field "$devices" "$ip" "wifi_blocked")
rl_kbit=$(get_device_field "$devices" "$ip" "rate_limit_kbit")
shape_kbit=$(get_device_field "$devices" "$ip" "shape_kbit")
rl_up=$(get_device_field "$devices" "$ip" "rate_limit_kbit_up")
shape_up=$(get_device_field "$devices" "$ip" "shape_kbit_up")
local status_line=""
[ "$blocked" = "true" ] || [ "$blocked" = "1" ] && status_line="${status_line}🚫 Internet blocked\n"
[ "$wifi_blocked" = "true" ] || [ "$wifi_blocked" = "1" ] && status_line="${status_line}📵 WiFi blocked\n"
[ "${rl_kbit:-0}" -gt 0 ] 2>/dev/null && status_line="${status_line}⚡ Limiter: ${rl_kbit} kbit/s\n"
[ "${shape_kbit:-0}" -gt 0 ] 2>/dev/null && status_line="${status_line}🔧 Shaper: ${shape_kbit} kbit/s\n"
[ "${rl_kbit:-0}" -gt 0 ] 2>/dev/null && status_line="${status_line}⚡ Limiter: $(tg_ceiling "$rl_kbit" "$rl_up")\n"
[ "${shape_kbit:-0}" -gt 0 ] 2>/dev/null && status_line="${status_line}🔧 Shaper: $(tg_ceiling "$shape_kbit" "$shape_up")\n"
[ -z "$status_line" ] && status_line="✅ No restrictions\n"
local text
+2 -2
View File
@@ -10,8 +10,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-vantage
PKG_VERSION:=1.0.16
PKG_RELEASE:=30
PKG_VERSION:=1.0.17
PKG_RELEASE:=31
PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage dashboard
@@ -584,16 +584,21 @@ h2, h3{
--vt-oc: var(--vt-ok);
background: color-mix(in oklab, var(--vt-oc) 16%, transparent);
}
/* the ring pulses only while something needs attention */
/* the ring always pulses, or it reads as stuck: slowly and softly while
all is fine, at the full rate when something needs attention */
.vt-app .vt-orb::after {
content: ""; position: absolute; inset: 0; border-radius: 50%;
border: 2px solid var(--vt-oc); opacity: .35;
animation: vt-ring-soft 5s ease-out infinite;
}
.vt-app .vt-orb-warn::after, .vt-app .vt-orb-err::after { animation: vt-ring 2.6s ease-out infinite; }
/* paused (overview.js setMotion): the ring stands still */
.vt-app.vt-still .vt-orb::after { animation: none; }
.vt-app .vt-orb-core { width: 14px; height: 14px; border-radius: 50%; background: var(--vt-oc); box-shadow: 0 0 0 4px color-mix(in oklab, var(--vt-oc) 22%, transparent); }
.vt-app .vt-orb-warn { --vt-oc: var(--vt-warn); }
.vt-app .vt-orb-err { --vt-oc: var(--vt-err); }
@keyframes vt-ring { 0% { transform: scale(.7); opacity: .5; } 100% { transform: scale(1.35); opacity: 0; } }
@keyframes vt-ring-soft { 0% { transform: scale(.85); opacity: .35; } 100% { transform: scale(1.3); opacity: 0; } }
.vt-app .vt-head-text { min-width: 0; flex: 1; }
.vt-app .vt-headline {
@@ -610,8 +615,8 @@ h2, h3{
}
.vt-app .vt-live-beat { position: relative; width: 8px; height: 8px; border-radius: 50%; background: var(--vt-beat-c, var(--vt-ok)); }
/* the beat is a ring that scales and fades (compositor only; an animated
box-shadow repainted every frame), only while reconnecting: a live page
keeps a still dot. It sits behind the dot (z-index -1 in the pill's
box-shadow repainted every frame): slowly while live, at the full rate
while reconnecting, not at all while paused. It sits behind the dot (z-index -1 in the pill's
stacking context) so the dot keeps its colour, and it is transparent
unless the animation runs: nothing shows without motion. */
.vt-app .vt-live-beat::after {
@@ -621,8 +626,10 @@ h2, h3{
}
.vt-app .vt-live-paused .vt-live-beat { --vt-beat-c: var(--vt-text-3); }
.vt-app .vt-live-err .vt-live-beat { --vt-beat-c: var(--vt-warn); }
.vt-app .vt-live-ok .vt-live-beat::after { animation: vt-beat-soft 5s ease-out infinite; }
.vt-app .vt-live-err .vt-live-beat::after { animation: vt-beat 2s ease-out infinite; }
@keyframes vt-beat { 0% { transform: scale(1); opacity: 1; } 70%, 100% { transform: scale(2.75); opacity: 0; } }
@keyframes vt-beat-soft { 0% { transform: scale(1); opacity: .6; } 70%, 100% { transform: scale(2.2); opacity: 0; } }
.vt-app .vt-health {
display: grid; gap: 10px; margin-bottom: var(--vt-sp-4);
@@ -235,7 +235,7 @@ function mount(host, band, style) {
var cv = document.createElement('canvas'), ctx = cv.getContext && cv.getContext('2d');
var env = { w: 0, h: 0, box: { t: 0, b: 0 }, origin: { x: 0, y: 0 }, count: 2, speed: packetSpeed(0), share: 0.7, frame: frameMs(0), colour: '#7a9a3a', muted: '#8a9099' };
var down = null, up = null, originEl = null;
var scene = null, cur = 'none', raf = 0, timer = 0, last = 0, dpr = 1, inView = true, hidden = false, still = false, mq = null;
var scene = null, cur = 'none', raf = 0, timer = 0, last = 0, dpr = 1, inView = true, hidden = false, still = false, paused = false, mq = null;
var focused = typeof document.hasFocus === 'function' ? document.hasFocus() : true;
var ro = null, io = null, onMq = null, stillRated = false, mo = null, scheme = null, onScheme = null;
@@ -339,7 +339,7 @@ function mount(host, band, style) {
}
function again() { timer = 0; if (running() && !raf) raf = requestAnimationFrame(tick); }
function running() { return !!scene && !still && !hidden && focused && inView && usable(); }
function running() { return !!scene && !still && !paused && !hidden && focused && inView && usable(); }
function halt() {
if (raf) cancelAnimationFrame(raf);
if (timer) clearTimeout(timer);
@@ -407,6 +407,8 @@ function mount(host, band, style) {
run();
},
running: running,
/* the page's refresh is paused: the last frame stays */
pause: function(on) { paused = !!on; run(); },
stop: function() {
halt(); scene = null;
if (ro) ro.disconnect();
@@ -31,6 +31,7 @@ var GONE_KEEP_MS = 5 * 60 * 1000; /* disconnected stations stay listed */
var TALKERS = 3; /* top talkers shown; the tile keeps room for all */
var SWITCH_MS = 30 * 1000; /* LuCI runs swconfig for each switch's link state: at most this often */
var STORE_KEY = 'vantage.hist.v1';
var VERSION = '1.0.17'; /* this package's, as its Makefile has it (tests/version.test.js) */
var SVGNS = 'http://www.w3.org/2000/svg';
var HOSTAPD_OBJ = /^[A-Za-z0-9_.-]{1,64}$/;
@@ -458,8 +459,17 @@ function spanShort(ms) {
return s >= 60 ? _('%d min', 'vantage').format(Math.floor(s / 60)) : _('%d s', 'vantage').format(s);
}
/* the history belongs to one login: the next one in this tab starts empty,
under any theme. Vantage's login page also deletes it (login.js). */
function storeOwner() {
return String(L.env.sessionid || '').slice(-8);
}
function readStore() {
try { return JSON.parse(window.sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) { return null; }
try {
var v = JSON.parse(window.sessionStorage.getItem(STORE_KEY) || 'null');
return v && (v.sid || '') === storeOwner() ? v : null;
} catch (e) { return null; }
}
function writeStore(v) {
@@ -901,7 +911,7 @@ return view.extend({
Object.keys(st.clientHist).slice(0, 32).forEach(function(mac) {
keepClients[mac] = st.clientHist[mac].slice(-60).filter(function(p) { return p.every(function(v) { return isFinite(v); }); });
});
writeStore({ net: st.hist.net, cpu: st.hist.cpu, mem: st.hist.mem, clients: keepClients });
writeStore({ sid: storeOwner(), net: st.hist.net, cpu: st.hist.cpu, mem: st.hist.mem, clients: keepClients });
this.stickyGen(m);
m.clients.forEach(function(c) { c.exp = insight.experience(c); c.firstSeen = p.state.firstSeen[c.mac]; c.isNew = !!p.isNew[c.mac]; });
@@ -1113,9 +1123,13 @@ return view.extend({
poll.add(L.bind(this.refresh, this), POLL_S);
poll.add(L.bind(this.refreshFast, this), FAST_S);
document.addEventListener('visibilitychange', function() {
if (!document.hidden && self.st.paused) self.refresh();
if (!document.hidden && self.st.paused && poll.active()) self.refresh();
else self.paintLive();
});
/* LuCI's refresh paused or resumed from its indicator; on resume
CPU starts over, rather than read as the average over the pause */
document.addEventListener('poll-stop', function() { window.setTimeout(function() { self.paintLive(); }, 0); });
document.addEventListener('poll-start', function() { self.st.stat = null; self.paintLive(); });
document.addEventListener('keydown', L.bind(this.handleKey, this));
window.setInterval(function() { self.paintLive(); }, 1000);
if (window.ResizeObserver) {
@@ -1668,6 +1682,9 @@ return view.extend({
paintHead: function() {
var s = this.st.summary, clients = this.m.clients.length;
this.el.orb.className = 'vt-orb vt-orb-' + s.level;
/* the Vantage theme's refresh dot pulses fast in the same colour
while something needs attention (vantage.css) */
document.documentElement.setAttribute('data-vt-alert', s.level === 'warn' || s.level === 'err' ? s.level : '');
txt(this.el.headline, s.headline);
txt(this.el.subline, this.identity(this.m.device).concat(
this.m.wired ? [] : [ clients === 1 ? _('1 client') : _('%d clients').format(clients) ]).join(' · '));
@@ -1688,15 +1705,36 @@ return view.extend({
paintLive: function() {
if (!this.el || !this.el.live) return;
var st = this.st, state, label;
if (st.paused || document.hidden) { state = 'paused'; label = _('Paused while hidden'); }
/* LuCI's own refresh can be paused (its indicator in the header) */
var stopped = typeof poll.active === 'function' && !poll.active();
if (st.paused || document.hidden || stopped) { state = 'paused'; label = stopped && !document.hidden ? _('Paused') : _('Paused while hidden'); }
else if (st.lastErr) { state = 'err'; label = _('Reconnecting…', 'vantage'); }
else { state = 'ok'; label = _('Live') + ' · ' + fmt.ago(Date.now() - st.lastOk); }
this.el.live.className = 'vt-livepill vt-live-' + state;
/* the Vantage theme's refresh dot pulses fast and amber while this
reconnects (vantage.css) */
document.documentElement.setAttribute('data-vt-live', state);
/* the Vantage theme's poll indicator already says the page is
live: then this pill shows only when it is not */
this.el.live.hidden = state === 'ok' && vantageTheme();
live, or that its refresh is paused: then this pill shows only
when neither */
this.el.live.hidden = (state === 'ok' || stopped) && vantageTheme();
this.el.live.setAttribute('title', label);
txt(this.el.liveText, label);
this.setMotion(state !== 'paused');
},
/* Nothing moves while the page is paused: the status circle's ring
(app.css, .vt-still), the flow dots stay where they are and the
backdrop keeps its last frame. */
setMotion: function(on) {
if (this.motionOn === on) return;
this.motionOn = on;
if (this.el && this.el.root) this.el.root.classList.toggle('vt-still', !on);
var map = this.wireMap || {};
Object.keys(map).forEach(function(k) {
map[k].anims.forEach(function(a) { if (on) a.play(); else a.pause(); });
});
if (this.backdrop) this.backdrop.pause(!on);
},
/* ----------------------------------------------------------- health */
@@ -2302,6 +2340,7 @@ return view.extend({
/* every dot takes the whole trip, started i/n of the way along */
var a = dot.animate(wires.keyframes(g.samples), { duration: dur, delay: -dur * i / n, iterations: Infinity, easing: 'linear' });
a.playbackRate = rate;
if (this.motionOn === false) a.pause();
g.dots.push(dot);
g.anims.push(a);
}
@@ -2428,7 +2467,8 @@ return view.extend({
}));
patch(el.fw, [
E('span', {}, [ d.firmware || '' ]),
d.kernel ? E('span', { 'class': 'vt-hint' }, [ _('Kernel %s').format(d.kernel) ]) : ''
d.kernel ? E('span', { 'class': 'vt-hint' }, [ _('Kernel %s').format(d.kernel) ]) : '',
E('span', { 'class': 'vt-hint' }, [ 'Vantage ' + VERSION ])
]);
},
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+15
View File
@@ -13710,3 +13710,18 @@ msgstr ""
msgid "Wireless scan failed: %s"
msgstr ""
msgid "Export eMMC partition"
msgstr ""
msgid "Download the raw contents of the selected eMMC partition."
msgstr ""
msgid "Choose eMMC partition"
msgstr ""
msgid "Download eMMC partition"
msgstr ""
msgid "Export partition"
msgstr ""
+15
View File
@@ -14238,3 +14238,18 @@ msgstr "启用 CSA"
msgid "Announce channel switches to clients. Automatic channel selection remains available when disabled."
msgstr "向客户端通告信道切换。关闭后仍可自动选择信道。"
msgid "Export eMMC partition"
msgstr "导出 eMMC 分区"
msgid "Download the raw contents of the selected eMMC partition."
msgstr "下载所选 eMMC 分区的原始内容。"
msgid "Choose eMMC partition"
msgstr "选择 eMMC 分区"
msgid "Download eMMC partition"
msgstr "下载 eMMC 分区"
msgid "Export partition"
msgstr "导出分区"
+7 -3
View File
@@ -1,17 +1,21 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=Fluent dashboard for LuCI
LUCI_DEPENDS:=+luci-base +libiwinfo
LUCI_DESCRIPTION:=A fixed-card, theme-independent LuCI status dashboard with system, network, wireless, DHCP lease, and wireless client information.
LUCI_DEPENDS:=+luci-base +libiwinfo +rpcd-mod-ucode +ucode-mod-fs +ucode-mod-uci
LUCI_DESCRIPTION:=A theme-independent configurable dashboard workbench with shared layouts and live system, network, wireless and client widgets.
LUCI_MAINTAINER:=LazuliKao
LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0
PKG_NAME:=luci-mod-fluentdashboard
PKG_VERSION:=1.0.9
PKG_RELEASE:=1
PKG_RELEASE:=2
PKG_LICENSE:=Apache-2.0
PKG_BUILD_DEPENDS:=iwinfo
define Package/luci-mod-fluentdashboard/conffiles
/etc/config/fluentdashboard
endef
# call BuildPackage - OpenWrt buildroot signature
include $(TOPDIR)/feeds/luci/luci.mk
@@ -1,7 +1,7 @@
.fluent-dashboard {
--fluent-dashboard-border: var(--fluent-card-border, var(--inputborder-color, color-mix(in srgb, currentColor 16%, transparent)));
--fluent-dashboard-muted: var(--fluent-text-secondary, color-mix(in srgb, currentColor 68%, transparent));
--fluent-dashboard-surface: var(--fluent-card-bg, rgba(var(--primary-rgbbody, 255, 255, 255), 0.7));
--fluent-dashboard-surface: var(--fluent-card-bg, rgba(var(--primary-rgbbody, 255, 255, 255), .7));
--fluent-dashboard-shadow: var(--fluent-card-shadow, 0 .1rem .3rem var(--input-boxcolor, color-mix(in srgb, CanvasText 8%, transparent)));
color: var(--fluent-text, CanvasText);
container-type: inline-size;
@@ -12,6 +12,7 @@
}
.fluent-dashboard__header {
z-index: 0;
color: highlighttext;
background: linear-gradient(120deg, Highlight, color-mix(in srgb, Highlight 68%, CanvasText));
border-radius: .75rem;
@@ -21,9 +22,8 @@
margin-block-end: 1rem;
padding: 1.25rem;
display: flex;
z-index: 0;
overflow: hidden;
position: relative;
overflow: hidden;
}
.fluent-dashboard__header h1, .fluent-dashboard__header p {
@@ -239,3 +239,394 @@
transition: none;
}
}
.fluent-dashboard {
--dash-card-bg: var(--fluent-card-bg, Canvas);
--dash-text: var(--fluent-text, CanvasText);
--dash-border: var(--fluent-card-border, color-mix(in srgb, CanvasText 18%, transparent));
--dash-gap: .75rem;
--dash-radius: var(--fluent-border-radius, .5rem);
color: var(--dash-text);
}
.fluent-dashboard__toolbar {
gap: var(--dash-gap);
margin-block: var(--dash-gap);
border: 0;
flex-wrap: wrap;
padding: 0;
display: flex;
}
.fluent-dashboard__workspace {
gap: var(--dash-gap);
grid-template-columns: minmax(0, 1fr);
display: grid;
}
.fluent-dashboard__canvas {
min-width: 0;
min-height: 12rem;
}
.fluent-dashboard__library, .fluent-dashboard__properties {
border: 1px solid var(--dash-border);
border-radius: var(--dash-radius);
min-width: 0;
padding: var(--dash-gap);
}
.fluent-dashboard__library > button, .fluent-dashboard__properties > button {
margin: .2rem;
}
.fluent-dashboard__field {
margin-block: var(--dash-gap);
display: block;
}
.fluent-dashboard__field > input {
width: 100%;
display: block;
}
.fluent-dashboard .grid-stack-item-content > .fluent-dashboard__card {
flex-direction: column;
height: 100%;
display: flex;
}
.fluent-dashboard__card-header {
flex: none;
justify-content: space-between;
align-items: center;
min-height: 2rem;
display: flex;
}
.fluent-dashboard__card-header > .fluent-dashboard__card-title {
border: 0;
flex: 1;
padding-block: .5rem;
}
.fluent-dashboard__card-header > button {
flex: none;
margin-inline-end: .5rem;
font-size: .75rem;
}
.fluent-dashboard__card-content {
min-height: 0;
overflow: auto;
}
.fluent-dashboard__widget-status {
padding-inline: var(--dash-gap);
margin: 0;
font-size: .85rem;
}
.fluent-dashboard__card--editing .fluent-dashboard__card-header {
cursor: move;
}
.fluent-dashboard__card--hidden {
opacity: .5;
}
.fluent-dashboard__card--locked .fluent-dashboard__card-header {
cursor: default;
}
.fluent-dashboard__tile--selected > .grid-stack-item-content {
outline-offset: -2px;
outline: 2px solid highlight;
}
.fluent-dashboard__group {
border: 1px solid var(--dash-border);
border-radius: var(--dash-radius);
background: var(--dash-card-bg);
flex-direction: column;
height: 100%;
display: flex;
}
.fluent-dashboard__group > .fluent-dashboard__card-header {
height: 40px;
}
.fluent-dashboard__group-description {
padding: .25rem var(--dash-gap);
margin: 0;
font-size: .85rem;
}
.fluent-dashboard__group-body {
flex: 1;
min-height: 80px;
}
.fluent-dashboard__metric, .fluent-dashboard__traffic {
padding: var(--dash-gap);
flex-direction: column;
gap: .4rem;
display: flex;
overflow: auto;
}
.fluent-dashboard__metric-value {
overflow-wrap: anywhere;
font-size: clamp(1rem, 1.5vw, 1.6rem);
}
.fluent-dashboard__metric-detail {
color: var(--dash-text);
opacity: .7;
overflow-wrap: anywhere;
}
.fluent-dashboard__metric progress {
width: 100%;
accent-color: var(--fluent-primary, Highlight);
}
.fluent-dashboard__traffic svg {
width: 100%;
height: 5rem;
min-height: 2rem;
}
.fluent-dashboard__traffic polyline {
fill: none;
stroke-width: 1.5px;
vector-effect: non-scaling-stroke;
}
.fluent-dashboard__traffic-rx {
stroke: var(--fluent-primary, Highlight);
}
.fluent-dashboard__traffic-tx {
stroke: var(--fluent-success, #26884a);
}
.fluent-dashboard__shortcuts {
gap: var(--dash-gap);
padding: var(--dash-gap);
flex-wrap: wrap;
display: flex;
}
.fluent-dashboard__shortcuts a {
border: 1px solid var(--dash-border);
border-radius: var(--dash-radius);
color: var(--dash-text);
align-items: center;
padding: .5rem;
display: inline-flex;
}
.fluent-dashboard__notice:empty, .fluent-dashboard [hidden] {
display: none;
}
.fluent-dashboard .grid-stack-item-content {
min-width: 0;
overflow: hidden;
}
.fluent-dashboard__canvas .grid-stack-item > .grid-stack-item-content {
overflow: visible;
}
.fluent-dashboard__group-body.grid-stack {
width: 100%;
min-width: 0;
}
.fluent-dashboard__workspace--editing .fluent-dashboard__canvas > .grid-stack-item {
touch-action: auto;
}
@media (min-width: 1400px) {
.fluent-dashboard__workspace--editing {
grid-template-columns: 12rem minmax(0, 1fr) 15rem;
align-items: start;
}
}
.fluent-dashboard .grid-stack {
position: relative;
}
.fluent-dashboard .grid-stack-rtl {
direction: ltr;
}
.fluent-dashboard .grid-stack-rtl > .grid-stack-item {
direction: rtl;
}
.fluent-dashboard .grid-stack-placeholder > .placeholder-content {
background-color: #0000001a;
width: auto;
margin: 0;
position: absolute;
z-index: 0 !important;
}
.fluent-dashboard .grid-stack > .grid-stack-item {
width: var(--gs-column-width);
height: var(--gs-cell-height);
padding: 0;
position: absolute;
top: 0;
left: 0;
}
.fluent-dashboard .grid-stack > .grid-stack-item > .grid-stack-item-content {
width: auto;
margin: 0;
position: absolute;
overflow: hidden auto;
}
.fluent-dashboard .grid-stack > .grid-stack-item.size-to-content:not(.size-to-content-max) > .grid-stack-item-content {
overflow-y: hidden;
}
.fluent-dashboard .grid-stack > .grid-stack-item > .grid-stack-item-content, .fluent-dashboard .grid-stack > .grid-stack-placeholder > .placeholder-content {
top: var(--gs-item-margin-top);
right: var(--gs-item-margin-right);
bottom: var(--gs-item-margin-bottom);
left: var(--gs-item-margin-left);
}
.fluent-dashboard .grid-stack-item > .ui-resizable-handle {
-ms-touch-action: none;
touch-action: none;
font-size: .1px;
display: block;
position: absolute;
}
.fluent-dashboard .grid-stack-item.ui-resizable-disabled > .ui-resizable-handle, .fluent-dashboard .grid-stack-item.ui-resizable-autohide > .ui-resizable-handle {
display: none;
}
.fluent-dashboard .grid-stack-item > .ui-resizable-ne, .fluent-dashboard .grid-stack-item > .ui-resizable-nw, .fluent-dashboard .grid-stack-item > .ui-resizable-se, .fluent-dashboard .grid-stack-item > .ui-resizable-sw {
background-image: url("data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" stroke=\"%23666\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" viewBox=\"0 0 20 20\"><path d=\"m10 3 2 2H8l2-2v14l-2-2h4l-2 2\"/></svg>");
background-position: center;
background-repeat: no-repeat;
}
.fluent-dashboard .grid-stack-item > .ui-resizable-ne, .fluent-dashboard .grid-stack-item > .ui-resizable-sw {
transform: rotate(45deg);
}
.fluent-dashboard .grid-stack-item > .ui-resizable-nw, .fluent-dashboard .grid-stack-item > .ui-resizable-se {
transform: rotate(-45deg);
}
.fluent-dashboard .grid-stack-item > .ui-resizable-nw {
cursor: nw-resize;
width: 20px;
height: 20px;
top: var(--gs-item-margin-top);
left: var(--gs-item-margin-left);
}
.fluent-dashboard .grid-stack-item > .ui-resizable-n {
cursor: n-resize;
height: 10px;
top: var(--gs-item-margin-top);
left: 25px;
right: 25px;
}
.fluent-dashboard .grid-stack-item > .ui-resizable-ne {
cursor: ne-resize;
width: 20px;
height: 20px;
top: var(--gs-item-margin-top);
right: var(--gs-item-margin-right);
}
.fluent-dashboard .grid-stack-item > .ui-resizable-e {
cursor: e-resize;
width: 10px;
top: 15px;
bottom: 15px;
right: var(--gs-item-margin-right);
}
.fluent-dashboard .grid-stack-item > .ui-resizable-se {
cursor: se-resize;
width: 20px;
height: 20px;
bottom: var(--gs-item-margin-bottom);
right: var(--gs-item-margin-right);
}
.fluent-dashboard .grid-stack-item > .ui-resizable-s {
cursor: s-resize;
height: 10px;
left: 25px;
bottom: var(--gs-item-margin-bottom);
right: 25px;
}
.fluent-dashboard .grid-stack-item > .ui-resizable-sw {
cursor: sw-resize;
width: 20px;
height: 20px;
bottom: var(--gs-item-margin-bottom);
left: var(--gs-item-margin-left);
}
.fluent-dashboard .grid-stack-item > .ui-resizable-w {
cursor: w-resize;
width: 10px;
top: 15px;
bottom: 15px;
left: var(--gs-item-margin-left);
}
.fluent-dashboard .grid-stack-item.ui-draggable-dragging > .ui-resizable-handle {
display: none !important;
}
.fluent-dashboard .grid-stack-item.ui-draggable-dragging {
will-change: left,top;
}
.fluent-dashboard .grid-stack-item.ui-resizable-resizing {
will-change: width,height;
}
.fluent-dashboard .ui-draggable-dragging, .fluent-dashboard .ui-resizable-resizing {
z-index: 10000;
}
.fluent-dashboard .ui-draggable-dragging > .grid-stack-item-content, .fluent-dashboard .ui-resizable-resizing > .grid-stack-item-content {
opacity: .8;
box-shadow: 1px 4px 6px #0003;
}
.fluent-dashboard .grid-stack-animate, .fluent-dashboard .grid-stack-animate .grid-stack-item {
transition: left .3s, top .3s, height .3s, width .3s;
}
.fluent-dashboard .grid-stack-animate .grid-stack-item.ui-draggable-dragging, .fluent-dashboard .grid-stack-animate .grid-stack-item.ui-resizable-resizing, .fluent-dashboard .grid-stack-animate .grid-stack-item.grid-stack-placeholder {
transition: left, top, height, width;
}
.fluent-dashboard .grid-stack > .grid-stack-item[gs-y="0"] {
top: 0;
}
.fluent-dashboard .grid-stack > .grid-stack-item[gs-x="0"] {
left: 0%;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
config dashboard 'global'
option schema_version '1'
option revision '0'
@@ -6,8 +6,17 @@
"iwinfo": [ "assoclist" ],
"luci": [ "getVersion" ],
"luci-rpc": [ "getDHCPLeases" ],
"luci.fluentdashboard": [ "load" ],
"system": [ "board", "info" ]
}
}
},
"luci-mod-fluentdashboard-edit": {
"description": "Grant permission to publish the shared Fluent dashboard layout",
"write": {
"ubus": {
"luci.fluentdashboard": [ "save" ]
}
}
}
}
@@ -0,0 +1,120 @@
'use strict';
import { open, readfile, writefile, rename, unlink, mkdtemp, rmdir } from 'fs';
import { cursor } from 'uci';
const config = '/etc/config/fluentdashboard';
const max_size = 262144;
function require_value(ok, message) { if (!ok) die(message); }
function integer(v, min, max) { return type(v) == 'int' && v >= min && v <= max; }
function id(v) { return type(v) == 'string' && match(v, /^[A-Za-z0-9_-]{1,64}$/); }
function safe_json(v, depth) {
if (depth > 16) return false;
if (type(v) == 'object' || type(v) == 'array') {
for (let k, item in v) {
if (k in [ '__proto__', 'constructor', 'prototype' ] || !safe_json(item, depth + 1)) return false;
}
}
return true;
}
function validate(doc) {
require_value(type(doc) == 'object' && doc.schemaVersion == 1 && safe_json(doc, 0), 'Invalid schema');
require_value(integer(doc.revision, 0, 9007199254740990), 'Invalid revision');
require_value(type(doc.dashboards) == 'array' && length(doc.dashboards) >= 1 && length(doc.dashboards) <= 16, 'Invalid dashboards');
let dashboards = {};
for (let dashboard in doc.dashboards) {
require_value(type(dashboard) == 'object' && id(dashboard.id) && !exists(dashboards, dashboard.id), 'Invalid dashboard id');
dashboards[dashboard.id] = true;
require_value(type(dashboard.name) == 'string' && length(trim(dashboard.name)) && length(dashboard.name) <= 128, 'Invalid name');
require_value(type(dashboard.nodes) == 'array' && length(dashboard.nodes) <= 120, 'Too many nodes');
let nodes = {};
for (let node in dashboard.nodes) {
require_value(type(node) == 'object' && id(node.id) && !exists(nodes, node.id), 'Invalid node id');
nodes[node.id] = node;
require_value(node.kind in [ 'widget', 'group' ] && exists(node, 'parentId') && (node.parentId == null || id(node.parentId)), 'Invalid node');
for (let flag in [ 'hidden', 'locked', 'collapsed' ]) require_value(!exists(node, flag) || type(node[flag]) == 'bool', 'Invalid flag');
require_value(!exists(node, 'title') || (type(node.title) == 'string' && length(node.title) <= 256), 'Invalid title');
let t = node.tile;
require_value(type(t) == 'object' && t.size in [ 'small', 'medium', 'wide', 'large', 'xlarge', 'full', 'custom' ], 'Invalid tile');
require_value(integer(t.x, 0, 11) && integer(t.y, 0, 10000) && integer(t.w, 1, 12) && integer(t.h, 1, 48) && integer(t.order, 0, 100000), 'Invalid coordinates');
if (node.kind == 'widget')
require_value(type(node.widgetType) == 'string' && match(node.widgetType, /^[A-Za-z0-9_.-]{1,128}$/) && integer(node.widgetVersion, 1, 100000) && type(node.config) == 'object', 'Invalid widget');
else {
require_value(type(node.title) == 'string', 'Invalid group title');
require_value(!exists(node, 'description') || (type(node.description) == 'string' && length(node.description) <= 2048), 'Invalid description');
}
}
for (let node in dashboard.nodes) {
let seen = {}; seen[node.id] = true;
let parent = node.parentId, depth = node.kind == 'group' ? 1 : 0;
while (parent != null) {
require_value(!exists(seen, parent) && nodes[parent]?.kind == 'group', 'Invalid parent graph');
seen[parent] = true;
require_value(++depth <= 2, 'Too many group levels');
parent = nodes[parent].parentId;
}
require_value(node.tile.x + node.tile.w <= (node.parentId == null ? 12 : nodes[node.parentId].tile.w), 'Tile outside parent');
}
}
require_value(type(doc.defaultDashboardId) == 'string' && exists(dashboards, doc.defaultDashboardId), 'Invalid default dashboard');
require_value(!exists(doc, 'meta') || type(doc.meta) == 'object', 'Invalid metadata');
}
function load_document() {
const uci = cursor('/etc/config', '/var/lock', '');
require_value(uci.load('fluentdashboard'), 'Cannot read dashboard configuration');
let raw = uci.get('fluentdashboard', 'global', 'layout');
let revision = int(uci.get('fluentdashboard', 'global', 'revision'));
require_value(integer(revision, 0, 9007199254740990), 'Invalid stored revision');
if (!raw && revision == 0) return { document: null, revision: 0 };
require_value(type(raw) == 'string' && length(raw) <= max_size, 'Invalid stored layout');
let doc = json(raw);
validate(doc);
require_value(doc.revision == revision, 'Stored revision mismatch');
return { document: doc, revision };
}
function save_document(args) {
require_value(type(args.layout) == 'string' && length(args.layout) <= max_size, 'Layout too large');
let doc = json(args.layout);
validate(doc);
require_value(integer(args.expectedRevision, 0, 9007199254740989) && doc.revision == args.expectedRevision, 'Invalid expected revision');
const current = load_document();
if (current.revision != args.expectedRevision) return { error: 'conflict', revision: current.revision };
doc.revision = current.revision + 1;
// Isolated UCI cursor: no global /tmp/.uci deltas, commit then atomic same-filesystem rename.
const staging = mkdtemp('/etc/config/.fluentdashboard-XXXXXX');
require_value(staging, 'Cannot create staging directory');
let result;
try {
const old = readfile(config);
require_value(old != null && writefile(staging + '/fluentdashboard', old) == length(old), 'Cannot stage config');
const uci = cursor(staging, staging, '');
require_value(uci.load('fluentdashboard'), 'Cannot load staged config');
require_value(uci.set('fluentdashboard', 'global', 'layout', sprintf('%J', doc)) && uci.set('fluentdashboard', 'global', 'revision', sprintf('%d', doc.revision)) && uci.set('fluentdashboard', 'global', 'schema_version', '1'), 'Cannot set config');
require_value(uci.commit('fluentdashboard'), 'Cannot commit config');
require_value(writefile(staging + '/previous', old) == length(old) && rename(staging + '/previous', config + '.previous'), 'Cannot preserve previous config');
require_value(rename(staging + '/fluentdashboard', config), 'Cannot publish config');
result = { document: doc, revision: doc.revision };
} catch (error) { result = { error: 'storage', message: `${error}` }; }
unlink(staging + '/fluentdashboard'); unlink(staging + '/previous'); rmdir(staging);
return result;
}
return {
'luci.fluentdashboard': {
load: { call: function() { try { return load_document(); } catch (error) { return { error: 'corrupt', message: `${error}` }; } } },
save: {
args: { layout: '', expectedRevision: 0 },
call: function(request) {
const lock = open('/var/lock/fluentdashboard.lock', 'a', 0600);
if (!lock) return { error: 'storage', message: 'Cannot open lock' };
if (!lock.lock('xn')) { lock.close(); return { error: 'busy', message: 'Another save is in progress' }; }
let result;
try { result = save_document(request.args); } catch (error) { result = { error: 'invalid', message: `${error}` }; }
lock.close();
return result;
}
}
}
};
@@ -307,16 +307,6 @@ function loadIwinfoInfoMap(force) {
return Promise.all(queryTargets.map((name) =>
L.resolveDefault(callIwinfoInfoCompat(name), null).then((info) => [ name, info ])
)).then(async (entries) => {
await Promise.all(entries.map(async ([ name, info ]) => {
const iface = uci.sections('wireless', 'wifi-iface').find((iface) =>
isQcaWifiHwtype(uci.get('wireless', iface.device, 'type')) &&
resolver.aliasMap[iface['.name']] == name);
if (!iface || info?.mode != 'Master' || !/^ath\d+(?:\.\d+)?$/.test(name))
return;
const output = await L.resolveDefault(fs.exec_direct('/usr/sbin/cfg80211tool',
[ name, 'get_maxrate' ]), '');
info.theoretical_bitrate = parseQcaMaxPhyRate(output);
}));
const phyQueries = {};
for (const [ name, info ] of entries) {
if (info?.hardware?.id?.[0] != 0x14c3 || !/^phy\d+$/.test(info?.phy || ''))
@@ -948,13 +938,13 @@ function getFrequencyListBand(entry, hwmode) {
return null;
}
function normalizeIwinfoBitRate(rate) {
function normalizeIwinfoBitRate(rate, inKbit = false) {
rate = +rate;
if (isNaN(rate) || rate <= 0)
return null;
return (rate > 100000) ? (rate / 1000) : rate;
return (inKbit || rate > 100000) ? (rate / 1000) : rate;
}
function getIwinfoInfoCandidates(radioNet) {
@@ -982,9 +972,10 @@ function getIwinfoInfoCandidates(radioNet) {
}
function getDisplayIwinfoBitRate(radioNet) {
const inKbit = isQcaWifiHwtype(uci.get('wireless', radioNet.getWifiDeviceName(), 'type'));
for (const candidate of getIwinfoInfoCandidates(radioNet)) {
const info = cachedIwinfoInfoMap ? cachedIwinfoInfoMap[candidate] : null;
const rate = normalizeIwinfoBitRate(info != null ? info.bitrate : null);
const rate = normalizeIwinfoBitRate(info != null ? info.bitrate : null, inKbit);
if (rate != null)
return rate;
@@ -1022,21 +1013,16 @@ function getDisplayTheoreticalBitRate(radioNet) {
return null;
for (const candidate of getIwinfoInfoCandidates(radioNet)) {
const rate = cachedIwinfoInfoMap?.[candidate]?.theoretical_bitrate;
const info = cachedIwinfoInfoMap?.[candidate];
/* QSDK SIOCGIWRATE reports the BSS PHY ceiling in kbit/s. */
const rate = isQcaWifiHwtype(hwtype)
? +info?.bitrate / 1000 : info?.theoretical_bitrate;
if (rate > 0)
return rate;
}
return null;
}
/* QSDK returns the running BSS PHY ceiling in kbit/s, including its
* bandwidth-dependent NSS mapping. Never infer it from client rates. */
function parseQcaMaxPhyRate(output) {
const match = String(output || '').match(/\bget_maxrate:\s*(\d+)\s*$/m);
const rate = match ? +match[1] : 0;
return rate > 0 ? Math.round(rate / 100) / 10 : null;
}
function getDisplayBitRate(radioNet) {
if (isQcaWifiHwtype(uci.get('wireless', radioNet.getWifiDeviceName(), 'type')) &&
radioNet.getMode() == 'ap')
@@ -8,7 +8,6 @@
"/proc/sys/net/ipv6/conf/*/mtu": [ "read" ],
"/proc/sys/net/ipv6/conf/*/hop_limit": [ "read" ],
"/usr/sbin/iw": [ "exec" ],
"/usr/sbin/cfg80211tool ath* get_maxrate": [ "exec" ],
"/usr/libexec/luci-peeraddr": [ "exec" ],
"/usr/sbin/wlanconfig": [ "exec" ],
"/proc/sys/net/ipv[46]/conf/*": [ "read" ],
@@ -136,6 +136,30 @@ function findStorageSize(procmtd, procpart, procmounts) {
}
function loadEmmcPartitions() {
const devices = Object.create(null);
return L.resolveDefault(fs.list('/sys/class/block'), []).then(entries =>
Promise.all(entries.filter(entry => /^mmcblk\d+(?:p\d+|boot[01])$/.test(entry.name)).map(async entry => {
const name = entry.name;
const disk = name.match(/^(mmcblk\d+)/)[1];
devices[disk] ??= L.resolveDefault(fs.trimmed(`/sys/class/block/${disk}/device/type`), '');
if (await devices[disk] != 'MMC')
return null;
const [ uevent, sectors ] = await Promise.all([
L.resolveDefault(fs.read(`/sys/class/block/${name}/uevent`), ''),
L.resolveDefault(fs.trimmed(`/sys/class/block/${name}/size`), '0')
]);
const size = +sectors * 512;
if (!Number.isSafeInteger(size) || size <= 0)
return null;
return { device: name, name: uevent.match(/^PARTNAME=(.+)$/m)?.[1] || name, size };
}))
).then(partitions => partitions.filter(Boolean).sort((a, b) => L.naturalCompare(a.device, b.device)));
}
const mapdata = { actions: {}, config: {} };
return view.extend({
@@ -145,7 +169,8 @@ return view.extend({
fs.trimmed('/proc/sys/kernel/hostname'),
fs.trimmed('/proc/mtd'),
fs.trimmed('/proc/partitions'),
fs.trimmed('/proc/mounts')
fs.trimmed('/proc/mounts'),
loadEmmcPartitions()
];
return Promise.all(tasks);
@@ -243,18 +268,19 @@ return view.extend({
.finally(function() { btn.firstChild.data = _('Upload archive...') });
},
handleBlock(hostname, ev) {
const mtdblock = dom.parent(ev.target, '.cbi-section').querySelector('[data-name="mtdselect"] select');
const mtdnumber = mtdblock.value;
const mtdname = mtdblock.selectedOptions[0].text.replace(/([^a-zA-Z0-9]+)/g, '-');
handleBlock(hostname, selector, ev) {
const block = dom.parent(ev.target, '.cbi-section').querySelector('[data-name="%s"] select'.format(selector));
const device = selector == 'mtdselect' ? 'mtdblock' + block.value : block.value;
const name = block.selectedOptions[0].text.replace(/([^a-zA-Z0-9]+)/g, '-');
const id = selector == 'mtdselect' ? 'mtd' + block.value : device;
const form = E('form', {
'method': 'post',
'action': L.env.cgi_base + '/cgi-download',
'enctype': 'application/x-www-form-urlencoded'
}, [
E('input', { 'type': 'hidden', 'name': 'sessionid', 'value': rpc.getSessionID() }),
E('input', { 'type': 'hidden', 'name': 'path', 'value': '/dev/mtdblock%d'.format(mtdnumber) }),
E('input', { 'type': 'hidden', 'name': 'filename', 'value': '%s.mtd%d.%s.bin'.format(hostname, mtdnumber, mtdname) })
E('input', { 'type': 'hidden', 'name': 'path', 'value': '/dev/' + device }),
E('input', { 'type': 'hidden', 'name': 'filename', 'value': '%s.%s.%s.bin'.format(hostname, id, name) })
]);
ev.currentTarget.parentNode.appendChild(form);
@@ -452,7 +478,7 @@ return view.extend({
});
},
render([p_fstat, hostname, procmtd, procpart, procmounts]) {
render([p_fstat, hostname, procmtd, procpart, procmounts, emmcPartitions]) {
const has_sysupgrade = (p_fstat.type == 'file');
const has_rootfs_data = (procmtd.match(/"rootfs_data"/) != null) || (procmounts.match("overlayfs:/overlay / ") != null);
const storage_size = findStorageSize(procmtd, procpart, procmounts);
@@ -509,10 +535,24 @@ return view.extend({
o = ss.option(form.Button, 'mtddownload', _('Download mtdblock'));
o.inputstyle = 'action important';
o.inputtitle = _('Save mtdblock');
o.onclick = L.bind(this.handleBlock, this, hostname);
o.onclick = L.bind(this.handleBlock, this, hostname, 'mtdselect');
}
if (emmcPartitions.length) {
o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Export eMMC partition'), _('Download the raw contents of the selected eMMC partition.'));
ss = o.subsection;
o = ss.option(form.ListValue, 'emmcselect', _('Choose eMMC partition'));
for (const partition of emmcPartitions)
o.value(partition.device, '%s (%s, %1024.2mB)'.format(partition.name, partition.device, partition.size));
o = ss.option(form.Button, 'emmcdl', _('Download eMMC partition'));
o.inputstyle = 'action important';
o.inputtitle = _('Export partition');
o.onclick = L.bind(this.handleBlock, this, hostname, 'emmcselect');
}
o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Flash new firmware image'),
has_sysupgrade
? _('Upload a sysupgrade-compatible image here to replace the running firmware.')
@@ -152,6 +152,12 @@
"cgi-io": [ "backup", "download" ],
"file": {
"/dev/mtdblock[0-9]*": [ "read" ],
"/dev/mmcblk[0-9]*p[0-9]*": [ "read" ],
"/dev/mmcblk[0-9]*boot[01]": [ "read" ],
"/sys/class/block": [ "list" ],
"/sys/class/block/mmcblk[0-9]*/device/type": [ "read" ],
"/sys/class/block/mmcblk[0-9]*/uevent": [ "read" ],
"/sys/class/block/mmcblk[0-9]*/size": [ "read" ],
"/etc/sysupgrade.conf": [ "read" ],
"/lib/upgrade/platform.sh": [ "list" ],
"/proc/[0-9]*/mounts": [ "read" ],
@@ -162,7 +168,7 @@
"/sbin/sysupgrade --list-backup": [ "exec" ]
},
"ubus": {
"file": [ "exec", "read", "stat" ]
"file": [ "exec", "list", "read", "stat" ]
}
},
"write": {
+1 -1
View File
@@ -9,7 +9,7 @@ LUCI_TITLE:=Design Theme
LUCI_DEPENDS:=
PKG_LICENSE:=Apache-2.0
PKG_VERSION:=7.1
PKG_RELEASE:=12
PKG_RELEASE:=13
LUCI_MINIFY_CSS:=
CONFIG_LUCI_CSSTIDY:=
@@ -4803,6 +4803,444 @@ body.node-admin-services-dockerman-overview[data-page="admin-services-dockerman"
min-width: 200px;
}
/* Keep wide tables scrollable without widening the content panel. */
#maincontent .design-table-scroll,
#modal_overlay .design-table-scroll {
display: block;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
#maincontent .tabs {
display: flex;
flex-wrap: wrap;
}
#maincontent .tabs > li {
display: block;
max-width: 100%;
}
#maincontent .cbi-value > .cbi-value-field {
min-width: 0;
}
@media screen and (max-width: 768px) {
/* Keep client details readable while retaining the sortable column headings. */
#maincontent #status_leases,
#maincontent #status_leases > tbody {
display: block;
max-width: 100%;
}
#status_leases .tr {
position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .15rem .5rem;
margin: .5rem 0;
padding: .5rem;
border: 1px solid var(--border_color);
border-radius: 8px;
}
#status_leases .td {
display: grid;
grid-column: 1 / -1;
grid-template-columns: 5.5rem minmax(0, 1fr);
gap: .5rem;
min-width: 0;
padding: .15rem 0;
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
}
#status_leases .td::before {
display: block;
content: attr(data-title);
padding: 0;
font-weight: normal;
white-space: normal;
opacity: .7;
}
#status_leases .td:first-child {
position: absolute;
top: .7rem;
left: .5rem;
display: flex;
align-items: center;
}
#status_leases .td:nth-child(2) {
display: block;
padding-left: 1.5rem;
font-weight: 600;
}
#status_leases .td:first-child::before,
#status_leases .td:nth-child(2)::before {
display: none;
}
#status_leases .td > * {
min-width: 0;
max-width: 100%;
}
#status_leases .td:nth-child(4):not(:has(div)) {
display: none;
}
#status_leases .design-client-mac {
display: inline-block;
white-space: nowrap;
}
#status_leases .td:nth-child(6),
#status_leases .td:nth-child(7),
#status_leases .td:nth-child(9) {
grid-column: auto;
grid-template-columns: auto minmax(0, 1fr);
gap: .35rem;
align-items: center;
}
#status_leases .td:nth-child(8) {
grid-template-columns: max-content minmax(0, 1fr);
}
#status_leases .td:nth-child(8)::before,
#status_leases .luci-client-rate[data-direction="total"] {
white-space: nowrap;
}
#status_leases .td.cbi-section-actions {
display: flex;
grid-column: auto;
align-items: center;
justify-content: flex-end;
}
#status_leases .td.cbi-section-actions::before {
display: none;
}
#status_leases .cbi-button {
min-width: 0;
margin: 0;
padding: .3rem .5rem;
font-size: .8rem;
}
#status_leases .tr.table-titles {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
gap: .35rem;
margin: 0;
padding: .25rem 0 .5rem;
border: 0;
border-radius: 0;
}
#status_leases .th {
display: block;
flex: 0 0 auto;
padding: .5rem;
border-radius: 4px;
background: var(--inputbg_color);
}
#status_leases .tr.placeholder,
#status_leases .tr.placeholder > .td {
position: static;
display: block;
height: auto;
}
[data-page="admin-network-wireless"] #cbi-wireless .cbi-section-table,
[data-page="admin-network-wireless"] #cbi-wireless .cbi-section-tbody,
#maincontent #wifi_assoclist_table {
display: block;
max-width: 100%;
}
[data-page="admin-network-wireless"] #cbi-wireless .cbi-section-table-row {
position: relative;
display: block;
padding: .65rem;
margin-bottom: .6rem;
border: 1px solid var(--border_color);
border-radius: 8px;
}
[data-page="admin-network-wireless"] #cbi-wireless .cbi-section-table-row > .td {
display: block;
padding: 0;
white-space: normal;
overflow-wrap: anywhere;
}
[data-page="admin-network-wireless"] #cbi-wireless .cbi-section-table-row > .td[data-name="_badge"],
[data-page="admin-network-wireless"] #cbi-wireless .cbi-section-table-row > .td.cbi-section-actions {
width: auto !important;
min-width: 0 !important;
max-width: 100%;
}
[data-page="admin-network-wireless"] #cbi-wireless .td[data-name="_badge"] {
position: absolute;
top: .65rem;
right: .65rem;
}
[data-page="admin-network-wireless"] #cbi-wireless .design-wireless-radio > .td[data-name="_badge"] {
right: auto;
left: .65rem;
}
[data-page="admin-network-wireless"] #cbi-wireless .design-wireless-radio > .td[data-name="_stat"] {
min-height: 64px;
margin-left: 72px;
}
[data-page="admin-network-wireless"] #cbi-wireless .td[data-name="_stat"] big {
font-size: .95rem;
}
[data-page="admin-network-wireless"] #cbi-wireless .td[data-name="_stat"] .nowrap {
display: block;
white-space: normal;
}
[data-page="admin-network-wireless"] #cbi-wireless .cbi-section-table-row:not(.design-wireless-radio) .td[data-name="_stat"] > div > .nowrap:first-child {
padding-right: 85px;
font-weight: 600;
}
[data-page="admin-network-wireless"] #cbi-wireless .cbi-section-table-row:not(.design-wireless-radio) .td[data-name="_stat"] strong {
display: inline-block;
min-width: 4.5rem;
}
[data-page="admin-network-wireless"] #cbi-wireless .td[data-name="_stat"] br,
[data-page="admin-network-wireless"] #cbi-wireless .design-wireless-sep,
[data-page="admin-network-wireless"] #cbi-wireless .wireless-status-sep {
display: none;
}
[data-page="admin-network-wireless"] #cbi-wireless .td.cbi-section-actions > div {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .35rem;
margin-top: .5rem;
}
[data-page="admin-network-wireless"] #cbi-wireless .cbi-button {
width: 100%;
min-width: 0;
margin: 0;
padding: .4rem;
font-size: .85rem;
}
#wifi_assoclist_table .tr {
display: block;
margin: .5rem 0;
padding: .5rem;
border: 1px solid var(--border_color);
border-radius: 8px;
}
#wifi_assoclist_table .td {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr);
gap: .5rem;
padding: .2rem 0;
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
}
#wifi_assoclist_table .td::before {
display: block;
content: attr(data-title);
padding: 0;
font-weight: normal;
white-space: normal;
opacity: .7;
}
#wifi_assoclist_table .td > * {
min-width: 0;
max-width: 100%;
}
#wifi_assoclist_table .design-client-mac {
display: inline-block;
white-space: nowrap;
}
#wifi_assoclist_table .tr.table-titles {
display: flex;
overflow-x: auto;
gap: .35rem;
margin: 0;
padding: .25rem 0 .5rem;
border: 0;
border-radius: 0;
}
#wifi_assoclist_table .th {
display: block;
flex: 0 0 auto;
padding: .5rem;
border-radius: 4px;
background: var(--inputbg_color);
}
#wifi_assoclist_table .tr.placeholder,
#wifi_assoclist_table .tr.placeholder > .td {
position: static;
display: block;
height: auto;
text-align: center;
}
#wifi_assoclist_table .tr.placeholder > .td::before {
display: none;
}
#maincontent .cbi-value,
#modal_overlay .cbi-value {
display: flex;
}
#maincontent .cbi-value > .cbi-value-title,
#maincontent .cbi-value > .cbi-value-field,
#modal_overlay .cbi-value > .cbi-value-title,
#modal_overlay .cbi-value > .cbi-value-field {
width: 100%;
max-width: 100%;
min-width: 0;
}
#maincontent input:not([type="checkbox"]):not([type="radio"]),
#maincontent select,
#maincontent textarea,
#maincontent .cbi-dropdown,
#modal_overlay input:not([type="checkbox"]):not([type="radio"]),
#modal_overlay select,
#modal_overlay textarea,
#modal_overlay .cbi-dropdown {
min-width: 0;
max-width: 100%;
}
#modal_overlay .cbi-section,
#modal_overlay .cbi-section-node,
#modal_overlay .network-status-table .ifacebox-body .ifacebadge {
min-width: 0;
max-width: 100%;
}
#modal_overlay .cbi-value-field .ifacebadge {
white-space: normal;
overflow-wrap: anywhere;
}
[data-page="admin-system-package-manager"] #maincontent .controls > * {
flex-basis: 100%;
padding: .25rem 0;
gap: .4rem;
}
[data-page="admin-system-package-manager"] #maincontent .control-group > input {
flex: 1 1 0;
width: 0;
margin: 0;
}
[data-page="admin-system-package-manager"] #maincontent .control-group > .btn {
flex: 0 0 auto;
margin: 0;
min-height: 2.8rem;
display: flex;
align-items: center;
justify-content: center;
white-space: normal;
line-height: 1.3;
}
[data-page="admin-system-package-manager"] #maincontent #action-label + .control-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
[data-page="admin-system-package-manager"] #maincontent #action-label + .control-group > :last-child {
grid-column: 1 / -1;
}
[data-page="admin-system-package-manager"] #maincontent .controls > *,
[data-page="admin-system-package-manager"] #maincontent .controls > * > * {
min-width: 0;
max-width: 100%;
white-space: normal;
}
[data-page="admin-system-package-manager"] #maincontent .control-group {
display: flex;
flex-wrap: wrap;
gap: .25rem;
}
}
#maincontent .cbi-section,
#maincontent .cbi-section-node,
#maincontent .cbi-value > .cbi-section {
min-width: 0;
max-width: 100%;
}
@media screen and (max-width: 768px) {
#maincontent .cbi-value > .cbi-section {
width: 100%;
}
}
#maincontent .cbi-map-descr,
#maincontent .cbi-section-descr,
#maincontent .cbi-value-description {
overflow-wrap: anywhere;
}
@media screen and (max-width: 992px) {
.showSide {
top: 0;
left: 0;
width: 60px;
height: 55px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
}
#maincontent .cbi-section-create {
flex-wrap: wrap;
max-width: 100%;
}
#maincontent .cbi-section-create > * {
min-width: 0;
max-width: 100%;
}
@media (prefers-color-scheme: dark) {
code, pre {
@@ -66,10 +66,6 @@
}
function getOverviewPortColumns(width, cardCount) {
if (width <= 480) {
return 1;
}
if (width <= 768) {
return Math.min(cardCount, 2);
}
@@ -80,6 +76,7 @@
function syncOverviewPortStatus() {
var view = document.getElementById('view');
var portGroups = new Map();
var portIconSelector = 'img[src*="/luci-static/resources/icons/port_"], img[src*="/images/port_"]';
var mediaPath = (L.env && L.env.media) ? L.env.media : '/luci-static/design';
var iconMap = {
'alias': 'ethernet',
@@ -148,7 +145,7 @@
grid.style &&
grid.style.display === 'grid' &&
grid.style.gridTemplateColumns.indexOf('minmax(70px, 1fr)') !== -1 &&
grid.querySelector('img[src*="/luci-static/resources/icons/port_"]');
grid.querySelector(portIconSelector);
if (!isPortGrid) {
return;
@@ -161,7 +158,7 @@
});
});
Array.prototype.forEach.call(view.querySelectorAll('img[src*="/luci-static/resources/icons/port_"]'), function (img) {
Array.prototype.forEach.call(view.querySelectorAll(portIconSelector), function (img) {
var card = img.closest('.ifacebox');
var slot = card ? card.closest('.port-status-slot') : null;
var parent = card ? (slot ? slot.parentElement : card.parentElement) : null;
@@ -375,25 +372,96 @@
});
}
function syncWirelessCards() {
document.querySelectorAll('#cbi-wireless .cbi-section-table-row').forEach(function (row) {
row.classList.toggle('design-wireless-radio', !!row.querySelector('.wireless-radio-badge'));
row.querySelectorAll('[data-name="_stat"] div').forEach(function (container) {
Array.from(container.childNodes).forEach(function (node) {
if (node.nodeType === Node.TEXT_NODE && /^\s*\|\s*$/.test(node.textContent)) {
var separator = document.createElement('span');
separator.className = 'design-wireless-sep';
container.replaceChild(separator, node);
separator.appendChild(node);
}
});
});
});
}
function initWirelessIcons() {
if (document.body.getAttribute('data-page') !== 'admin-network-wireless') {
return;
}
syncWirelessIcons();
syncWirelessCards();
new MutationObserver(syncWirelessIcons).observe(document.body, {
new MutationObserver(function () {
syncWirelessIcons();
syncWirelessCards();
}).observe(document.body, {
childList: true,
subtree: true
});
}
function initResponsiveTables() {
var pending = false;
function sync() {
pending = false;
document.querySelectorAll('#status_leases .tr > .td:nth-child(5), #wifi_assoclist_table .tr > .td:nth-child(2)').forEach(function (cell) {
var text = cell.firstChild;
var match = text && text.nodeType === Node.TEXT_NODE && text.textContent.match(/^([\da-f]{2}(?::[\da-f]{2}){5})(.*)$/i);
if (match && cell.childNodes.length === 1) {
var address = document.createElement('span');
var mac = document.createElement('span');
mac.className = 'design-client-mac';
mac.textContent = match[1];
address.appendChild(mac);
address.appendChild(document.createTextNode(match[2]));
cell.replaceChild(address, text);
}
});
document.querySelectorAll('#maincontent table, #maincontent .table, #modal_overlay table, #modal_overlay .table').forEach(function (table) {
var parent = table.parentElement;
var style = window.getComputedStyle(parent);
var available = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
if (available > 0 && table.getClientRects().length) {
table.classList.toggle('design-table-scroll', table.scrollWidth > available + 1);
}
});
}
function schedule() {
if (!pending) {
pending = true;
window.requestAnimationFrame(sync);
}
}
new MutationObserver(schedule).observe(document.body, {
childList: true,
subtree: true
});
window.addEventListener('resize', schedule);
document.addEventListener('click', schedule);
document.fonts.ready.then(schedule);
schedule();
}
function init() {
applySafeAreaFix();
initIndicators();
initOverviewPortStatus();
initNetworkInterfaceIcons();
initWirelessIcons();
initResponsiveTables();
setHeaderShadow();
$(window).on('resize', setHeaderShadow);
}
@@ -21,7 +21,7 @@
const show_proxy_icon = access('/etc/config/shadowsocksr') || access('/etc/config/google_fu_mode');
const show_openclash_icon = access('/etc/config/openclash');
const show_homeproxy_icon = access('/etc/config/homeproxy');
const theme_rev = `${version?.luciversion ?? 'dev'}-designfix-20260927`;
const theme_rev = `${version?.luciversion ?? 'dev'}-designfix-20261108-3`;
http.prepare_content('text/html; charset=UTF-8');
-%}
+2 -2
View File
@@ -2,11 +2,11 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-theme-fluent
PKG_VERSION:=1.0.9
PKG_RELEASE:=2
PKG_RELEASE:=3
PKG_LICENSE:=Apache-2.0
LUCI_TITLE:=Fluent Theme for LuCI
LUCI_DEPENDS:=+luci-base
LUCI_DEPENDS:=+luci-base +flock
LUCI_MINIFY_CSS:=0
define Package/$(PKG_NAME)/conffiles
/etc/config/fluent
@@ -6676,6 +6676,52 @@ cbi-dropdown[open] > ul.dropdown:not(.preview) > li .create-item-input, .cbi-dro
outline: none;
}
cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group, .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group, .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group {
cursor: default;
padding: 0;
display: block;
}
cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > .fluent-select-group__label, .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > .fluent-select-group__label, .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group > .fluent-select-group__label {
padding: var(--fluent-space-xxs) var(--fluent-space-xs);
color: var(--fluent-text-secondary);
display: block;
}
cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul, .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul, .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group > ul {
margin: 0;
padding: 0;
list-style: none;
}
cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"], .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"], .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"] {
min-height: var(--fluent-input-height);
padding: 5px var(--fluent-space-xs);
border-radius: var(--fluent-radius-md);
cursor: pointer;
align-items: center;
display: flex;
}
cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"]:hover, .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"]:hover, .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"]:hover {
background-color: var(--fluent-dropdown-option-hover);
}
cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][selected], .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][selected], .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][selected] {
background-color: var(--fluent-dropdown-option-selected);
font-weight: var(--fluent-font-weight-semibold);
}
cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][disabled], .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][disabled], .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][disabled] {
color: var(--fluent-text-disabled);
cursor: not-allowed;
pointer-events: none;
}
cbi-dropdown.fluent-custom-select > ul.dropdown [hidden], .cbi-dropdown.fluent-custom-select > ul.dropdown [hidden], .fluent-custom-select.fluent-custom-select > ul.dropdown [hidden] {
display: none;
}
cbi-dropdown > select, .cbi-dropdown > select, .fluent-custom-select > select {
box-sizing: border-box;
width: 100%;
@@ -6841,12 +6887,12 @@ cbi-dropdown ul.dropdown:not(.preview) > li[selected] input[type="checkbox"]:dis
background-size: 12px 12px !important;
}
cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li, .cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li, .fluent-custom-select:not([multiple]) > ul.dropdown:not(.preview) > li {
cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li, cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"], .cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li, .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"], .fluent-custom-select:not([multiple]) > ul.dropdown:not(.preview) > li, .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"] {
padding-inline-start: 32px;
position: relative !important;
}
cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li:before, .cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li:before, .fluent-custom-select:not([multiple]) > ul.dropdown:not(.preview) > li:before {
cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li:before, cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"]:before, .cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li:before, .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"]:before, .fluent-custom-select:not([multiple]) > ul.dropdown:not(.preview) > li:before, .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"]:before {
--icon-check: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+CiAgPHBhdGggZD0iTTEzLjg1NCAzLjY0NmEuNS41IDAgMCAxIDAgLjcwOGwtNyA3YS41LjUgMCAwIDEtLjcwOCAwbC0zLjUtMy41YS41LjUgMCAxIDEgLjcwOC0uNzA4TDYuNSAxMC4yOTNsNi42NDYtNi42NDdhLjUuNSAwIDAgMSAuNzA4IDB6Ii8+Cjwvc3ZnPgo=);
content: "" !important;
inset-inline-start: 8px !important;
@@ -6868,14 +6914,18 @@ cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li:before, .cbi-dropd
mask-repeat: no-repeat !important;
}
cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[selected]:before, .cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[selected]:before, .fluent-custom-select:not([multiple]) > ul.dropdown:not(.preview) > li[selected]:before {
cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[selected]:before, cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][selected]:before, .cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[selected]:before, .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][selected]:before, .fluent-custom-select:not([multiple]) > ul.dropdown:not(.preview) > li[selected]:before, .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][selected]:before {
background-color: var(--fluent-text) !important;
}
cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[disabled][selected]:before, cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[unselectable][selected]:before, .cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[disabled][selected]:before, .cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[unselectable][selected]:before, .fluent-custom-select:not([multiple]) > ul.dropdown:not(.preview) > li[disabled][selected]:before, .fluent-custom-select:not([multiple]) > ul.dropdown:not(.preview) > li[unselectable][selected]:before {
cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[disabled][selected]:before, cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[unselectable][selected]:before, cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][disabled][selected]:before, cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][unselectable][selected]:before, .cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[disabled][selected]:before, .cbi-dropdown:not([multiple]) > ul.dropdown:not(.preview) > li[unselectable][selected]:before, .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][disabled][selected]:before, .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][unselectable][selected]:before, .fluent-custom-select:not([multiple]) > ul.dropdown:not(.preview) > li[disabled][selected]:before, .fluent-custom-select:not([multiple]) > ul.dropdown:not(.preview) > li[unselectable][selected]:before, .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][disabled][selected]:before, .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group > ul > li[role="option"][unselectable][selected]:before {
background-color: var(--fluent-text-disabled) !important;
}
cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group:before, .cbi-dropdown.fluent-custom-select > ul.dropdown > .fluent-select-group:before, .fluent-custom-select.fluent-custom-select > ul.dropdown > .fluent-select-group:before {
display: none !important;
}
cbi-dropdown[open] > ul:not(.preview) > li:has(.zonebadge) {
background: none !important;
padding: 0 !important;
@@ -10048,6 +10098,17 @@ html[dir="rtl"] .fluent-menu-editor__category-header:after, body[data-fluent-dir
display: flex;
}
.fluent-about-manager [hidden] {
display: none;
}
.fluent-about-manager > .fluent-update-changelog {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 12rem;
overflow: auto;
}
.fluent-about-manager h3 {
color: var(--fluent-text);
margin: 8px 0 4px;
@@ -10124,23 +10185,6 @@ html[dir="rtl"] .fluent-menu-editor__category-header:after, body[data-fluent-dir
display: flex;
}
.fluent-update-controls .fluent-update-channel-selector {
color: var(--fluent-text);
align-items: center;
gap: 8px;
font-size: 13px;
display: flex;
}
.fluent-update-controls .fluent-update-channel-selector label {
color: var(--fluent-text-secondary);
}
.fluent-update-controls .fluent-update-channel-selector select {
min-width: 140px;
padding: 4px 8px;
}
.fluent-update-status {
border-radius: var(--fluent-radius-sm);
background-color: var(--fluent-bg);
@@ -10214,71 +10258,6 @@ html[dir="rtl"] .fluent-menu-editor__category-header:after, body[data-fluent-dir
overflow-y: auto;
}
.fluent-update-card .fluent-update-footer {
justify-content: flex-end;
display: flex;
}
.fluent-update-card .fluent-update-success {
text-align: center;
padding: 8px 0;
}
.fluent-update-card .fluent-update-success h4 {
color: #107c41;
margin: 0 0 8px;
font-weight: 600;
}
.fluent-update-card .fluent-update-success p {
color: var(--fluent-text-secondary);
margin: 0;
font-size: 13px;
}
.fluent-progress-bar {
background-color: var(--fluent-border);
border-radius: 2px;
width: 100%;
height: 4px;
position: relative;
overflow: hidden;
}
.fluent-progress-bar__fill {
background-color: var(--fluent-primary);
border-radius: 2px;
height: 100%;
transition: width .2s ease-in-out;
}
.fluent-progress-bar__fill.fill-verify, .fluent-progress-bar__fill.fill-install {
transform-origin: 0;
animation: 1.5s linear infinite fluent-indeterminate-progress;
width: 100% !important;
}
.fluent-progress-text {
color: var(--fluent-text-secondary);
text-align: center;
margin-top: -8px;
font-size: 12px;
}
@keyframes fluent-indeterminate-progress {
0% {
transform: translateX(-100%) scaleX(.2);
}
50% {
transform: translateX(-20%) scaleX(.5);
}
100% {
transform: translateX(100%) scaleX(.2);
}
}
.fluent-mac-selector-group {
align-items: center;
gap: var(--fluent-space-xs);
@@ -1670,28 +1670,32 @@ function setupSelectionPause() {
;// CONCATENATED MODULE: ./web/resources/utils/select-dropdown.tsx
let select_dropdown_r = !1;
function select_dropdown_s() {
let select_dropdown_l = !1, select_dropdown_a = !1, select_dropdown_s = new WeakMap(), select_dropdown_d = 0;
function select_dropdown_c() {
document.querySelectorAll("cbi-dropdown[open], .cbi-dropdown[open], .fluent-custom-select[open]").forEach((e)=>{
if (e instanceof HTMLElement) if (e.classList.contains("fluent-custom-select")) {
let t = e.previousElementSibling;
t instanceof HTMLSelectElement && select_dropdown_f(e, t);
} else select_dropdown_a(e);
t instanceof HTMLSelectElement && select_dropdown_h(e, t);
} else select_dropdown_u(e);
});
}
function select_dropdown_a(e) {
let t = e.getBoundingClientRect(), n = window.innerHeight - t.bottom, o = t.top, l = e.querySelector("ul.dropdown, ul:not(.preview)"), i = l ? l.querySelectorAll("li").length : 8, r = l ? l.scrollHeight : 0, s = Math.min(r > 0 ? r + 8 : 36 * i + 10, select_dropdown_c), a = n < s && o > n, d = a ? Math.max(64, o - select_dropdown_u - select_dropdown_p) : Math.max(64, n - select_dropdown_u - select_dropdown_p);
e.setAttribute("data-open-direction", a ? "up" : "down"), e.style.setProperty("--fluent-dropdown-max-height", `${Math.min(select_dropdown_c, s, d)}px`);
function select_dropdown_u(e) {
let t = e.getBoundingClientRect(), n = window.innerHeight - t.bottom, r = t.top, i = e.querySelector("ul.dropdown, ul:not(.preview)"), o = i ? i.querySelectorAll("li").length : 8, l = i ? i.scrollHeight : 0, a = Math.min(l > 0 ? l + 8 : 36 * o + 10, select_dropdown_b), s = n < a && r > n, d = s ? Math.max(64, r - select_dropdown_m - select_dropdown_f) : Math.max(64, n - select_dropdown_m - select_dropdown_f);
e.setAttribute("data-open-direction", s ? "up" : "down"), e.style.setProperty("--fluent-dropdown-max-height", `${Math.min(select_dropdown_b, a, d)}px`);
}
function setupFluentSelects() {
select_dropdown_r || (select_dropdown_r = !0, window.addEventListener("scroll", select_dropdown_s, !0), window.addEventListener("resize", select_dropdown_s), new MutationObserver((e)=>{
select_dropdown_l || (select_dropdown_l = !0, window.addEventListener("scroll", select_dropdown_c, !0), window.addEventListener("resize", select_dropdown_c), document.addEventListener("click", (e)=>{
document.querySelectorAll(".fluent-custom-select[open]").forEach((t)=>{
t.contains(e.target) || e.target === t.previousElementSibling || select_dropdown_y(t);
});
}, !0), new MutationObserver((e)=>{
e.forEach((e)=>{
if ("attributes" === e.type && "open" === e.attributeName) {
let t = e.target;
if (t.hasAttribute("open")) if (t.classList.contains("fluent-custom-select")) {
let e = t.previousElementSibling;
e instanceof HTMLSelectElement && select_dropdown_f(t, e);
} else ("CBI-DROPDOWN" === t.tagName || t.classList.contains("cbi-dropdown")) && select_dropdown_a(t);
e instanceof HTMLSelectElement && select_dropdown_h(t, e);
} else ("CBI-DROPDOWN" === t.tagName || t.classList.contains("cbi-dropdown")) && select_dropdown_u(t);
}
});
}).observe(document.body, {
@@ -1700,55 +1704,59 @@ function setupFluentSelects() {
"open"
],
subtree: !0
})), "0" === document.body.getAttribute("data-theme-custom-select") || (document.querySelectorAll("select").forEach((e)=>{
select_dropdown_h(e);
})), "0" === document.body.getAttribute("data-theme-custom-select") || select_dropdown_a || (select_dropdown_a = !0, document.querySelectorAll("select").forEach((e)=>{
select_dropdown_v(e);
}), document.querySelectorAll("cbi-dropdown, .cbi-dropdown").forEach((e)=>{
e.classList.contains("fluent-custom-select") || select_dropdown_d(e);
e.classList.contains("fluent-custom-select") || select_dropdown_p(e);
}), new MutationObserver((e)=>{
let t = new Set();
e.forEach((e)=>{
e.addedNodes.forEach((e)=>{
e.nodeType === Node.ELEMENT_NODE && ("SELECT" === e.tagName ? select_dropdown_h(e) : "CBI-DROPDOWN" === e.tagName || e.classList.contains("cbi-dropdown") ? e.classList.contains("fluent-custom-select") || select_dropdown_d(e) : (e.querySelectorAll("select").forEach((e)=>{
select_dropdown_h(e);
e.removedNodes.forEach((e)=>{
e instanceof HTMLSelectElement ? t.add(e) : e instanceof Element && e.querySelectorAll("select").forEach((e)=>{
t.add(e);
});
}), e.addedNodes.forEach((e)=>{
e.nodeType === Node.ELEMENT_NODE && ("SELECT" === e.tagName ? select_dropdown_v(e) : "CBI-DROPDOWN" === e.tagName || e.classList.contains("cbi-dropdown") ? e.classList.contains("fluent-custom-select") || select_dropdown_p(e) : (e.querySelectorAll("select").forEach((e)=>{
select_dropdown_v(e);
}), e.querySelectorAll("cbi-dropdown, .cbi-dropdown").forEach((e)=>{
e.classList.contains("fluent-custom-select") || select_dropdown_d(e);
e.classList.contains("fluent-custom-select") || select_dropdown_p(e);
})));
});
}), t.forEach((e)=>{
let t = select_dropdown_s.get(e);
t && (e.isConnected ? (e.nextElementSibling !== t.dropdown && (select_dropdown_y(t.dropdown), e.after(t.dropdown)), t.refreshAncestors()) : t.dispose());
});
}).observe(document.body, {
childList: !0,
subtree: !0
}));
}
function select_dropdown_d(e) {
function select_dropdown_p(e) {
let t;
(t = e.querySelector("span.open")) && "true" !== t.getAttribute("data-chevron-upgraded") && (t.setAttribute("data-chevron-upgraded", "true"), t.innerHTML = "");
let n = e.querySelector("ul:not(.preview)");
n && !n.classList.contains("dropdown") && n.classList.add("dropdown");
}
let select_dropdown_c = 350, select_dropdown_u = 8, select_dropdown_p = 2;
function select_dropdown_b(e, t) {
let n = t.offsetTop, o = n + t.offsetHeight, l = e.scrollTop, i = l + e.clientHeight;
n < l ? e.scrollTop = n : o > i && (e.scrollTop = o - e.clientHeight);
}
function select_dropdown_f(e, t) {
let r = e.getBoundingClientRect(), s = getEffectiveDocumentDirection(), a = getViewportInlineSize(), d = window.innerHeight, b = d - r.bottom, f = r.top;
if (b < Math.min(32 * t.options.length + 10, select_dropdown_c) && f > b ? e.setAttribute("data-open-direction", "up") : e.setAttribute("data-open-direction", "down"), !e.closest("#modal_overlay .modal, .fluent-mac-overlay-card, [class*='overlay']")) return void e.removeAttribute("data-fluent-floating");
let m = Math.min(32 * t.options.length + 10, select_dropdown_c, 0.45 * d), h = b < m && f > b, v = Math.min(m, Math.max(64, h ? f - select_dropdown_u - select_dropdown_p : b - select_dropdown_u - select_dropdown_p)), w = Math.min(r.width, a - 2 * select_dropdown_u), y = Math.min(Math.max(select_dropdown_u, getRectInlineStart(r, s, a)), a - w - select_dropdown_u), A = h ? Math.max(select_dropdown_u, r.top - v - select_dropdown_p) : Math.min(d - select_dropdown_u - v, r.bottom + select_dropdown_p);
let select_dropdown_b = 350, select_dropdown_m = 8, select_dropdown_f = 2;
function select_dropdown_h(e, t) {
let l = e.getBoundingClientRect(), a = getEffectiveDocumentDirection(), s = getViewportInlineSize(), d = window.innerHeight, c = d - l.bottom, u = l.top;
if (c < Math.min(32 * t.options.length + 10, select_dropdown_b) && u > c ? e.setAttribute("data-open-direction", "up") : e.setAttribute("data-open-direction", "down"), !e.closest("#modal_overlay .modal, .fluent-mac-overlay-card, [class*='overlay']")) return void e.removeAttribute("data-fluent-floating");
let p = Math.min(32 * t.options.length + 10, select_dropdown_b, 0.45 * d), h = c < p && u > c, y = Math.min(p, Math.max(64, h ? u - select_dropdown_m - select_dropdown_f : c - select_dropdown_m - select_dropdown_f)), v = Math.min(l.width, s - 2 * select_dropdown_m), w = Math.min(Math.max(select_dropdown_m, getRectInlineStart(l, a, s)), s - v - select_dropdown_m), A = h ? Math.max(select_dropdown_m, l.top - y - select_dropdown_f) : Math.min(d - select_dropdown_m - y, l.bottom + select_dropdown_f);
e.setAttribute("data-open-direction", h ? "up" : "down"), e.setAttribute("data-fluent-floating", "modal"), setInlineCssCustomProperties(e.style, {
inlineStart: y,
inlineSize: w
}, s, a, {
inlineStart: w,
inlineSize: v
}, a, s, {
inlineStart: "--fluent-dropdown-left",
inlineSize: "--fluent-dropdown-width"
}), e.style.setProperty("--fluent-dropdown-top", `${A}px`), e.style.setProperty("--fluent-dropdown-max-height", `${v}px`);
}), e.style.setProperty("--fluent-dropdown-top", `${A}px`), e.style.setProperty("--fluent-dropdown-max-height", `${y}px`);
}
function select_dropdown_m(e) {
e.removeAttribute("open"), e.removeAttribute("data-fluent-floating"), e.style.removeProperty("--fluent-dropdown-left"), e.style.removeProperty("--fluent-dropdown-top"), e.style.removeProperty("--fluent-dropdown-width"), e.style.removeProperty("--fluent-dropdown-max-height"), e.closest(".cbi-value-field, .cbi-value")?.classList.remove("cbi-dropdown-open");
function select_dropdown_y(e) {
e.removeAttribute("open"), e.setAttribute("aria-expanded", "false"), e.removeAttribute("aria-activedescendant"), e.removeAttribute("data-fluent-floating"), e.style.removeProperty("--fluent-dropdown-left"), e.style.removeProperty("--fluent-dropdown-top"), e.style.removeProperty("--fluent-dropdown-width"), e.style.removeProperty("--fluent-dropdown-max-height"), e.closest(".cbi-value-field, .cbi-value")?.classList.remove("cbi-dropdown-open");
let t = e.querySelector("ul.dropdown");
t instanceof HTMLElement && "function" == typeof t.hidePopover && t.matches(":popover-open") && t.hidePopover();
}
function select_dropdown_h(n) {
if (n?.tagName !== "SELECT" || "true" === n.getAttribute("data-fluent-transformed") || n.closest(".cbi-dropdown") || n.multiple || function(e) {
function select_dropdown_v(n) {
if (n?.tagName !== "SELECT" || !n.isConnected || "true" === n.getAttribute("data-fluent-transformed") || n.closest(".cbi-dropdown") || n.multiple || function(e) {
let t = e.getAttribute("style") || "";
if (/\bdisplay\s*:\s*none/i.test(t) || "none" === e.style.display) return !0;
if (!e.isConnected) return !1;
@@ -1765,163 +1773,207 @@ function select_dropdown_h(n) {
t = t.parentElement;
}
if (!n) return !0;
let o = e.cloneNode(!1);
o.style.display = "", document.body.appendChild(o);
let l = window.getComputedStyle(o), i = "none" === l.display;
return document.body.removeChild(o), i;
let r = e.cloneNode(!1);
r.style.display = "", document.body.appendChild(r);
let i = window.getComputedStyle(r), o = "none" === i.display;
return document.body.removeChild(r), o;
}
} catch (e) {}
return !1;
}(n)) return;
let o = n.getAttribute("style");
let r = n.getAttribute("style"), i = n.style.getPropertyValue("display"), o = n.style.getPropertyPriority("display");
n.setAttribute("data-fluent-transformed", "true"), n.style.setProperty("display", "none", "important");
let l = jsx("li", {});
l.setAttribute("selected", "");
let i = jsx("ul", {
let a = jsx("ul", {
class: "dropdown"
}), r = jsx("span", {
});
a.id = `fluent-select-list-${++select_dropdown_d}`, a.setAttribute("role", "listbox");
let c = jsx("span", {
class: "open"
}), s = jsxs("div", {
}), u = jsxs("div", {
class: "fluent-custom-select",
children: [
jsx("ul", {
children: l
}),
r,
i
c,
a
]
});
if (s.setAttribute("tabindex", "0"), o) {
let e = o.replace(/\bdisplay\s*:\s*[^;]+(;|$)/gi, "").trim();
e && s.setAttribute("style", e);
if (u.setAttribute("tabindex", "0"), u.setAttribute("role", "combobox"), u.setAttribute("aria-haspopup", "listbox"), u.setAttribute("aria-controls", a.id), u.setAttribute("aria-expanded", "false"), u.setAttribute("aria-required", String(n.required)), u.querySelector("ul:not(.dropdown)")?.setAttribute("aria-hidden", "true"), c.setAttribute("aria-hidden", "true"), r) {
let e = r.replace(/\bdisplay\s*:\s*[^;]+(;|$)/gi, "").trim();
e && u.setAttribute("style", e);
}
n.disabled && (s.setAttribute("disabled", ""), s.removeAttribute("tabindex"));
let a = ()=>{
i.innerHTML = "";
let t = "", o = !1;
if (Array.from(n.options).forEach((n)=>{
let l = jsx("li", {
children: n.text
});
l.setAttribute("data-value", n.value), n.selected && (l.setAttribute("selected", ""), t = n.text, o = !0), n.disabled && l.setAttribute("disabled", ""), i.appendChild(l);
}), !o && n.options.length > 0) {
let e = n.options[n.selectedIndex >= 0 ? n.selectedIndex : 0];
t = e.text;
let o = i.querySelector(`li[data-value="${e.value}"]`);
o?.setAttribute("selected", "");
let p = new Map(), b = null, m = 0, f = 0, v = "", w = 0, A = null, g = [], E = (e)=>!e.hidden && !e.closest("[hidden]") && !e.matches(":disabled"), L = ()=>Array.from(n.options).filter((e)=>E(e)), x = (e)=>{
if ((b = e && p.has(e) && E(e) ? e : null) && u.hasAttribute("open")) {
let e = p.get(b);
if (e) {
let t, n, r, i;
u.setAttribute("aria-activedescendant", e.id), n = (t = e.offsetTop) + e.offsetHeight, i = (r = a.scrollTop) + a.clientHeight, t < r ? a.scrollTop = t : n > i && (a.scrollTop = n - a.clientHeight);
}
} else u.removeAttribute("aria-activedescendant");
}, S = ()=>{
let e = n.matches(":disabled");
u.toggleAttribute("disabled", e), u.setAttribute("aria-disabled", String(e)), u.setAttribute("aria-required", String(n.required)), e ? (u.removeAttribute("tabindex"), select_dropdown_y(u)) : u.setAttribute("tabindex", "0");
let t = g.map((e)=>e.id).filter(Boolean), r = n.getAttribute("aria-labelledby");
if (r || t.length) u.setAttribute("aria-labelledby", r || t.join(" ")), u.removeAttribute("aria-label");
else {
u.removeAttribute("aria-labelledby");
let e = n.getAttribute("aria-label") || g.map((e)=>e.textContent?.trim()).filter(Boolean).join(" ") || n.title;
e ? u.setAttribute("aria-label", e) : u.removeAttribute("aria-label");
}
l.textContent = t;
let i = n.options[n.selectedIndex];
for (let [e, t] of (l.textContent = i?.text ?? "", p)){
let n = e === i;
t.toggleAttribute("selected", n), t.setAttribute("aria-selected", String(n));
let r = !E(e);
t.toggleAttribute("disabled", r), t.setAttribute("aria-disabled", String(r));
}
b && !E(b) && x(i && E(i) ? i : null), u.hasAttribute("open") && !b && x(i && E(i) ? i : L()[0] || null);
}, M = ()=>{
let t = new Map(), r = null, i = null;
a.replaceChildren(), Array.from(n.options).forEach((n, o)=>{
let l = n.parentElement instanceof HTMLOptGroupElement ? n.parentElement : null;
if (l !== r && (r = l, i = null), l && !i) {
let t = jsx("li", {
class: "fluent-select-group"
});
t.setAttribute("role", "group"), t.setAttribute("aria-label", l.label), t.hidden = l.hidden;
let n = jsx("span", {
class: "fluent-select-group__label"
});
n.textContent = l.label, n.setAttribute("aria-hidden", "true"), (i = jsx("ul", {})).setAttribute("role", "presentation"), t.append(n, i), a.append(t);
}
let s = jsx("li", {});
s.id = `${a.id}-option-${o}`, s.textContent = n.text, s.setAttribute("role", "option"), s.hidden = n.hidden || !!l?.hidden, (i || a).append(s), t.set(n, s);
}), p = t, b && !p.has(b) && (b = null), S();
}, k = (e)=>{
!n.matches(":disabled") && E(e) && (n.selectedIndex = Array.from(n.options).indexOf(e), S(), n.dispatchEvent(new Event("input", {
bubbles: !0
})), n.dispatchEvent(new Event("change", {
bubbles: !0
})));
}, T = (e)=>{
!(e.target instanceof Element && e.target.closest("a, button, input, select, textarea")) && (n.matches(":disabled") || u.focus());
}, C = ()=>{
window.clearTimeout(f), f = window.setTimeout(()=>{
n.isConnected && (M(), x(null));
}, 0);
}, q = new MutationObserver(()=>S()), P = ()=>{
q.disconnect(), Array.from(function*() {
let e = n.parentElement;
for(; e;)e instanceof HTMLFieldSetElement && (yield e), e = e.parentElement;
}()).forEach((e)=>{
q.observe(e, {
attributes: !0,
attributeFilter: [
"disabled"
]
});
});
let e = n.form;
A !== e && (A?.removeEventListener("reset", C), A = e, A?.addEventListener("reset", C));
let t = Array.from(n.labels || []);
g.forEach((e)=>{
e.removeEventListener("click", T);
}), (g = t).forEach((e)=>{
e.addEventListener("click", T);
}), S();
};
a(), n.parentNode?.insertBefore(s, n.nextSibling), s.addEventListener("click", (e)=>{
if (s.hasAttribute("disabled")) return;
let t = e.target.closest("ul.dropdown > li");
if (t) {
let o = t.getAttribute("data-value");
null === o || t.hasAttribute("disabled") || (n.value = o, n.dispatchEvent(new Event("change", {
bubbles: !0
})), n.dispatchEvent(new Event("input", {
bubbles: !0
}))), select_dropdown_m(s), e.stopPropagation();
M(), n.parentNode?.insertBefore(u, n.nextSibling), u.addEventListener("click", (e)=>{
if (u.hasAttribute("disabled")) return;
let t = e.target.closest('[role="option"]');
if (t && a.contains(t)) {
let n = Array.from(p).find(([, e])=>e === t)?.[0];
n && E(n) && k(n), select_dropdown_y(u), e.stopPropagation();
return;
}
if (s.hasAttribute("open")) select_dropdown_m(s);
else {
document.querySelectorAll("cbi-dropdown[open], .cbi-dropdown[open], .fluent-custom-select[open]").forEach((e)=>{
e instanceof HTMLElement && e.classList.contains("fluent-custom-select") ? select_dropdown_m(e) : (e.removeAttribute("open"), e.closest(".cbi-value-field, .cbi-value")?.classList.remove("cbi-dropdown-open"));
}), select_dropdown_f(s, n), s.setAttribute("open", ""), s.closest(".cbi-value-field, .cbi-value")?.classList.add("cbi-dropdown-open");
let e = i.querySelector("li[selected]");
e && select_dropdown_b(i, e);
if (!e.target.closest('[role="group"]')) {
if (u.hasAttribute("open")) select_dropdown_y(u);
else {
document.querySelectorAll("cbi-dropdown[open], .cbi-dropdown[open], .fluent-custom-select[open]").forEach((e)=>{
e instanceof HTMLElement && e.classList.contains("fluent-custom-select") ? select_dropdown_y(e) : (e.removeAttribute("open"), e.closest(".cbi-value-field, .cbi-value")?.classList.remove("cbi-dropdown-open"));
}), M(), select_dropdown_h(u, n), u.setAttribute("open", ""), u.setAttribute("aria-expanded", "true"), u.closest(".cbi-value-field, .cbi-value")?.classList.add("cbi-dropdown-open"), x(E(n.options[n.selectedIndex]) ? n.options[n.selectedIndex] : L()[0] || null);
let e = ()=>{
if (!u.hasAttribute("open")) {
m = 0;
return;
}
S(), m = requestAnimationFrame(e);
};
m || (m = requestAnimationFrame(e));
}
e.stopPropagation();
}
e.stopPropagation();
});
let d = (e)=>{
!s.contains(e.target) && e.target !== n && s.hasAttribute("open") && select_dropdown_m(s);
};
document.addEventListener("click", d, !0), s.addEventListener("keydown", (e)=>{
if (s.hasAttribute("disabled")) return;
let t = s.hasAttribute("open"), o = Array.from(i.querySelectorAll("li:not([disabled])")), l = o.findIndex((e)=>e.hasAttribute("selected"));
}), u.addEventListener("keydown", (e)=>{
if (u.hasAttribute("disabled")) return;
let t = u.hasAttribute("open"), n = L(), r = b ? n.indexOf(b) : -1;
switch(e.key){
case "Enter":
case " ":
if (e.preventDefault(), t) {
let e = o[l];
e && e.click();
} else s.click();
e.preventDefault(), t ? b && (k(b), select_dropdown_y(u)) : u.click();
break;
case "Escape":
t && (e.preventDefault(), select_dropdown_m(s));
t && (e.preventDefault(), select_dropdown_y(u));
break;
case "ArrowDown":
if (e.preventDefault(), t) {
if (o.length > 0) {
let e = (l + 1) % o.length;
o.forEach((t, o)=>{
if (o === e) {
t.setAttribute("selected", ""), select_dropdown_b(i, t);
let e = t.getAttribute("data-value");
null !== e && (n.value = e, n.dispatchEvent(new Event("change", {
bubbles: !0
})), n.dispatchEvent(new Event("input", {
bubbles: !0
})));
} else t.removeAttribute("selected");
});
}
} else s.click();
e.preventDefault(), t ? n.length > 0 && x(n[(r + 1) % n.length]) : u.click();
break;
case "ArrowUp":
if (e.preventDefault(), t) {
if (o.length > 0) {
let e = (l - 1 + o.length) % o.length;
o.forEach((t, o)=>{
if (o === e) {
t.setAttribute("selected", ""), select_dropdown_b(i, t);
let e = t.getAttribute("data-value");
null !== e && (n.value = e, n.dispatchEvent(new Event("change", {
bubbles: !0
})), n.dispatchEvent(new Event("input", {
bubbles: !0
})));
} else t.removeAttribute("selected");
});
}
} else s.click();
e.preventDefault(), t ? n.length > 0 && x(n[(r - 1 + n.length) % n.length]) : u.click();
break;
case "Home":
case "End":
e.preventDefault(), t || u.click(), x("Home" === e.key ? n[0] || null : n[n.length - 1] || null);
break;
case "Tab":
t && select_dropdown_m(s);
t && select_dropdown_y(u);
break;
default:
if (1 === e.key.length && !e.ctrlKey && !e.altKey && !e.metaKey) {
v += e.key.toLocaleLowerCase(), window.clearTimeout(w), w = window.setTimeout(()=>{
v = "";
}, 700);
let r = n.find((e)=>e.text.trim().toLocaleLowerCase().startsWith(v));
r && (t || u.click(), x(r));
}
}
}), n.addEventListener("change", ()=>{
let e = n.value, t = Array.from(i.querySelectorAll("li")), o = "";
t.forEach((t)=>{
t.getAttribute("data-value") === e ? (t.setAttribute("selected", ""), o = t.textContent || "") : t.removeAttribute("selected");
}), l.textContent = o;
});
let c = new MutationObserver(()=>{
a();
let H = ()=>{
S(), x(null);
};
n.addEventListener("change", H), n.addEventListener("input", H), u.addEventListener("focus", ()=>{
P(), M();
});
c.observe(n, {
childList: !0
let N = new MutationObserver((e)=>{
e.some((e)=>"childList" === e.type || e.target !== n) ? M() : S(), e.some((e)=>"form" === e.attributeName || "id" === e.attributeName) && P();
});
let u = new MutationObserver((e)=>{
e.forEach((e)=>{
"disabled" === e.attributeName && (n.disabled ? (s.setAttribute("disabled", ""), s.removeAttribute("tabindex"), select_dropdown_m(s)) : (s.removeAttribute("disabled"), s.setAttribute("tabindex", "0")));
});
});
u.observe(n, {
N.observe(n, {
childList: !0,
subtree: !0,
characterData: !0,
attributes: !0,
attributeFilter: [
"disabled"
"disabled",
"hidden",
"label",
"selected",
"value",
"required",
"aria-label",
"aria-labelledby",
"id",
"form"
]
});
let p = new MutationObserver((e)=>{
e.forEach((e)=>{
e.removedNodes.forEach((e)=>{
(e === n || e.contains?.(n)) && (document.removeEventListener("click", d, !0), c.disconnect(), u.disconnect(), p.disconnect(), s.remove());
});
});
});
n.parentNode && p.observe(n.parentNode, {
childList: !0
}), P(), select_dropdown_s.set(n, {
dropdown: u,
refreshAncestors: P,
dispose: ()=>{
select_dropdown_y(u), m && cancelAnimationFrame(m), window.clearTimeout(f), window.clearTimeout(w), N.disconnect(), q.disconnect(), A?.removeEventListener("reset", C), g.forEach((e)=>{
e.removeEventListener("click", T);
}), n.removeEventListener("change", H), n.removeEventListener("input", H), u.remove(), n.removeAttribute("data-fluent-transformed"), i ? n.style.setProperty("display", i, o) : n.style.removeProperty("display"), select_dropdown_s.delete(n);
}
});
}
@@ -93,169 +93,81 @@ function jsxDEV(t, n) {
;// CONCATENATED MODULE: ./web/resources/utils/update.ts
let update_t = L.rpc;
const callGetVersion = update_t.declare({
const callGetVersion = L.rpc.declare({
object: "luci.fluent",
method: "get_version"
});
const callStartDownload = update_t.declare({
const callStartUpdate = L.rpc.declare({
object: "luci.fluent",
method: "start_download",
method: "start_update",
params: [
"url",
"i18n_url",
"i18n_urls"
"manifest"
]
});
const callCheckDownload = update_t.declare({
const callUpdateStatus = L.rpc.declare({
object: "luci.fluent",
method: "check_download"
});
const callDoInstall = update_t.declare({
object: "luci.fluent",
method: "do_install",
method: "update_status",
params: [
"hash",
"i18n_hash",
"i18n_hashes"
"job"
]
});
class GitHubAPIError extends Error {
status;
constructor(t, e){
super(t), this.name = "GitHubAPIError", this.status = e;
super(t), this.status = e, this.name = "GitHubAPIError";
}
}
function matchI18nAsset(t, e, l) {
let s = e.toLowerCase().trim();
for (let e of t){
let t = String(e.name || "").toLowerCase();
if ("apk" === l) {
if (t.startsWith(`luci-i18n-fluent-${s}-`) && t.endsWith(".apk")) return e;
} else if ((t.startsWith(`luci-i18n-fluent-${s}_`) || t.startsWith(`luci-i18n-fluent-${s}-`)) && t.endsWith(".ipk")) return e;
}
return null;
function matchI18nAsset(t, e, a) {
let r = `luci-i18n-fluent-${e.toLowerCase().trim()}${"apk" === a ? "-" : "_"}`;
return t.find((t)=>t.name.toLowerCase().startsWith(r) && t.name.endsWith(`.${a}`)) ?? null;
}
async function fetchLatestRelease(t, e, l = [], s) {
let a = {};
s && (a.Authorization = `token ${s}`);
let r = await fetch("nightly" === t ? "https://api.github.com/repos/LazuliKao/luci-theme-fluent/releases/tags/nightly" : "https://api.github.com/repos/LazuliKao/luci-theme-fluent/releases/latest", {
headers: a
}), n = await r.json();
if (!r.ok) {
let t = n?.message ? `: ${n.message}` : "";
throw new GitHubAPIError(`GitHub API returned status ${r.status}${t}`, r.status);
}
let i = n.assets || [], o = null, u = [];
for (let t of i){
let l = String(t.name || "");
"apk" === e && /^luci-theme-fluent-[0-9].*\.apk$/.test(l) ? o = t : "ipk" === e && /^luci-theme-fluent_[^/]+\.ipk$/.test(l) && (o = t);
}
for (let t of l){
let l = matchI18nAsset(i, t, e);
l && !u.includes(l) && u.push(l);
}
function updateManifest(t, e = "") {
return JSON.stringify({
packages: t.map((t)=>{
let a = t.digest ?? "";
if (!/^sha256:[a-f\d]{64}$/i.test(a)) throw Error(`Missing SHA-256 digest: ${t.name}`);
if (!t.browser_download_url.startsWith("https://")) throw Error(`Invalid package URL: ${t.name}`);
return {
url: e + t.browser_download_url,
sha256: a.slice(7).toLowerCase()
};
})
});
}
async function fetchLatestRelease(t, e, a = [], r) {
let s = await fetch(`https://api.github.com/repos/LazuliKao/luci-theme-fluent/releases/${"nightly" === t ? "tags/nightly" : "latest"}`, {
headers: r ? {
Authorization: `Bearer ${r}`
} : {},
signal: AbortSignal.timeout(30000)
}), n = await s.json();
if (!s.ok) throw new GitHubAPIError(`GitHub API returned status ${s.status}: ${n.message ?? ""}`, s.status);
let i = n.assets ?? [], o = "apk" === e ? /^luci-theme-fluent-[0-9].*\.apk$/ : /^luci-theme-fluent_[^/]+\.ipk$/, l = i.find((t)=>o.test(t.name)) ?? null, u = a.map((t)=>{
let a = matchI18nAsset(i, t, e);
if (!a) throw Error(`Missing translation package: ${t}`);
return a;
});
return {
tag_name: String(n.tag_name || ""),
published_at: String(n.published_at || ""),
body: String(n.body || ""),
html_url: String(n.html_url || ""),
package_asset: o,
tag_name: String(n.tag_name ?? ""),
published_at: String(n.published_at ?? ""),
body: String(n.body ?? ""),
package_asset: l,
i18n_assets: u
};
}
;// CONCATENATED MODULE: ./web/resources/view/fluent-config/tabs/about.tsx
let about_l = L.form, about_n = "https://ghfast.top/";
let about_u = about_l.DummyValue.extend({
renderWidget: (l, u, p)=>{
let h = "1.0.1", b = "ipk", f = [], g = jsxs("div", {
class: "fluent-about-logo",
children: [
jsx("img", {
src: `${L.media()}/img/fluent.svg`,
alt: "Fluent Theme Logo"
}),
jsx("h2", {
children: "Fluent Theme"
}),
jsx("p", {
class: "fluent-about-subtitle",
children: _("Fluent Design 2 theme for LuCI")
})
]
}), y = jsxs("div", {
class: "fluent-about-details",
children: [
jsxs("div", {
class: "fluent-about-detail-row",
children: [
jsxs("strong", {
children: [
_("Author"),
":"
]
}),
jsx("span", {
children: "LazuliKao"
})
]
}),
jsxs("div", {
class: "fluent-about-detail-row",
children: [
jsxs("strong", {
children: [
_("Installed version"),
":"
]
}),
jsx("span", {
class: "fluent-about-current-version",
children: "v..."
})
]
}),
jsxs("div", {
class: "fluent-about-detail-row",
children: [
jsxs("strong", {
children: [
_("Package format"),
":"
]
}),
jsx("span", {
class: "fluent-about-pkg-type",
children: "..."
})
]
}),
jsxs("div", {
class: "fluent-about-detail-row",
children: [
jsxs("strong", {
children: [
_("Source code"),
":"
]
}),
jsx("span", {
children: jsx("a", {
href: "https://github.com/LazuliKao/luci-theme-fluent",
target: "_blank",
rel: "noreferrer",
children: "GitHub Repository"
})
})
]
})
]
}), m = jsxs("select", {
let about_d = L.form.DummyValue.extend({
renderWidget: ()=>{
let d, c = jsx("span", {
children: "\u2026"
}), u = jsx("span", {
children: "\u2026"
}), h = jsxs("select", {
id: "fluent-update-channel",
class: "cbi-input-select",
id: "update-channel-select",
children: [
jsx("option", {
value: "stable",
@@ -266,284 +178,238 @@ let about_u = about_l.DummyValue.extend({
children: _("Nightly Channel (Prerelease)")
})
]
}), w = jsxs("select", {
}), b = jsxs("select", {
id: "fluent-update-method",
class: "cbi-input-select",
id: "update-method-select",
children: [
jsx("option", {
value: "backend_official",
value: "",
children: _("Backend (Official GitHub)")
}),
jsx("option", {
value: "backend_ghproxy",
value: "https://ghfast.top/",
children: _("Backend (GHProxy Acceleration)")
})
]
}), v = jsx("button", {
class: "btn cbi-button cbi-button-action",
}), p = jsx("button", {
type: "button",
class: "btn cbi-button-action",
children: _("Check for updates")
}), k = jsxs("div", {
class: "fluent-update-controls",
style: "display: flex; gap: 15px; align-items: center; flex-wrap: wrap;",
children: [
jsxs("div", {
class: "fluent-update-channel-selector",
children: [
jsxs("label", {
htmlFor: "update-channel-select",
children: [
_("Update channel"),
": "
]
}),
m
]
}),
v
]
}), x = jsx("div", {
}), f = jsx("p", {
class: "fluent-update-status",
style: "display: none"
}), T = jsx("div", {
class: "fluent-progress-bar__fill",
style: "width: 0%"
}), C = jsx("div", {
class: "fluent-progress-bar",
style: "display: none",
children: T
}), S = jsx("div", {
class: "fluent-progress-text",
style: "display: none"
}), $ = jsx("div", {
role: "status"
}), g = jsx("div", {
class: "fluent-update-card",
style: "display: none"
}), F = jsxs("div", {
hidden: !0
}), m = jsx("pre", {
class: "fluent-update-changelog",
hidden: !0
}), w = jsx("button", {
type: "button",
class: "btn cbi-button-action",
hidden: !0,
onclick: ()=>window.location.reload(),
children: _("Reload Web Interface")
}), v = jsxs("div", {
class: "fluent-about-manager",
children: [
g,
y,
jsxs("div", {
class: "fluent-about-logo",
children: [
jsx("img", {
src: `${L.media()}/img/fluent.svg`,
alt: ""
}),
jsx("h2", {
children: "Fluent Theme"
}),
jsx("p", {
class: "fluent-about-subtitle",
children: _("Fluent Design 2 theme for LuCI")
})
]
}),
jsxs("div", {
class: "fluent-about-details",
children: [
jsxs("div", {
class: "fluent-about-detail-row",
children: [
jsxs("strong", {
children: [
_("Author"),
":"
]
}),
jsx("span", {
children: "LazuliKao"
})
]
}),
jsxs("div", {
class: "fluent-about-detail-row",
children: [
jsxs("strong", {
children: [
_("Installed version"),
":"
]
}),
c
]
}),
jsxs("div", {
class: "fluent-about-detail-row",
children: [
jsxs("strong", {
children: [
_("Package format"),
":"
]
}),
u
]
}),
jsxs("div", {
class: "fluent-about-detail-row",
children: [
jsxs("strong", {
children: [
_("Source code"),
":"
]
}),
jsx("a", {
href: "https://github.com/LazuliKao/luci-theme-fluent",
target: "_blank",
rel: "noreferrer",
children: "GitHub"
})
]
})
]
}),
jsx("hr", {
class: "fluent-about-divider"
}),
jsx("h3", {
children: _("Software Update")
}),
k,
x,
$,
C,
S
jsxs("div", {
class: "fluent-update-controls",
children: [
jsx("label", {
htmlFor: "fluent-update-channel",
children: _("Update channel")
}),
h,
jsx("label", {
htmlFor: "fluent-update-method",
children: _("Download method")
}),
b,
p
]
}),
f,
g,
m,
w
]
}), P = (e, t = "info")=>{
dom.content(x, [
document.createTextNode(e)
]), x.className = `fluent-update-status status-${t}`, x.style.display = "block";
}, A = (e, t, a)=>{
C.style.display = "block", S.style.display = "block", T.style.width = `${t}%`, T.className = `fluent-progress-bar__fill fill-${e}`, dom.content(S, [
document.createTextNode(`${a} (${t}%)`)
]);
}, z = ()=>{
C.style.display = "none", S.style.display = "none";
};
(async ()=>{
});
function y(e, t = "info") {
f.textContent = e, f.className = `fluent-update-status status-${t}`;
}
function k(e) {
h.disabled = b.disabled = p.disabled = e, g.querySelectorAll("button").forEach((t)=>{
t.disabled = e;
});
}
async function C(e) {
let t = e, a = Date.now() + 1200000;
k(!0);
try {
let e = await callGetVersion();
h = e.version, b = e.pkg_type, f = e.installed_i18n || (e.i18n_zh_cn_installed ? [
"zh-cn"
] : []);
let t = y.querySelector(".fluent-about-current-version");
t && (t.textContent = `v${h}`);
let a = y.querySelector(".fluent-about-pkg-type");
a && (a.textContent = "apk" === b ? "APK (OpenWrt 25.12+)" : "IPK (OpenWrt 24.10)");
for(; v.isConnected;){
if (0 !== t.result) throw Error(t.message || _("Unable to read update status."));
if (m.hidden = !t.log, m.textContent !== t.log && (m.textContent = t.log ?? ""), "done" === t.stage) {
y(_("Theme successfully updated. Reload the web interface to use the new version."), "success"), w.hidden = !1;
return;
}
if ("error" === t.stage) return void y(_("Update failed. Check the installation log below."), "error");
if ("downloading" !== t.stage && "installing" !== t.stage) throw Error(_("Unexpected update status."));
if (y("downloading" === t.stage ? _("Downloading and verifying packages...") : _("Installing packages...")), Date.now() >= a) throw Error(_("Timed out waiting for update status."));
if (await new Promise((e)=>setTimeout(e, 1000)), !v.isConnected) return;
if (!t.job) throw Error(_("Missing update job."));
t = await callUpdateStatus(t.job);
}
} catch (e) {
console.error("Failed to fetch version", e), P(_("Failed to fetch current theme version."), "error");
y(`${_("Update status unavailable. Reload and sign in again to check the result.")} ${String(e)}`, "warn"), w.hidden = !1;
} finally{
k(!1);
}
})();
let N = async (l)=>{
let s = m.value;
}
async function x(n) {
k(!0), g.hidden = !0, y(_("Checking for updates..."));
try {
let c = await fetchLatestRelease(s, b, f, l);
console.log(c), v.disabled = !1;
let u = h.replace(/^v/, "").trim(), p = c.tag_name.replace(/^v/, "").trim(), g = L.naturalCompare(p, u) > 0, y = !g && "nightly" !== s;
if (y ? P(_("Your theme is up to date!"), "success") : P(g ? _("A new version is available!") : _("Nightly build available (reinstallation check)."), g ? "warn" : "info"), !c.package_asset) return void P(_("No matching package asset found for your system architecture in this release."), "error");
let k = c.body ? c.body : "", x = jsx("button", {
class: "btn cbi-button cbi-button-save",
let a = await fetchLatestRelease(h.value, d.pkg_type, d.installed_i18n, n), i = a.package_asset;
if (!i) throw Error(_("No matching theme package found in this release."));
let o = d.version.replace(/^v/, ""), c = a.tag_name.replace(/^v/, ""), u = "stable" === h.value && 0 >= L.naturalCompare(c, o), p = jsx("button", {
type: "button",
style: "white-space: nowrap;",
class: "btn cbi-button-save",
children: _("Download and Install")
}), T = [
});
dom.content(g, [
jsxs("div", {
class: "fluent-update-header",
children: [
jsx("span", {
class: "fluent-update-badge",
children: "nightly" === p ? "Nightly" : `v${p}`
children: a.tag_name
}),
jsx("span", {
class: "fluent-update-date",
children: c.published_at.split("T")[0]
children: a.published_at.split("T")[0]
})
]
})
];
k && T.push(jsx("pre", {
class: "fluent-update-changelog",
children: k
})), y || T.push(jsxs("div", {
class: "fluent-update-footer",
style: "display: flex; align-items: center; justify-content: flex-end; gap: 10px; margin-top: 15px; flex-wrap: wrap;",
children: [
w,
x
}),
jsx("pre", {
class: "fluent-update-changelog",
children: a.body
}),
...u ? [] : [
p
]
})), dom.content($, T), $.style.display = "block", x.addEventListener("click", async ()=>{
let l = c.package_asset;
if (!l) return void P(_("No matching package asset found for your system architecture in this release."), "error");
x.disabled = !0, m.disabled = !0, w.disabled = !0, v.disabled = !0, P(_("Starting update process..."), "info");
let s = c.i18n_assets || [], d = null, u = [];
for (let e of (l.digest?.startsWith("sha256:") ? d = l.digest.replace("sha256:", "") : (console.warn("Unable to determine expected package hash from digest. Skipping verification."), d = "skip"), s))e.digest?.startsWith("sha256:") ? u.push(e.digest.replace("sha256:", "")) : u.push("skip");
let p = async ()=>{
let c = w.value.includes("ghproxy");
P(_("Starting backend download..."), "info"), A("download", 0, _("Downloading on router"));
let p = c ? about_n + l.browser_download_url : l.browser_download_url, h = s.map((e)=>c ? about_n + e.browser_download_url : e.browser_download_url).join(" "), b = await callStartDownload(p, h.split(" ")[0] || "", h);
if (0 !== b.result) throw Error(b.message || "Failed to start router download.");
for(;;){
let e = await callCheckDownload(), t = e.size || 0, a = s.reduce((e, t)=>e + (t.size || 0), 0), n = l.size + a, i = n > 0 ? Math.min(Math.round(t / n * 100), 100) : 0;
if (A("download", i, `${_("Downloading on router")} (${(t / 1024).toFixed(0)} / ${(n / 1024).toFixed(0)} KB)`), !e.running) if (0 !== e.code) throw Error("Router background download failed or file is empty.");
else break;
await new Promise((e)=>setTimeout(e, 1000));
}
if (!await new Promise((l)=>{
L.ui.showModal(_("Confirm Installation"), jsxs(Fragment, {
children: [
jsx("div", {
children: jsx("p", {
children: _("The theme package is ready. Are you sure you want to proceed with the installation?")
})
}),
jsxs("div", {
class: "right",
children: [
jsx("button", {
type: "button",
class: "btn",
onclick: ()=>{
L.ui.hideModal(), l(!1);
},
children: _("Cancel")
}),
jsx("button", {
type: "button",
class: "btn cbi-button-action",
onclick: ()=>{
L.ui.hideModal(), l(!0);
},
children: _("Continue")
})
]
})
]
}));
})) throw Error(_("Installation cancelled by user."));
P(_("Triggering installation on router..."), "info"), A("install", 100, _("Installing package"));
let f = await callDoInstall(d, u[0] || "", u.join(" "));
if (0 !== f.result) throw Error(f.message || "Router installation failed.");
let g = jsx("pre", {
style: "background: var(--fluent-code-bg, #1a1a1a); color: var(--fluent-text, #fff); padding: 10px; border-radius: 4px; font-family: monospace; font-size: 12px; white-space: pre-wrap; word-wrap: break-word; max-height: 200px; overflow-y: auto; margin-top: 15px;"
});
(async ()=>{
let e = L.env.ubuspath || "/ubus", t = L.env.sessionid;
for(;;)try {
let a = await fetch(e, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "call",
params: [
t,
"luci.fluent",
"get_install_log",
{}
]
})
}), l = await a.json();
if (l.error) break;
l.result?.[1]?.log && (g.textContent = l.result[1].log, g.scrollTop = g.scrollHeight), await new Promise((e)=>setTimeout(e, 1000));
} catch {
break;
}
})(), P(_("Theme successfully updated! Reloading RPC service, please refresh the page in 5 seconds."), "success"), A("done", 100, _("Finished")), dom.content($, [
jsxs("div", {
class: "fluent-update-success",
children: [
jsx("h3", {
style: "color: var(--fluent-success); text-align: center;",
children: _("Upgrade Successful!")
}),
jsx("p", {
style: "text-align: center; margin: 15px 0;",
children: _("The theme is being installed on your router. The logs are displayed below. Reloading the web interface will apply the changes once RPCD restarts.")
}),
g,
jsx("div", {
style: "display: flex; justify-content: center; margin-top: 20px;",
children: jsx("button", {
class: "btn cbi-button-action",
type: "button",
style: "margin-top: 15px",
onclick: ()=>{
window.location.reload();
},
children: _("Reload Web Interface")
})
})
]
})
]);
};
]), g.hidden = !1, y(u ? _("Your theme is up to date!") : _("A new version is available!"), u ? "success" : "info"), p.addEventListener("click", async ()=>{
k(!0), m.hidden = w.hidden = !0;
try {
await p();
let e = updateManifest([
i,
...a.i18n_assets
], b.value), t = await callStartUpdate(e);
if (0 !== t.result || !t.job) throw Error(t.message || _("Unable to start update."));
await C({
result: 0,
job: t.job,
stage: "downloading"
});
} catch (e) {
console.error("Update failed", e), P(`${_("Update failed")}: ${e instanceof Error ? e.message : String(e)}`, "error"), x.disabled = !1, x.removeAttribute("disabled"), m.disabled = !1, m.removeAttribute("disabled"), w.disabled = !1, w.removeAttribute("disabled"), v.disabled = !1, v.removeAttribute("disabled"), z();
y(`${_("Unable to start update.")} ${String(e)}`, "error");
} finally{
k(!1);
}
});
} catch (l) {
if (v.disabled = !1, console.error("Failed checking updates", l), l instanceof GitHubAPIError && 403 === l.status) {
} catch (n) {
if (y(String(n), "error"), n instanceof GitHubAPIError && (403 === n.status || 429 === n.status)) {
let n = jsx("input", {
type: "text",
type: "password",
class: "cbi-input-text",
style: "width: 100%",
placeholder: "ghp_..."
}), r = jsx("p", {
children: _("API rate limit exceeded. Please enter a GitHub Token to continue. This token is only used for frontend requests and will not be saved on the router backend.")
}), i = jsxs("p", {
style: "margin-top: 10px; font-size: 13px; color: var(--fluent-text-secondary);",
children: [
_("You can create a new token at"),
" ",
jsx("a", {
href: "https://github.com/settings/personal-access-tokens",
target: "_blank",
rel: "noopener noreferrer",
children: "https://github.com/settings/personal-access-tokens/new"
}),
".",
_("The token does NOT require any permissions/scopes to be granted (read-only public access is sufficient).")
]
}), s = jsx("pre", {
style: "margin-top: 10px; margin-bottom: 15px; font-size: 12px; white-space: pre-wrap; word-break: break-word; color: var(--fluent-error-text); background: var(--fluent-card-bg); padding: 8px; border-radius: var(--fluent-border-radius);",
children: l instanceof Error ? l.message : String(l)
"aria-label": _("GitHub token"),
autocomplete: "off"
});
L.ui.showModal(_("GitHub Token Required"), jsxs(Fragment, {
children: [
r,
i,
s,
jsx("p", {
children: _("API rate limit exceeded. Please enter a GitHub Token to continue. This token is only used for frontend requests and will not be saved on the router backend.")
}),
n,
jsxs("div", {
class: "right",
@@ -559,7 +425,7 @@ let about_u = about_l.DummyValue.extend({
class: "btn cbi-button-save",
onclick: ()=>{
let e = n.value.trim();
L.ui.hideModal(), e && (P(_("Retrying with token..."), "info"), v.disabled = !0, N(e));
L.ui.hideModal(), e && x(e);
},
children: _("Submit")
})
@@ -567,16 +433,31 @@ let about_u = about_l.DummyValue.extend({
})
]
}));
} else P(`${_("Failed to check for updates")}: ${l instanceof Error ? l.message : String(l)}`, "error");
}
} finally{
k(!1);
}
};
return v.addEventListener("click", ()=>{
P(_("Checking for updates..."), "info"), v.disabled = !0, $.style.display = "none", z(), N();
}), F;
}
return k(!0), (async ()=>{
try {
if (!(d = await callGetVersion()).version || ![
"ipk",
"apk"
].includes(d.pkg_type)) throw Error(_("Unable to determine installed theme version."));
c.textContent = d.version, u.textContent = d.pkg_type.toUpperCase(), p.addEventListener("click", ()=>{
x();
});
let e = await callUpdateStatus("");
if (0 !== e.result) throw Error(e.message);
"idle" !== e.stage && await C(e), k(!1);
} catch (e) {
y(String(e), "error");
}
})(), v;
}
});
const registerAboutTab = (e)=>{
e.tab("about", _("About")), e.taboption("about", about_u, "_about_mgr");
e.tab("about", _("About")), e.taboption("about", about_d, "_about_mgr");
};
;// CONCATENATED MODULE: ./web/resources/fluent-defaults.ts
+125
View File
@@ -0,0 +1,125 @@
#!/bin/sh
# The worker is copied into its job directory before launch: upgrading this
# package must not replace the script while the shell is still reading it.
if [ "$1" = worker ]; then
job="$2"
stage() { printf '%s\n' "$1" > "$job/state.tmp" && mv "$job/state.tmp" "$job/state"; }
finish() {
code=$?
trap - EXIT
if [ "$code" -ne 0 ]; then
printf 'Update failed (exit %s).\n' "$code"
stage error
fi
rm -f "$job"/*.ipk "$job"/*.apk
exit "$code"
}
trap finish EXIT
trap 'exit 1' HUP INT TERM
set -e
stage downloading
set --
i=0
while IFS="$(printf '\t')" read -r hash url; do
i=$((i + 1))
file="$job/$i.$(cat "$job/format")"
printf 'Downloading package %s...\n' "$i"
if command -v uclient-fetch >/dev/null 2>&1; then
uclient-fetch -T 60 -O "$file" "$url"
else
wget -T 60 -O "$file" "$url"
fi
printf '%s %s\n' "$hash" "$file" | sha256sum -c -
set -- "$@" "$file"
done < "$job/packages"
[ "$#" -gt 0 ]
stage installing
if [ "$(cat "$job/format")" = apk ]; then
apk add --allow-untrusted "$@"
else
opkg install "$@"
fi
stage done
exit 0
fi
. /usr/share/libubox/jshn.sh
umask 077
work=/tmp/fluent-updater
error() {
json_init
json_add_int result 1
json_add_string message "$1"
json_dump
exit 0
}
read -r input
json_load "$input" || error 'Invalid request'
case "$1" in
start_update)
json_get_var manifest manifest
json_load "$manifest" || error 'Invalid package manifest'
json_select packages || error 'Missing packages'
json_get_keys keys
[ -n "$keys" ] || error 'Missing packages'
mkdir -p "$work" || error 'Unable to create update directory'
exec 9>"$work/lock"
flock -n 9 || error 'An update is already running'
job=$(mktemp -d "$work/job.XXXXXX") || error 'Unable to create update job'
trap 'rm -rf "$job"' EXIT
count=0
for key in $keys; do
count=$((count + 1))
[ "$count" -le 32 ] || error 'Too many packages'
json_select "$key" || error 'Invalid package'
json_get_var url url
json_get_var hash sha256
case "$url" in https://?*) ;; *) error 'Package URLs must use HTTPS' ;; esac
case "$url" in *[[:space:]]*) error 'Invalid package URL' ;; esac
case "$hash" in ''|*[!a-fA-F0-9]*) error 'Invalid SHA-256 digest' ;; esac
[ "${#hash}" -eq 64 ] || error 'Missing SHA-256 digest'
printf '%s\t%s\n' "$hash" "$url" >> "$job/packages" || error 'Unable to write package manifest'
json_select ..
done
if command -v apk >/dev/null 2>&1; then echo apk; else echo ipk; fi > "$job/format" || error 'Unable to write package format'
cp "$0" "$job/worker" || error 'Unable to prepare update worker'
echo downloading > "$job/state" || error 'Unable to write update state'
# Keep only the latest job, under the same lock as the worker.
for old in "$work"/job.*; do
[ "$old" = "$job" ] || rm -rf "$old"
done
printf '%s\n' "${job##*/}" > "$work/latest.tmp" && mv "$work/latest.tmp" "$work/latest" || error 'Unable to publish update job'
nohup setsid /bin/sh "$job/worker" worker "$job" > "$job/log" 2>&1 < /dev/null &
echo "$!" > "$job/pid"
trap - EXIT
json_init
json_add_int result 0
json_add_string job "${job##*/}"
json_dump
;;
update_status)
json_get_var id job
[ -n "$id" ] || id=$(cat "$work/latest" 2>/dev/null)
case "$id" in job.*) ;; '') echo '{"result":0,"stage":"idle"}'; exit 0 ;; *) error 'Invalid job' ;; esac
case "$id" in *[!a-zA-Z0-9.]*) error 'Invalid job' ;; esac
job="$work/$id"
[ -f "$job/state" ] || error 'Update job no longer available'
state=$(cat "$job/state")
case "$state" in
downloading|installing)
pid=$(cat "$job/pid" 2>/dev/null)
[ -z "$pid" ] || kill -0 "$pid" 2>/dev/null || state=error
;;
esac
json_init
json_add_int result 0
json_add_string job "$id"
json_add_string stage "$state"
json_add_string log "$(tail -n 100 "$job/log" 2>/dev/null)"
json_dump
;;
esac
@@ -1,274 +1,86 @@
#!/bin/sh
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
if command -v apk >/dev/null 2>&1; then
PKG_EXT=".apk"
else
PKG_EXT=".ipk"
fi
readonly bg_path="/www/luci-static/fluent/background"
readonly tmp_path="/tmp/fluent_background.tmp"
readonly update_pkg="/tmp/fluent-update${PKG_EXT}"
readonly i18n_pkg="/tmp/fluent-i18n-update${PKG_EXT}"
case "$1" in
"list")
json_init
json_add_object "avail"
json_close_object
json_add_object "remove"
json_add_string "filename" "filename"
json_close_object
json_add_object "rename"
json_add_string "newname" "filename"
json_close_object
json_add_object "set_mode"
json_add_string "mode" "mode"
json_close_object
json_add_object "get_version"
json_close_object
json_add_object "start_download"
json_add_string "url" "url"
json_add_string "i18n_url" "i18n_url"
json_add_string "i18n_urls" "i18n_urls"
json_close_object
json_add_object "check_download"
json_close_object
json_add_object "do_install"
json_add_string "hash" "hash"
json_add_string "i18n_hash" "i18n_hash"
json_add_string "i18n_hashes" "i18n_hashes"
json_close_object
json_add_object "get_install_log"
json_close_object
json_dump
json_cleanup
;;
"call")
case "$2" in
"avail")
list)
json_init
json_add_int "avail" "$(df | grep -E '/$' | awk '{print $4}')"
for method in avail get_version; do
json_add_object "$method"
json_close_object
done
for pair in remove:filename rename:newname set_mode:mode start_update:manifest update_status:job; do
json_add_object "${pair%:*}"
json_add_string "${pair#*:}" ""
json_close_object
done
json_dump
json_cleanup
;;
"remove")
read -r input
json_load "$input"
json_get_var filename "filename"
json_cleanup
if dirname "$filename" | grep -q ".."; then
echo '{ "result": 255 }'
exit 255
fi
rm -f "$bg_path/$filename"
echo '{ "result": 0 }'
;;
"rename")
read -r input
json_load "$input"
json_get_var newname "newname"
json_cleanup
if dirname "$newname" | grep -q ".."; then
echo '{ "result": 255 }'
exit 255
fi
mkdir -p "$bg_path"
if mv "$tmp_path" "$bg_path/$newname" 2>"/dev/null"; then
chmod 0644 "$bg_path/$newname"
echo '{ "result": 0 }'
else
echo '{ "result": 1 }'
fi
;;
"set_mode")
read -r input
json_load "$input"
json_get_var mode "mode"
json_cleanup
case "$mode" in
light|dark|auto) ;;
*)
echo '{ "result": 255 }'
exit 255
call)
case "$2" in
start_update|update_status)
exec /bin/sh /usr/libexec/fluent/update "$2"
;;
avail)
json_init
json_add_int avail "$(df | grep -E '/$' | awk '{print $4}')"
json_dump
;;
remove|rename)
read -r input
json_load "$input"
if [ "$2" = remove ]; then
json_get_var name filename
else
json_get_var name newname
fi
case "$name" in
''|.|..|*/*) echo '{"result":255}'; exit 0 ;;
esac
if [ "$2" = remove ]; then
rm -f "$bg_path/$name"
result=$?
else
mkdir -p "$bg_path"
mv "$tmp_path" "$bg_path/$name" && chmod 0644 "$bg_path/$name"
result=$?
fi
printf '{"result":%s}\n' "$result"
;;
set_mode)
read -r input
json_load "$input"
json_get_var mode mode
case "$mode" in
light|dark|auto) ;;
*) echo '{"result":255}'; exit 0 ;;
esac
uci -q set fluent.@global[0].mode="$mode" && uci -q commit fluent
printf '{"result":%s}\n' "$?"
;;
get_version)
if command -v apk >/dev/null 2>&1; then
pkg_type=apk
version=$(apk list -I luci-theme-fluent 2>/dev/null | awk '{print $1}' | sed 's/^luci-theme-fluent-//' | head -1)
installed_i18n=$(apk info 2>/dev/null | sed -n 's/^luci-i18n-fluent-//p')
else
pkg_type=ipk
version=$(opkg status luci-theme-fluent 2>/dev/null | sed -n 's/^Version: //p')
installed_i18n=$(opkg list-installed 2>/dev/null | awk '/^luci-i18n-fluent-/ { sub(/^luci-i18n-fluent-/, "", $1); print $1 }')
fi
json_init
json_add_string version "$version"
json_add_string pkg_type "$pkg_type"
json_add_array installed_i18n
for lang in $installed_i18n; do
json_add_string "" "$lang"
done
json_close_array
json_dump
;;
esac
if uci -q set fluent.@global[0].mode="$mode" && uci -q commit fluent; then
echo '{ "result": 0 }'
else
echo '{ "result": 1 }'
fi
;;
"get_version")
version=""
pkg_type="ipk"
i18n_installed=0
installed_i18n=""
if command -v apk >/dev/null 2>&1; then
pkg_type="apk"
version=$(apk list -I luci-theme-fluent 2>/dev/null | awk '{print $1}' | sed 's/^luci-theme-fluent-//' | head -1)
installed_i18n=$(apk list -I 2>/dev/null | grep -E '^luci-i18n-fluent-' | awk '{print $1}' | sed 's/^luci-i18n-fluent-//')
else
version=$(opkg status luci-theme-fluent 2>/dev/null | grep -m1 'Version:' | awk '{print $2}')
installed_i18n=$(opkg list-installed 2>/dev/null | grep -E '^luci-i18n-fluent-' | awk '{print $1}' | sed 's/^luci-i18n-fluent-//')
fi
if echo "$installed_i18n" | grep -q "^zh-cn$"; then
i18n_installed=1
fi
if [ -z "$version" ]; then
version="1.0.1-dev"
fi
json_init
json_add_string "version" "$version"
json_add_string "pkg_type" "$pkg_type"
json_add_boolean "i18n_zh_cn_installed" "$i18n_installed"
json_add_array "installed_i18n"
for lang in $installed_i18n; do
json_add_string "" "$lang"
done
json_close_array
json_dump
json_cleanup
;;
"start_download")
read -r input
json_load "$input"
json_get_var url "url"
json_get_var i18n_url "i18n_url"
json_get_var i18n_urls "i18n_urls"
json_cleanup
rm -f "$update_pkg"
rm -f /tmp/fluent-i18n-*
rm -f /tmp/fluent-update.log
if [ -n "$url" ]; then
download_cmd=""
if command -v uclient-fetch >/dev/null 2>&1; then
download_cmd="uclient-fetch -O $update_pkg \"$url\""
urls_to_dl="${i18n_urls:-$i18n_url}"
idx=1
for u in $urls_to_dl; do
download_cmd="$download_cmd && uclient-fetch -O /tmp/fluent-i18n-${idx}${PKG_EXT} \"$u\""
idx=$((idx + 1))
done
else
download_cmd="wget -O $update_pkg \"$url\""
urls_to_dl="${i18n_urls:-$i18n_url}"
idx=1
for u in $urls_to_dl; do
download_cmd="$download_cmd && wget -O /tmp/fluent-i18n-${idx}${PKG_EXT} \"$u\""
idx=$((idx + 1))
done
fi
nohup sh -c "$download_cmd >/tmp/fluent-update.log 2>&1" >/dev/null 2>&1 &
echo '{ "result": 0 }'
else
echo '{ "result": 255, "message": "Missing URL" }'
fi
;;
"check_download")
json_init
size1=$(ls -l $update_pkg 2>/dev/null | awk '{print $5}')
size2=$(ls -l /tmp/fluent-i18n-* 2>/dev/null | awk '{sum+=$5} END {print sum}')
size=$(( ${size1:-0} + ${size2:-0} ))
json_add_int "size" "$size"
if pgrep -f "fluent-update" >/dev/null; then
json_add_boolean "running" 1
else
json_add_boolean "running" 0
if [ "$size" -eq 0 ]; then
# If process exited and size is 0, download failed
json_add_int "code" 255
else
json_add_int "code" 0
fi
fi
json_dump
json_cleanup
;;
"do_install")
read -r input
json_load "$input"
json_get_var hash "hash"
json_get_var i18n_hash "i18n_hash"
json_get_var i18n_hashes "i18n_hashes"
json_cleanup
if [ -z "$hash" ]; then
echo '{ "result": 255, "message": "Missing hash parameter" }'
exit 255
fi
if [ ! -f "$update_pkg" ]; then
echo '{ "result": 255, "message": "Package file not found" }'
exit 255
fi
if [ "$hash" != "skip" ]; then
if ! echo "$hash" | grep -qE '^[0-9a-fA-F]{64}$'; then
echo '{ "result": 255, "message": "Invalid hash parameter" }'
exit 255
fi
actual_hash=$(sha256sum "$update_pkg" | awk '{print $1}')
if [ "$actual_hash" != "$hash" ]; then
echo '{ "result": 255, "message": "Hash verification failed" }'
exit 255
fi
fi
hashes_to_check="${i18n_hashes:-$i18n_hash}"
if [ -n "$hashes_to_check" ]; then
idx=1
for h in $hashes_to_check; do
target_i18n_pkg="/tmp/fluent-i18n-${idx}${PKG_EXT}"
if [ -f "$target_i18n_pkg" ] && [ "$h" != "skip" ]; then
if ! echo "$h" | grep -qE '^[0-9a-fA-F]{64}$'; then
echo '{ "result": 255, "message": "Invalid i18n hash parameter" }'
exit 255
fi
actual_i18n_hash=$(sha256sum "$target_i18n_pkg" | awk '{print $1}')
if [ "$actual_i18n_hash" != "$h" ]; then
echo '{ "result": 255, "message": "i18n Hash verification failed" }'
exit 255
fi
fi
idx=$((idx + 1))
done
fi
install_cmd=""
pkgs="$update_pkg"
i18n_files=$(ls /tmp/fluent-i18n-* 2>/dev/null)
if [ -n "$i18n_files" ]; then
pkgs="$pkgs $i18n_files"
fi
if command -v apk >/dev/null 2>&1; then
install_cmd="apk add --allow-untrusted $pkgs"
else
install_cmd="opkg install $pkgs"
fi
nohup sh -c "sleep 1 && $install_cmd > /tmp/fluent-install.log 2>&1 ; rm -f $pkgs ; /etc/init.d/rpcd restart" >/dev/null 2>&1 &
echo '{ "result": 0 }'
;;
"get_install_log")
json_init
if [ -f "/tmp/fluent-install.log" ]; then
json_add_string "log" "$(cat /tmp/fluent-install.log)"
else
json_add_string "log" ""
fi
json_dump
json_cleanup
;;
esac
;;
esac
@@ -6,18 +6,16 @@
"/www/luci-static/fluent/background/*": [ "list" ]
},
"ubus": {
"luci.fluent": [ "avail", "remove", "rename", "set_mode", "get_version", "check_download", "get_install_log" ]
"luci.fluent": [ "avail", "get_version", "update_status" ]
},
"uci": [ "fluent" ]
},
"write": {
"file": {
"/tmp/fluent_background.tmp": [ "write" ],
"/tmp/fluent-update.ipk": [ "write" ],
"/tmp/fluent-update.apk": [ "write" ]
"/tmp/fluent_background.tmp": [ "write" ]
},
"ubus": {
"luci.fluent": [ "set_mode", "do_install", "start_download" ]
"luci.fluent": [ "remove", "rename", "set_mode", "start_update" ]
},
"uci": [ "fluent" ]
}
+2 -2
View File
@@ -10,8 +10,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-theme-vantage
PKG_VERSION:=1.0.16
PKG_RELEASE:=35
PKG_VERSION:=1.0.17
PKG_RELEASE:=36
PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage theme
@@ -40,10 +40,20 @@ const BACKDROP_EVENT = 'vantage:backdrop';
save (those keys are dropped then); this browser's last pick (the old
key, also what the dashboard reads under another theme) counts until
the router has one. */
/* this package's version, as its Makefile has it (tests/version.test.js) */
const VERSION = '1.0.17';
/* Check for updates (Appearance): when asked, never on its own, the browser
reads the project's 24.10 package list on GitHub Pages (the same release
as 25.12's), without cookies or a referrer. Only a version number passes;
the link is a constant, to the latest release. */
const UPDATE_INDEX = 'https://anothadev.github.io/luci-theme-vantage/24.10/Packages';
const LOOK = {
theme: { own: 'vantage.theme.own', before: THEME_KEY, ok: [ 'auto', 'light', 'dark' ] },
login: { own: 'vantage.login.own', before: LOGIN_KEY, ok: [ 'grid', 'stars', 'none' ] },
backdrop: { own: 'vantage.backdrop.own', before: BACKDROP_KEY, ok: [ 'grid', 'stars', 'none' ] }
backdrop: { own: 'vantage.backdrop.own', before: BACKDROP_KEY, ok: [ 'grid', 'stars', 'none' ] },
/* what shows next to the logo; the hostname and model come from system board */
brand: { own: 'vantage.brand.own', before: 'vantage.brand', ok: [ 'none', 'hostname', 'model' ] }
};
const LOOK_SYNC = 'vantage.look'; /* another tab saved one: { name, value } */
const LOOK_SET_EVENT = 'vantage:backdrop-set'; /* the dashboard's own picker hands its pick over */
@@ -154,6 +164,23 @@ function svg(name, size) {
function storageGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
/* '1.0.16' or '1.0.16-r1' -> [ 1, 0, 16 ]; null for anything else */
function versionParts(v) {
const m = /^(\d{1,4}(?:\.\d{1,4}){1,3})(?:-r\d{1,4})?$/.exec(String(v == null ? '' : v));
return m ? m[1].split('.').map(Number) : null;
}
function newerVersion(a, b) {
for (let i = 0; i < Math.max(a.length, b.length); i++)
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
return false;
}
/* the theme's version in an opkg package list, without its release */
function listedVersion(text) {
const block = String(text || '').split(/\n\s*\n/).find(b => /^Package: luci-theme-vantage$/m.test(b));
const m = block && /^Version: (\S+)$/m.exec(block);
return m && versionParts(m[1]) ? m[1].replace(/-r\d+$/, '') : null;
}
/* the value in force, from [ pending, own, router, before ]: the first
the theme knows, else the first choice (the default); the templates'
inline scripts and login.js do the same without the pending one */
@@ -176,6 +203,7 @@ function setLookAttr(root, name, value) {
if (name === 'theme') root.setAttribute('data-vt-look-theme', value);
else if (name === 'login') root.setAttribute('data-vt-look-login', value);
else if (name === 'backdrop') root.setAttribute('data-vt-look-backdrop', value);
else if (name === 'brand') root.setAttribute('data-vt-look-brand', value);
}
function storageSet(k, v) { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch (e) {} }
@@ -452,20 +480,33 @@ function replaceTable(table, block) {
}
/* Memory: one bar. in use = total - available; cache (reclaimable) =
available - free; free = total - LuCI's "Used" (which is total - free). */
available - free; free = total - LuCI's "Used" (which is total - free).
A table without "Total Available" (Kwrt's luci-mod-status, #103) is read
its own way, below. */
function enhanceMemory() {
statusSections(_('Memory')).forEach(section => {
const table = pendingTable(section);
if (!table) return;
const rows = barRows(table), get = l => (rows.find(r => r.label === l) || {}).pair;
const avail = get(_('Total Available')), used = get(_('Used')),
buffered = get(_('Buffered')), cached = get(_('Cached')), swap = get(_('Swap free'));
buffered = get(_('Buffered')), cached = get(_('Cached')),
swapFree = get(_('Swap free')), swapUsed = get(_('Swap used'));
const total = (used || avail || {}).total;
if (!total || !used) return;
const free = Math.max(0, total - used.value);
let available = avail ? avail.value : free + (buffered ? buffered.value : 0) + (cached ? cached.value : 0);
available = Math.min(total, Math.max(available, free));
let free, available;
/* the row decides, not its value: LuCI shows "?" when it has none */
if (rows.some(r => r.label === _('Total Available'))) {
free = Math.max(0, total - used.value);
available = avail ? avail.value : free + (buffered ? buffered.value : 0) + (cached ? cached.value : 0);
available = Math.min(total, Math.max(available, free));
}
else {
/* Kwrt: "Used" is total - MemAvailable. MemFree isn't shown, so the
cache stands for what is reclaimable and Free is an estimate. */
available = Math.min(total, Math.max(0, total - used.value));
free = Math.max(0, available - (cached ? cached.value : 0));
}
const inuse = total - available, reclaim = available - free;
const usedPct = pctOf(inuse, total);
@@ -489,14 +530,16 @@ function enhanceMemory() {
])
];
/* "Swap free" in LuCI's table, "Swap used" in Kwrt's */
const swap = swapFree ? { used: Math.max(0, swapFree.total - swapFree.value), total: swapFree.total }
: swapUsed ? { used: swapUsed.value, total: swapUsed.total } : null;
if (swap) {
const swapUsed = swap.total - swap.value;
children.push(E('div', { 'class': 'v-meter-rows v-meter-swap' }, E('div', { 'class': 'v-meter-row' }, [
E('span', { 'class': 'v-meter-label' }, [ _('Swap', 'vantage-theme') ]),
E('div', { 'class': 'v-meter-bar is-thin', 'role': 'img',
'aria-label': '%s: %s / %s'.format(_('Swap', 'vantage-theme'), fmtBytes(swapUsed), fmtBytes(swap.total)) },
segment('v-seg-used', swapUsed, swap.total)),
E('span', { 'class': 'v-meter-val' }, [ E('b', {}, [ fmtBytes(swapUsed) ]), ' / ' + fmtBytes(swap.total) ])
'aria-label': '%s: %s / %s'.format(_('Swap', 'vantage-theme'), fmtBytes(swap.used), fmtBytes(swap.total)) },
segment('v-seg-used', swap.used, swap.total)),
E('span', { 'class': 'v-meter-val' }, [ E('b', {}, [ fmtBytes(swap.used) ]), ' / ' + fmtBytes(swap.total) ])
])));
}
@@ -1549,6 +1592,29 @@ return baseclass.extend({
this.syncAppearance();
},
/* next to the logo: nothing unless set. In the rail when it is pinned
open, else first in the top bar (vantage.css); text only */
brandPref() {
return this.lookPref('brand');
},
setBrandPref(pref) {
this.setLook('brand', pref, () => this.applyBrand());
},
applyBrand() {
const v = this.brandPref(), b = this.board;
const name = !b ? '' : v === 'hostname' ? (host.chipNames(b.hostname, '')[0] || '') : v === 'model' ? host.productName(b) : '';
[ 'vt-brand-name', 'vt-top-name' ].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
if (el.textContent !== name) el.textContent = name;
el.title = name;
el.hidden = !name;
});
this.syncAppearance();
},
/* the login page's backdrop (login.js): grid packets unless set */
loginPref() {
return this.lookPref('login');
@@ -1620,6 +1686,7 @@ return baseclass.extend({
}
this.applyTheme();
this.applyBackdrop();
this.applyBrand();
});
/* the dashboard's own picker (Customize), saved as Appearance's */
window.addEventListener(LOOK_SET_EVENT, ev => {
@@ -1641,6 +1708,7 @@ return baseclass.extend({
});
this.applyTheme();
this.applyBackdrop();
this.applyBrand();
},
/* Appearance, under the colour-theme button: the colour theme, the
@@ -1651,7 +1719,7 @@ return baseclass.extend({
scrolling the page close it. */
/* one of Appearance's radio groups */
appearGroup(name, legend, opts, set) {
const look = { 'vt-colour': 'theme', 'vt-login': 'login', 'vt-backdrop': 'backdrop' }[name];
const look = { 'vt-colour': 'theme', 'vt-logo-name': 'brand', 'vt-login': 'login', 'vt-backdrop': 'backdrop' }[name];
return E('fieldset', { 'class': 'vt-appear-set' }, [
E('legend', {}, [ legend ]),
E('div', { 'class': 'vt-appear-track' }, opts.map(o => {
@@ -1678,16 +1746,22 @@ return baseclass.extend({
group('vt-colour', _('Colour theme'), [
[ 'auto', _('Auto', 'vantage-theme'), 'auto' ], [ 'light', _('Light', 'vantage-theme'), 'sun' ], [ 'dark', _('Dark', 'vantage-theme'), 'moon' ]
], v => this.setTheme(v)),
group('vt-logo-name', _('Next to the logo'), [
[ 'none', _('Logo only') ], [ 'hostname', _('Hostname') ], [ 'model', _('Model') ]
], v => this.setBrandPref(v)),
group('vt-login', _('Login background'), [ [ 'grid', login.grid ], [ 'stars', login.stars ], [ 'none', login.none ] ], v => this.setLoginPref(v))
].concat(this.hasDashboard() ? [ this.backdropGroup() ] : [], [
/* where the picks are kept, when not for every browser (syncAppearance) */
(this.appearNote = E('p', { 'class': 'vt-appear-note', 'role': 'status', 'hidden': 'hidden' }))
(this.appearNote = E('p', { 'class': 'vt-appear-note', 'role': 'status', 'hidden': 'hidden' })),
this.versionEl()
]));
box.addEventListener('keydown', ev => {
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); this.closeAppearance(true); }
else if (ev.key === 'Tab') {
const sets = box.querySelectorAll('fieldset');
const edge = ev.shiftKey ? sets[0] : sets[sets.length - 1];
/* forward, the panel ends at the update check's link when it has one */
const last = Array.prototype.slice.call(box.querySelectorAll('.vt-appear-ver button, .vt-appear-ver a[href]')).pop();
const edge = ev.shiftKey ? sets[0] : (last || sets[sets.length - 1]);
if (edge.contains(ev.target)) { ev.preventDefault(); this.closeAppearance(true); }
}
});
@@ -1704,9 +1778,51 @@ return baseclass.extend({
return (this.appear = box);
},
/* "Vantage 1.0.16 · Check for updates", and what the check found */
versionEl() {
const btn = E('button', { 'type': 'button', 'class': 'vt-appear-check' }, [ _('Check for updates') ]);
const out = E('span', { 'class': 'vt-appear-update', 'role': 'status', 'hidden': 'hidden' });
btn.addEventListener('click', () => this.checkUpdates(btn, out));
return E('p', { 'class': 'vt-appear-ver' }, [ E('span', { 'class': 'vt-appear-num' }, [ 'Vantage ' + VERSION ]), ' · ', btn, out ]);
},
releaseLink(label) {
return E('a', { 'href': 'https://github.com/anothaDev/luci-theme-vantage/releases/latest', 'target': '_blank', 'rel': 'noopener noreferrer' }, [ label ]);
},
checkUpdates(btn, out) {
/* aria-disabled, not disabled: a disabled button drops the focus,
and with it the panel's keys */
if (btn.getAttribute('aria-disabled') === 'true') return;
btn.setAttribute('aria-disabled', 'true');
out.textContent = _('Checking…');
out.hidden = false;
const ctl = window.AbortController ? new AbortController() : null;
const timer = ctl ? setTimeout(() => ctl.abort(), 10000) : 0;
fetch(UPDATE_INDEX, { cache: 'no-store', credentials: 'omit', referrerPolicy: 'no-referrer', signal: ctl ? ctl.signal : undefined })
.then(r => r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status)))
.then(text => {
const latest = listedVersion(text);
if (!latest) throw new Error('no version');
out.textContent = '';
if (newerVersion(versionParts(latest), versionParts(VERSION))) {
out.appendChild(document.createTextNode(_('Version %s is available.').format(latest) + ' '));
out.appendChild(this.releaseLink(_('What\'s new')));
}
else out.textContent = _('Up to date');
})
/* offline, or a router whose pages forbid the request (a
Content-Security-Policy): the release page still opens */
.catch(() => {
out.textContent = _('Could not check for updates.') + ' ';
out.appendChild(this.releaseLink(_('See the latest release')));
})
.then(() => { clearTimeout(timer); btn.removeAttribute('aria-disabled'); });
},
syncAppearance() {
if (!this.appear) return;
const now = { 'vt-colour': this.themePref(), 'vt-login': this.loginPref(), 'vt-backdrop': this.backdropPref() };
const now = { 'vt-colour': this.themePref(), 'vt-logo-name': this.brandPref(), 'vt-login': this.loginPref(), 'vt-backdrop': this.backdropPref() };
Array.prototype.forEach.call(this.appear.querySelectorAll('input[type="radio"]'), r => { r.checked = (now[r.name] === r.value); });
const state = this.lookState();
const note = state === 'relogin' ? _('Log in again to save Appearance for every browser.')
@@ -1757,7 +1873,9 @@ return baseclass.extend({
if (!board || !board.hostname) return;
const product = host.productName(board);
this.hostname = board.hostname;
this.board = board;
this.updateTitle();
this.applyBrand();
if (!chip) return;
chip.textContent = '';
/* device strings go in as text nodes (E() would parse a string as HTML) */
@@ -8,9 +8,14 @@
* frame is a handful of batched draws, at most about 60 a second, and nothing runs while the
* window is in the background. With reduced motion it draws one still
* frame. Logging in speeds it up while the form posts.
*
* This page means there is no session, so it also drops the history the
* dashboard kept in this tab (overview.js STORE_KEY): station MACs and samples
* shouldn't outlive a logout.
*/
(function () {
'use strict';
try { sessionStorage.removeItem('vantage.hist.v1'); } catch (e) {}
var v = [ null, document.documentElement.getAttribute('data-vt-look-login'), null ], ok = [ 'grid', 'stars', 'none' ], pick = 'grid', i;
try { v[0] = localStorage.getItem('vantage.login.own'); v[2] = localStorage.getItem('vantage.login'); } catch (e) {}
for (i = 2; i >= 0; i--) if (ok.indexOf(v[i]) >= 0) pick = v[i];
@@ -393,6 +393,15 @@ html[data-darkmode="true"] .vt-brand-tile { fill: #16181b; }
text-decoration: none !important;
}
.vt-brand svg { flex: none; }
/* Appearance "Next to the logo" (menu-vantage.js applyBrand): beside the
logo while the rail is pinned open, else first in the top bar, as
"name / page"; not on phones, where the logo isn't shown either */
.vt-brand-name { display: none; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
html[data-rail="pinned"] .vt-brand-name:not([hidden]) { display: block; }
.vt-top-name { flex: none; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); font-weight: 600; }
.vt-top-name[hidden], html[data-rail="pinned"] .vt-top-name { display: none; }
.vt-top-name + .vt-crumbs::before { content: "/"; flex: none; margin-inline-end: 4px; color: var(--text-3); font-family: var(--mono); font-size: var(--fs-sm); }
.vt-top-name[hidden] + .vt-crumbs::before, html[data-rail="pinned"] .vt-top-name + .vt-crumbs::before { content: none; }
.vt-rail-list {
list-style: none; margin: 0; padding: var(--sp-2) 0;
@@ -626,6 +635,12 @@ a.vt-crumb:hover { color: var(--text); }
/* where the picks are kept, when not for every browser */
.vt-appear-note { max-width: 32ch; margin: 0; color: var(--text-3); font-size: var(--fs-sm); line-height: 1.4; }
.vt-appear-note[hidden] { display: none; }
.vt-appear-ver { margin: 0; color: var(--text-3); font-size: var(--fs-micro); }
.vt-appear-num { font-family: var(--mono); }
.vt-appear-check { margin: 0; padding: 0; border: 0; background: none; color: var(--accent-text); font: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
.vt-appear-check[aria-disabled="true"] { color: var(--text-3); cursor: default; text-decoration: none; }
.vt-appear-update { display: block; margin-top: 4px; color: var(--text-2); }
.vt-appear-update[hidden] { display: none; }
.vt-appear-set > legend { margin: 0 0 6px; padding: 0; color: var(--text-3); font-size: var(--fs-micro); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
.vt-appear-track { display: flex; background: var(--seg-bg); border-radius: var(--r-lg); box-shadow: inset 0 0 0 1px var(--line); }
.vt-appear-opt {
@@ -672,12 +687,38 @@ a.vt-crumb:hover { color: var(--text); }
}
#indicators > span[data-clickable] { cursor: pointer; }
#indicators > span[data-clickable]:hover { border-color: var(--text-3); color: var(--text); }
/* auto-refresh on: a still dot (the label says it, a pulse only
distracted); paused: a square */
/* auto-refresh on: a dot with a slow, soft ring (#100); fast while the
dashboard reports a problem: amber while it reconnects or warns, red for
an error (luci-app-vantage sets html[data-vt-live] and [data-vt-alert]);
paused: a still square. The ring scales and fades on the compositor,
behind the dot (z-index -1 in the pill's own stack), and is transparent
unless it runs, so reduced motion shows none. */
#indicators > span[data-indicator="poll-status"][data-style="active"] { position: relative; isolation: isolate; }
#indicators > span[data-indicator="poll-status"][data-style="active"]::before {
content: ""; width: 7px; height: 7px; border-radius: 50%;
background: var(--ok);
}
#indicators > span[data-indicator="poll-status"][data-style="active"]::after {
content: ""; position: absolute; z-index: -1; left: 10px; top: calc(50% - 3.5px);
width: 7px; height: 7px; border-radius: 50%;
background: color-mix(in oklab, var(--ok) 50%, transparent);
opacity: 0; pointer-events: none;
animation: vt-live-soft 5s ease-out infinite;
}
html[data-vt-live="err"] #indicators > span[data-indicator="poll-status"][data-style="active"]::after, html[data-vt-alert="warn"] #indicators > span[data-indicator="poll-status"][data-style="active"]::after {
background: color-mix(in oklab, var(--warn) 50%, transparent);
animation: vt-live 2s ease-out infinite;
}
html[data-vt-alert="err"] #indicators > span[data-indicator="poll-status"][data-style="active"]::after {
background: color-mix(in oklab, var(--err) 50%, transparent);
animation: vt-live 2s ease-out infinite;
}
@media (width > 900px) {
html[data-vt-live="err"] #indicators > span[data-indicator="poll-status"][data-style="active"]::before, html[data-vt-alert="warn"] #indicators > span[data-indicator="poll-status"][data-style="active"]::before { background: var(--warn); }
html[data-vt-alert="err"] #indicators > span[data-indicator="poll-status"][data-style="active"]::before { background: var(--err); }
}
@keyframes vt-live-soft { 0% { transform: scale(1); opacity: .6; } 70%, 100% { transform: scale(2.2); opacity: 0; } }
@keyframes vt-live { 0% { transform: scale(1); opacity: 1; } 70%, 100% { transform: scale(2.7); opacity: 0; } }
#indicators > span[data-indicator="poll-status"][data-style="inactive"]::before {
content: ""; width: 7px; height: 7px; border-radius: 1px; background: var(--text-3);
}
@@ -1717,6 +1758,14 @@ table { border-collapse: separate; border-spacing: 0; width: 100%; }
.cbi-section-table-descr > .th { font-weight: 400; text-transform: none; letter-spacing: 0; font-size: var(--fs-sm); }
.cbi-section-table-cell, .cbi-section-table-row > .td { vertical-align: middle; }
.td.cbi-value-field .cbi-dropdown, .td.cbi-value-field input:not([type="checkbox"]), .td.cbi-value-field select { max-width: 220px; }
/* A text field is width:100%, which an auto table layout counts as 0 wide, so
a column with a short header (Name and Path in Services > Network Shares)
shrank to a sliver and hid its value. Give the field a floor: 7em, which a
wide table (Samba's shares) still nearly fits beside; not on a phone,
where each row is a card of fixed columns and a floor overflows it. */
@media (width > 720px) {
.td.cbi-value-field input[type="text"], .td.cbi-value-field input:not([type]) { min-width: 7em; }
}
/* key/value tables (status pages): quiet label column */
.table:not(:has(.table-titles)) .tr > .td:first-child:nth-last-child(2) {
@@ -3212,14 +3261,18 @@ body[data-page="admin-system-filemanager"] .sort-button { border-color: var(--li
.vt-search { width: 36px; padding: 0; justify-content: center; }
.vt-search-label, .vt-search .vt-kbd { display: none; }
.vt-host { max-width: 180px; }
/* the name next to the logo crowded the host chip, which says it too */
.vt-top-name, .vt-top-name + .vt-crumbs::before { display: none !important; }
}
@media (max-width: 900px) {
.vt-host { display: none !important; }
.vt-top-name, .vt-top-name + .vt-crumbs::before { display: none !important; }
/* indicators go compact (a sign, a count) before the crumbs get cut;
the label stays as text for screen readers and as the title */
#indicators > span[data-indicator] { height: 28px; padding: 0 8px; }
#indicators > span[data-indicator="poll-status"] { font-size: 0; gap: 0; width: 28px; justify-content: center; padding: 0; }
#indicators > span[data-indicator="poll-status"][data-style="active"]::after { left: calc(50% - 3.5px); }
/* without its label the pill is the pause / resume button: a pause
sign while it refreshes, a play sign while paused */
#indicators > span[data-indicator="poll-status"][data-style="active"]::before {
+139 -90
View File
@@ -13,7 +13,7 @@ msgid_plural "%d more chains at this hook"
msgstr[0] ""
msgstr[1] ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1403
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1446
msgctxt "vantage-theme"
msgid "%d result"
msgid_plural "%d results"
@@ -56,7 +56,7 @@ msgctxt "vantage-theme"
msgid "Accept"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1323
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1366
msgid "Actions"
msgstr ""
@@ -65,22 +65,22 @@ msgctxt "nft match any traffic"
msgid "Any packet"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1311
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1312
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1313
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1316
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1320
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1677
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:132
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1354
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1355
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1356
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1359
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1363
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1745
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:136
msgctxt "vantage-theme"
msgid "Appearance"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:48
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:50
msgid "Authorization Required"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1679
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1747
msgctxt "vantage-theme"
msgid "Auto"
msgstr ""
@@ -95,7 +95,7 @@ msgctxt "vantage-theme"
msgid "Back to the calling chain"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:120
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:124
msgctxt "vantage-theme"
msgid "Breadcrumb"
msgstr ""
@@ -113,17 +113,17 @@ msgctxt "vantage-theme"
msgid "Bridged between ports"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:462
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:492
msgid "Buffered"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:473
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:483
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:487
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:514
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:524
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:528
msgid "Cache"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:462
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:492
msgid "Cached"
msgstr ""
@@ -142,19 +142,27 @@ msgctxt "vantage-theme"
msgid "Chains no rule jumps to directly"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:544
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:587
msgid "Channel"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:950
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1783
msgid "Check for updates"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1798
msgid "Checking…"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:993
msgid "Collapse navigation"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1678
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1746
msgid "Colour theme"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1347
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1390
msgid "Command palette"
msgstr ""
@@ -173,11 +181,15 @@ msgctxt "vantage-theme"
msgid "Continues in %s, then comes back here"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1714
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1817
msgid "Could not check for updates."
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1830
msgid "Could not save this for every browser. Try again."
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1679
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1747
msgctxt "vantage-theme"
msgid "Dark"
msgstr ""
@@ -201,8 +213,8 @@ msgstr ""
msgid "Egress"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:950
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:105
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:993
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:107
msgid "Expand navigation"
msgstr ""
@@ -219,7 +231,7 @@ msgid ""
"their counters see only its first packets."
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1313
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1356
msgid "Follow system theme"
msgstr ""
@@ -237,21 +249,25 @@ msgid ""
"if br_netfilter is enabled; the ruleset doesn't show that.)"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:474
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:488
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:515
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:529
msgctxt "vantage-theme"
msgid "Free"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1558
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1624
msgid "Grid packets"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:100
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:101
msgctxt "vantage-theme"
msgid "Home"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1750
msgid "Hostname"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:63
msgid "IPv4 traffic table \"%h\""
msgstr ""
@@ -264,9 +280,9 @@ msgstr ""
msgid "IPv6 traffic table \"%h\""
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:473
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:482
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:486
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:514
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:523
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:527
msgctxt "vantage-theme"
msgid "In use"
msgstr ""
@@ -280,11 +296,11 @@ msgctxt "vantage-theme"
msgid "Input"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:52
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:54
msgid "Invalid username and/or password! Please try again."
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1343
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1386
msgid "Jump to a page or action…"
msgstr ""
@@ -293,31 +309,35 @@ msgctxt "vantage-theme"
msgid "Leaves unmatched unchanged"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1679
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1747
msgctxt "vantage-theme"
msgid "Light"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:28
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:64
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:30
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:66
msgid "Log in"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1712
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1828
msgid "Log in again to save Appearance for every browser."
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1322
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:135
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1365
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:139
msgid "Log out"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1316
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1681
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1359
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1752
msgid "Login background"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:99
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1750
msgid "Logo only"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:100
msgctxt "vantage-theme"
msgid "Main navigation"
msgstr ""
@@ -327,13 +347,17 @@ msgctxt "vantage-theme"
msgid "Masquerade"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:457
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:472
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:487
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:513
msgid "Memory"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1961
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1978
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1750
msgid "Model"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2079
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2096
msgctxt "vantage-theme"
msgid "More actions"
msgstr ""
@@ -351,8 +375,8 @@ msgstr ""
msgid "Network device table \"%h\""
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1320
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1670
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1363
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1738
msgid "Network path background"
msgstr ""
@@ -361,7 +385,11 @@ msgctxt "vantage-theme"
msgid "Next rule"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1401
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1749
msgid "Next to the logo"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1444
msgid "No matching pages"
msgstr ""
@@ -375,17 +403,17 @@ msgctxt "vantage-theme"
msgid "No rules: %s"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1558
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1624
msgctxt "vantage-theme"
msgid "None"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1218
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1261
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:17
msgid "Not found"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1238
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1281
msgid "Other pages in %s"
msgstr ""
@@ -399,7 +427,7 @@ msgctxt "vantage-theme"
msgid "Packet path"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:141
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:145
msgid "Page tabs"
msgstr ""
@@ -413,7 +441,7 @@ msgctxt "vantage-theme"
msgid "Passes unmatched"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:61
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:63
msgid "Password"
msgstr ""
@@ -442,7 +470,7 @@ msgctxt "vantage-theme"
msgid "Reject"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1345
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1388
msgctxt "vantage-theme"
msgid "Results"
msgstr ""
@@ -462,17 +490,21 @@ msgctxt "vantage-theme"
msgid "Rules table"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1713
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1829
msgid "Saved in this browser only."
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1343
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:123
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:125
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1386
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:127
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:129
msgid "Search pages"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1322
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1818
msgid "See the latest release"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1365
msgid "Session"
msgstr ""
@@ -483,47 +515,52 @@ msgid_plural "Show %d more rules"
msgstr[0] ""
msgstr[1] ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2118
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2236
msgid "Show full description"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2107
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2225
msgid "Show full help: %s"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:96
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:97
msgid "Skip to content"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1558
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1624
msgid "Starfield"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:509
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:552
msgid "Storage"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:495
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:497
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:538
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:540
msgctxt "vantage-theme"
msgid "Swap"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:462
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:493
msgid "Swap free"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:493
msgid "Swap used"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:249
msgctxt "vantage-theme"
msgid "This device"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:474
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:515
msgctxt "vantage-theme"
msgid "Total"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:461
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:491
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:499
msgid "Total Available"
msgstr ""
@@ -537,19 +574,23 @@ msgctxt "vantage-theme"
msgid "Unchanged"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1311
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1812
msgid "Up to date"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1354
msgid "Use dark theme"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1312
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1355
msgid "Use light theme"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:461
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:491
msgid "Used"
msgstr ""
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:57
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:59
msgid "Username"
msgstr ""
@@ -565,21 +606,29 @@ msgctxt "vantage-theme"
msgid "Verdict map %s"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1809
msgid "Version %s is available."
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:204
msgctxt "vantage-theme"
msgid "View"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:537
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1810
msgid "What's new"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:580
msgid "Wireless"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:478
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:519
msgctxt "vantage-theme"
msgid "available"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1353
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1396
msgid "close"
msgstr ""
@@ -593,12 +642,12 @@ msgctxt "vantage-theme"
msgid "drops unmatched %s"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:524
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:528
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:567
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:571
msgid "free"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1351
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1394
msgid "navigate"
msgstr ""
@@ -617,7 +666,7 @@ msgctxt "vantage-theme"
msgid "on %s"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1352
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1395
msgctxt "vantage-theme"
msgid "open"
msgstr ""
@@ -627,9 +676,9 @@ msgctxt "vantage-theme"
msgid "priority %d"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:473
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:483
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:487
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:514
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:524
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:528
msgid "reclaimable"
msgstr ""
@@ -638,12 +687,12 @@ msgctxt "vantage-theme"
msgid "rules drop some"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:524
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:528
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:567
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:571
msgid "total"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:524
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:528
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:567
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:571
msgid "used"
msgstr ""
+139 -90
View File
@@ -21,7 +21,7 @@ msgid "%d more chain at this hook"
msgid_plural "%d more chains at this hook"
msgstr[0] "此钩子还有 %d 条链"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1403
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1446
msgctxt "vantage-theme"
msgid "%d result"
msgid_plural "%d results"
@@ -62,7 +62,7 @@ msgctxt "vantage-theme"
msgid "Accept"
msgstr "接受"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1323
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1366
msgid "Actions"
msgstr "操作"
@@ -71,22 +71,22 @@ msgctxt "nft match any traffic"
msgid "Any packet"
msgstr "任意数据包"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1311
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1312
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1313
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1316
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1320
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1677
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:132
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1354
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1355
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1356
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1359
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1363
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1745
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:136
msgctxt "vantage-theme"
msgid "Appearance"
msgstr "外观"
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:48
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:50
msgid "Authorization Required"
msgstr "需要授权"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1679
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1747
msgctxt "vantage-theme"
msgid "Auto"
msgstr "自动"
@@ -101,7 +101,7 @@ msgctxt "vantage-theme"
msgid "Back to the calling chain"
msgstr "返回调用链"
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:120
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:124
msgctxt "vantage-theme"
msgid "Breadcrumb"
msgstr "面包屑"
@@ -119,17 +119,17 @@ msgctxt "vantage-theme"
msgid "Bridged between ports"
msgstr "端口间桥接"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:462
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:492
msgid "Buffered"
msgstr "已缓冲"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:473
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:483
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:487
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:514
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:524
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:528
msgid "Cache"
msgstr "缓存"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:462
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:492
msgid "Cached"
msgstr "已缓存"
@@ -148,19 +148,27 @@ msgctxt "vantage-theme"
msgid "Chains no rule jumps to directly"
msgstr "没有规则直接跳转到的链"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:544
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:587
msgid "Channel"
msgstr "信道"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:950
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1783
msgid "Check for updates"
msgstr "检查更新"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1798
msgid "Checking…"
msgstr "正在检查…"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:993
msgid "Collapse navigation"
msgstr "收起导航"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1678
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1746
msgid "Colour theme"
msgstr "颜色主题"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1347
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1390
msgid "Command palette"
msgstr "命令面板"
@@ -179,11 +187,15 @@ msgctxt "vantage-theme"
msgid "Continues in %s, then comes back here"
msgstr "转入 %s,随后返回此处"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1714
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1817
msgid "Could not check for updates."
msgstr "无法检查更新。"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1830
msgid "Could not save this for every browser. Try again."
msgstr "无法为所有浏览器保存此设置,请重试。"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1679
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1747
msgctxt "vantage-theme"
msgid "Dark"
msgstr "深色"
@@ -207,8 +219,8 @@ msgstr "丢弃未匹配流量"
msgid "Egress"
msgstr "出口"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:950
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:105
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:993
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:107
msgid "Expand navigation"
msgstr "展开导航"
@@ -227,7 +239,7 @@ msgstr ""
"流量卸载已开启:一条转发连接建立后,它的数据包可以跳过 prerouting、forward "
"和 postrouting,因此这些链和它们的计数器只会看到该连接最初的数据包。"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1313
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1356
msgid "Follow system theme"
msgstr "跟随系统主题"
@@ -248,21 +260,25 @@ msgstr ""
"达 input、forward 或 output 链,这里也没有桥接链会过滤它们。(启用 "
"br_netfilter 时内核可以把它们交给 IP 链;规则集不体现这一点。)"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:474
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:488
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:515
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:529
msgctxt "vantage-theme"
msgid "Free"
msgstr "空闲"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1558
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1624
msgid "Grid packets"
msgstr "网格数据包"
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:100
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:101
msgctxt "vantage-theme"
msgid "Home"
msgstr "首页"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1750
msgid "Hostname"
msgstr "主机名"
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:63
msgid "IPv4 traffic table \"%h\""
msgstr "IPv4 流量表 \"%h\""
@@ -275,9 +291,9 @@ msgstr "IPv4/IPv6 流量表 \"%h\""
msgid "IPv6 traffic table \"%h\""
msgstr "IPv6 流量表 \"%h\""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:473
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:482
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:486
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:514
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:523
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:527
msgctxt "vantage-theme"
msgid "In use"
msgstr "已用"
@@ -291,11 +307,11 @@ msgctxt "vantage-theme"
msgid "Input"
msgstr "输入"
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:52
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:54
msgid "Invalid username and/or password! Please try again."
msgstr "用户名或密码错误,请重试。"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1343
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1386
msgid "Jump to a page or action…"
msgstr "跳转到页面或操作…"
@@ -304,31 +320,35 @@ msgctxt "vantage-theme"
msgid "Leaves unmatched unchanged"
msgstr "未匹配流量保持原样"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1679
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1747
msgctxt "vantage-theme"
msgid "Light"
msgstr "浅色"
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:28
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:64
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:30
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:66
msgid "Log in"
msgstr "登录"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1712
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1828
msgid "Log in again to save Appearance for every browser."
msgstr "重新登录后即可为所有浏览器保存外观设置。"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1322
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:135
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1365
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:139
msgid "Log out"
msgstr "退出登录"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1316
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1681
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1359
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1752
msgid "Login background"
msgstr "登录背景"
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:99
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1750
msgid "Logo only"
msgstr "仅标志"
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:100
msgctxt "vantage-theme"
msgid "Main navigation"
msgstr "主导航"
@@ -338,13 +358,17 @@ msgctxt "vantage-theme"
msgid "Masquerade"
msgstr "伪装(NAT)"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:457
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:472
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:487
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:513
msgid "Memory"
msgstr "内存"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1961
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1978
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1750
msgid "Model"
msgstr "型号"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2079
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2096
msgctxt "vantage-theme"
msgid "More actions"
msgstr "更多操作"
@@ -362,8 +386,8 @@ msgstr "网络"
msgid "Network device table \"%h\""
msgstr "网络设备表 \"%h\""
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1320
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1670
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1363
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1738
msgid "Network path background"
msgstr "网络路径背景"
@@ -372,7 +396,11 @@ msgctxt "vantage-theme"
msgid "Next rule"
msgstr "下一条规则"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1401
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1749
msgid "Next to the logo"
msgstr "标志旁显示"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1444
msgid "No matching pages"
msgstr "没有匹配的页面"
@@ -386,17 +414,17 @@ msgctxt "vantage-theme"
msgid "No rules: %s"
msgstr "无规则:%s"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1558
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1624
msgctxt "vantage-theme"
msgid "None"
msgstr "无"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1218
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1261
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:17
msgid "Not found"
msgstr "未找到"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1238
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1281
msgid "Other pages in %s"
msgstr "%s 中的其他页面"
@@ -410,7 +438,7 @@ msgctxt "vantage-theme"
msgid "Packet path"
msgstr "数据包路径"
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:141
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:145
msgid "Page tabs"
msgstr "页面标签页"
@@ -424,7 +452,7 @@ msgctxt "vantage-theme"
msgid "Passes unmatched"
msgstr "放行未匹配流量"
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:61
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:63
msgid "Password"
msgstr "密码"
@@ -453,7 +481,7 @@ msgctxt "vantage-theme"
msgid "Reject"
msgstr "拒绝"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1345
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1388
msgctxt "vantage-theme"
msgid "Results"
msgstr "结果"
@@ -473,17 +501,21 @@ msgctxt "vantage-theme"
msgid "Rules table"
msgstr "规则表"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1713
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1829
msgid "Saved in this browser only."
msgstr "仅保存在此浏览器中。"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1343
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:123
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:125
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1386
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:127
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:129
msgid "Search pages"
msgstr "搜索页面"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1322
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1818
msgid "See the latest release"
msgstr "查看最新版本"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1365
msgid "Session"
msgstr "会话"
@@ -493,47 +525,52 @@ msgid "Show %d more rule"
msgid_plural "Show %d more rules"
msgstr[0] "再显示 %d 条规则"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2118
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2236
msgid "Show full description"
msgstr "显示完整描述"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2107
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:2225
msgid "Show full help: %s"
msgstr "显示完整帮助:%s"
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:96
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:97
msgid "Skip to content"
msgstr "跳至正文"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1558
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1624
msgid "Starfield"
msgstr "星空"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:509
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:552
msgid "Storage"
msgstr "存储"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:495
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:497
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:538
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:540
msgctxt "vantage-theme"
msgid "Swap"
msgstr "交换空间"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:462
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:493
msgid "Swap free"
msgstr "空闲交换空间"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:493
msgid "Swap used"
msgstr ""
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:249
msgctxt "vantage-theme"
msgid "This device"
msgstr "本设备"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:474
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:515
msgctxt "vantage-theme"
msgid "Total"
msgstr "总计"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:461
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:491
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:499
msgid "Total Available"
msgstr "总可用"
@@ -547,19 +584,23 @@ msgctxt "vantage-theme"
msgid "Unchanged"
msgstr "不变"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1311
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1812
msgid "Up to date"
msgstr "已是最新版本"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1354
msgid "Use dark theme"
msgstr "使用深色主题"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1312
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1355
msgid "Use light theme"
msgstr "使用浅色主题"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:461
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:491
msgid "Used"
msgstr "已用"
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:57
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:59
msgid "Username"
msgstr "用户名"
@@ -577,21 +618,29 @@ msgctxt "vantage-theme"
msgid "Verdict map %s"
msgstr "判定映射 %s"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1809
msgid "Version %s is available."
msgstr "版本 %s 可用。"
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:204
msgctxt "vantage-theme"
msgid "View"
msgstr "视图"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:537
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1810
msgid "What's new"
msgstr "更新内容"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:580
msgid "Wireless"
msgstr "无线"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:478
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:519
msgctxt "vantage-theme"
msgid "available"
msgstr "可用"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1353
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1396
msgid "close"
msgstr "关闭"
@@ -605,12 +654,12 @@ msgctxt "vantage-theme"
msgid "drops unmatched %s"
msgstr "丢弃未匹配的 %s"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:524
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:528
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:567
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:571
msgid "free"
msgstr "空闲"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1351
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1394
msgid "navigate"
msgstr "移动"
@@ -629,7 +678,7 @@ msgctxt "vantage-theme"
msgid "on %s"
msgstr "接口 %s"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1352
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1395
msgctxt "vantage-theme"
msgid "open"
msgstr "打开"
@@ -639,9 +688,9 @@ msgctxt "vantage-theme"
msgid "priority %d"
msgstr "优先级 %d"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:473
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:483
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:487
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:514
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:524
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:528
msgid "reclaimable"
msgstr "可回收"
@@ -650,13 +699,13 @@ msgctxt "vantage-theme"
msgid "rules drop some"
msgstr "部分规则会丢弃"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:524
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:528
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:567
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:571
msgid "total"
msgstr "总计"
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:524
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:528
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:567
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:571
msgid "used"
msgstr "已用"
@@ -6,6 +6,7 @@
# option theme 'auto' auto, light or dark
# option login 'grid' the login page's background: grid, stars or none
# option backdrop 'grid' the dashboard's network path background, likewise
# option brand 'none' next to the logo: none, hostname or model
#
# The dashboard's Customize saves here too: its chart style (option chart) and
# one layout per screen size (option layout_wide, layout_mid, layout_narrow and
@@ -23,6 +23,7 @@
const look_theme = look('theme', [ 'auto', 'light', 'dark' ]);
const look_login = look('login', [ 'grid', 'stars', 'none' ]);
const look_backdrop = look('backdrop', [ 'grid', 'stars', 'none' ]);
const look_brand = look('brand', [ 'none', 'hostname', 'model' ]);
/* The dashboard's chart style and layouts, saved with it. A layout passes
only in the form vantage.layout reads, 'wide=hero:0:4 chart:0:2:h;mid=default' */
const look_chart = look('chart', [ 'overlay', 'log', 'mirror', 'mirror-split', 'lanes' ]);
@@ -53,11 +54,9 @@
});
return would ? 'relogin' : 'no';
})();
const boardinfo = ubus.call("system", "board");
const hostname = striptags(boardinfo?.hostname ?? '?');
-%}
<!DOCTYPE html>
<html lang="{{ lang }}" data-darkmode="true" data-vt-look-theme="{{ look_theme }}" data-vt-look-login="{{ look_login }}" data-vt-look-backdrop="{{ look_backdrop }}" data-vt-look-chart="{{ look_chart }}" data-vt-look-layout="{{ look_layout }}" data-vt-look-save="{{ look_save }}">
<html lang="{{ lang }}" data-darkmode="true" data-vt-look-theme="{{ look_theme }}" data-vt-look-login="{{ look_login }}" data-vt-look-backdrop="{{ look_backdrop }}" data-vt-look-brand="{{ look_brand }}" data-vt-look-chart="{{ look_chart }}" data-vt-look-layout="{{ look_layout }}" data-vt-look-save="{{ look_save }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
@@ -108,7 +107,7 @@ const hostname = striptags(boardinfo?.hostname ?? '?');
<circle cx="21.5" cy="10.5" r="1.9" class="vt-brand-dot"></circle>
<circle cx="21.5" cy="21.5" r="1.9" class="vt-brand-dot"></circle>
</svg>
<h3 class="vt-brand-word">{{ hostname }}</h3>
<span class="vt-brand-name" id="vt-brand-name" hidden></span>
</a>
<ul class="vt-rail-list" id="vt-rail-list"></ul>
<div class="vt-rail-foot">
@@ -126,6 +125,8 @@ const hostname = striptags(boardinfo?.hostname ?? '?');
<span class="vt-brand vt-brand-static" aria-hidden="true">
<svg viewBox="0 0 32 32" width="28" height="28"><rect x="1" y="1" width="30" height="30" rx="7" class="vt-brand-tile"/><path d="M9 10.5 16 22" class="vt-brand-l"/><path d="M16 22 23 10.5" class="vt-brand-r"/><circle cx="23" cy="10.5" r="1.9" class="vt-brand-dot"/></svg>
</span>
{% else %}
<span class="vt-top-name" id="vt-top-name" hidden></span>
{% endif %}
<nav class="vt-crumbs" id="vt-crumbs" aria-label="{{ entityencode(_('Breadcrumb', 'vantage-theme'), true) }}"><span class="vt-crumb vt-crumb-current">{{ entityencode(page_title ?? 'LuCI', true) }}</span></nav>
<div class="vt-top-actions">
@@ -1,7 +1,8 @@
{#
Vantage theme - login page.
Rendered with { duser, fuser } for visitors who are not logged in, so it
shows nothing about the device: no hostname, model or firmware version.
Rendered with { duser, fuser } for visitors who are not logged in. Of the
device it shows the hostname only, as the heading (LuCI's Bootstrap shows
it too): no model or firmware version.
fuser is attacker-controlled (formvalue also reads the query string): it is
only tested, never echoed; the error is generic. The form posts back to the
requested URL (no action attribute). The inline script is data-free: the
@@ -17,8 +18,7 @@
const look = (opt, ok) => { const v = uci.get('vantage_theme', 'appearance', opt); return (type(v) == 'string' && index(ok, v) >= 0) ? v : ''; };
const look_theme = look('theme', [ 'auto', 'light', 'dark' ]);
const look_login = look('login', [ 'grid', 'stars', 'none' ]);
const boardinfo = ubus.call("system", "board");
const hostname = striptags(boardinfo?.hostname ?? '?');
const host = (() => { const h = uci.get_first('system', 'system', 'hostname'); return (type(h) == 'string' && length(h)) ? substr(h, 0, 63) : null; })();
-%}
<!DOCTYPE html>
<html lang="{{ entityencode(dispatcher.lang ?? 'en', true) }}" data-darkmode="true" data-vt-look-theme="{{ look_theme }}" data-vt-look-login="{{ look_login }}">
@@ -54,10 +54,7 @@
<circle cx="21.5" cy="10.5" r="1.9" class="vt-brand-dot"></circle>
<circle cx="21.5" cy="21.5" r="1.9" class="vt-brand-dot"></circle>
</svg>
<div>
<h1 class="vt-login-title">{{ hostname }}</h1>
<p class="vt-login-sub"><span class="vt-login-dot" aria-hidden="true"></span>{{ entityencode(_('Authorization Required'), true) }}</p>
</div>
<h1 class="vt-login-title">{{ entityencode(host ?? _('Authorization Required'), true) }}</h1>
</div>
{% if (fuser): %}
+3 -3
View File
@@ -50,12 +50,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=wwand
PKG_VERSION:=1.9.0_pre3
PKG_RELEASE:=88
PKG_VERSION:=1.9.0_pre10
PKG_RELEASE:=89
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
PKG_SOURCE_VERSION:=0a1f02375df53f21a105b375b9af051c36464530
PKG_SOURCE_VERSION:=e9fd1db72db20faced06e323c06e8a1062607bf2
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only