diff --git a/btfout/vmlinux-btf_6.6.144-r1_x86_64.ipk b/btfout/vmlinux-btf_6.6.144-r1_x86_64.ipk new file mode 100644 index 000000000..7a697d79b Binary files /dev/null and b/btfout/vmlinux-btf_6.6.144-r1_x86_64.ipk differ diff --git a/clashoo/Makefile b/clashoo/Makefile index 02c797291..fa45e2c98 100644 --- a/clashoo/Makefile +++ b/clashoo/Makefile @@ -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) diff --git a/gately/Makefile b/gately/Makefile index 46e3872cb..dd2322fb1 100644 --- a/gately/Makefile +++ b/gately/Makefile @@ -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)? diff --git a/honk/Makefile b/honk/Makefile index b033c0a11..475bb7a64 100644 --- a/honk/Makefile +++ b/honk/Makefile @@ -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 diff --git a/istoreenhance/Makefile b/istoreenhance/Makefile index 1d9af408b..20504bc0d 100644 --- a/istoreenhance/Makefile +++ b/istoreenhance/Makefile @@ -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 diff --git a/ks-cli/Makefile b/ks-cli/Makefile index ac53eca16..813edbd9c 100644 --- a/ks-cli/Makefile +++ b/ks-cli/Makefile @@ -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)/ diff --git a/luci-app-istoreenhance/Makefile b/luci-app-istoreenhance/Makefile index f50d0755a..183011f98 100644 --- a/luci-app-istoreenhance/Makefile +++ b/luci-app-istoreenhance/Makefile @@ -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 diff --git a/luci-app-kaiplus/Makefile b/luci-app-kaiplus/Makefile index 03cb26d27..da53c1f5d 100644 --- a/luci-app-kaiplus/Makefile +++ b/luci-app-kaiplus/Makefile @@ -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 diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index ce49bd8e2..8e74e61db 100644 --- a/luci-app-open-box/Makefile +++ b/luci-app-open-box/Makefile @@ -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 diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index 89e466c6d..d56e71223 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -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:= \ diff --git a/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm b/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm index 1ff279a4b..eea98008d 100644 --- a/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm +++ b/luci-app-passwall/luasrc/view/passwall/app_update/app_version.htm @@ -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; + }
@@ -343,13 +354,13 @@ local com = self.com <%=api.get_version()%>
- +
- +
@@ -362,13 +373,13 @@ local com = self.com <%=translate("Null")%>
- +
- +
<%end%> diff --git a/luci-app-passwall2/Makefile b/luci-app-passwall2/Makefile index 2393e2c0c..994085f5d 100644 --- a/luci-app-passwall2/Makefile +++ b/luci-app-passwall2/Makefile @@ -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:= \ diff --git a/luci-app-passwall2/luasrc/view/passwall2/app_update/app_version.htm b/luci-app-passwall2/luasrc/view/passwall2/app_update/app_version.htm index 65d86fcaa..56805b833 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/app_update/app_version.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/app_update/app_version.htm @@ -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; + }
@@ -351,7 +362,7 @@ local com = self.com <%=api.get_version()%>
-
+
<%=translate("Null")%>
-
+
', 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 @@ -
diff --git a/luci-app-ssr-plus/po/templates/ssr-plus.pot b/luci-app-ssr-plus/po/templates/ssr-plus.pot index ce5cdf347..6b889b7a8 100644 --- a/luci-app-ssr-plus/po/templates/ssr-plus.pot +++ b/luci-app-ssr-plus/po/templates/ssr-plus.pot @@ -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 "" diff --git a/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po b/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po index eaca309e9..cdf9b5ff4 100644 --- a/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po +++ b/luci-app-ssr-plus/po/zh_Hans/ssr-plus.po @@ -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 特性" diff --git a/luci-app-ssr-plus/root/usr/share/shadowsocksr/update_components.sh b/luci-app-ssr-plus/root/usr/share/shadowsocksr/update_components.sh index 257f8f2b1..1948c69b6 100755 --- a/luci-app-ssr-plus/root/usr/share/shadowsocksr/update_components.sh +++ b/luci-app-ssr-plus/root/usr/share/shadowsocksr/update_components.sh @@ -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 diff --git a/luci-app-tailscale-ng/.gitignore b/luci-app-tailscale-ng/.gitignore index 906716dfe..919576b5c 100644 --- a/luci-app-tailscale-ng/.gitignore +++ b/luci-app-tailscale-ng/.gitignore @@ -1,3 +1,4 @@ .deploy/target.env .deploy/.ssh/ .private +/artifacts/ diff --git a/luci-app-tailscale-ng/Makefile b/luci-app-tailscale-ng/Makefile index e81316dc6..0670ea5f0 100644 --- a/luci-app-tailscale-ng/Makefile +++ b/luci-app-tailscale-ng/Makefile @@ -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 diff --git a/luci-app-tailscale-ng/README.md b/luci-app-tailscale-ng/README.md index 4c02aa570..45f1a9753 100644 --- a/luci-app-tailscale-ng/README.md +++ b/luci-app-tailscale-ng/README.md @@ -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 diff --git a/luci-app-tailscale-ng/docs/local-build.md b/luci-app-tailscale-ng/docs/local-build.md new file mode 100644 index 000000000..cbe15db66 --- /dev/null +++ b/luci-app-tailscale-ng/docs/local-build.md @@ -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`. diff --git a/luci-app-tailscale-ng/po/templates/luci-app-tailscale-ng.pot b/luci-app-tailscale-ng/po/templates/luci-app-tailscale-ng.pot new file mode 100644 index 000000000..cc716994d --- /dev/null +++ b/luci-app-tailscale-ng/po/templates/luci-app-tailscale-ng.pot @@ -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. 192.168.1.0/24." +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 "" diff --git a/luci-app-tailscale-ng/po/zh_Hans/luci-app-tailscale-ng.po b/luci-app-tailscale-ng/po/zh_Hans/luci-app-tailscale-ng.po index 15796d947..55139d3e6 100644 --- a/luci-app-tailscale-ng/po/zh_Hans/luci-app-tailscale-ng.po +++ b/luci-app-tailscale-ng/po/zh_Hans/luci-app-tailscale-ng.po @@ -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) \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" diff --git a/luci-app-tailscale-ng/scripts/build-packages.sh b/luci-app-tailscale-ng/scripts/build-packages.sh new file mode 100755 index 000000000..303e18abf --- /dev/null +++ b/luci-app-tailscale-ng/scripts/build-packages.sh @@ -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 <&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" diff --git a/luci-app-trafficctl/Makefile b/luci-app-trafficctl/Makefile index ff13579d0..029f8b700 100644 --- a/luci-app-trafficctl/Makefile +++ b/luci-app-trafficctl/Makefile @@ -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 diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh index 87cf6a43c..510b75f88 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-device.sh @@ -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 diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh index c75484ee0..73e318c17 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-fw.sh @@ -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 diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-metrics.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-metrics.sh index 79188d864..8132a75d5 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-metrics.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-metrics.sh @@ -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") diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-portfw.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-portfw.sh index e3172a5ea..8a987c1fb 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-portfw.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-portfw.sh @@ -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 diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit-stats.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit-stats.sh index bdab3d121..de44c6dc5 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit-stats.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-ratelimit-stats.sh @@ -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 diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh index d5a62fdf0..efaa25814 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-summary.sh @@ -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}" } diff --git a/luci-app-trafficctl/root/usr/local/bin/trafficctl-telegram.sh b/luci-app-trafficctl/root/usr/local/bin/trafficctl-telegram.sh index 34589c5c3..6ea08c877 100755 --- a/luci-app-trafficctl/root/usr/local/bin/trafficctl-telegram.sh +++ b/luci-app-trafficctl/root/usr/local/bin/trafficctl-telegram.sh @@ -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 diff --git a/luci-app-vantage/Makefile b/luci-app-vantage/Makefile index dd48c59bc..4006ef24e 100644 --- a/luci-app-vantage/Makefile +++ b/luci-app-vantage/Makefile @@ -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 diff --git a/luci-app-vantage/htdocs/luci-static/resources/vantage/app.css b/luci-app-vantage/htdocs/luci-static/resources/vantage/app.css index 6222eb043..d896170aa 100644 --- a/luci-app-vantage/htdocs/luci-static/resources/vantage/app.css +++ b/luci-app-vantage/htdocs/luci-static/resources/vantage/app.css @@ -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); diff --git a/luci-app-vantage/htdocs/luci-static/resources/vantage/backdrop.js b/luci-app-vantage/htdocs/luci-static/resources/vantage/backdrop.js index 0acea2092..acb6501d1 100644 --- a/luci-app-vantage/htdocs/luci-static/resources/vantage/backdrop.js +++ b/luci-app-vantage/htdocs/luci-static/resources/vantage/backdrop.js @@ -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(); diff --git a/luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js b/luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js index 4e9307d51..99c53d5fd 100644 --- a/luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js +++ b/luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js @@ -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 ]) ]); }, diff --git a/luci-app-vantage/po/templates/vantage.pot b/luci-app-vantage/po/templates/vantage.pot index 933ed12e8..0ba7940e9 100644 --- a/luci-app-vantage/po/templates/vantage.pot +++ b/luci-app-vantage/po/templates/vantage.pot @@ -1,7 +1,7 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3574 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3614 msgid "%d MHz (%d–%d MHz)" msgstr "" @@ -29,19 +29,19 @@ msgid_plural "%d SSIDs · %d idle" msgstr[0] "" msgstr[1] "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1673 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2143 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2694 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1690 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2181 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2734 msgid "%d clients" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3031 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3071 msgid "%d connected" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2170 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2174 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3030 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2208 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2212 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3070 msgid "%d devices" msgstr "" @@ -56,22 +56,22 @@ msgid_plural "%d measured clients are above %s" msgstr[0] "" msgstr[1] "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:458 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:459 msgctxt "vantage" msgid "%d min" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:118 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:452 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:453 msgctxt "vantage" msgid "%d min ago" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3029 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3069 msgid "%d of %d" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1560 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1574 msgid "%d of %d columns" msgstr "" @@ -101,17 +101,17 @@ msgid_plural "%d radios up" msgstr[0] "" msgstr[1] "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2686 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2726 msgid "%d radios" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:458 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:459 msgctxt "vantage" msgid "%d s" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:117 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:452 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:453 msgctxt "vantage" msgid "%d s ago" msgstr "" @@ -125,7 +125,7 @@ msgstr "" msgid "%d weak" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2705 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2745 msgid "%d–%d MHz of the %d–%d MHz band" msgstr "" @@ -143,7 +143,7 @@ msgstr "" msgid "%s (IPv%d)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3396 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3436 msgid "%s (new this session)" msgstr "" @@ -154,10 +154,10 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:166 #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:103 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2712 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2726 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3128 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3567 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2752 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2766 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3168 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3607 msgid "%s MHz" msgstr "" @@ -176,13 +176,13 @@ msgid "%s at %s; below %s roaming and throughput suffer" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:83 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2387 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2426 msgctxt "vantage" msgid "%s available" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2732 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3575 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2772 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3615 msgid "%s dBm" msgstr "" @@ -194,13 +194,13 @@ msgstr "" msgid "%s device on a newer radio; uses more airtime per byte" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3601 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3641 msgid "%s error" msgid_plural "%s errors" msgstr[0] "" msgstr[1] "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3363 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3403 msgid "%s experience" msgstr "" @@ -216,11 +216,11 @@ msgstr "" msgid "%s has no carrier: cable unplugged or switch port down" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1526 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1540 msgid "%s hidden" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3645 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3685 msgid "%s in · %s out" msgstr "" @@ -236,7 +236,7 @@ msgstr "" msgid "%s is in no zone and accepts every connection" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1519 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1533 msgid "%s is narrower" msgstr "" @@ -244,17 +244,17 @@ msgstr "" msgid "%s is not up" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3087 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3127 msgid "" "%s is shared by %d devices (a switch or another access point behind it); its " "%s link is not theirs" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3086 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3126 msgid "%s is shared by %d devices behind the switch" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1522 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1536 msgid "%s is wider" msgstr "" @@ -270,23 +270,23 @@ msgstr "" msgid "%s may open the router's admin" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1641 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1655 msgid "%s moved" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1516 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1530 msgid "%s moved down" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1507 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1521 msgid "%s moved left" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1510 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1524 msgid "%s moved right" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1513 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1527 msgid "%s moved up" msgstr "" @@ -299,9 +299,9 @@ msgid "%s negotiated only %d Mbit/s; a cable pair or port may be faulty" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:84 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2388 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2417 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3671 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2427 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2456 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3711 msgid "%s of %s" msgstr "" @@ -322,21 +322,21 @@ msgstr "" msgid "%s of frames to the device fail; interference likely" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3669 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3709 msgid "%s over %d core" msgid_plural "%s over %d cores" msgstr[0] "" msgstr[1] "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2481 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2521 msgid "%s radio" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3401 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3441 msgid "%s sent · %s retried · %s failed" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1566 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1580 msgid "%s shown" msgstr "" @@ -352,11 +352,11 @@ msgstr "" msgid "%s used" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2425 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2464 msgid "%s used of %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3084 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3124 msgid "%s · %d devices" msgstr "" @@ -364,7 +364,7 @@ msgstr "" msgid "%s · %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3393 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3433 msgid "%s · channel %d · %d MHz" msgstr "" @@ -380,27 +380,27 @@ msgstr "" msgid "%s: no carrier" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3575 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3615 msgid "(configured %d dBm, limited by regulatory rules)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2142 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3220 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2180 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3260 msgid "(hidden)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1673 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2143 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2694 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1690 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2181 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2734 msgid "1 client" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3031 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3071 msgid "1 connected" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2174 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3030 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2212 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3070 msgid "1 device" msgstr "" @@ -408,7 +408,7 @@ msgstr "" msgid "1 older device (Wi-Fi 4 or earlier) slows airtime" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2686 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2726 msgid "1 radio" msgstr "" @@ -436,20 +436,20 @@ msgstr "" msgid "60 GHz" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2894 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2934 msgid "Active" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3405 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3437 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3445 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 msgid "Address type" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3634 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3674 msgid "Addresses" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2734 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2774 msgid "Airtime" msgstr "" @@ -458,8 +458,8 @@ msgctxt "vantage" msgid "Alias" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2885 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2891 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2925 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2931 msgid "All" msgstr "" @@ -488,7 +488,7 @@ msgstr "" msgid "Busiest radio: %s of Wi-Fi traffic" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2352 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2391 msgid "Busy share of each CPU core" msgstr "" @@ -504,8 +504,8 @@ msgstr[1] "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:71 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:72 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2345 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3669 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2384 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3709 msgctxt "vantage" msgid "CPU" msgstr "" @@ -514,78 +514,78 @@ msgstr "" msgid "Cache (reclaimable)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3510 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3550 msgid "Cancel" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3388 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3428 msgctxt "vantage" msgid "Capabilities" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3626 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3666 msgid "Carrier" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3574 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3614 msgid "Centre frequency" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2725 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2765 msgid "Ch %d" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2123 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2693 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2161 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2733 msgid "Ch %d · %d MHz" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3567 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3607 msgid "Channel" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2123 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2161 msgid "Channel —" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2758 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2798 msgctxt "vantage" msgid "Clear filter" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1203 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2731 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2776 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2777 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1217 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2771 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2816 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2817 msgid "Clients" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3581 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3621 msgid "Clients on this radio" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3237 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3277 msgid "Close" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3143 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3183 msgid "Connected" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3113 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3153 msgid "Connected after you opened this page" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3394 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3434 msgid "Connected for" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3391 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3422 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3462 msgid "Connection" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2379 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2418 msgid "Core %d: %s" msgstr "" @@ -593,19 +593,19 @@ msgstr "" msgid "Could not read the firewall configuration" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3493 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3533 msgid "Could not save the name (no permission or the device refused it)." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1403 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1417 msgid "Could not save this for every browser. Try again." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3642 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3682 msgid "Counters since boot" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3579 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3619 msgid "Country" msgstr "" @@ -614,13 +614,13 @@ msgid "" "Custom rule files are configured (%s); rules in them are not listed here." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1662 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1676 msgid "Customize" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1470 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1471 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1661 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1484 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1485 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1675 msgid "Customize the dashboard" msgstr "" @@ -628,16 +628,16 @@ msgstr "" msgid "DHCP" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3450 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3490 msgid "DHCP hostname" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:42 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3640 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3680 msgid "DNS" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2054 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2092 msgid "DNS %s" msgstr "" @@ -646,113 +646,113 @@ msgstr "" msgid "Dashboard" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2966 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3058 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3660 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3006 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3098 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3700 msgid "Device" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3517 msgid "Device name" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3506 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3546 msgid "Device type" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3044 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3084 msgid "" "Devices on the Ethernet ports appear here once they send traffic; the bridge " "forgets quiet ones after about five minutes." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2124 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2695 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2720 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3572 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2162 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2735 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2760 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3612 msgid "Disabled" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3357 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3397 msgid "Disconnected %s; shown from the last sample." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1485 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1499 msgctxt "vantage" msgid "Done" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:45 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2124 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2695 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2720 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3572 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2162 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2735 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2760 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3612 msgctxt "vantage" msgid "Down" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2458 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2553 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2628 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2498 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2593 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2668 msgid "Download" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:377 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:378 msgid "Download (to clients)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3399 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3439 msgctxt "vantage" msgid "Downloaded" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1547 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1561 msgid "" "Drag a tile by its handle or use its buttons. Done saves it for medium " "screens in every browser." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1548 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1562 msgid "" "Drag a tile by its handle or use its buttons. Done saves it for narrow " "screens in every browser." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1546 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1560 msgid "" "Drag a tile by its handle or use its buttons. Done saves it for wide screens " "in every browser." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1547 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1561 msgid "" "Drag a tile by its handle or use its buttons. Saved in this browser for " "medium screens." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1548 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1562 msgid "" "Drag a tile by its handle or use its buttons. Saved in this browser for " "narrow screens." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1546 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1560 msgid "" "Drag a tile by its handle or use its buttons. Saved in this browser for wide " "screens." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1498 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1512 msgid "Drag to move" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3645 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3685 msgctxt "vantage" msgid "Dropped" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3199 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3239 msgid "Edit" msgstr "" @@ -760,7 +760,7 @@ msgstr "" msgid "Enhanced Open (OWE)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1725 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1763 msgid "Error" msgstr "" @@ -774,8 +774,8 @@ msgctxt "vantage" msgid "Excellent" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3137 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3177 msgid "Experience" msgstr "" @@ -793,20 +793,20 @@ msgstr "" msgid "Fair signal %s; lower rates at range" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2755 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2956 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2795 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2996 msgid "Filter by name, address, SSID…" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2956 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2996 msgid "Filter by name, address, port…" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2756 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2796 msgid "Filter clients" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3663 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3703 msgctxt "vantage" msgid "Firmware" msgstr "" @@ -819,7 +819,7 @@ msgstr "" msgid "Forwarding is accepted by default" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3401 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3441 msgid "Frames to device" msgstr "" @@ -828,15 +828,15 @@ msgctxt "vantage" msgid "Free" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3386 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3426 msgid "From device" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3126 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3166 msgid "From device: %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3621 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3661 msgid "From network" msgstr "" @@ -846,9 +846,9 @@ msgid "" "files are not shown." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2052 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3078 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3637 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2090 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3118 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3677 msgid "Gateway" msgstr "" @@ -861,7 +861,7 @@ msgstr "" msgid "General Settings" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3387 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3427 msgctxt "vantage" msgid "Generation" msgstr "" @@ -871,13 +871,13 @@ msgctxt "vantage" msgid "GiB" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3405 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3437 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3445 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 msgid "Globally unique" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3405 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3437 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3445 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 msgid "Globally unique · %s" msgstr "" @@ -895,7 +895,7 @@ msgstr "" msgid "Good signal %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1445 msgid "Grid packets" msgstr "" @@ -911,36 +911,36 @@ msgstr "" msgid "Half duplex" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1099 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1109 msgctxt "vantage" msgid "Health checks" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1563 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3220 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1577 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3260 msgctxt "vantage" msgid "Hidden" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1525 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1539 msgid "Hide %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3406 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3438 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3446 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3478 msgid "IP addresses" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3635 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3675 msgid "IPv4" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3636 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3676 msgid "IPv6" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3403 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3435 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3443 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3475 msgid "Identity" msgstr "" @@ -948,7 +948,7 @@ msgstr "" msgid "In no zone: the default policies apply." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3632 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3672 msgid "In the tunnel" msgstr "" @@ -962,21 +962,21 @@ msgctxt "vantage" msgid "Input" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2102 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2140 msgid "Interface" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3629 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3669 msgid "Interface up for" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3420 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3607 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3619 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3460 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3647 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3659 msgid "Interfaces" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2052 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2090 msgid "Internet" msgstr "" @@ -1002,11 +1002,11 @@ msgid "" "itself." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3664 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3704 msgid "Kernel" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2470 msgid "Kernel %s" msgstr "" @@ -1015,58 +1015,58 @@ msgctxt "vantage" msgid "KiB" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2770 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2810 msgid "Kind of client" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3395 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3435 msgctxt "vantage" msgid "Last activity" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2967 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3102 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3433 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3007 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3142 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3473 msgid "Last heard" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1483 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1497 msgid "Layout reset" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3112 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3152 msgid "Left %s" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:174 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3114 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3154 msgctxt "vantage" msgid "Legacy" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2966 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3082 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3384 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3006 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3122 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3424 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3625 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3464 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3665 msgctxt "vantage" msgid "Link" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3125 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3165 msgid "Link rate" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1693 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2966 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3093 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3130 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1712 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3006 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3133 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3170 msgid "Live" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3668 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3708 msgid "Load" msgstr "" @@ -1082,24 +1082,24 @@ msgid_plural "Load %s · %d cores" msgstr[0] "" msgstr[1] "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3670 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3710 msgctxt "vantage" msgid "Load average" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2333 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2372 msgid "Load average over 1, 5 and 15 minutes" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2369 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2408 msgid "Load avg %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3666 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3706 msgid "Local time" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1362 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1376 msgid "Log in again to save this for every browser." msgstr "" @@ -1115,8 +1115,8 @@ msgstr "" msgid "MAC" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3404 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3436 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3444 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3476 msgid "MAC address" msgstr "" @@ -1124,11 +1124,11 @@ msgstr "" msgid "MCS %d" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1518 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1532 msgid "Make %s narrower" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1521 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1535 msgid "Make %s wider" msgstr "" @@ -1145,11 +1145,11 @@ msgid "Measuring…" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:83 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2356 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2395 msgid "Memory" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3671 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3711 msgid "Memory in use" msgstr "" @@ -1158,63 +1158,63 @@ msgctxt "vantage" msgid "MiB" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1417 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1431 msgid "Mirrored" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1418 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1432 msgid "Mirrored, scale per direction" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3573 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3613 msgid "Mode" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3661 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3701 msgid "Model" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1515 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1529 msgid "Move %s down" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1506 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1520 msgid "Move %s left" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1509 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1523 msgid "Move %s right" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1512 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1526 msgid "Move %s up" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1498 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1512 msgid "Move %s: drag, or use the arrow keys" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3505 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3545 msgid "Name" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:134 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3545 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3585 msgid "Name contains invisible or control characters" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3407 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3439 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3447 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3479 msgid "Name from" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3063 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3103 msgid "Name from: %s" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:132 #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:137 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3546 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3586 msgid "Name is longer than %d characters" msgstr "" @@ -1222,27 +1222,27 @@ msgstr "" msgid "Name must be text" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3348 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3417 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3388 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3457 msgid "Name this device" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2966 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3092 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3122 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3392 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3006 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3132 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3162 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3432 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3472 msgid "Network" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1203 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1839 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1840 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1217 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1877 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1878 msgid "Network path" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1436 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1450 msgid "Network path background" msgstr "" @@ -1250,13 +1250,13 @@ msgstr "" msgid "Networks: %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2896 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3113 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2936 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3153 msgctxt "vantage" msgid "New" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1999 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2037 msgid "No SSIDs" msgstr "" @@ -1264,24 +1264,24 @@ msgstr "" msgid "No clients" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3046 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3586 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3086 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3626 msgid "No clients connected" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3174 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3214 msgid "No clients connected." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3037 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3077 msgid "No clients in this view" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3037 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3077 msgid "No clients match “%s”" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2053 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2091 msgctxt "vantage" msgid "No default route" msgstr "" @@ -1290,7 +1290,7 @@ msgstr "" msgid "No interface with a route" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3611 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3651 msgid "No interface with a route is up." msgstr "" @@ -1299,7 +1299,7 @@ msgid "No internet-facing zone found" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:47 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3626 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3666 msgid "No link" msgstr "" @@ -1307,7 +1307,7 @@ msgstr "" msgid "No network interface with a route is up" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1980 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2018 msgid "No other interfaces are up" msgstr "" @@ -1317,7 +1317,7 @@ msgid "No problems detected" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:105 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2003 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2041 msgid "No radios reported" msgstr "" @@ -1334,16 +1334,16 @@ msgstr "" msgid "No stations are associated right now" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3043 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3083 msgid "No wired devices heard" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3208 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3248 msgctxt "vantage" msgid "No wireless networks configured." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2700 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2740 msgid "No wireless radios are reported by this device." msgstr "" @@ -1358,39 +1358,39 @@ msgstr "" msgid "No zones are configured." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3174 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3214 msgid "Nobody is moving data right now." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2733 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2773 msgid "Noise" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3389 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3429 msgid "Noise / SNR" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3577 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3578 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3617 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3618 msgid "Noise floor" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:105 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1445 msgid "None" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:150 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3544 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3584 msgid "Not a station MAC address" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3339 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3379 msgid "Not connected" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3094 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3429 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3134 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3469 msgid "Not counted per device on a shared port" msgstr "" @@ -1398,7 +1398,7 @@ msgstr "" msgid "Not in effect" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2406 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2445 msgid "Not reported" msgstr "" @@ -1406,7 +1406,7 @@ msgstr "" msgid "Not reported by the driver" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3453 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3493 msgid "Nothing better known" msgstr "" @@ -1427,7 +1427,7 @@ msgstr "" msgid "Older %s device" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3114 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3154 msgid "Older Wi-Fi generation than the radio; uses more airtime" msgstr "" @@ -1435,7 +1435,7 @@ msgstr "" msgid "On this router" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 msgctxt "vantage" msgid "Online" msgstr "" @@ -1452,7 +1452,7 @@ msgctxt "vantage" msgid "Open" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3012 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3052 msgid "Open details of %s" msgstr "" @@ -1478,11 +1478,11 @@ msgid "" "new rules: what is open is unknown." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1415 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1429 msgid "Overlaid" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1416 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1430 msgid "Overlaid, log scale" msgstr "" @@ -1490,11 +1490,15 @@ msgstr "" msgid "Overview" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3388 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3428 msgid "PMF" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1691 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1710 +msgid "Paused" +msgstr "" + +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1710 msgid "Paused while hidden" msgstr "" @@ -1508,7 +1512,7 @@ msgid "" "on to your network: what every router lets in." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3662 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3702 msgctxt "vantage" msgid "Platform" msgstr "" @@ -1518,7 +1522,7 @@ msgctxt "vantage" msgid "Poor" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3423 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3463 msgid "Port" msgstr "" @@ -1526,11 +1530,11 @@ msgstr "" msgid "Port %d" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3087 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3127 msgid "Port %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2167 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2205 msgid "Port total: %s" msgstr "" @@ -1538,11 +1542,11 @@ msgstr "" msgid "Private" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3405 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3445 msgid "Private Wi-Fi address (randomised by the device)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3437 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 msgid "Private address (randomised by the device)" msgstr "" @@ -1550,26 +1554,26 @@ msgstr "" msgid "Private device" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3659 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3699 msgid "Processes" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3628 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3668 msgid "Protocol" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2765 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2767 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2805 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2807 msgid "Quick filters" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3393 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3571 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3433 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3611 msgctxt "vantage" msgid "Radio" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2721 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2761 msgid "Radio details" msgstr "" @@ -1581,58 +1585,58 @@ msgstr "" msgid "Radio is enabled but not up" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2745 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3565 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2785 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3605 msgid "Radio settings" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1203 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2673 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2675 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1217 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2713 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2715 msgid "Radios" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2054 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2092 msgid "Reachability not probed" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3079 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3119 msgid "Reached through the uplink port %s, not connected to this device" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3619 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3659 msgid "Realtime graphs" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3643 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3683 msgctxt "vantage" msgid "Received" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1692 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1711 msgctxt "vantage" msgid "Reconnecting…" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2912 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2952 msgid "Remove filter: %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3511 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3551 msgid "Remove name" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3348 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3417 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3388 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3457 msgctxt "vantage" msgid "Rename" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1484 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1498 msgid "Reset" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3448 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3488 msgid "Reverse DNS of its address" msgstr "" @@ -1640,7 +1644,7 @@ msgstr "" msgid "Root (overlay)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3639 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3679 msgid "Route metric" msgstr "" @@ -1652,20 +1656,20 @@ msgstr "" msgid "SSH (port 22)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3503 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3543 msgid "Save" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3396 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3436 msgid "Seen since" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3644 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3684 msgctxt "vantage" msgid "Sent" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1419 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1433 msgid "Separate lanes" msgstr "" @@ -1673,57 +1677,57 @@ msgstr "" msgid "Settings to check" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3424 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3464 msgid "Shared by %d devices" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3083 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3123 msgid "Shared port" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1565 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1579 msgid "Show %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3038 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3078 msgid "Show all clients" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2342 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2381 msgid "Show or hide system details" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1840 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1878 msgid "Show or hide the network path" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2676 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2716 msgid "Show or hide the radio details" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2461 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2501 msgid "Show or hide the throughput chart" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3199 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3239 msgid "Show or hide the wireless networks" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2141 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3217 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2179 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3257 msgid "Show the clients of %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2167 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2205 msgid "Show the devices on %s" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:119 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:120 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:128 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3119 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3372 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3159 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3412 msgid "Signal" msgstr "" @@ -1731,7 +1735,7 @@ msgstr "" msgid "Signal not reported" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3627 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3667 msgctxt "vantage" msgid "Speed" msgstr "" @@ -1742,24 +1746,24 @@ msgid_plural "Standard housekeeping: %d rules" msgstr[0] "" msgstr[1] "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1445 msgid "Starfield" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3047 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3087 msgid "Stations appear here as soon as they associate with one of the SSIDs." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3572 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3612 msgid "Status" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:93 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2359 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2398 msgid "Storage" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3512 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3552 msgid "Stored on this device in /etc/config/vantage" msgstr "" @@ -1775,16 +1779,16 @@ msgstr "" msgid "Strong signal, %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2416 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2455 msgctxt "vantage" msgid "Swap" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3427 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3467 msgid "Switch ports" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:390 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:391 msgid "Switch ports of VLAN %d on %s: %s" msgstr "" @@ -1792,13 +1796,13 @@ msgstr "" msgid "Switched off" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1204 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2341 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2342 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1218 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2380 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2381 msgid "System" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3659 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3699 msgid "System settings" msgstr "" @@ -1811,7 +1815,7 @@ msgid "Telnet (port 23)" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:76 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2373 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2412 msgid "Temperature %s" msgstr "" @@ -1819,11 +1823,11 @@ msgstr "" msgid "Temporary (RAM)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2967 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3007 msgid "The bridge forgets a device after about five minutes without traffic" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3041 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3081 msgid "" "The bridges have learned so many addresses that listing them here would " "strain this device." @@ -1836,7 +1840,7 @@ msgid "" "reject in General Settings." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3549 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3589 msgid "The device could not write /etc/config/vantage." msgstr "" @@ -1860,7 +1864,7 @@ msgid "" "an uplink in no zone would be open. Set Input to reject in General Settings." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3451 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3491 msgid "The name the device announces (WPS)" msgstr "" @@ -1872,7 +1876,7 @@ msgstr "" msgid "The only measured client" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3078 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3118 msgid "The router this device sends its traffic to" msgstr "" @@ -1888,7 +1892,7 @@ msgstr "" msgid "The uplinks could not be checked" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2069 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2107 msgid "This access point" msgstr "" @@ -1896,11 +1900,11 @@ msgstr "" msgid "This address family is in no zone: the default policies apply." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2069 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2107 msgid "This device" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3548 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3588 msgid "This device already stores %d names; remove some first." msgstr "" @@ -1908,7 +1912,7 @@ msgstr "" msgid "This device reports no radios" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3608 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3648 msgid "This line is down or has no default route now." msgstr "" @@ -1918,7 +1922,7 @@ msgid "" "the firewall pages." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3556 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3596 msgid "This radio is not reported any more." msgstr "" @@ -1926,23 +1930,23 @@ msgstr "" msgid "This setup is beyond what this overview checks" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3339 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3379 msgid "This station is not associated right now." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1203 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2453 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2455 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3378 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1217 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2493 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2495 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3418 msgctxt "vantage" msgid "Throughput" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1424 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1438 msgid "Throughput chart" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2446 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2486 msgid "Throughput per link; arrow keys read the values over time" msgstr "" @@ -1951,38 +1955,38 @@ msgctxt "vantage" msgid "TiB" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3385 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3425 msgid "To device" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3099 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3134 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3187 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3139 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3174 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3227 msgid "To device %s, from device %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3126 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3166 msgid "To device: %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3622 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3662 msgid "To network" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3040 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3080 msgid "Too many devices to list" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1204 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3151 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3152 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1218 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3191 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3192 msgctxt "vantage" msgid "Top talkers" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3398 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3428 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3568 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3438 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3468 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3608 msgid "Traffic" msgstr "" @@ -1998,16 +2002,16 @@ msgid "" "networks. Set Forward to reject in General Settings." msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2065 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2103 msgid "Traffic to the internet goes through %s, a tunnel over this line" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3631 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3671 msgid "Tunnel" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2732 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3575 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2772 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3615 msgid "Tx power" msgstr "" @@ -2018,16 +2022,16 @@ msgid "" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:272 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3338 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3378 msgctxt "vantage" msgid "Unknown device" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3547 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3587 msgid "Unknown device type" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3556 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3596 msgid "Unknown radio" msgstr "" @@ -2042,14 +2046,14 @@ msgid "Unnamed rule" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:57 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2720 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3572 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3626 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2760 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3612 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3666 msgctxt "vantage" msgid "Up" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2366 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2405 msgid "Up %s" msgstr "" @@ -2058,47 +2062,47 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:49 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:51 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:57 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2479 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3611 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2519 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3651 msgctxt "vantage" msgid "Uplink" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2479 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2480 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2519 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2520 msgid "Uplink %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3604 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3615 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3644 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3655 msgid "Uplink · %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2459 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2555 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2629 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2499 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2595 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2669 msgid "Upload" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:378 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:379 msgid "Upload (from clients)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3400 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3440 msgctxt "vantage" msgid "Uploaded" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3079 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3119 msgid "Upstream" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3665 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3705 msgid "Uptime" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3080 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3432 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3120 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3472 msgid "VLAN %d" msgstr "" @@ -2233,11 +2237,11 @@ msgstr "" msgid "WPS" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3408 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3448 msgid "WPS device name" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1725 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1763 msgid "Warning" msgstr "" @@ -2251,7 +2255,7 @@ msgid "Weak" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:157 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2895 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2935 msgid "Weak signal" msgstr "" @@ -2275,27 +2279,27 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:106 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:108 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:110 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2949 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2989 msgid "Wi-Fi" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2011 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2949 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2049 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2989 msgid "Wired" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1204 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3198 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3199 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1218 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3238 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3239 msgctxt "vantage" msgid "Wireless networks" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3358 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3398 msgid "Wireless settings" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3447 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3487 msgid "Your name for this device" msgstr "" @@ -2333,15 +2337,15 @@ msgstr "" msgid "any zone (default policy)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2726 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2766 msgid "auto" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3396 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3436 msgid "before this page opened" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3425 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3465 msgid "behind %s, VLAN %d" msgstr "" @@ -2350,16 +2354,16 @@ msgctxt "vantage" msgid "bit/s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2587 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2627 msgid "collecting · %s of data" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2587 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2627 msgid "collecting · first samples" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:251 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2567 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:252 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2607 msgid "collecting…" msgstr "" @@ -2371,18 +2375,18 @@ msgstr "" msgid "dBm" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1929 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2067 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1967 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2105 msgid "default route" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2067 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2105 msgid "default route via %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:385 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3083 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3424 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:386 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3123 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3464 msgid "down" msgstr "" @@ -2391,7 +2395,7 @@ msgctxt "vantage" msgid "drop" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3517 msgctxt "vantage" msgid "e.g. Living room TV" msgstr "" @@ -2430,8 +2434,8 @@ msgid "half duplex" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:62 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2087 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3225 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2125 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3265 msgid "idle" msgstr "" @@ -2473,18 +2477,18 @@ msgctxt "vantage" msgid "kbit/s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2351 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2586 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2390 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2626 msgid "last 5 min" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:175 #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:104 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3128 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3168 msgid "legacy" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:385 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:386 msgid "link up" msgstr "" @@ -2492,45 +2496,45 @@ msgstr "" msgid "mDNS" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3449 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3489 msgid "mDNS announcement" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3380 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3420 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3471 msgid "measuring" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2562 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2602 msgid "measuring…" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2543 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2583 msgctxt "vantage" msgid "no data" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2060 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2098 msgid "no uplink" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3406 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3446 msgid "not known to this AP" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3438 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3478 msgid "not known to this device" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3578 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3618 msgid "not reported (driver placeholder %s)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3389 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3429 msgid "not reported by the driver" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2583 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2623 msgctxt "vantage" msgid "now" msgstr "" @@ -2539,16 +2543,16 @@ msgstr "" msgid "now %s down, %s up" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3387 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3427 msgid "older than the radio (%s)" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2560 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2600 msgctxt "vantage" msgid "peak" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2352 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2391 msgid "per core" msgstr "" @@ -2557,11 +2561,11 @@ msgctxt "vantage" msgid "port forward" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3374 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3414 msgid "range %s … %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3621 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3661 msgid "received on %s" msgstr "" @@ -2574,7 +2578,7 @@ msgstr "" msgid "router port %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3622 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3662 msgid "sent on %s" msgstr "" @@ -2582,12 +2586,12 @@ msgstr "" msgid "short GI" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3102 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3433 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3142 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3473 msgid "static entry" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3426 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3466 msgid "switch port %s" msgstr "" @@ -2599,11 +2603,11 @@ msgstr "" msgid "the covered subnets of zone %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3430 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3470 msgid "the port in total: %s" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3425 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3465 msgid "the port: %s" msgstr "" @@ -2619,7 +2623,7 @@ msgstr "" msgid "traffic rule" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2072 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2110 msgid "up %s" msgstr "" @@ -2636,6 +2640,6 @@ msgstr "" msgid "zones %s and %s if they share a device" msgstr "" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2580 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2620 msgid "−%d min" msgstr "" diff --git a/luci-app-vantage/po/zh_Hans/vantage.po b/luci-app-vantage/po/zh_Hans/vantage.po index f2130e23c..9c538b3e5 100644 --- a/luci-app-vantage/po/zh_Hans/vantage.po +++ b/luci-app-vantage/po/zh_Hans/vantage.po @@ -12,7 +12,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3574 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3614 msgid "%d MHz (%d–%d MHz)" msgstr "%d MHz(%d–%d MHz)" @@ -36,19 +36,19 @@ msgid "%d SSID · %d idle" msgid_plural "%d SSIDs · %d idle" msgstr[0] "%d 个 SSID · %d 个空闲" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1673 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2143 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2694 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1690 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2181 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2734 msgid "%d clients" msgstr "%d 个客户端" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3031 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3071 msgid "%d connected" msgstr "已连接 %d 个" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2170 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2174 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3030 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2208 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2212 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3070 msgid "%d devices" msgstr "%d 台设备" @@ -62,22 +62,22 @@ msgid "%d measured client is above %s" msgid_plural "%d measured clients are above %s" msgstr[0] "%d 个已测客户端高于 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:458 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:459 msgctxt "vantage" msgid "%d min" msgstr "%d 分钟" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:118 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:452 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:453 msgctxt "vantage" msgid "%d min ago" msgstr "%d 分钟前" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3029 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3069 msgid "%d of %d" msgstr "%d/%d" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1560 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1574 msgid "%d of %d columns" msgstr "%d/%d 列" @@ -104,17 +104,17 @@ msgid "%d radio up" msgid_plural "%d radios up" msgstr[0] "%d 个射频已上线" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2686 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2726 msgid "%d radios" msgstr "%d 个射频" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:458 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:459 msgctxt "vantage" msgid "%d s" msgstr "%d 秒" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:117 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:452 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:453 msgctxt "vantage" msgid "%d s ago" msgstr "%d 秒前" @@ -128,7 +128,7 @@ msgstr "%d 项需要关注" msgid "%d weak" msgstr "%d 个信号弱" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2705 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2745 msgid "%d–%d MHz of the %d–%d MHz band" msgstr "%d–%d MHz 属于 %d–%d MHz 频段" @@ -146,7 +146,7 @@ msgstr "%s(%s)已启用但未上线" msgid "%s (IPv%d)" msgstr "%s(IPv%d)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3396 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3436 msgid "%s (new this session)" msgstr "%s(本次新增)" @@ -157,10 +157,10 @@ msgstr "%s Gbit/s" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:166 #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:103 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2712 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2726 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3128 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3567 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2752 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2766 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3168 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3607 msgid "%s MHz" msgstr "%s MHz" @@ -179,13 +179,13 @@ msgid "%s at %s; below %s roaming and throughput suffer" msgstr "%s(%s);低于 %s 时漫游和吞吐量会受影响" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:83 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2387 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2426 msgctxt "vantage" msgid "%s available" msgstr "可用 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2732 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3575 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2772 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3615 msgid "%s dBm" msgstr "%s dBm" @@ -197,12 +197,12 @@ msgstr "%s 设备" msgid "%s device on a newer radio; uses more airtime per byte" msgstr "%s 设备连接在较新的射频上,每字节占用更多空口时间" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3601 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3641 msgid "%s error" msgid_plural "%s errors" msgstr[0] "%s 个错误" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3363 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3403 msgid "%s experience" msgstr "%s 体验" @@ -218,11 +218,11 @@ msgstr "overlay 空闲 %s(已用 %s)" msgid "%s has no carrier: cable unplugged or switch port down" msgstr "%s 无载波:网线未插或交换机端口关闭" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1526 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1540 msgid "%s hidden" msgstr "%s 已隐藏" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3645 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3685 msgid "%s in · %s out" msgstr "%s 入 · %s 出" @@ -238,7 +238,7 @@ msgstr "%s 被转发到 %s" msgid "%s is in no zone and accepts every connection" msgstr "%s 不属于任何区域,接受所有连接" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1519 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1533 msgid "%s is narrower" msgstr "%s 已变窄" @@ -246,18 +246,18 @@ msgstr "%s 已变窄" msgid "%s is not up" msgstr "%s 未上线" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3087 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3127 msgid "" "%s is shared by %d devices (a switch or another access point behind it); its " "%s link is not theirs" msgstr "" "%s 由 %d 台设备共用(其后接有交换机或另一个接入点);%s 的链路不属于这些设备" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3086 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3126 msgid "%s is shared by %d devices behind the switch" msgstr "%s 由交换机后的 %d 台设备共用" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1522 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1536 msgid "%s is wider" msgstr "%s 已加宽" @@ -273,23 +273,23 @@ msgstr "%s 链路" msgid "%s may open the router's admin" msgstr "%s 可能暴露了路由器管理界面" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1641 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1655 msgid "%s moved" msgstr "%s 已移动" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1516 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1530 msgid "%s moved down" msgstr "%s 已下移" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1507 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1521 msgid "%s moved left" msgstr "%s 已左移" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1510 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1524 msgid "%s moved right" msgstr "%s 已右移" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1513 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1527 msgid "%s moved up" msgstr "%s 已上移" @@ -302,9 +302,9 @@ msgid "%s negotiated only %d Mbit/s; a cable pair or port may be faulty" msgstr "%s 仅协商到 %d Mbit/s;网线线对或端口可能有故障" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:84 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2388 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2417 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3671 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2427 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2456 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3711 msgid "%s of %s" msgstr "%s / %s" @@ -325,20 +325,20 @@ msgstr "发往设备的帧失败率:%s" msgid "%s of frames to the device fail; interference likely" msgstr "发往设备的帧失败率:%s,可能存在干扰" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3669 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3709 msgid "%s over %d core" msgid_plural "%s over %d cores" msgstr[0] "%s / %d 核" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2481 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2521 msgid "%s radio" msgstr "%s 射频" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3401 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3441 msgid "%s sent · %s retried · %s failed" msgstr "发送 %s · 重传 %s · 失败 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1566 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1580 msgid "%s shown" msgstr "%s 已显示" @@ -354,11 +354,11 @@ msgstr "%s 已连接,速率 %s" msgid "%s used" msgstr "已用 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2425 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2464 msgid "%s used of %s" msgstr "已用 %s / 共 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3084 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3124 msgid "%s · %d devices" msgstr "%s · %d 台设备" @@ -366,7 +366,7 @@ msgstr "%s · %d 台设备" msgid "%s · %s" msgstr "%s · %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3393 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3433 msgid "%s · channel %d · %d MHz" msgstr "%s · 信道 %d · %d MHz" @@ -382,27 +382,27 @@ msgstr "%s · 链路较慢" msgid "%s: no carrier" msgstr "%s:无载波" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3575 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3615 msgid "(configured %d dBm, limited by regulatory rules)" msgstr "(配置 %d dBm,受管制规则限制)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2142 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3220 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2180 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3260 msgid "(hidden)" msgstr "(隐藏)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1673 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2143 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2694 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1690 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2181 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2734 msgid "1 client" msgstr "1 个客户端" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3031 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3071 msgid "1 connected" msgstr "已连接 1 个" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2174 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3030 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2212 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3070 msgid "1 device" msgstr "1 台设备" @@ -410,7 +410,7 @@ msgstr "1 台设备" msgid "1 older device (Wi-Fi 4 or earlier) slows airtime" msgstr "1 台旧设备(Wi-Fi 4 或更早)占用更多空口时间" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2686 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2726 msgid "1 radio" msgstr "1 个射频" @@ -438,20 +438,20 @@ msgstr "6 GHz" msgid "60 GHz" msgstr "60 GHz" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2894 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2934 msgid "Active" msgstr "活跃" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3405 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3437 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3445 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 msgid "Address type" msgstr "地址类型" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3634 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3674 msgid "Addresses" msgstr "地址" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2734 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2774 msgid "Airtime" msgstr "空口时间" @@ -460,8 +460,8 @@ msgctxt "vantage" msgid "Alias" msgstr "别名" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2885 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2891 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2925 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2931 msgid "All" msgstr "全部" @@ -490,7 +490,7 @@ msgstr "B" msgid "Busiest radio: %s of Wi-Fi traffic" msgstr "最繁忙射频:占 Wi-Fi 流量的 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2352 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2391 msgid "Busy share of each CPU core" msgstr "每个 CPU 核心的繁忙占比" @@ -505,8 +505,8 @@ msgstr[0] "繁忙;负载 %s(%d 核);查看最繁忙进程" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:71 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:72 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2345 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3669 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2384 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3709 msgctxt "vantage" msgid "CPU" msgstr "CPU" @@ -515,78 +515,78 @@ msgstr "CPU" msgid "Cache (reclaimable)" msgstr "缓存(可回收)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3510 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3550 msgid "Cancel" msgstr "取消" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3388 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3428 msgctxt "vantage" msgid "Capabilities" msgstr "能力" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3626 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3666 msgid "Carrier" msgstr "链路状态" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3574 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3614 msgid "Centre frequency" msgstr "中心频率" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2725 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2765 msgid "Ch %d" msgstr "信道 %d" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2123 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2693 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2161 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2733 msgid "Ch %d · %d MHz" msgstr "信道 %d · %d MHz" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3567 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3607 msgid "Channel" msgstr "信道" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2123 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2161 msgid "Channel —" msgstr "信道 —" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2758 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2798 msgctxt "vantage" msgid "Clear filter" msgstr "清除筛选" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1203 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2731 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2776 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2777 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1217 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2771 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2816 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2817 msgid "Clients" msgstr "客户端" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3581 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3621 msgid "Clients on this radio" msgstr "此射频上的客户端" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3237 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3277 msgid "Close" msgstr "关闭" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3143 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3183 msgid "Connected" msgstr "已连接" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3113 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3153 msgid "Connected after you opened this page" msgstr "打开本页面后连接" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3394 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3434 msgid "Connected for" msgstr "已连接时长" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3391 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3422 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3462 msgid "Connection" msgstr "连接" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2379 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2418 msgid "Core %d: %s" msgstr "核心 %d:%s" @@ -594,19 +594,19 @@ msgstr "核心 %d:%s" msgid "Could not read the firewall configuration" msgstr "无法读取防火墙配置" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3493 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3533 msgid "Could not save the name (no permission or the device refused it)." msgstr "无法保存名称(无权限或设备拒绝)。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1403 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1417 msgid "Could not save this for every browser. Try again." msgstr "无法为所有浏览器保存此设置,请重试。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3642 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3682 msgid "Counters since boot" msgstr "自启动以来的计数器" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3579 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3619 msgid "Country" msgstr "国家" @@ -615,13 +615,13 @@ msgid "" "Custom rule files are configured (%s); rules in them are not listed here." msgstr "已配置自定义规则文件(%s);其中的规则不会在此列出。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1662 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1676 msgid "Customize" msgstr "自定义" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1470 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1471 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1661 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1484 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1485 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1675 msgid "Customize the dashboard" msgstr "自定义仪表盘" @@ -629,16 +629,16 @@ msgstr "自定义仪表盘" msgid "DHCP" msgstr "DHCP" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3450 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3490 msgid "DHCP hostname" msgstr "DHCP 主机名" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:42 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3640 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3680 msgid "DNS" msgstr "DNS" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2054 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2092 msgid "DNS %s" msgstr "DNS %s" @@ -647,117 +647,117 @@ msgstr "DNS %s" msgid "Dashboard" msgstr "仪表盘" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2966 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3058 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3660 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3006 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3098 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3700 msgid "Device" msgstr "设备" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3517 msgid "Device name" msgstr "设备名" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3506 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3546 msgid "Device type" msgstr "设备类型" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3044 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3084 msgid "" "Devices on the Ethernet ports appear here once they send traffic; the bridge " "forgets quiet ones after about five minutes." msgstr "" "以太网端口上的设备发送流量后会显示在这里;网桥会在约五分钟无流量后忘记它们。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2124 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2695 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2720 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3572 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2162 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2735 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2760 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3612 msgid "Disabled" msgstr "已禁用" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3357 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3397 msgid "Disconnected %s; shown from the last sample." msgstr "断开于 %s;显示最后一次采样的数据。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1485 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1499 msgctxt "vantage" msgid "Done" msgstr "完成" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:45 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2124 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2695 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2720 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3572 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2162 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2735 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2760 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3612 msgctxt "vantage" msgid "Down" msgstr "已下线" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2458 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2553 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2628 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2498 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2593 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2668 msgid "Download" msgstr "下载" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:377 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:378 msgid "Download (to clients)" msgstr "下载(到客户端)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3399 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3439 msgctxt "vantage" msgid "Downloaded" msgstr "已下载" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1547 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1561 msgid "" "Drag a tile by its handle or use its buttons. Done saves it for medium " "screens in every browser." msgstr "" "拖动卡片手柄或使用其按钮移动。点击“完成”后,中屏布局将保存到所有浏览器。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1548 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1562 msgid "" "Drag a tile by its handle or use its buttons. Done saves it for narrow " "screens in every browser." msgstr "" "拖动卡片手柄或使用其按钮移动。点击“完成”后,窄屏布局将保存到所有浏览器。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1546 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1560 msgid "" "Drag a tile by its handle or use its buttons. Done saves it for wide screens " "in every browser." msgstr "" "拖动卡片手柄或使用其按钮移动。点击“完成”后,宽屏布局将保存到所有浏览器。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1547 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1561 msgid "" "Drag a tile by its handle or use its buttons. Saved in this browser for " "medium screens." msgstr "拖动卡片手柄或使用其按钮移动。中屏布局保存在此浏览器中。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1548 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1562 msgid "" "Drag a tile by its handle or use its buttons. Saved in this browser for " "narrow screens." msgstr "拖动卡片手柄或使用其按钮移动。窄屏布局保存在此浏览器中。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1546 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1560 msgid "" "Drag a tile by its handle or use its buttons. Saved in this browser for wide " "screens." msgstr "拖动卡片手柄或使用其按钮移动。宽屏布局保存在此浏览器中。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1498 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1512 msgid "Drag to move" msgstr "拖动以移动" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3645 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3685 msgctxt "vantage" msgid "Dropped" msgstr "已丢弃" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3199 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3239 msgid "Edit" msgstr "编辑" @@ -765,7 +765,7 @@ msgstr "编辑" msgid "Enhanced Open (OWE)" msgstr "增强开放 (OWE)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1725 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1763 msgid "Error" msgstr "错误" @@ -779,8 +779,8 @@ msgctxt "vantage" msgid "Excellent" msgstr "极好" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3137 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3177 msgid "Experience" msgstr "体验" @@ -798,20 +798,20 @@ msgstr "信号一般" msgid "Fair signal %s; lower rates at range" msgstr "信号一般 %s;远距离速率较低" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2755 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2956 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2795 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2996 msgid "Filter by name, address, SSID…" msgstr "按名称、地址、SSID…筛选" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2956 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2996 msgid "Filter by name, address, port…" msgstr "按名称、地址、端口…筛选" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2756 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2796 msgid "Filter clients" msgstr "筛选客户端" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3663 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3703 msgctxt "vantage" msgid "Firmware" msgstr "固件" @@ -824,7 +824,7 @@ msgstr "从 %s 转发到 %s" msgid "Forwarding is accepted by default" msgstr "默认接受转发" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3401 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3441 msgid "Frames to device" msgstr "发往设备的帧" @@ -833,15 +833,15 @@ msgctxt "vantage" msgid "Free" msgstr "空闲" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3386 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3426 msgid "From device" msgstr "来自设备" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3126 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3166 msgid "From device: %s" msgstr "来自设备:%s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3621 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3661 msgid "From network" msgstr "来自网络" @@ -851,9 +851,9 @@ msgid "" "files are not shown." msgstr "内容来自防火墙配置。由 UPnP 或自定义规则文件开放的端口不会显示。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2052 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3078 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3637 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2090 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3118 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3677 msgid "Gateway" msgstr "网关" @@ -866,7 +866,7 @@ msgstr "Gbit/s" msgid "General Settings" msgstr "基本设置" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3387 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3427 msgctxt "vantage" msgid "Generation" msgstr "代际" @@ -876,13 +876,13 @@ msgctxt "vantage" msgid "GiB" msgstr "GiB" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3405 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3437 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3445 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 msgid "Globally unique" msgstr "全局唯一" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3405 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3437 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3445 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 msgid "Globally unique · %s" msgstr "全局唯一 · %s" @@ -900,7 +900,7 @@ msgstr "信号良好" msgid "Good signal %s" msgstr "信号良好 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1445 msgid "Grid packets" msgstr "网格数据包" @@ -916,36 +916,36 @@ msgstr "HTTPS(端口 443)" msgid "Half duplex" msgstr "半双工" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1099 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1109 msgctxt "vantage" msgid "Health checks" msgstr "健康检查" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1563 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3220 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1577 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3260 msgctxt "vantage" msgid "Hidden" msgstr "隐藏" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1525 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1539 msgid "Hide %s" msgstr "隐藏 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3406 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3438 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3446 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3478 msgid "IP addresses" msgstr "IP 地址" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3635 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3675 msgid "IPv4" msgstr "IPv4" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3636 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3676 msgid "IPv6" msgstr "IPv6" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3403 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3435 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3443 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3475 msgid "Identity" msgstr "身份" @@ -953,7 +953,7 @@ msgstr "身份" msgid "In no zone: the default policies apply." msgstr "不属于任何区域:适用默认策略。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3632 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3672 msgid "In the tunnel" msgstr "隧道内" @@ -967,21 +967,21 @@ msgctxt "vantage" msgid "Input" msgstr "入站数据" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2102 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2140 msgid "Interface" msgstr "接口" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3629 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3669 msgid "Interface up for" msgstr "接口已启用时长" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3420 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3607 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3619 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3460 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3647 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3659 msgid "Interfaces" msgstr "接口" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2052 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2090 msgid "Internet" msgstr "互联网" @@ -1011,11 +1011,11 @@ msgstr "" "它将来自互联网上任何地址的 %s 转发到 %s。无法读取接口列表,因此本页无法判断该" "地址是否为路由器本身。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3664 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3704 msgid "Kernel" msgstr "内核" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2470 msgid "Kernel %s" msgstr "内核 %s" @@ -1024,58 +1024,58 @@ msgctxt "vantage" msgid "KiB" msgstr "KiB" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2770 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2810 msgid "Kind of client" msgstr "客户端类型" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3395 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3435 msgctxt "vantage" msgid "Last activity" msgstr "最后活动" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2967 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3102 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3433 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3007 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3142 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3473 msgid "Last heard" msgstr "最后活动" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1483 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1497 msgid "Layout reset" msgstr "布局已重置" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3112 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3152 msgid "Left %s" msgstr "离开于 %s" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:174 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3114 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3154 msgctxt "vantage" msgid "Legacy" msgstr "旧版" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2966 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3082 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3384 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3006 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3122 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3424 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3625 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3464 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3665 msgctxt "vantage" msgid "Link" msgstr "链路" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3125 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3165 msgid "Link rate" msgstr "链路速率" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1693 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2966 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3093 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3130 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1712 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3006 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3133 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3170 msgid "Live" msgstr "实时" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3668 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3708 msgid "Load" msgstr "负载" @@ -1089,24 +1089,24 @@ msgid "Load %s · %d core" msgid_plural "Load %s · %d cores" msgstr[0] "负载 %s · %d 核" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3670 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3710 msgctxt "vantage" msgid "Load average" msgstr "平均负载" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2333 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2372 msgid "Load average over 1, 5 and 15 minutes" msgstr "1、5、15 分钟平均负载" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2369 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2408 msgid "Load avg %s" msgstr "平均负载 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3666 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3706 msgid "Local time" msgstr "本地时间" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1362 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1376 msgid "Log in again to save this for every browser." msgstr "重新登录后即可为所有浏览器保存此设置。" @@ -1122,8 +1122,8 @@ msgstr "链路速率低于信号预期" msgid "MAC" msgstr "MAC" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3404 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3436 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3444 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3476 msgid "MAC address" msgstr "MAC 地址" @@ -1131,11 +1131,11 @@ msgstr "MAC 地址" msgid "MCS %d" msgstr "MCS %d" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1518 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1532 msgid "Make %s narrower" msgstr "使 %s 变窄" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1521 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1535 msgid "Make %s wider" msgstr "使 %s 加宽" @@ -1152,11 +1152,11 @@ msgid "Measuring…" msgstr "测量中…" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:83 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2356 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2395 msgid "Memory" msgstr "内存" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3671 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3711 msgid "Memory in use" msgstr "已用内存" @@ -1165,63 +1165,63 @@ msgctxt "vantage" msgid "MiB" msgstr "MiB" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1417 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1431 msgid "Mirrored" msgstr "镜像" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1418 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1432 msgid "Mirrored, scale per direction" msgstr "镜像,每个方向单独刻度" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3573 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3613 msgid "Mode" msgstr "模式" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3661 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3701 msgid "Model" msgstr "型号" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1515 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1529 msgid "Move %s down" msgstr "下移 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1506 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1520 msgid "Move %s left" msgstr "左移 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1509 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1523 msgid "Move %s right" msgstr "右移 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1512 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1526 msgid "Move %s up" msgstr "上移 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1498 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1512 msgid "Move %s: drag, or use the arrow keys" msgstr "移动 %s:拖动或使用方向键" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3505 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3545 msgid "Name" msgstr "名称" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:134 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3545 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3585 msgid "Name contains invisible or control characters" msgstr "名称包含不可见或控制字符" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3407 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3439 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3447 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3479 msgid "Name from" msgstr "名称来源" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3063 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3103 msgid "Name from: %s" msgstr "名称来源:%s" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:132 #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:137 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3546 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3586 msgid "Name is longer than %d characters" msgstr "名称超过 %d 个字符" @@ -1229,27 +1229,27 @@ msgstr "名称超过 %d 个字符" msgid "Name must be text" msgstr "名称必须是文本" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3348 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3417 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3388 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3457 msgid "Name this device" msgstr "命名此设备" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2966 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3092 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3122 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3392 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3006 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3132 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3162 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3432 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3472 msgid "Network" msgstr "网络" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1203 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1839 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1840 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1217 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1877 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1878 msgid "Network path" msgstr "网络路径" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1436 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1450 msgid "Network path background" msgstr "网络路径背景" @@ -1257,13 +1257,13 @@ msgstr "网络路径背景" msgid "Networks: %s" msgstr "网络:%s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2896 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3113 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2936 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3153 msgctxt "vantage" msgid "New" msgstr "新增" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1999 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2037 msgid "No SSIDs" msgstr "无 SSID" @@ -1271,24 +1271,24 @@ msgstr "无 SSID" msgid "No clients" msgstr "无客户端" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3046 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3586 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3086 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3626 msgid "No clients connected" msgstr "无客户端连接" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3174 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3214 msgid "No clients connected." msgstr "没有客户端连接。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3037 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3077 msgid "No clients in this view" msgstr "此视图中没有客户端" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3037 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3077 msgid "No clients match “%s”" msgstr "没有匹配“%s”的客户端" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2053 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2091 msgctxt "vantage" msgid "No default route" msgstr "无默认路由" @@ -1297,7 +1297,7 @@ msgstr "无默认路由" msgid "No interface with a route" msgstr "没有带路由的接口" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3611 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3651 msgid "No interface with a route is up." msgstr "没有带路由的接口处于启用状态。" @@ -1306,7 +1306,7 @@ msgid "No internet-facing zone found" msgstr "未找到面向互联网的区域" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:47 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3626 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3666 msgid "No link" msgstr "无链路" @@ -1314,7 +1314,7 @@ msgstr "无链路" msgid "No network interface with a route is up" msgstr "没有带路由的网络接口处于启用状态" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1980 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2018 msgid "No other interfaces are up" msgstr "没有其他接口处于启用状态" @@ -1324,7 +1324,7 @@ msgid "No problems detected" msgstr "未检测到问题" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:105 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2003 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2041 msgid "No radios reported" msgstr "未报告射频" @@ -1341,16 +1341,16 @@ msgstr "无信号" msgid "No stations are associated right now" msgstr "当前没有站点关联" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3043 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3083 msgid "No wired devices heard" msgstr "未发现有线设备" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3208 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3248 msgctxt "vantage" msgid "No wireless networks configured." msgstr "未配置无线网络。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2700 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2740 msgid "No wireless radios are reported by this device." msgstr "此设备未报告任何无线射频。" @@ -1367,39 +1367,39 @@ msgstr "" msgid "No zones are configured." msgstr "尚未配置区域。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3174 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3214 msgid "Nobody is moving data right now." msgstr "当前没有设备在传输数据。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2733 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2773 msgid "Noise" msgstr "噪声" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3389 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3429 msgid "Noise / SNR" msgstr "噪声 / 信噪比" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3577 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3578 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3617 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3618 msgid "Noise floor" msgstr "底噪" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:105 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1445 msgid "None" msgstr "无" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:150 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3544 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3584 msgid "Not a station MAC address" msgstr "不是站点 MAC 地址" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3339 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3379 msgid "Not connected" msgstr "未连接" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3094 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3429 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3134 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3469 msgid "Not counted per device on a shared port" msgstr "共用端口不按设备统计" @@ -1407,7 +1407,7 @@ msgstr "共用端口不按设备统计" msgid "Not in effect" msgstr "未生效" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2406 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2445 msgid "Not reported" msgstr "未报告" @@ -1415,7 +1415,7 @@ msgstr "未报告" msgid "Not reported by the driver" msgstr "驱动未报告" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3453 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3493 msgid "Nothing better known" msgstr "无更多信息" @@ -1436,7 +1436,7 @@ msgstr "OWE" msgid "Older %s device" msgstr "较旧的 %s 设备" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3114 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3154 msgid "Older Wi-Fi generation than the radio; uses more airtime" msgstr "比射频支持的 Wi-Fi 代际更旧;占用更多空口时间" @@ -1444,7 +1444,7 @@ msgstr "比射频支持的 Wi-Fi 代际更旧;占用更多空口时间" msgid "On this router" msgstr "在此路由器上" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 msgctxt "vantage" msgid "Online" msgstr "在线" @@ -1460,7 +1460,7 @@ msgctxt "vantage" msgid "Open" msgstr "开放" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3012 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3052 msgid "Open details of %s" msgstr "打开 %s 的详情" @@ -1488,11 +1488,11 @@ msgstr "" "基本设置中的选项 %s 的值被 firewall4 拒绝,因此不会加载新规则:无法确定开放了" "哪些内容。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1415 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1429 msgid "Overlaid" msgstr "叠加" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1416 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1430 msgid "Overlaid, log scale" msgstr "叠加,对数刻度" @@ -1500,11 +1500,15 @@ msgstr "叠加,对数刻度" msgid "Overview" msgstr "概览" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3388 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3428 msgid "PMF" msgstr "PMF" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1691 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1710 +msgid "Paused" +msgstr "已暂停" + +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1710 msgid "Paused while hidden" msgstr "页面隐藏时暂停" @@ -1520,7 +1524,7 @@ msgstr "" "Ping 和其他控制消息、组播、DHCP 续租以及转发到你网络的 IPsec:每台路由器都会" "放行的流量。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3662 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3702 msgctxt "vantage" msgid "Platform" msgstr "平台" @@ -1530,7 +1534,7 @@ msgctxt "vantage" msgid "Poor" msgstr "较差" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3423 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3463 msgid "Port" msgstr "端口" @@ -1538,11 +1542,11 @@ msgstr "端口" msgid "Port %d" msgstr "端口 %d" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3087 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3127 msgid "Port %s" msgstr "端口 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2167 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2205 msgid "Port total: %s" msgstr "端口合计:%s" @@ -1550,11 +1554,11 @@ msgstr "端口合计:%s" msgid "Private" msgstr "私有" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3405 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3445 msgid "Private Wi-Fi address (randomised by the device)" msgstr "私有 Wi-Fi 地址(设备随机化)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3437 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 msgid "Private address (randomised by the device)" msgstr "私有地址(设备随机化)" @@ -1562,26 +1566,26 @@ msgstr "私有地址(设备随机化)" msgid "Private device" msgstr "私有设备" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3659 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3699 msgid "Processes" msgstr "进程" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3628 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3668 msgid "Protocol" msgstr "协议" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2765 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2767 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2805 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2807 msgid "Quick filters" msgstr "快速筛选" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3393 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3571 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3433 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3611 msgctxt "vantage" msgid "Radio" msgstr "射频" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2721 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2761 msgid "Radio details" msgstr "射频详情" @@ -1593,58 +1597,58 @@ msgstr "射频已禁用" msgid "Radio is enabled but not up" msgstr "射频已启用但未上线" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2745 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3565 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2785 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3605 msgid "Radio settings" msgstr "射频设置" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1203 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2673 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2675 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1217 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2713 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2715 msgid "Radios" msgstr "射频" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2054 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2092 msgid "Reachability not probed" msgstr "未探测可达性" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3079 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3119 msgid "Reached through the uplink port %s, not connected to this device" msgstr "经由上行端口 %s 到达,并未连接到本设备" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3619 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3659 msgid "Realtime graphs" msgstr "实时图表" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3643 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3683 msgctxt "vantage" msgid "Received" msgstr "已接收" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1692 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1711 msgctxt "vantage" msgid "Reconnecting…" msgstr "重新连接中…" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2912 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2952 msgid "Remove filter: %s" msgstr "移除筛选:%s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3511 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3551 msgid "Remove name" msgstr "移除名称" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3348 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3417 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3388 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3457 msgctxt "vantage" msgid "Rename" msgstr "重命名" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1484 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1498 msgid "Reset" msgstr "重置" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3448 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3488 msgid "Reverse DNS of its address" msgstr "其地址的反向 DNS" @@ -1652,7 +1656,7 @@ msgstr "其地址的反向 DNS" msgid "Root (overlay)" msgstr "Root(overlay)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3639 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3679 msgid "Route metric" msgstr "路由跃点数" @@ -1664,20 +1668,20 @@ msgstr "路由器管理界面已开放:%s" msgid "SSH (port 22)" msgstr "SSH(端口 22)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3503 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3543 msgid "Save" msgstr "保存" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3396 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3436 msgid "Seen since" msgstr "首次出现" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3644 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3684 msgctxt "vantage" msgid "Sent" msgstr "已发送" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1419 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1433 msgid "Separate lanes" msgstr "分开显示" @@ -1685,57 +1689,57 @@ msgstr "分开显示" msgid "Settings to check" msgstr "待检查的设置" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3424 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3464 msgid "Shared by %d devices" msgstr "%d 台设备共用" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3083 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3123 msgid "Shared port" msgstr "共用端口" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1565 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1579 msgid "Show %s" msgstr "显示 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3038 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3078 msgid "Show all clients" msgstr "显示全部客户端" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2342 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2381 msgid "Show or hide system details" msgstr "显示/隐藏系统详情" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1840 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1878 msgid "Show or hide the network path" msgstr "显示/隐藏网络路径" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2676 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2716 msgid "Show or hide the radio details" msgstr "显示/隐藏射频详情" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2461 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2501 msgid "Show or hide the throughput chart" msgstr "显示/隐藏吞吐量图表" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3199 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3239 msgid "Show or hide the wireless networks" msgstr "显示/隐藏无线网络" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2141 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3217 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2179 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3257 msgid "Show the clients of %s" msgstr "显示 %s 的客户端" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2167 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2205 msgid "Show the devices on %s" msgstr "显示 %s 上的设备" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:119 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:120 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:128 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2968 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3119 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3372 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3008 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3159 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3412 msgid "Signal" msgstr "信号强度" @@ -1743,7 +1747,7 @@ msgstr "信号强度" msgid "Signal not reported" msgstr "未报告信号" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3627 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3667 msgctxt "vantage" msgid "Speed" msgstr "速率" @@ -1753,24 +1757,24 @@ msgid "Standard housekeeping: %d rule" msgid_plural "Standard housekeeping: %d rules" msgstr[0] "标准常规规则:%d 条" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1445 msgid "Starfield" msgstr "星空" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3047 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3087 msgid "Stations appear here as soon as they associate with one of the SSIDs." msgstr "站点一旦关联到任一 SSID 便会显示在这里。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3572 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3612 msgid "Status" msgstr "状态" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:93 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2359 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2398 msgid "Storage" msgstr "存储" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3512 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3552 msgid "Stored on this device in /etc/config/vantage" msgstr "保存在本设备 /etc/config/vantage 中" @@ -1786,16 +1790,16 @@ msgstr "信号强 %s,链路 %s" msgid "Strong signal, %s" msgstr "信号强,%s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2416 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2455 msgctxt "vantage" msgid "Swap" msgstr "交换分区" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3427 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3467 msgid "Switch ports" msgstr "交换机端口" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:390 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:391 msgid "Switch ports of VLAN %d on %s: %s" msgstr "VLAN %d(%s)的交换机端口:%s" @@ -1803,13 +1807,13 @@ msgstr "VLAN %d(%s)的交换机端口:%s" msgid "Switched off" msgstr "已关闭" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1204 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2341 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2342 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1218 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2380 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2381 msgid "System" msgstr "系统" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3659 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3699 msgid "System settings" msgstr "系统设置" @@ -1822,7 +1826,7 @@ msgid "Telnet (port 23)" msgstr "Telnet(端口 23)" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:76 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2373 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2412 msgid "Temperature %s" msgstr "温度 %s" @@ -1830,11 +1834,11 @@ msgstr "温度 %s" msgid "Temporary (RAM)" msgstr "临时(RAM)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2967 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3007 msgid "The bridge forgets a device after about five minutes without traffic" msgstr "设备约五分钟无流量后,网桥会将其遗忘" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3041 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3081 msgid "" "The bridges have learned so many addresses that listing them here would " "strain this device." @@ -1849,7 +1853,7 @@ msgstr "" "该处适用默认入站数据策略且为接受,因此互联网上的任何主机都能访问路由器本身。" "请将其加入 wan 区域,或在基本设置中将入站数据设为拒绝。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3549 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3589 msgid "The device could not write /etc/config/vantage." msgstr "设备无法写入 /etc/config/vantage。" @@ -1875,7 +1879,7 @@ msgstr "" "无法读取接口列表,且默认入站数据策略为接受:不属于任何区域的上行接口将会开" "放。请在基本设置中将入站数据设为拒绝。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3451 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3491 msgid "The name the device announces (WPS)" msgstr "设备广播的名称(WPS)" @@ -1887,7 +1891,7 @@ msgstr "唯一已测客户端高于 %s" msgid "The only measured client" msgstr "唯一已测客户端" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3078 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3118 msgid "The router this device sends its traffic to" msgstr "此设备发送流量所经的路由器" @@ -1903,7 +1907,7 @@ msgstr "保存的名称包含无效的配置节 ID" msgid "The uplinks could not be checked" msgstr "无法检查上行接口" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2069 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2107 msgid "This access point" msgstr "此接入点" @@ -1911,11 +1915,11 @@ msgstr "此接入点" msgid "This address family is in no zone: the default policies apply." msgstr "此地址族不属于任何区域:适用默认策略。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2069 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2107 msgid "This device" msgstr "此设备" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3548 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3588 msgid "This device already stores %d names; remove some first." msgstr "此设备已存储 %d 个名称;请先移除部分。" @@ -1923,7 +1927,7 @@ msgstr "此设备已存储 %d 个名称;请先移除部分。" msgid "This device reports no radios" msgstr "此设备未报告任何射频" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3608 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3648 msgid "This line is down or has no default route now." msgstr "此线路已断开,或当前没有默认路由。" @@ -1933,7 +1937,7 @@ msgid "" "the firewall pages." msgstr "本页无法判断开放了哪些内容。请重新加载;如果仍无效,请检查防火墙页面。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3556 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3596 msgid "This radio is not reported any more." msgstr "驱动不再报告此射频。" @@ -1941,23 +1945,23 @@ msgstr "驱动不再报告此射频。" msgid "This setup is beyond what this overview checks" msgstr "此配置超出本概览的检查范围" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3339 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3379 msgid "This station is not associated right now." msgstr "此站点当前未关联。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1203 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2453 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2455 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3378 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1217 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2493 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2495 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3418 msgctxt "vantage" msgid "Throughput" msgstr "吞吐量" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1424 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1438 msgid "Throughput chart" msgstr "吞吐量图表" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2446 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2486 msgid "Throughput per link; arrow keys read the values over time" msgstr "每条链路的吞吐量;方向键可查看历史值" @@ -1966,38 +1970,38 @@ msgctxt "vantage" msgid "TiB" msgstr "TiB" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3385 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3425 msgid "To device" msgstr "到设备" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3099 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3134 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3187 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3139 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3174 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3227 msgid "To device %s, from device %s" msgstr "到设备 %s,来自设备 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3126 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3166 msgid "To device: %s" msgstr "到设备:%s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3622 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3662 msgid "To network" msgstr "发往网络" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3040 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3080 msgid "Too many devices to list" msgstr "设备过多,无法列出" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1204 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3151 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3152 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1218 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3191 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3192 msgctxt "vantage" msgid "Top talkers" msgstr "流量大户" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3398 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3428 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3568 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3438 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3468 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3608 msgid "Traffic" msgstr "流量" @@ -2015,16 +2019,16 @@ msgstr "" "未被任何转发覆盖的互联网流量仍会到达你的其他网络。请在基本设置中将转发设为拒" "绝。" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2065 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2103 msgid "Traffic to the internet goes through %s, a tunnel over this line" msgstr "去往互联网的流量经过 %s,一条在此线路上运行的隧道" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3631 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3671 msgid "Tunnel" msgstr "隧道" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2732 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3575 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2772 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3615 msgid "Tx power" msgstr "发射功率" @@ -2036,16 +2040,16 @@ msgstr "" "已安装 UPnP:它运行时,你网络中的设备可以自行开放端口,这些端口不会在此列出。" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:272 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3338 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3378 msgctxt "vantage" msgid "Unknown device" msgstr "未知设备" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3547 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3587 msgid "Unknown device type" msgstr "未知设备类型" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3556 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3596 msgid "Unknown radio" msgstr "未知射频" @@ -2060,14 +2064,14 @@ msgid "Unnamed rule" msgstr "未命名的规则" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:57 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2720 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3572 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3626 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2760 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3612 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3666 msgctxt "vantage" msgid "Up" msgstr "已上线" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2366 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2405 msgid "Up %s" msgstr "已运行 %s" @@ -2076,47 +2080,47 @@ msgstr "已运行 %s" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:49 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:51 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:57 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2479 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3611 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2519 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3651 msgctxt "vantage" msgid "Uplink" msgstr "上行链路" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2479 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2480 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2519 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2520 msgid "Uplink %s" msgstr "上行链路 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3604 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3615 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3644 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3655 msgid "Uplink · %s" msgstr "上行链路 · %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2459 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2555 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2629 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2499 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2595 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2669 msgid "Upload" msgstr "上传" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:378 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:379 msgid "Upload (from clients)" msgstr "上传(来自客户端)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3400 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3440 msgctxt "vantage" msgid "Uploaded" msgstr "已上传" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3079 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3119 msgid "Upstream" msgstr "上游" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3665 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3705 msgid "Uptime" msgstr "运行时间" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3080 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3432 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3120 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3472 msgid "VLAN %d" msgstr "VLAN %d" @@ -2261,11 +2265,11 @@ msgstr "WPA3 个人版" msgid "WPS" msgstr "WPS" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3408 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3448 msgid "WPS device name" msgstr "WPS 设备名称" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1725 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1763 msgid "Warning" msgstr "警告" @@ -2279,7 +2283,7 @@ msgid "Weak" msgstr "弱" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:157 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2895 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2935 msgid "Weak signal" msgstr "信号弱" @@ -2302,27 +2306,27 @@ msgstr "开放内容取决于 firewall4 如何处理 %s,本页无法跟踪。 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:106 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:108 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:110 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2949 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2989 msgid "Wi-Fi" msgstr "Wi-Fi" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2011 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2949 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2049 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2989 msgid "Wired" msgstr "有线" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1204 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3198 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3199 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1218 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3238 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3239 msgctxt "vantage" msgid "Wireless networks" msgstr "无线网络" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3358 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3398 msgid "Wireless settings" msgstr "无线设置" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3447 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3487 msgid "Your name for this device" msgstr "你为此设备设置的名称" @@ -2360,15 +2364,15 @@ msgstr "任意区域" msgid "any zone (default policy)" msgstr "任意区域(默认策略)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2726 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2766 msgid "auto" msgstr "自动" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3396 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3436 msgid "before this page opened" msgstr "打开本页之前" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3425 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3465 msgid "behind %s, VLAN %d" msgstr "位于 %s 之后,VLAN %d" @@ -2377,16 +2381,16 @@ msgctxt "vantage" msgid "bit/s" msgstr "bit/s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2587 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2627 msgid "collecting · %s of data" msgstr "采集中 · 已采集 %s 数据" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2587 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2627 msgid "collecting · first samples" msgstr "采集中 · 首个样本" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:251 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2567 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:252 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2607 msgid "collecting…" msgstr "采集中…" @@ -2398,18 +2402,18 @@ msgstr "自定义规则文件" msgid "dBm" msgstr "dBm" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1929 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2067 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1967 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2105 msgid "default route" msgstr "默认路由" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2067 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2105 msgid "default route via %s" msgstr "默认路由经由 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:385 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3083 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3424 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:386 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3123 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3464 msgid "down" msgstr "已断开" @@ -2418,7 +2422,7 @@ msgctxt "vantage" msgid "drop" msgstr "丢弃" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3477 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3517 msgctxt "vantage" msgid "e.g. Living room TV" msgstr "例如:客厅电视" @@ -2457,8 +2461,8 @@ msgid "half duplex" msgstr "半双工" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:62 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2087 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3225 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2125 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3265 msgid "idle" msgstr "空闲" @@ -2500,18 +2504,18 @@ msgctxt "vantage" msgid "kbit/s" msgstr "kbit/s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2351 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2586 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2390 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2626 msgid "last 5 min" msgstr "最近 5 分钟" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:175 #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:104 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3128 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3168 msgid "legacy" msgstr "旧版" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:385 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:386 msgid "link up" msgstr "已连接" @@ -2519,45 +2523,45 @@ msgstr "已连接" msgid "mDNS" msgstr "mDNS" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3449 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3489 msgid "mDNS announcement" msgstr "mDNS 广播" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3380 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3431 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3420 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3471 msgid "measuring" msgstr "测量中" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2562 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2602 msgid "measuring…" msgstr "测量中…" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2543 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2583 msgctxt "vantage" msgid "no data" msgstr "无数据" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2060 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2098 msgid "no uplink" msgstr "无上行链路" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3406 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3446 msgid "not known to this AP" msgstr "此 AP 未识别" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3438 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3478 msgid "not known to this device" msgstr "此设备未识别" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3578 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3618 msgid "not reported (driver placeholder %s)" msgstr "未报告(驱动占位值 %s)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3389 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3429 msgid "not reported by the driver" msgstr "驱动未报告" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2583 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2623 msgctxt "vantage" msgid "now" msgstr "现在" @@ -2566,16 +2570,16 @@ msgstr "现在" msgid "now %s down, %s up" msgstr "当前下行 %s,上行 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3387 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3427 msgid "older than the radio (%s)" msgstr "比射频支持的标准更旧(%s)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2560 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2600 msgctxt "vantage" msgid "peak" msgstr "峰值" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2352 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2391 msgid "per core" msgstr "每核" @@ -2584,11 +2588,11 @@ msgctxt "vantage" msgid "port forward" msgstr "端口转发" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3374 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3414 msgid "range %s … %s" msgstr "范围 %s … %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3621 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3661 msgid "received on %s" msgstr "在 %s 上接收" @@ -2601,7 +2605,7 @@ msgstr "拒绝" msgid "router port %s" msgstr "路由器端口 %s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3622 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3662 msgid "sent on %s" msgstr "在 %s 上发送" @@ -2609,12 +2613,12 @@ msgstr "在 %s 上发送" msgid "short GI" msgstr "短 GI" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3102 -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3433 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3142 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3473 msgid "static entry" msgstr "静态条目" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3426 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3466 msgid "switch port %s" msgstr "交换机端口 %s" @@ -2626,11 +2630,11 @@ msgstr "已关闭" msgid "the covered subnets of zone %s" msgstr "区域 %s 覆盖的子网" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3430 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3470 msgid "the port in total: %s" msgstr "该端口合计:%s" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3425 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:3465 msgid "the port: %s" msgstr "端口:%s" @@ -2646,7 +2650,7 @@ msgstr "此路由器" msgid "traffic rule" msgstr "通信规则" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2072 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2110 msgid "up %s" msgstr "已运行 %s" @@ -2663,7 +2667,7 @@ msgstr "区域转发" msgid "zones %s and %s if they share a device" msgstr "区域 %s 和 %s(若两者共用设备)" -#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2580 +#: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2620 msgid "−%d min" msgstr "−%d 分钟" diff --git a/luci-base/po/templates/base.pot b/luci-base/po/templates/base.pot index c1349f7ba..39b3a8554 100644 --- a/luci-base/po/templates/base.pot +++ b/luci-base/po/templates/base.pot @@ -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 "" diff --git a/luci-base/po/zh_Hans/base.po b/luci-base/po/zh_Hans/base.po index c4978bf20..bc56dc6e2 100644 --- a/luci-base/po/zh_Hans/base.po +++ b/luci-base/po/zh_Hans/base.po @@ -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 "导出分区" diff --git a/luci-mod-fluentdashboard/Makefile b/luci-mod-fluentdashboard/Makefile index d0efff9e2..85b20fa50 100644 --- a/luci-mod-fluentdashboard/Makefile +++ b/luci-mod-fluentdashboard/Makefile @@ -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 diff --git a/luci-mod-fluentdashboard/htdocs/luci-static/resources/view/fluentdashboard/index.css b/luci-mod-fluentdashboard/htdocs/luci-static/resources/view/fluentdashboard/index.css index 9195e80ee..a6c2c414f 100644 --- a/luci-mod-fluentdashboard/htdocs/luci-static/resources/view/fluentdashboard/index.css +++ b/luci-mod-fluentdashboard/htdocs/luci-static/resources/view/fluentdashboard/index.css @@ -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,"); + 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%; +} diff --git a/luci-mod-fluentdashboard/htdocs/luci-static/resources/view/fluentdashboard/index.js b/luci-mod-fluentdashboard/htdocs/luci-static/resources/view/fluentdashboard/index.js index a4b5b99e3..11e6a1920 100644 --- a/luci-mod-fluentdashboard/htdocs/luci-static/resources/view/fluentdashboard/index.js +++ b/luci-mod-fluentdashboard/htdocs/luci-static/resources/view/fluentdashboard/index.js @@ -94,6 +94,8026 @@ function jsxDEV(t, n) { ;// CONCATENATED MODULE: ../node_modules/.pnpm/@lazulikao+luci-types@https_685a81b9ead15f36368ce6e6ebcf4a27/node_modules/@lazulikao/luci-types/src/jsx/jsx-runtime.ts +;// CONCATENATED MODULE: ./web/fluentdashboard/data/scheduler.ts +const dispatchTogether = (t)=>Promise.allSettled(t.map((t)=>{ + try { + return t.load(); + } catch (t) { + return Promise.reject(t); + } + })); +class Scheduler { + dispatch; + windowMs; + now; + entries = new Map(); + queue = new Set(); + timer; + tickTimer; + paused = !1; + disposed = !1; + constructor(t = dispatchTogether, e = 50, s = Date.now){ + this.dispatch = t, this.windowMs = e, this.now = s; + } + register(t) { + if (this.entries.has(t.key)) throw Error(`Duplicate data source: ${t.key}`); + this.entries.set(t.key, { + source: t, + state: { + status: "idle", + stale: !1 + }, + listeners: new Set(), + due: 0, + failures: 0 + }); + } + state(t) { + return { + ...this.entry(t).state + }; + } + subscribe(t, e) { + if (this.disposed) throw Error("Scheduler destroyed"); + let s = this.entry(t); + return s.listeners.add(e), e({ + ...s.state + }), !this.paused && this.now() >= s.due && this.request(t), this.tickTimer || (this.tickTimer = setInterval(()=>this.tick(), 250)), ()=>{ + s.listeners.delete(e), [ + ...this.entries.values() + ].some((t)=>t.listeners.size) || (clearInterval(this.tickTimer), this.tickTimer = void 0); + }; + } + request(t) { + if (this.disposed) return Promise.reject(Error("Scheduler destroyed")); + let e = this.entry(t); + return e.flight || (e.flight = new Promise((t)=>{ + e.complete = t; + }), this.queue.add(e), this.timer || (this.timer = setTimeout(()=>{ + this.timer = void 0, this.flush(); + }, this.windowMs))), e.flight; + } + setPaused(t) { + this.paused = t, t || this.tick(); + } + tick() { + if (!this.paused && !this.disposed) for (let t of this.entries.values())t.listeners.size && !t.flight && this.now() >= t.due && this.request(t.source.key); + } + async flush() { + let t; + clearTimeout(this.timer), this.timer = void 0; + let e = [ + ...this.queue + ]; + if (this.queue.clear(), e.length && !this.disposed) { + for (let t of e)t.state = { + ...t.state, + status: "loading", + attemptedAt: this.now() + }, this.notify(t); + try { + t = await this.dispatch(e.map((t)=>t.source)); + } catch (s) { + t = e.map(()=>({ + status: "rejected", + reason: s + })); + } + this.disposed || e.forEach((e, s)=>{ + let i = t[s]; + i?.status === "fulfilled" ? (e.state = { + status: "ready", + data: i.value, + stale: !1, + attemptedAt: e.state.attemptedAt, + updatedAt: this.now() + }, e.failures = 0) : (e.failures++, e.state = { + ...e.state, + status: "error", + stale: void 0 !== e.state.updatedAt, + error: i?.status === "rejected" ? i.reason : Error("Missing batch result") + }), e.due = this.now() + (e.failures ? Math.min(300000, Math.max(1000, e.source.interval) * 2 ** Math.min(8, e.failures)) : e.source.interval); + let r = e.complete; + e.complete = void 0, e.flight = void 0, r?.({ + ...e.state + }), this.notify(e); + }); + } + } + destroy() { + for (let t of (this.disposed = !0, clearTimeout(this.timer), clearInterval(this.tickTimer), this.queue.clear(), this.entries.values()))t.listeners.clear(), t.complete?.({ + ...t.state + }), t.complete = void 0, t.flight = void 0; + } + entry(t) { + let e = this.entries.get(t); + if (!e) throw Error(`Unknown source: ${t}`); + return e; + } + notify(t) { + for (let e of [ + ...t.listeners + ])try { + e({ + ...t.state + }); + } catch (t) { + console.error("Dashboard subscriber failed", t); + } + } +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/data/service.ts + +class DataService { + scheduler; + pauses = new Set(); + detachVisibility; + constructor(s = new Scheduler()){ + this.scheduler = s; + } + register(e) { + this.scheduler.register(e); + } + subscribe(e, s) { + return this.scheduler.subscribe(e, s); + } + select(e, s, i) { + return this.subscribe(e, (e)=>i({ + ...e, + data: void 0 === e.data ? void 0 : s(e.data) + })); + } + refresh(e) { + return Promise.all([ + ...new Set(e) + ].map((e)=>this.scheduler.request(e))); + } + pause(e, s) { + s ? this.pauses.add(e) : this.pauses.delete(e), this.scheduler.setPaused(this.pauses.size > 0); + } + observeVisibility(e) { + this.detachVisibility?.(); + let s = ()=>this.pause("visibility", e.hidden); + e.addEventListener("visibilitychange", s), s(), this.detachVisibility = ()=>e.removeEventListener("visibilitychange", s); + } + destroy() { + this.detachVisibility?.(), this.scheduler.destroy(); + } +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/core/types.ts +const SCHEMA_VERSION = 1; +const BASE_COLUMNS = 12; +const MAX_GROUP_DEPTH = 2; +const MAX_NODES_PER_DASHBOARD = 120; +const MAX_DASHBOARDS = 16; +const MAX_TILE_ROWS = 48; +const GROUP_HEADER_ROWS = 1; +const GROUP_MIN_BODY_ROWS = 2; +function isGroup(t) { + return t?.kind === "group"; +} +function isWidget(t) { + return t?.kind === "widget"; +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/core/tree.ts + +function indexNodes(e) { + let t = new Map(), r = new Map(); + for (let r of e)t.set(r.id, r); + for (let n of e){ + let e = null !== n.parentId && t.has(n.parentId) ? n.parentId : null, o = r.get(e); + o ? o.push(n) : r.set(e, [ + n + ]); + } + return { + byId: t, + children: r + }; +} +function childrenOf(e, t) { + return e.children.get(t) ?? []; +} +function ancestorsOf(e, r) { + let n = [], o = new Set([ + r + ]), d = e.byId.get(r); + for(; d?.parentId && !o.has(d.parentId);){ + o.add(d.parentId); + let r = e.byId.get(d.parentId); + if (!isGroup(r)) break; + n.push(r), d = r; + } + return n; +} +function descendantsOf(e, t) { + let r = [], n = [ + ...childrenOf(e, t) + ], o = new Set([ + t + ]); + for(; n.length;){ + let t = n.pop(); + o.has(t.id) || (o.add(t.id), r.push(t), n.push(...childrenOf(e, t.id))); + } + return r; +} +function depthOf(e, t) { + return ancestorsOf(e, t).length; +} +function groupLevels(e, r, n = new Set()) { + if (n.has(r)) throw Error("Cyclic group hierarchy"); + if (!isGroup(e.byId.get(r))) return 0; + n.add(r); + let o = 0; + for (let t of childrenOf(e, r))o = Math.max(o, groupLevels(e, t.id, n)); + return n.delete(r), 1 + o; +} +function isSelfOrAncestor(e, t, r) { + return t === r || ancestorsOf(e, r).some((e)=>e.id === t); +} +function innerColumns(t, r) { + if (null === r) return BASE_COLUMNS; + let n = t.byId.get(r); + return n ? n.tile.w : BASE_COLUMNS; +} +function findDashboard(e, t) { + return e.find((e)=>e.id === t); +} +function byReadingOrder(e, t) { + return e.tile.y - t.tile.y || e.tile.x - t.tile.x || e.id.localeCompare(t.id); +} +function byFlowOrder(e, t) { + return e.tile.order - t.tile.order || byReadingOrder(e, t); +} +function dashboardNodeCount(e) { + return e.nodes.length; +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/layout/engine.ts + + +function engine_collides(t, e) { + return t.x < e.x + e.w && e.x < t.x + t.w && t.y < e.y + e.h && e.y < t.y + t.h; +} +function engine_d(t, e) { + for (let o of e)if (engine_collides(t, o)) return !0; + return !1; +} +function layoutRows(t) { + let e = 0; + for (let o of t)e = Math.max(e, o.y + o.h); + return e; +} +function compactVertical(t, e) { + let o = [ + ...t + ].sort((t, e)=>t.y - e.y || t.x - e.x || t.id.localeCompare(e.id)), i = []; + for (let t of o){ + let o = Math.min(t.w, e), r = Math.min(Math.max(0, t.x), e - o), n = Math.max(0, t.y); + for(; n > 0 && !engine_d({ + x: r, + y: n - 1, + w: o, + h: t.h + }, i);)n--; + for(; engine_d({ + x: r, + y: n, + w: o, + h: t.h + }, i);)n++; + i.push({ + ...t, + x: r, + y: n, + w: o + }); + } + return i; +} +function findFreeSpot(t, e, o, i, r = 0) { + let n = Math.min(e, i), l = Math.max(layoutRows(t) + 1, r); + for(let e = Math.max(0, r); e <= l; e++)for(let r = 0; r + n <= i; r++)if (!engine_d({ + x: r, + y: e, + w: n, + h: o + }, t)) return { + x: r, + y: e + }; + return { + x: 0, + y: l + }; +} +function flowLayout(t, e) { + let o = [], i = 0, r = 0; + for (let n of t){ + let t = Math.max(1, Math.min(n.w, e)), l = r, a = i; + for(;;){ + if (a + t > e) { + a = 0, l++; + continue; + } + if (!engine_d({ + x: a, + y: l, + w: t, + h: n.h + }, o)) break; + a++; + } + o.push({ + ...n, + x: a, + y: l, + w: t + }), i = a + t, r = l; + } + return o; +} +function foldWidth(t, e) { + return Math.max(1, Math.min(t, e)); +} +const COLUMN_STEPS = [ + 12, + 6, + 2 +]; +function resolveLayout(e, d, f = {}) { + let u = { + index: indexNodes(e), + options: f, + grids: new Map(), + items: new Map() + }; + return !function e(i, r, d, f) { + let u, h = d >= f, s = childrenOf(i.index, r).filter((t)=>i.options.includeHidden || !t.hidden), x = s.map((t)=>{ + let o = foldWidth(t.tile.w, d), f = t.tile.h, u = isGroup(t) && (i.options.collapsed?.has(t.id) ?? !1); + return isGroup(t) && (u ? (f = GROUP_HEADER_ROWS, e(i, t.id, o, t.tile.w)) : f = GROUP_HEADER_ROWS + Math.max(e(i, t.id, o, t.tile.w).rows, GROUP_MIN_BODY_ROWS)), { + id: t.id, + parentId: r, + kind: t.kind, + x: t.tile.x, + y: t.tile.y, + w: o, + h: f, + collapsed: u, + hidden: !0 === t.hidden + }; + }); + if (h) u = x; + else { + let e = new Map(s.map((t)=>[ + t.id, + t + ])); + u = flowLayout([ + ...x + ].sort((o, i)=>byFlowOrder(e.get(o.id), e.get(i.id))), d); + } + let p = { + parentId: r, + columns: d, + rows: layoutRows(u), + authored: h, + items: u + }; + for (let t of (i.grids.set(r, p), u))i.items.set(t.id, t); + return p; + }(u, null, Math.max(1, d), BASE_COLUMNS), { + columns: d, + grids: u.grids, + items: u.items + }; +} +function deriveGroupHeights(t) { + let e = resolveLayout(t, BASE_COLUMNS, { + includeHidden: !0 + }), o = new Map(); + for (let i of t){ + if (!isGroup(i)) continue; + let t = e.items.get(i.id); + t && t.h !== i.tile.h && o.set(i.id, t.h); + } + return o; +} +function readingOrderRanks(t, r) { + let n = indexNodes(t), l = new Map(); + return [ + ...childrenOf(n, r) + ].sort(byReadingOrder).forEach((t, e)=>{ + l.set(t.id, e); + }), l; +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/core/ids.ts +let ids_t = 0; +function createId(r = "n") { + ids_t = (ids_t + 1) % 1679616; + let e = Date.now().toString(36).slice(-6), n = Math.floor(1679616 * Math.random()).toString(36).padStart(4, "0"); + return `${r}_${e}${ids_t.toString(36).padStart(4, "0")}${n}`; +} +let ids_r = /^[A-Za-z0-9_-]{1,64}$/; +function isValidId(t) { + return "string" == typeof t && ids_r.test(t); +} +function uniqueId(t, r) { + if (!r.has(t)) return t; + for(let e = 2;; e++){ + let n = `${t.slice(0, 58)}_${e}`; + if (!r.has(n)) return n; + } +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/core/schema.ts + + +const MAX_DOCUMENT_BYTES = 262144; +class DocumentValidationError extends Error { +} +let schema_a = (e)=>null !== e && "object" == typeof e && !Array.isArray(e), schema_s = (e, t, r)=>Number.isSafeInteger(e) && Number(e) >= t && Number(e) <= r; +function schema_l(e, t) { + if (!e) throw new DocumentValidationError(`Invalid dashboard document: ${t}`); +} +function validateDocument(p) { + schema_l(schema_a(p) && function e(t, r = 0, o = new Set()) { + if (r > 16) return !1; + if (null === t || "string" == typeof t || "boolean" == typeof t) return !0; + if ("number" == typeof t) return Number.isFinite(t); + if ("object" != typeof t || o.has(t) || !Array.isArray(t) && Object.getPrototypeOf(t) !== Object.prototype && null !== Object.getPrototypeOf(t)) return !1; + o.add(t); + let n = Object.entries(t).every(([t, n])=>![ + "__proto__", + "constructor", + "prototype" + ].includes(t) && e(n, r + 1, o)); + return o.delete(t), n; + }(p), "JSON value"), schema_l(new TextEncoder().encode(JSON.stringify(p)).length <= 262144, "size limit"), schema_l(p.schemaVersion === SCHEMA_VERSION, "schemaVersion"), schema_l(schema_s(p.revision, 0, Number.MAX_SAFE_INTEGER - 1), "revision"), schema_l(Array.isArray(p.dashboards) && p.dashboards.length > 0 && p.dashboards.length <= MAX_DASHBOARDS, "dashboards"); + let u = new Set(); + for (let r of p.dashboards){ + schema_l(schema_a(r) && isValidId(r.id) && !u.has(r.id), "dashboard id"), u.add(r.id), schema_l("string" == typeof r.name && r.name.trim().length > 0 && r.name.length <= 128, "dashboard name"), schema_l(Array.isArray(r.nodes) && r.nodes.length <= MAX_NODES_PER_DASHBOARD, "nodes"); + let d = new Map(); + for (let o of r.nodes){ + for (let t of (schema_l(schema_a(o) && isValidId(o.id) && !d.has(o.id), "node id"), d.set(o.id, o), schema_l("widget" === o.kind || "group" === o.kind, "node kind"), schema_l(Object.hasOwn(o, "parentId") && (null === o.parentId || isValidId(o.parentId)), "parentId"), [ + "hidden", + "locked", + "collapsed" + ]))schema_l(void 0 === o[t] || "boolean" == typeof o[t], t); + schema_l(void 0 === o.title || "string" == typeof o.title && o.title.length <= 256, "title"); + let r = o.tile; + schema_l(schema_a(r), "tile"), schema_l([ + "small", + "medium", + "wide", + "large", + "xlarge", + "full", + "custom" + ].includes(String(r.size)), "tile size"), schema_l(schema_s(r.x, 0, BASE_COLUMNS - 1) && schema_s(r.y, 0, 10000) && schema_s(r.w, 1, BASE_COLUMNS) && schema_s(r.h, 1, MAX_TILE_ROWS) && schema_s(r.order, 0, 100000), "tile coordinates"), "widget" === o.kind ? (schema_l("string" == typeof o.widgetType && /^[a-zA-Z0-9_.-]{1,128}$/.test(o.widgetType), "widgetType"), schema_l(schema_s(o.widgetVersion, 1, 100000) && schema_a(o.config), "widget config/version")) : (schema_l("string" == typeof o.title, "group title"), schema_l(void 0 === o.description || "string" == typeof o.description && o.description.length <= 2048, "group description")); + } + for (let e of d.values()){ + let r = new Set([ + e.id + ]), n = e.parentId, i = +("group" === e.kind); + for(; null !== n;){ + schema_l("string" == typeof n && !r.has(n), "parent cycle"), r.add(n); + let e = d.get(n); + schema_l(e?.kind === "group", "missing/non-group parent"), schema_l(++i <= MAX_GROUP_DEPTH, "group depth"), n = e.parentId; + } + let a = e.tile, s = null === e.parentId ? void 0 : d.get(String(e.parentId)), p = s ? s.tile.w : BASE_COLUMNS; + schema_l(a.x + a.w <= p, "parent width"); + } + } + schema_l("string" == typeof p.defaultDashboardId && u.has(p.defaultDashboardId), "default dashboard"), schema_l(void 0 === p.meta || schema_a(p.meta), "meta"); +} +function cloneDocument(e) { + return JSON.parse(JSON.stringify(e)); +} +function migrateDocument(e) { + return validateDocument(e), cloneDocument(e); +} +function createDefaultDocument() { + return { + schemaVersion: SCHEMA_VERSION, + revision: 0, + defaultDashboardId: "overview", + meta: { + presetVersion: 1 + }, + dashboards: [ + { + id: "overview", + name: "Overview", + nodes: [ + "system.info", + "system.resources", + "network.internet", + "network.interfaces", + "wireless.radios", + "dhcp.leases", + "wireless.clients" + ].map((e, t)=>({ + id: e.replaceAll(".", "_"), + kind: "widget", + parentId: null, + widgetType: e, + widgetVersion: 1, + config: {}, + tile: { + size: "xlarge", + x: t % 2 * 6, + y: 8 * Math.floor(t / 2), + w: 6, + h: 8, + order: t + } + })) + } + ] + }; +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/core/tiles.ts + +const TILE_PRESETS = { + small: { + w: 1, + h: 2 + }, + medium: { + w: 2, + h: 4 + }, + wide: { + w: 4, + h: 4 + }, + large: { + w: 4, + h: 8 + }, + xlarge: { + w: 6, + h: 8 + }, + full: { + w: BASE_COLUMNS, + h: 8 + } +}; +const TILE_SIZE_ORDER = [ + "small", + "medium", + "wide", + "large", + "xlarge", + "full" +]; +const DEFAULT_CONSTRAINTS = { + min: { + w: 1, + h: 2 + }, + max: { + w: BASE_COLUMNS, + h: MAX_TILE_ROWS + } +}; +function tiles_n(e, t, n, i) { + return Math.min(n, Math.max(t, "number" == typeof e && Number.isFinite(e) ? Math.round(e) : i)); +} +function detectTileSize(t, n, i = BASE_COLUMNS) { + for (let e of TILE_SIZE_ORDER){ + let r = TILE_PRESETS[e]; + if ("full" === e ? t === i && n === r.h : r.w === t && r.h === n) return e; + } + return "custom"; +} +function clampDimensions(i, r = DEFAULT_CONSTRAINTS, m = BASE_COLUMNS) { + let h = Math.max(1, Math.min(m, r.max.w)), l = Math.min(h, Math.max(1, r.min.w)), a = Math.max(1, Math.min(MAX_TILE_ROWS, r.max.h)), o = Math.min(a, Math.max(1, r.min.h)); + return { + w: tiles_n(i.w, l, h, l), + h: tiles_n(i.h, o, a, o) + }; +} +function presetDimensions(t, n = DEFAULT_CONSTRAINTS, i = BASE_COLUMNS) { + let r = TILE_PRESETS[t]; + return clampDimensions({ + w: "full" === t ? i : r.w, + h: r.h + }, n, i); +} +function allowedPresets(t = DEFAULT_CONSTRAINTS, n = BASE_COLUMNS) { + return TILE_SIZE_ORDER.filter((e)=>{ + let i = TILE_PRESETS[e], r = "full" === e ? n : i.w; + return r >= t.min.w && r <= Math.min(n, t.max.w) && i.h >= t.min.h && i.h <= t.max.h; + }); +} +function sanitizeTile(t, i = BASE_COLUMNS) { + let r = null !== t && "object" == typeof t ? t : {}, { w: m, h } = clampDimensions({ + w: r.w, + h: r.h + }, DEFAULT_CONSTRAINTS, i), l = tiles_n(r.x, 0, Math.max(0, i - m), 0), a = tiles_n(r.y, 0, 10000, 0), o = tiles_n(r.order, 0, 100000, 0); + return { + size: detectTileSize(m, h, i), + x: l, + y: a, + w: m, + h, + order: o + }; +} +function withDimensions(t, n, i, r = BASE_COLUMNS) { + return { + ...t, + w: n, + h: i, + size: detectTileSize(n, i, r) + }; +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/core/commands.ts + + + + +class EditCommand { + label; + edit; + before; + constructor(e, t){ + this.label = e, this.edit = t; + } + execute(e) { + let r = cloneDocument(e); + return this.edit(r), validateDocument(r), this.before = cloneDocument(e), r; + } + undo() { + if (!this.before) throw Error("Command has not executed"); + return cloneDocument(this.before); + } +} +function commands_n(e, t) { + let o = e.dashboards.find((e)=>e.id === t); + if (!o) throw Error("Dashboard not found"); + return o; +} +function commands_s(e, t, o = !1) { + let r = e.nodes.find((e)=>e.id === t); + if (!r) throw Error("Node not found"); + if (!o && (r.locked || ancestorsOf(indexNodes(e.nodes), t).some((e)=>e.locked))) throw Error("Node is locked"); + return r; +} +function commands_a(t, o) { + let r = indexNodes(t.nodes); + for (let i of [ + ...new Set(t.nodes.map((e)=>e.parentId)) + ].sort((e, t)=>(null === t ? 0 : ancestorsOf(r, t).length + 1) - (null === e ? 0 : ancestorsOf(r, e).length + 1))){ + let l = t.nodes.filter((e)=>e.parentId === i), n = l.filter((e)=>e.locked || ancestorsOf(r, e.id).some((e)=>e.locked)); + for(let t = 0; t < n.length; t++)for(let o = 0; o < t; o++)if (engine_collides(n[t].tile, n[o].tile)) throw Error("Locked tiles overlap"); + for (let t of l.filter((e)=>!n.includes(e)).sort((e, t)=>Number(o.has(t.id)) - Number(o.has(e.id)) || e.tile.y - t.tile.y || e.tile.x - t.tile.x)){ + for(; n.some((o)=>engine_collides(o.tile, t.tile));){ + if (t.tile.y >= 10000) throw Error("Dashboard layout exceeds the row limit"); + t.tile.y++; + } + n.push(t); + } + if (null !== i) { + let e = r.byId.get(i); + e?.kind === "group" && (e.tile.h = Math.max(3, ...l.map((e)=>e.tile.y + e.tile.h + 1))); + } + } +} +class MoveNodeCommand extends EditCommand { + constructor(e, t){ + super("Move tiles", (d)=>{ + let i = commands_n(d, e); + for (let e of t){ + let t = commands_s(i, e.id); + t.parentId = e.parentId, t.tile = { + ...t.tile, + x: e.x, + y: e.y, + w: e.w, + h: e.h, + size: detectTileSize(e.w, e.h) + }; + } + validateDocument(d), commands_a(i, new Set(t.map((e)=>e.id))); + }); + } +} +class ResizeNodeCommand extends EditCommand { + constructor(e, t, o, d){ + super("Resize tile", (i)=>{ + let l = commands_n(i, e), m = commands_s(l, t); + m.tile = { + ...m.tile, + w: o, + h: d, + size: detectTileSize(o, d) + }, commands_a(l, new Set([ + t + ])); + }); + } +} +class AddNodeCommand extends EditCommand { + constructor(e, t){ + super("Add tile", (o)=>{ + let r = commands_n(o, e); + r.nodes.push(JSON.parse(JSON.stringify(t))), commands_a(r, new Set()); + }); + } +} +class RemoveNodeCommand extends EditCommand { + constructor(e, t){ + super("Remove tile and contents", (o)=>{ + let r = commands_n(o, e); + commands_s(r, t); + let d = new Set([ + t, + ...descendantsOf(indexNodes(r.nodes), t).map((e)=>e.id) + ]); + for (let e of d)commands_s(r, e); + r.nodes = r.nodes.filter((e)=>!d.has(e.id)); + }); + } +} +class UpdateConfigCommand extends EditCommand { + constructor(e, t, o, r){ + super("Configure widget", (d)=>{ + let i = commands_s(commands_n(d, e), t); + if ("widget" !== i.kind) throw Error("Not a widget"); + i.config = JSON.parse(JSON.stringify(o)), void 0 !== r && (i.title = r); + }); + } +} +class CreateGroupCommand extends AddNodeCommand { + constructor(e, t){ + super(e, t); + } +} +class DissolveGroupCommand extends EditCommand { + constructor(e, t){ + super("Dissolve group", (o)=>{ + let r = commands_n(o, e), d = commands_s(r, t); + if ("group" !== d.kind) throw Error("Not a group"); + let i = r.nodes.filter((e)=>e.parentId === t), l = null === d.parentId ? 12 : commands_s(r, d.parentId, !0).tile.w; + if (i.some((e)=>e.tile.w > l)) throw Error("A child tile is wider than the destination group"); + for (let e of i){ + commands_s(r, e.id), e.parentId = d.parentId; + let t = null === d.parentId ? void 0 : commands_s(r, d.parentId, !0); + e.tile.x = Math.max(0, Math.min(e.tile.x + d.tile.x, (t?.tile.w ?? 12) - e.tile.w)), e.tile.y += d.tile.y; + } + r.nodes = r.nodes.filter((e)=>e.id !== t), commands_a(r, new Set()); + }); + } +} +class ToggleLockCommand extends EditCommand { + constructor(e, t){ + super("Toggle lock", (o)=>{ + let r = commands_s(commands_n(o, e), t, !0); + r.locked = !r.locked; + }); + } +} +class ToggleHideCommand extends EditCommand { + constructor(e, t){ + super("Toggle visibility", (o)=>{ + let r = commands_s(commands_n(o, e), t); + r.hidden = !r.hidden; + }); + } +} +class ToggleCollapseCommand extends EditCommand { + constructor(e, t){ + super("Toggle collapse", (o)=>{ + let r = commands_s(commands_n(o, e), t); + "group" === r.kind && (r.collapsed = !r.collapsed); + }); + } +} +class BatchCommand extends EditCommand { + constructor(e, t = "Batch edit"){ + super(t, (t)=>{ + let o = t; + for (let t of e)o = t.execute(o); + Object.assign(t, o); + }); + } +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/core/history.ts + +class History { + limit; + past = []; + future = []; + baseline; + current; + constructor(e, i = 100){ + this.limit = i, validateDocument(e), this.baseline = cloneDocument(e), this.current = cloneDocument(e); + } + get document() { + return cloneDocument(this.current); + } + get canUndo() { + return this.past.length > 0; + } + get canRedo() { + return this.future.length > 0; + } + get dirty() { + return JSON.stringify(this.current) !== JSON.stringify(this.baseline); + } + execute(t) { + let s = t.execute(this.current); + JSON.stringify(s) !== JSON.stringify(this.current) && (this.past.push(this.current), this.past.length > this.limit && this.past.shift(), this.current = s, this.future = []); + } + undo() { + let t = this.past.pop(); + t && (this.future.push(this.current), this.current = t); + } + redo() { + let t = this.future.pop(); + t && (this.past.push(this.current), this.current = t); + } + saved(e) { + validateDocument(e), this.current = cloneDocument(e), this.baseline = cloneDocument(e), this.past = [], this.future = []; + } + cancel() { + this.saved(this.baseline); + } +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/core/store.ts + +class DashboardStore { + history; + activeDashboardId; + isEditing = !1; + previewColumns = null; + selection = new Set(); + collapsed = new Set(); + listeners = new Set(); + constructor(s){ + this.history = new History(s), this.activeDashboardId = s.defaultDashboardId, this.resetCollapsed(); + } + get document() { + return this.history.document; + } + get activeDashboard() { + return this.document.dashboards.find((t)=>t.id === this.activeDashboardId) ?? this.document.dashboards[0]; + } + subscribe(t) { + return this.listeners.add(t), ()=>this.listeners.delete(t); + } + emit() { + this.document.dashboards.some((t)=>t.id === this.activeDashboardId) || (this.activeDashboardId = this.document.defaultDashboardId); + let t = new Set(this.activeDashboard.nodes.map((t)=>t.id)); + for (let s of this.selection)t.has(s) || this.selection.delete(s); + for (let t of [ + ...this.listeners + ])t(); + } + execute(t) { + if (!this.isEditing) throw Error("Enter edit mode first"); + this.history.execute(t), this.emit(); + } + undo() { + this.isEditing && (this.history.undo(), this.emit()); + } + redo() { + this.isEditing && (this.history.redo(), this.emit()); + } + edit() { + this.isEditing = !0, this.emit(); + } + cancel() { + this.history.cancel(), this.isEditing = !1, this.previewColumns = null, this.selection.clear(), this.resetCollapsed(), this.emit(); + } + saved(t) { + this.history.saved(t), this.isEditing = !1, this.previewColumns = null, this.selection.clear(), this.resetCollapsed(), this.emit(); + } + switchDashboard(t) { + if (!this.document.dashboards.some((s)=>s.id === t)) throw Error("Dashboard not found"); + this.activeDashboardId = t, this.selection.clear(), this.emit(); + } + resetCollapsed() { + for (let t of (this.collapsed.clear(), this.document.dashboards))for (let s of t.nodes)"group" === s.kind && s.collapsed && this.collapsed.add(s.id); + } +} + +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/utils.js +/** + * utils.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ +/** + * @internal Checks for obsolete method names and provides deprecation warnings. + * Creates a wrapper function that logs a deprecation warning when called. + * + * @param self the object context to apply the function to + * @param f the new function to call + * @param oldName the deprecated method name + * @param newName the new method name to use instead + * @param rev the version when the deprecation was introduced + * @returns a wrapper function that warns about deprecation + */ +// eslint-disable-next-line +function obsolete(self, f, oldName, newName, rev) { + const wrapper = (...args) => { + console.warn('gridstack.js: Function `' + oldName + '` is deprecated in ' + rev + ' and has been replaced ' + + 'with `' + newName + '`. It will be **removed** in a future release'); + return f.apply(self, args); + }; + wrapper.prototype = f.prototype; + return wrapper; +} +/** + * @internal Checks for obsolete grid options and migrates them to new names. + * Automatically copies old option values to new option names and shows deprecation warnings. + * + * @param opts the options object to check and migrate + * @param oldName the deprecated option name + * @param newName the new option name to use instead + * @param rev the version when the deprecation was introduced + */ +function obsoleteOpts(opts, oldName, newName, rev) { + if (opts[oldName] !== undefined) { + opts[newName] = opts[oldName]; + console.warn('gridstack.js: Option `' + oldName + '` is deprecated in ' + rev + ' and has been replaced with `' + + newName + '`. It will be **removed** in a future release'); + } +} +/** + * @internal Checks for obsolete grid options that have been completely removed. + * Shows deprecation warnings for options that are no longer supported. + * + * @param opts the options object to check + * @param oldName the removed option name + * @param rev the version when the option was removed + * @param info additional information about the removal + */ +function obsoleteOptsDel(opts, oldName, rev, info) { + if (opts[oldName] !== undefined) { + console.warn('gridstack.js: Option `' + oldName + '` is deprecated in ' + rev + info); + } +} +/** + * @internal Checks for obsolete HTML element attributes and migrates them. + * Automatically copies old attribute values to new attribute names and shows deprecation warnings. + * + * @param el the HTML element to check and migrate + * @param oldName the deprecated attribute name + * @param newName the new attribute name to use instead + * @param rev the version when the deprecation was introduced + */ +function obsoleteAttr(el, oldName, newName, rev) { + const oldAttr = el.getAttribute(oldName); + if (oldAttr !== null) { + el.setAttribute(newName, oldAttr); + console.warn('gridstack.js: attribute `' + oldName + '`=' + oldAttr + ' is deprecated on this object in ' + rev + ' and has been replaced with `' + + newName + '`. It will be **removed** in a future release'); + } +} +/** + * Collection of utility methods used throughout GridStack. + * These are general-purpose helper functions for DOM manipulation, + * positioning calculations, object operations, and more. + */ +class Utils { + /** + * Convert a potential selector into an actual list of HTML elements. + * Supports CSS selectors, element references, and special ID handling. + * + * @param els selector string, HTMLElement, or array of elements + * @param root optional root element to search within (defaults to document, useful for shadow DOM) + * @returns array of HTML elements matching the selector + * + * @example + * const elements = Utils.getElements('.grid-item'); + * const byId = Utils.getElements('#myWidget'); + * const fromShadow = Utils.getElements('.item', shadowRoot); + */ + static getElements(els, root = document) { + if (typeof els === 'string') { + const doc = ('getElementById' in root) ? root : undefined; + // Note: very common for people use to id='1,2,3' which is only legal as HTML5 id, but not CSS selectors + // so if we start with a number, assume it's an id and just return that one item... + // see https://github.com/gridstack/gridstack.js/issues/2234#issuecomment-1523796562 + if (doc && !isNaN(+els[0])) { // start with digit + const el = doc.getElementById(els); + return el ? [el] : []; + } + let list = root.querySelectorAll(els); + if (!list.length && els[0] !== '.' && els[0] !== '#') { + list = root.querySelectorAll('.' + els); + if (!list.length) { + list = root.querySelectorAll('#' + els); + } + } + return Array.from(list); + } + return [els]; + } + /** + * Convert a potential selector into a single HTML element. + * Similar to getElements() but returns only the first match. + * + * @param els selector string or HTMLElement + * @param root optional root element to search within (defaults to document) + * @returns the first HTML element matching the selector, or null if not found + * + * @example + * const element = Utils.getElement('#myWidget'); + * const first = Utils.getElement('.grid-item'); + */ + static getElement(els, root = document) { + if (typeof els === 'string') { + const doc = ('getElementById' in root) ? root : undefined; + if (!els.length) + return null; + if (doc && els[0] === '#') { + return doc.getElementById(els.substring(1)); + } + if (els[0] === '#' || els[0] === '.' || els[0] === '[') { + return root.querySelector(els); + } + // if we start with a digit, assume it's an id (error calling querySelector('#1')) as class are not valid CSS + if (doc && !isNaN(+els[0])) { // start with digit + return doc.getElementById(els); + } + // finally try string, then id, then class + let el = root.querySelector(els); + if (doc && !el) { + el = doc.getElementById(els); + } + if (!el) { + el = root.querySelector('.' + els); + } + return el; + } + return els; + } + /** + * Check if a widget should be lazy loaded based on node or grid settings. + * + * @param n the grid node to check + * @returns true if the item should be lazy loaded + * + * @example + * if (Utils.lazyLoad(node)) { + * // Set up intersection observer for lazy loading + * } + */ + static lazyLoad(n) { + return n.lazyLoad || n.grid?.opts?.lazyLoad && n.lazyLoad !== false; + } + /** + * Create a div element with the specified CSS classes. + * + * @param classes array of CSS class names to add + * @param parent optional parent element to append the div to + * @returns the created div element + * + * @example + * const div = Utils.createDiv(['grid-item', 'draggable']); + * const nested = Utils.createDiv(['content'], parentDiv); + */ + static createDiv(classes, parent) { + const el = document.createElement('div'); + classes.forEach(c => { if (c) + el.classList.add(c); }); + parent?.appendChild(el); + return el; + } + /** + * Check if a widget should resize to fit its content. + * + * @param n the grid node to check (can be undefined) + * @param strict if true, only returns true for explicit sizeToContent:true (not numbers) + * @returns true if the widget should resize to content + * + * @example + * if (Utils.shouldSizeToContent(node)) { + * // Trigger content-based resizing + * } + */ + static shouldSizeToContent(n, strict = false) { + return n?.grid && (strict ? + (n.sizeToContent === true || (n.grid.opts.sizeToContent === true && n.sizeToContent === undefined)) : + (!!n.sizeToContent || (n.grid.opts.sizeToContent && n.sizeToContent !== false))); + } + /** + * Check if two grid positions overlap/intersect. + * + * @param a first position with x, y, w, h properties + * @param b second position with x, y, w, h properties + * @returns true if the positions overlap + * + * @example + * const overlaps = Utils.isIntercepted( + * {x: 0, y: 0, w: 2, h: 1}, + * {x: 1, y: 0, w: 2, h: 1} + * ); // true - they overlap + */ + static isIntercepted(a, b) { + return !(a.y >= b.y + b.h || a.y + a.h <= b.y || a.x + a.w <= b.x || a.x >= b.x + b.w); + } + /** + * Check if two grid positions are touching (edges or corners). + * + * @param a first position + * @param b second position + * @returns true if the positions are touching + * + * @example + * const touching = Utils.isTouching( + * {x: 0, y: 0, w: 2, h: 1}, + * {x: 2, y: 0, w: 1, h: 1} + * ); // true - they share an edge + */ + static isTouching(a, b) { + return Utils.isIntercepted(a, { x: b.x - 0.5, y: b.y - 0.5, w: b.w + 1, h: b.h + 1 }); + } + /** + * Calculate the overlapping area between two grid positions. + * + * @param a first position + * @param b second position + * @returns the area of overlap (0 if no overlap) + * + * @example + * const overlap = Utils.areaIntercept( + * {x: 0, y: 0, w: 3, h: 2}, + * {x: 1, y: 0, w: 3, h: 2} + * ); // returns 4 (2x2 overlap) + */ + static areaIntercept(a, b) { + const x0 = (a.x > b.x) ? a.x : b.x; + const x1 = (a.x + a.w < b.x + b.w) ? a.x + a.w : b.x + b.w; + if (x1 <= x0) + return 0; // no overlap + const y0 = (a.y > b.y) ? a.y : b.y; + const y1 = (a.y + a.h < b.y + b.h) ? a.y + a.h : b.y + b.h; + if (y1 <= y0) + return 0; // no overlap + return (x1 - x0) * (y1 - y0); + } + /** + * Calculate the total area of a grid position. + * + * @param a position with width and height + * @returns the total area (width * height) + * + * @example + * const area = Utils.area({x: 0, y: 0, w: 3, h: 2}); // returns 6 + */ + static area(a) { + return a.w * a.h; + } + /** + * Sort an array of grid nodes by position (y first, then x). + * + * @param nodes array of nodes to sort + * @param dir sort direction: 1 for ascending (top-left first), -1 for descending + * @returns the sorted array (modifies original) + * + * @example + * const sorted = Utils.sort(nodes); // Sort top-left to bottom-right + * const reverse = Utils.sort(nodes, -1); // Sort bottom-right to top-left + */ + static sort(nodes, dir = 1) { + const und = 10000; + return nodes.sort((a, b) => { + const diffY = dir * ((a.y ?? und) - (b.y ?? und)); + if (diffY === 0) + return dir * ((a.x ?? und) - (b.x ?? und)); + return diffY; + }); + } + /** + * Find a grid node by its ID. + * + * @param nodes array of nodes to search + * @param id the ID to search for + * @returns the node with matching ID, or undefined if not found + * + * @example + * const node = Utils.find(nodes, 'widget-1'); + * if (node) console.log('Found node at:', node.x, node.y); + */ + static find(nodes, id) { + return id ? nodes.find(n => n.id === id) : undefined; + } + /** + * Convert various value types to boolean. + * Handles strings like 'false', 'no', '0' as false. + * + * @param v value to convert + * @returns boolean representation + * + * @example + * Utils.toBool('true'); // true + * Utils.toBool('false'); // false + * Utils.toBool('no'); // false + * Utils.toBool('1'); // true + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static toBool(v) { + if (typeof v === 'boolean') { + return v; + } + if (typeof v === 'string') { + v = v.toLowerCase(); + return !(v === '' || v === 'no' || v === 'false' || v === '0'); + } + return Boolean(v); + } + /** + * Convert a string value to a number, handling null and empty strings. + * + * @param value string or null value to convert + * @returns number value, or undefined for null/empty strings + * + * @example + * Utils.toNumber('42'); // 42 + * Utils.toNumber(''); // undefined + * Utils.toNumber(null); // undefined + */ + static toNumber(value) { + return (value === null || value.length === 0) ? undefined : Number(value); + } + /** + * Parse a height value with units into numeric value and unit string. + * Supports px, em, rem, vh, vw, %, cm, mm units. + * + * @param val height value as number or string with units + * @returns object with h (height) and unit properties + * + * @example + * Utils.parseHeight('100px'); // {h: 100, unit: 'px'} + * Utils.parseHeight('2rem'); // {h: 2, unit: 'rem'} + * Utils.parseHeight(50); // {h: 50, unit: 'px'} + */ + static parseHeight(val) { + let h; + let unit = 'px'; + if (typeof val === 'string') { + if (val === 'auto' || val === '') + h = 0; + else { + const match = val.match(/^(-[0-9]+\.[0-9]+|[0-9]*\.[0-9]+|-[0-9]+|[0-9]+)(px|em|rem|vh|vw|%|cm|mm)?$/); + if (!match) { + throw new Error(`Invalid height val = ${val}`); + } + unit = match[2] || 'px'; + h = parseFloat(match[1]); + } + } + else { + h = val; + } + return { h, unit }; + } + /** + * Copy unset fields from source objects to target object (shallow merge with defaults). + * Similar to Object.assign but only sets undefined/null fields. + * + * @param target the object to copy defaults into + * @param sources one or more source objects to copy defaults from + * @returns the modified target object + * + * @example + * const config = { width: 100 }; + * Utils.defaults(config, { width: 200, height: 50 }); + * // config is now { width: 100, height: 50 } + */ + // eslint-disable-next-line + static defaults(target, ...sources) { + sources.forEach(source => { + for (const key in source) { + if (!source.hasOwnProperty(key)) + return; + if (target[key] === null || target[key] === undefined) { + target[key] = source[key]; + } + else if (typeof source[key] === 'object' && typeof target[key] === 'object') { + // property is an object, recursively add it's field over... #1373 + Utils.defaults(target[key], source[key]); + } + } + }); + return target; + } + /** + * Compare two objects for equality (shallow comparison). + * Checks if objects have the same fields and values at one level deep. + * + * @param a first object to compare + * @param b second object to compare + * @returns true if objects have the same values + * + * @example + * Utils.same({x: 1, y: 2}, {x: 1, y: 2}); // true + * Utils.same({x: 1}, {x: 1, y: 2}); // false + */ + static same(a, b) { + if (typeof a !== 'object') + return a == b; + if (typeof a !== typeof b) + return false; + // else we have object, check just 1 level deep for being same things... + if (Object.keys(a).length !== Object.keys(b).length) + return false; + for (const key in a) { + if (a[key] !== b[key]) + return false; + } + return true; + } + /** + * Copy position and size properties from one widget to another. + * Copies x, y, w, h and optionally min/max constraints. + * + * @param a target widget to copy to + * @param b source widget to copy from + * @param doMinMax if true, also copy min/max width/height constraints + * @returns the target widget (a) + * + * @example + * Utils.copyPos(widget1, widget2); // Copy position/size + * Utils.copyPos(widget1, widget2, true); // Also copy constraints + */ + static copyPos(a, b, doMinMax = false) { + if (b.x !== undefined) + a.x = b.x; + if (b.y !== undefined) + a.y = b.y; + if (b.w !== undefined) + a.w = b.w; + if (b.h !== undefined) + a.h = b.h; + if (doMinMax) { + if (b.minW) + a.minW = b.minW; + if (b.minH) + a.minH = b.minH; + if (b.maxW) + a.maxW = b.maxW; + if (b.maxH) + a.maxH = b.maxH; + } + return a; + } + /** true if a and b has same size & position */ + static samePos(a, b) { + return a && b && a.x === b.x && a.y === b.y && (a.w || 1) === (b.w || 1) && (a.h || 1) === (b.h || 1); + } + /** given a node, makes sure it's min/max are valid */ + static sanitizeMinMax(node) { + // remove 0, undefine, null + if (!node.minW) { + delete node.minW; + } + if (!node.minH) { + delete node.minH; + } + if (!node.maxW) { + delete node.maxW; + } + if (!node.maxH) { + delete node.maxH; + } + } + /** removes field from the first object if same as the second objects (like diffing) and internal '_' for saving */ + static removeInternalAndSame(a, b) { + if (typeof a !== 'object' || typeof b !== 'object') + return; + for (let key in a) { + const aVal = a[key]; + const bVal = b[key]; + if (key[0] === '_' || aVal === bVal) { + delete a[key]; + } + else if (aVal && typeof aVal === 'object' && bVal !== undefined) { + Utils.removeInternalAndSame(aVal, bVal); + if (!Object.keys(aVal).length) { + delete a[key]; + } + } + } + } + /** removes internal fields '_' and default values for saving */ + static removeInternalForSave(n, removeEl = true) { + for (let key in n) { + if (key[0] === '_' || n[key] === null || n[key] === undefined) + delete n[key]; + } + delete n.grid; + if (removeEl) + delete n.el; + // delete default values (will be re-created on read) + if (!n.autoPosition) + delete n.autoPosition; + if (!n.noResize) + delete n.noResize; + if (!n.noMove) + delete n.noMove; + if (!n.locked) + delete n.locked; + if (n.w === 1 || n.w === n.minW) + delete n.w; + if (n.h === 1 || n.h === n.minH) + delete n.h; + } + /** return the closest parent (or itself) matching the given class */ + // static closestUpByClass(el: HTMLElement, name: string): HTMLElement { + // while (el) { + // if (el.classList.contains(name)) return el; + // el = el.parentElement + // } + // return null; + // } + /** delay calling the given function for given delay, preventing new calls from happening while waiting */ + static throttle(func, delay) { + let isWaiting = false; + return (...args) => { + if (!isWaiting) { + isWaiting = true; + setTimeout(() => { func(...args); isWaiting = false; }, delay); + } + }; + } + static removePositioningStyles(el) { + const style = el.style; + if (style.position) { + style.removeProperty('position'); + } + if (style.left) { + style.removeProperty('left'); + } + if (style.top) { + style.removeProperty('top'); + } + if (style.width) { + style.removeProperty('width'); + } + if (style.height) { + style.removeProperty('height'); + } + } + /** @internal returns the passed element if scrollable, else the closest parent that will, up to the entire document scrolling element */ + static getScrollElement(el) { + if (!el) + return document.scrollingElement || document.documentElement; // IE support + const style = getComputedStyle(el); + const overflowRegex = /(auto|scroll)/; + if (overflowRegex.test(style.overflow + style.overflowY)) { + return el; + } + else { + return Utils.getScrollElement(el.parentElement); + } + } + /** @internal */ + static updateScrollPosition(el, position, distance) { + const scrollEl = Utils.getScrollElement(el); + if (!scrollEl) + return; + const elRect = el.getBoundingClientRect(); + const scrollRect = scrollEl.getBoundingClientRect(); + const innerHeightOrClientHeight = (window.innerHeight || document.documentElement.clientHeight); + const offsetDiffDown = elRect.bottom - Math.min(scrollRect.bottom, innerHeightOrClientHeight); + const offsetDiffUp = elRect.top - Math.max(scrollRect.top, 0); + const prevScroll = scrollEl.scrollTop; + if (offsetDiffUp < 0 && distance < 0) { + // scroll up + if (el.offsetHeight > scrollRect.height) { + scrollEl.scrollTop += distance; + } + else { + scrollEl.scrollTop += Math.abs(offsetDiffUp) > Math.abs(distance) ? distance : offsetDiffUp; + } + } + else if (offsetDiffDown > 0 && distance > 0) { + // scroll down + if (el.offsetHeight > scrollRect.height) { + scrollEl.scrollTop += distance; + } + else { + scrollEl.scrollTop += offsetDiffDown > distance ? distance : offsetDiffDown; + } + } + position.top += scrollEl.scrollTop - prevScroll; + } + /** + * @internal Function used to scroll the page. + * + * @param event `MouseEvent` that triggers the resize + * @param el `HTMLElement` that's being resized + * @param distance Distance from the V edges to start scrolling + */ + static updateScrollResize(event, el, distance) { + const scrollEl = Utils.getScrollElement(el); + const height = scrollEl.clientHeight; + // #1727 event.clientY is relative to viewport, so must compare this against position of scrollEl getBoundingClientRect().top + // #1745 Special situation if scrollEl is document 'html': here browser spec states that + // clientHeight is height of viewport, but getBoundingClientRect() is rectangle of html element; + // this discrepancy arises because in reality scrollbar is attached to viewport, not html element itself. + const offsetTop = (scrollEl === Utils.getScrollElement()) ? 0 : scrollEl.getBoundingClientRect().top; + const pointerPosY = event.clientY - offsetTop; + const top = pointerPosY < distance; + const bottom = pointerPosY > height - distance; + if (top) { + // This also can be done with a timeout to keep scrolling while the mouse is + // in the scrolling zone. (will have smoother behavior) + scrollEl.scrollBy({ behavior: 'smooth', top: pointerPosY - distance }); + } + else if (bottom) { + scrollEl.scrollBy({ behavior: 'smooth', top: distance - (height - pointerPosY) }); + } + } + /** single level clone, returning a new object with same top fields. This will share sub objects and arrays */ + static clone(obj) { + if (obj === null || obj === undefined || typeof (obj) !== 'object') { + return obj; + } + // return Object.assign({}, obj); + if (obj instanceof Array) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [...obj]; + } + return { ...obj }; + } + /** + * Recursive clone version that returns a full copy, checking for nested objects and arrays ONLY. + * Note: this will use as-is any key starting with double __ (and not copy inside) some lib have circular dependencies. + */ + static cloneDeep(obj) { + // list of fields we will skip during cloneDeep (nested objects, other internal) + const skipFields = ['parentGrid', 'el', 'grid', 'subGrid', 'engine']; + // return JSON.parse(JSON.stringify(obj)); // doesn't work with date format ? + const ret = Utils.clone(obj); + for (const key in ret) { + // NOTE: we don't support function/circular dependencies so skip those properties for now... + if (ret.hasOwnProperty(key) && typeof (ret[key]) === 'object' && key.substring(0, 2) !== '__' && !skipFields.find(k => k === key)) { + ret[key] = Utils.cloneDeep(obj[key]); + } + } + return ret; + } + /** deep clone the given HTML node, removing teh unique id field */ + static cloneNode(el) { + const node = el.cloneNode(true); + node.removeAttribute('id'); + return node; + } + static appendTo(el, parent) { + let parentNode; + if (typeof parent === 'string') { + parentNode = Utils.getElement(parent); + } + else { + parentNode = parent; + } + if (parentNode) { + parentNode.appendChild(el); + } + } + // public static setPositionRelative(el: HTMLElement): void { + // if (!(/^(?:r|a|f)/).test(getComputedStyle(el).position)) { + // el.style.position = "relative"; + // } + // } + static addElStyles(el, styles) { + if (styles instanceof Object) { + for (const s in styles) { + if (styles.hasOwnProperty(s)) { + if (Array.isArray(styles[s])) { + // support fallback value + styles[s].forEach(val => { + el.style[s] = val; + }); + } + else { + el.style[s] = styles[s]; + } + } + } + } + } + static initEvent(e, info) { + const evt = { type: info.type }; + const obj = { + button: 0, + which: 0, + buttons: 1, + bubbles: true, + cancelable: true, + target: info.target ? info.target : e.target + }; + ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'].forEach(p => evt[p] = e[p]); // keys + ['pageX', 'pageY', 'clientX', 'clientY', 'screenX', 'screenY'].forEach(p => evt[p] = e[p]); // point info + return { ...evt, ...obj }; + } + /** copies the MouseEvent (or convert Touch) properties and sends it as another event to the given target */ + static simulateMouseEvent(e, simulatedType, target) { + const me = e; + const simulatedEvent = new MouseEvent(simulatedType, { + bubbles: true, + composed: true, + cancelable: true, + view: window, + detail: 1, + screenX: e.screenX, + screenY: e.screenY, + clientX: e.clientX, + clientY: e.clientY, + ctrlKey: me.ctrlKey ?? false, + altKey: me.altKey ?? false, + shiftKey: me.shiftKey ?? false, + metaKey: me.metaKey ?? false, + button: 0, + relatedTarget: e.target + }); + (target || e.target).dispatchEvent(simulatedEvent); + } + /** + * defines an element that is used to get the offset and scale from grid transforms + * returns the scale and offsets from said element + */ + static getValuesFromTransformedElement(parent) { + const transformReference = document.createElement('div'); + Utils.addElStyles(transformReference, { + opacity: '0', + position: 'fixed', + top: 0 + 'px', + left: 0 + 'px', + width: '1px', + height: '1px', + zIndex: '-999999', + }); + parent.appendChild(transformReference); + const transformValues = transformReference.getBoundingClientRect(); + parent.removeChild(transformReference); + transformReference.remove(); + return { + xScale: 1 / transformValues.width, + yScale: 1 / transformValues.height, + xOffset: transformValues.left, + yOffset: transformValues.top, + }; + } + /** swap the given object 2 field values */ + static swap(o, a, b) { + if (!o) + return; + const tmp = o[a]; + o[a] = o[b]; + o[b] = tmp; + } + /** returns true if event is inside the given element rectangle */ + // Note: Safari Mac has null event.relatedTarget which causes #1684 so check if DragEvent is inside the coordinates instead + // Utils.el.contains(event.relatedTarget as HTMLElement) + // public static inside(e: MouseEvent, el: HTMLElement): boolean { + // // srcElement, toElement, target: all set to placeholder when leaving simple grid, so we can't use that (Chrome) + // const target: HTMLElement = e.relatedTarget || (e as any).fromElement; + // if (!target) { + // const { bottom, left, right, top } = el.getBoundingClientRect(); + // return (e.x < right && e.x > left && e.y < bottom && e.y > top); + // } + // return el.contains(target); + // } + /** true if the item can be rotated (checking for prop, not space available) */ + static canBeRotated(n) { + return !(!n || n.w === n.h || n.locked || n.noResize || n.grid?.opts.disableResize || (n.minW && n.minW === n.maxW) || (n.minH && n.minH === n.maxH)); + } +} +//# sourceMappingURL=utils.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/gridstack-engine.js +/** + * gridstack-engine.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ + +/** + * Defines the GridStack engine that handles all grid layout calculations and node positioning. + * This is the core engine that performs grid manipulation without any DOM operations. + * + * The engine manages: + * - Node positioning and collision detection + * - Layout algorithms (compact, float, etc.) + * - Grid resizing and column changes + * - Widget movement and resizing logic + * + * NOTE: Values should not be modified directly - use the main GridStack API instead + * to ensure proper DOM updates and event triggers. + */ +class GridStackEngine { + constructor(opts = {}) { + this.addedNodes = []; + this.removedNodes = []; + this.defaultColumn = 12; + this.column = opts.column || this.defaultColumn; + if (this.column > this.defaultColumn) + this.defaultColumn = this.column; + this.maxRow = opts.maxRow; + this._float = opts.float; + this.nodes = opts.nodes || []; + this.onChange = opts.onChange; + } + /** + * Enable/disable batch mode for multiple operations to optimize performance. + * When enabled, layout updates are deferred until batch mode is disabled. + * + * @param flag true to enable batch mode, false to disable and apply changes + * @param doPack if true (default), pack/compact nodes when disabling batch mode + * @returns the engine instance for chaining + * + * @example + * // Start batch mode for multiple operations + * engine.batchUpdate(true); + * engine.addNode(node1); + * engine.addNode(node2); + * engine.batchUpdate(false); // Apply all changes at once + */ + batchUpdate(flag = true, doPack = true) { + if (!!this.batchMode === flag) + return this; + this.batchMode = flag; + if (flag) { + this._prevFloat = this._float; + this._float = true; // let things go anywhere for now... will restore and possibly reposition later + this.cleanNodes(); + this.saveInitial(); // since begin update (which is called multiple times) won't do this + } + else { + this._float = this._prevFloat; + delete this._prevFloat; + if (doPack) + this._packNodes(); + this._notify(); + } + return this; + } + // use entire row for hitting area (will use bottom reverse sorted first) if we not actively moving DOWN and didn't already skip + _useEntireRowArea(node, nn) { + return (!this.float || this.batchMode && !this._prevFloat) && !this._hasLocked && (!node._moving || node._skipDown || nn.y <= node.y); + } + /** @internal fix collision on given 'node', going to given new location 'nn', with optional 'collide' node already found. + * return true if we moved. */ + _fixCollisions(node, nn = node, collide, opt = {}) { + this.sortNodes(-1); // from last to first, so recursive collision move items in the right order + collide = collide || this.collide(node, nn); // REAL area collide for swap and skip if none... + if (!collide) + return false; + // swap check: if we're actively moving in gravity mode, see if we collide with an object the same size + if (node._moving && !opt.nested && !this.float) { + if (this.swap(node, collide)) + return true; + } + // during while() collisions MAKE SURE to check entire row so larger items don't leap frog small ones (push them all down starting last in grid) + let area = nn; + if (!this._loading && this._useEntireRowArea(node, nn)) { + area = { x: 0, w: this.column, y: nn.y, h: nn.h }; + collide = this.collide(node, area, opt.skip); // force new hit + } + let didMove = false; + const newOpt = { nested: true, pack: false }; + let counter = 0; + while (collide = collide || this.collide(node, area, opt.skip)) { // could collide with more than 1 item... so repeat for each + if (counter++ > this.nodes.length * 2) { + throw new Error("Infinite collide check"); + } + let moved; + // if colliding with a locked item OR loading (move after) OR moving down with top gravity (and collide could move up) -> skip past the collide, + // but remember that skip down so we only do this once (and push others otherwise). + if (collide.locked || this._loading || node._moving && !node._skipDown && nn.y > node.y && !this.float && + // can take space we had, or before where we're going + (!this.collide(collide, { ...collide, y: node.y }, node) || !this.collide(collide, { ...collide, y: nn.y - collide.h }, node))) { + node._skipDown = (node._skipDown || nn.y > node.y); + const newNN = { ...nn, y: collide.y + collide.h, ...newOpt }; + // pretent we moved to where we are now so we can continue any collision checks #2492 + moved = this._loading && Utils.samePos(node, newNN) ? true : this.moveNode(node, newNN); + if ((collide.locked || this._loading) && moved) { + Utils.copyPos(nn, node); // moving after lock become our new desired location + } + else if (!collide.locked && moved && opt.pack) { + // we moved after and will pack: do it now and keep the original drop location, but past the old collide to see what else we might push way + this._packNodes(); + nn.y = collide.y + collide.h; + Utils.copyPos(node, nn); + } + didMove = didMove || moved; + } + else { + // move collide down *after* where we will be, ignoring where we are now (don't collide with us) + moved = this.moveNode(collide, { ...collide, y: nn.y + nn.h, skip: node, ...newOpt }); + } + if (!moved) + return didMove; // break inf loop if we couldn't move after all (ex: maxRow, fixed) + collide = undefined; + } + return didMove; + } + /** + * Return the first node that intercepts/collides with the given node or area. + * Used for collision detection during drag and drop operations. + * + * @param skip the node to skip in collision detection (usually the node being moved) + * @param area the area to check for collisions (defaults to skip node's area) + * @param skip2 optional second node to skip in collision detection + * @returns the first colliding node, or undefined if no collision + * + * @example + * const colliding = engine.collide(draggedNode, {x: 2, y: 1, w: 2, h: 1}); + * if (colliding) { + * console.log('Would collide with:', colliding.id); + * } + */ + collide(skip, area = skip, skip2) { + const skipId = skip._id; + const skip2Id = skip2?._id; + return this.nodes.find(n => n._id !== skipId && n._id !== skip2Id && Utils.isIntercepted(n, area)); + } + /** + * Return all nodes that intercept/collide with the given node or area. + * Similar to collide() but returns all colliding nodes instead of just the first. + * + * @param skip the node to skip in collision detection + * @param area the area to check for collisions (defaults to skip node's area) + * @param skip2 optional second node to skip in collision detection + * @returns array of all colliding nodes + * + * @example + * const allCollisions = engine.collideAll(draggedNode); + * console.log('Colliding with', allCollisions.length, 'nodes'); + */ + collideAll(skip, area = skip, skip2) { + const skipId = skip._id; + const skip2Id = skip2?._id; + return this.nodes.filter(n => n._id !== skipId && n._id !== skip2Id && Utils.isIntercepted(n, area)); + } + /** does a pixel coverage collision based on where we started, returning the node that has the most coverage that is >50% mid line */ + directionCollideCoverage(node, o, collides) { + if (!o.rect || !node._rect) + return; + const r0 = node._rect; // where started + const r = { ...o.rect }; // where we are + // update dragged rect to show where it's coming from (above or below, etc...) + if (r.y > r0.y) { + r.h += r.y - r0.y; + r.y = r0.y; + } + else { + r.h += r0.y - r.y; + } + if (r.x > r0.x) { + r.w += r.x - r0.x; + r.x = r0.x; + } + else { + r.w += r0.x - r.x; + } + let collide; + let overMax = 0.5; // need >50% + for (let n of collides) { + if (n.locked || !n._rect) { + break; + } + const r2 = n._rect; // overlapping target + let yOver = Number.MAX_VALUE, xOver = Number.MAX_VALUE; + // depending on which side we started from, compute the overlap % of coverage + // (ex: from above/below we only compute the max horizontal line coverage) + if (r0.y < r2.y) { // from above + yOver = ((r.y + r.h) - r2.y) / r2.h; + } + else if (r0.y + r0.h > r2.y + r2.h) { // from below + yOver = ((r2.y + r2.h) - r.y) / r2.h; + } + if (r0.x < r2.x) { // from the left + xOver = ((r.x + r.w) - r2.x) / r2.w; + } + else if (r0.x + r0.w > r2.x + r2.w) { // from the right + xOver = ((r2.x + r2.w) - r.x) / r2.w; + } + const over = Math.min(xOver, yOver); + if (over > overMax) { + overMax = over; + collide = n; + } + } + o.collide = collide; // save it so we don't have to find it again + return collide; + } + /** does a pixel coverage returning the node that has the most coverage by area */ + /* + protected collideCoverage(r: GridStackPosition, collides: GridStackNode[]): {collide: GridStackNode, over: number} { + const collide: GridStackNode; + const overMax = 0; + collides.forEach(n => { + if (n.locked || !n._rect) return; + const over = Utils.areaIntercept(r, n._rect); + if (over > overMax) { + overMax = over; + collide = n; + } + }); + return {collide, over: overMax}; + } + */ + /** + * Cache the pixel rectangles for all nodes used for collision detection during drag operations. + * This optimization converts grid coordinates to pixel coordinates for faster collision detection. + * + * @param w width of a single grid cell in pixels + * @param h height of a single grid cell in pixels + * @param top top margin/padding in pixels + * @param right right margin/padding in pixels + * @param bottom bottom margin/padding in pixels + * @param left left margin/padding in pixels + * @returns the engine instance for chaining + * + * @internal This is typically called by GridStack during resize events + */ + cacheRects(w, h, top, right, bottom, left) { + this.nodes.forEach(n => n._rect = { + y: n.y * h + top, + x: n.x * w + left, + w: n.w * w - left - right, + h: n.h * h - top - bottom + }); + return this; + } + /** + * Attempt to swap the positions of two nodes if they meet swapping criteria. + * Nodes can swap if they are the same size or in the same column/row, not locked, and touching. + * + * @param a first node to swap + * @param b second node to swap + * @returns true if swap was successful, false if not possible, undefined if not applicable + * + * @example + * const swapped = engine.swap(nodeA, nodeB); + * if (swapped) { + * console.log('Nodes swapped successfully'); + * } + */ + swap(a, b) { + if (!b || b.locked || !a || a.locked) + return false; + function _doSwap() { + const x = b.x, y = b.y; + b.x = a.x; + b.y = a.y; // b -> a position + if (a.h != b.h) { + a.x = x; + a.y = b.y + b.h; // a -> goes after b + } + else if (a.w != b.w) { + a.x = b.x + b.w; + a.y = y; // a -> goes after b + } + else { + a.x = x; + a.y = y; // a -> old b position + } + a._dirty = b._dirty = true; + return true; + } + let touching; // remember if we called it (vs undefined) + // same size and same row or column, and touching + if (a.w === b.w && a.h === b.h && (a.x === b.x || a.y === b.y) && (touching = Utils.isTouching(a, b))) + return _doSwap(); + if (touching === false) + return; // IFF ran test and fail, bail out + // check for taking same columns (but different height) and touching + if (a.w === b.w && a.x === b.x && (touching || (touching = Utils.isTouching(a, b)))) { + if (b.y < a.y) { + const t = a; + a = b; + b = t; + } // swap a <-> b vars so a is first + return _doSwap(); + } + if (touching === false) + return; + // check if taking same row (but different width) and touching + if (a.h === b.h && a.y === b.y && (touching || (touching = Utils.isTouching(a, b)))) { + if (b.x < a.x) { + const t = a; + a = b; + b = t; + } // swap a <-> b vars so a is first + return _doSwap(); + } + return false; + } + /** + * Check if the specified rectangular area is empty (no nodes occupy any part of it). + * + * @param x the x coordinate (column) of the area to check + * @param y the y coordinate (row) of the area to check + * @param w the width in columns of the area to check + * @param h the height in rows of the area to check + * @returns true if the area is completely empty, false if any node overlaps + * + * @example + * if (engine.isAreaEmpty(2, 1, 3, 2)) { + * console.log('Area is available for placement'); + * } + */ + isAreaEmpty(x, y, w, h) { + const nn = { x: x || 0, y: y || 0, w: w || 1, h: h || 1 }; + return !this.collide(nn); + } + /** + * Re-layout grid items to reclaim any empty space. + * This optimizes the grid layout by moving items to fill gaps. + * + * @param layout layout algorithm to use: + * - 'compact' (default): find truly empty spaces, may reorder items + * - 'list': keep the sort order exactly the same, move items up sequentially + * @param doSort if true (default), sort nodes by position before compacting + * @returns the engine instance for chaining + * + * @example + * // Compact to fill empty spaces + * engine.compact(); + * + * // Compact preserving item order + * engine.compact('list'); + */ + compact(layout = 'compact', doSort = true) { + if (this.nodes.length === 0) + return this; + if (doSort) + this.sortNodes(); + const wasBatch = this.batchMode; + if (!wasBatch) + this.batchUpdate(); + const wasColumnResize = this._inColumnResize; + if (!wasColumnResize) + this._inColumnResize = true; // faster addNode() + const copyNodes = this.nodes; + this.nodes = []; // pretend we have no nodes to conflict layout to start with... + copyNodes.forEach((n, index, list) => { + let after; + if (!n.locked) { + n.autoPosition = true; + if (layout === 'list' && index) + after = list[index - 1]; + } + this.addNode(n, false, after); // 'false' for add event trigger + }); + if (!wasColumnResize) + delete this._inColumnResize; + if (!wasBatch) + this.batchUpdate(false); + return this; + } + /** + * Enable/disable floating widgets (default: `false`). + * When floating is enabled, widgets can move up to fill empty spaces. + * See [example](http://gridstackjs.com/demo/float.html) + * + * @param val true to enable floating, false to disable + * + * @example + * engine.float = true; // Enable floating + * engine.float = false; // Disable floating (default) + */ + set float(val) { + if (this._float === val) + return; + this._float = val || false; + if (!val) { + this._packNodes()._notify(); + } + } + /** + * Get the current floating mode setting. + * + * @returns true if floating is enabled, false otherwise + * + * @example + * const isFloating = engine.float; + * console.log('Floating enabled:', isFloating); + */ + get float() { return this._float || false; } + /** + * Sort the nodes array from first to last, or reverse. + * This is called during collision/placement operations to enforce a specific order. + * + * @param dir sort direction: 1 for ascending (first to last), -1 for descending (last to first) + * @returns the engine instance for chaining + * + * @example + * engine.sortNodes(); // Sort ascending (default) + * engine.sortNodes(-1); // Sort descending + */ + sortNodes(dir = 1) { + this.nodes = Utils.sort(this.nodes, dir); + return this; + } + /** @internal called to top gravity pack the items back OR revert back to original Y positions when floating */ + _packNodes() { + if (this.batchMode) { + return this; + } + this.sortNodes(); // first to last + if (this.float) { + // restore original Y pos + this.nodes.forEach(n => { + if (n._updating || n._orig === undefined || n.y === n._orig.y) + return; + let newY = n.y; + while (newY > n._orig.y) { + --newY; + const collide = this.collide(n, { x: n.x, y: newY, w: n.w, h: n.h }); + if (!collide) { + n._dirty = true; + n.y = newY; + } + } + }); + } + else { + // top gravity pack + this.nodes.forEach((n, i) => { + if (n.locked) + return; + while (n.y > 0) { + const newY = i === 0 ? 0 : n.y - 1; + const canBeMoved = i === 0 || !this.collide(n, { x: n.x, y: newY, w: n.w, h: n.h }); + if (!canBeMoved) + break; + // Note: must be dirty (from last position) for GridStack::OnChange CB to update positions + // and move items back. The user 'change' CB should detect changes from the original + // starting position instead. + n._dirty = (n.y !== newY); + n.y = newY; + } + }); + } + return this; + } + /** + * Prepare and validate a node's coordinates and values for the current grid. + * This ensures the node has valid position, size, and properties before being added to the grid. + * + * @param node the node to prepare and validate + * @param resizing if true, resize the node down if it's out of bounds; if false, move it to fit + * @returns the prepared node with valid coordinates + * + * @example + * const node = { w: 3, h: 2, content: 'Hello' }; + * const prepared = engine.prepareNode(node); + * console.log('Node prepared at:', prepared.x, prepared.y); + */ + prepareNode(node, resizing) { + node._id = node._id ?? GridStackEngine._idSeq++; + // make sure USER supplied id are unique in our list, else assign a new one as it will create issues during load/update/etc... + const id = node.id; + if (id) { + let count = 1; // append nice _n rather than some random number + while (this.nodes.find(n => n.id === node.id && n !== node)) { + node.id = id + '_' + (count++); + } + } + // if we're missing position, have the grid position us automatically (before we set them to 0,0) + if (node.x === undefined || node.y === undefined || node.x === null || node.y === null) { + node.autoPosition = true; + } + // assign defaults for missing required fields + const defaults = { x: 0, y: 0, w: 1, h: 1 }; + Utils.defaults(node, defaults); + if (!node.autoPosition) { + delete node.autoPosition; + } + if (!node.noResize) { + delete node.noResize; + } + if (!node.noMove) { + delete node.noMove; + } + Utils.sanitizeMinMax(node); + // check for NaN (in case messed up strings were passed. can't do parseInt() || defaults.x above as 0 is valid #) + if (typeof node.x == 'string') { + node.x = Number(node.x); + } + if (typeof node.y == 'string') { + node.y = Number(node.y); + } + if (typeof node.w == 'string') { + node.w = Number(node.w); + } + if (typeof node.h == 'string') { + node.h = Number(node.h); + } + if (isNaN(node.x)) { + node.x = defaults.x; + node.autoPosition = true; + } + if (isNaN(node.y)) { + node.y = defaults.y; + node.autoPosition = true; + } + if (isNaN(node.w)) { + node.w = defaults.w; + } + if (isNaN(node.h)) { + node.h = defaults.h; + } + this.nodeBoundFix(node, resizing); + return node; + } + /** + * Part 2 of preparing a node to fit inside the grid - validates and fixes coordinates and dimensions. + * This ensures the node fits within grid boundaries and respects min/max constraints. + * + * @param node the node to validate and fix + * @param resizing if true, resize the node to fit; if false, move the node to fit + * @returns the engine instance for chaining + * + * @example + * // Fix a node that might be out of bounds + * engine.nodeBoundFix(node, true); // Resize to fit + * engine.nodeBoundFix(node, false); // Move to fit + */ + nodeBoundFix(node, resizing) { + const before = node._orig || Utils.copyPos({}, node); + if (node.maxW) { + node.w = Math.min(node.w || 1, node.maxW); + } + if (node.maxH) { + node.h = Math.min(node.h || 1, node.maxH); + } + if (node.minW) { + node.w = Math.max(node.w || 1, node.minW); + } + if (node.minH) { + node.h = Math.max(node.h || 1, node.minH); + } + // if user loaded a larger than allowed widget for current # of columns, + // remember it's position & width so we can restore back (1 -> 12 column) #1655 #1985 + // IFF we're not in the middle of column resizing! + const saveOrig = (node.x || 0) + (node.w || 1) > this.column; + if (saveOrig && this.column < this.defaultColumn && !this._inColumnResize && !this.skipCacheUpdate && node._id != null && this.findCacheLayout(node, this.defaultColumn) === -1) { + const copy = { ...node }; // need _id + positions + if (copy.autoPosition || copy.x === undefined) { + delete copy.x; + delete copy.y; + } + else + copy.x = Math.min(this.defaultColumn - 1, copy.x); + copy.w = Math.min(this.defaultColumn, copy.w || 1); + this.cacheOneLayout(copy, this.defaultColumn); + } + if (node.w > this.column) { + node.w = this.column; + } + else if (node.w < 1) { + node.w = 1; + } + if (this.maxRow && node.h > this.maxRow) { + node.h = this.maxRow; + } + else if (node.h < 1) { + node.h = 1; + } + if (node.x < 0) { + node.x = 0; + } + if (node.y < 0) { + node.y = 0; + } + if (node.x + node.w > this.column) { + if (resizing) { + node.w = this.column - node.x; + } + else { + node.x = this.column - node.w; + } + } + if (this.maxRow && node.y + node.h > this.maxRow) { + if (resizing) { + node.h = this.maxRow - node.y; + } + else { + node.y = this.maxRow - node.h; + } + } + if (!Utils.samePos(node, before)) { + node._dirty = true; + } + return this; + } + /** + * Returns a list of nodes that have been modified from their original values. + * This is used to track which nodes need DOM updates. + * + * @param verify if true, performs additional verification by comparing current vs original positions + * @returns array of nodes that have been modified + * + * @example + * const changed = engine.getDirtyNodes(); + * console.log('Modified nodes:', changed.length); + * + * // Get verified dirty nodes + * const verified = engine.getDirtyNodes(true); + */ + getDirtyNodes(verify) { + // compare original x,y,w,h instead as _dirty can be a temporary state + if (verify) { + return this.nodes.filter(n => n._dirty && !Utils.samePos(n, n._orig)); + } + return this.nodes.filter(n => n._dirty); + } + /** @internal call this to call onChange callback with dirty nodes so DOM can be updated */ + _notify(removedNodes) { + if (this.batchMode || !this.onChange) + return this; + const dirtyNodes = (removedNodes || []).concat(this.getDirtyNodes()); + this.onChange(dirtyNodes); + return this; + } + /** + * Clean all dirty and last tried information from nodes. + * This resets the dirty state tracking for all nodes. + * + * @returns the engine instance for chaining + * + * @internal + */ + cleanNodes() { + if (this.batchMode) + return this; + this.nodes.forEach(n => { + delete n._dirty; + delete n._lastTried; + }); + return this; + } + /** + * Save the initial position/size of all nodes to track real dirty state. + * This creates a snapshot of current positions that can be restored later. + * + * Note: Should be called right after change events and before move/resize operations. + * + * @returns the engine instance for chaining + * + * @internal + */ + saveInitial() { + this.nodes.forEach(n => { + n._orig = Utils.copyPos({}, n); + delete n._dirty; + }); + this._hasLocked = this.nodes.some(n => n.locked); + return this; + } + /** + * Restore all nodes back to their initial values. + * This is typically called when canceling an operation (e.g., Esc key during drag). + * + * @returns the engine instance for chaining + * + * @internal + */ + restoreInitial() { + this.nodes.forEach(n => { + if (!n._orig || Utils.samePos(n, n._orig)) + return; + Utils.copyPos(n, n._orig); + n._dirty = true; + }); + this._notify(); + return this; + } + /** + * Find the first available empty spot for the given node dimensions. + * Updates the node's x,y attributes with the found position. + * + * @param node the node to find a position for (w,h must be set) + * @param nodeList optional list of nodes to check against (defaults to engine nodes) + * @param column optional column count (defaults to engine column count) + * @param after optional node to start search after (maintains order) + * @returns true if an empty position was found and node was updated + * + * @example + * const node = { w: 2, h: 1 }; + * if (engine.findEmptyPosition(node)) { + * console.log('Found position at:', node.x, node.y); + * } + */ + findEmptyPosition(node, nodeList = this.nodes, column = this.column, after) { + const start = after ? after.y * column + (after.x + after.w) : 0; + let found = false; + for (let i = start; !found; ++i) { + const x = i % column; + const y = Math.floor(i / column); + if (x + node.w > column) { + continue; + } + const box = { x, y, w: node.w, h: node.h }; + if (!nodeList.find(n => Utils.isIntercepted(box, n))) { + if (node.x !== x || node.y !== y) + node._dirty = true; + node.x = x; + node.y = y; + delete node.autoPosition; + found = true; + } + } + return found; + } + /** + * Add the given node to the grid, handling collision detection and re-packing. + * This is the main method for adding new widgets to the engine. + * + * @param node the node to add to the grid + * @param triggerAddEvent if true, adds node to addedNodes list for event triggering + * @param after optional node to place this node after (for ordering) + * @returns the added node (or existing node if duplicate) + * + * @example + * const node = { x: 0, y: 0, w: 2, h: 1, content: 'Hello' }; + * const added = engine.addNode(node, true); + */ + addNode(node, triggerAddEvent = false, after) { + const dup = this.nodes.find(n => n._id === node._id); + if (dup) + return dup; // prevent inserting twice! return it instead. + // skip prepareNode if we're in middle of column resize (not new) but do check for bounds! + this._inColumnResize ? this.nodeBoundFix(node) : this.prepareNode(node); + delete node._temporaryRemoved; + delete node._removeDOM; + let skipCollision; + if (node.autoPosition && this.findEmptyPosition(node, this.nodes, this.column, after)) { + delete node.autoPosition; // found our slot + skipCollision = true; + } + this.nodes.push(node); + if (triggerAddEvent) { + this.addedNodes.push(node); + } + if (!skipCollision) + this._fixCollisions(node); + if (!this.batchMode) { + this._packNodes()._notify(); + } + return node; + } + /** + * Remove the given node from the grid. + * + * @param node the node to remove + * @param removeDOM if true (default), marks node for DOM removal + * @param triggerEvent if true, adds node to removedNodes list for event triggering + * @returns the engine instance for chaining + * + * @example + * engine.removeNode(node, true, true); + */ + removeNode(node, removeDOM = true, triggerEvent = false) { + if (!this.nodes.find(n => n._id === node._id)) { + // TEST console.log(`Error: GridStackEngine.removeNode() node._id=${node._id} not found!`) + return this; + } + if (triggerEvent) { // we wait until final drop to manually track removed items (rather than during drag) + this.removedNodes.push(node); + } + if (removeDOM) + node._removeDOM = true; // let CB remove actual HTML (used to set _id to null, but then we loose layout info) + // don't use 'faster' .splice(findIndex(),1) in case node isn't in our list, or in multiple times. + this.nodes = this.nodes.filter(n => n._id !== node._id); + if (!node._isAboutToRemove) + this._packNodes(); // if dragged out, no need to relayout as already done... + this._notify([node]); + return this; + } + /** + * Remove all nodes from the grid. + * + * @param removeDOM if true (default), marks all nodes for DOM removal + * @param triggerEvent if true (default), triggers removal events + * @returns the engine instance for chaining + * + * @example + * engine.removeAll(); // Remove all nodes + */ + removeAll(removeDOM = true, triggerEvent = true) { + delete this._layouts; + if (!this.nodes.length) + return this; + removeDOM && this.nodes.forEach(n => n._removeDOM = true); // let CB remove actual HTML (used to set _id to null, but then we loose layout info) + const removedNodes = this.nodes; + this.removedNodes = triggerEvent ? removedNodes : []; + this.nodes = []; + return this._notify(removedNodes); + } + /** + * Check if a node can be moved to a new position, considering layout constraints. + * This is a safer version of moveNode() that validates the move first. + * + * For complex cases (like maxRow constraints), it simulates the move in a clone first, + * then applies the changes only if they meet all specifications. + * + * @param node the node to move + * @param o move options including target position + * @returns true if the node was successfully moved + * + * @example + * const canMove = engine.moveNodeCheck(node, { x: 2, y: 1 }); + * if (canMove) { + * console.log('Node moved successfully'); + * } + */ + moveNodeCheck(node, o) { + // if (node.locked) return false; + if (!this.changedPosConstrain(node, o)) + return false; + o.pack = true; + // simpler case: move item directly... + if (!this.maxRow) { + return this.moveNode(node, o); + } + // complex case: create a clone with NO maxRow (will check for out of bounds at the end) + let clonedNode; + const clone = new GridStackEngine({ + column: this.column, + float: this.float, + nodes: this.nodes.map(n => { + if (n._id === node._id) { + clonedNode = { ...n }; + return clonedNode; + } + return { ...n }; + }) + }); + if (!clonedNode) + return false; + // check if we're covering 50% collision and could move, while still being under maxRow or at least not making it worse + // (case where widget was somehow added past our max #2449) + const canMove = clone.moveNode(clonedNode, o) && clone.getRow() <= Math.max(this.getRow(), this.maxRow); + // else check if we can force a swap (float=true, or different shapes) on non-resize + if (!canMove && !o.resizing && o.collide) { + const collide = o.collide.el.gridstackNode; // find the source node the clone collided with at 50% + if (this.swap(node, collide)) { // swaps and mark dirty + this._notify(); + return true; + } + } + if (!canMove) + return false; + // if clone was able to move, copy those mods over to us now instead of caller trying to do this all over! + // Note: we can't use the list directly as elements and other parts point to actual node, so copy content + clone.nodes.filter(n => n._dirty).forEach(c => { + const n = this.nodes.find(a => a._id === c._id); + if (!n) + return; + Utils.copyPos(n, c); + n._dirty = true; + }); + this._notify(); + return true; + } + /** return true if can fit in grid height constrain only (always true if no maxRow) */ + willItFit(node) { + delete node._willFitPos; + if (!this.maxRow) + return true; + // create a clone with NO maxRow and check if still within size + const clone = new GridStackEngine({ + column: this.column, + float: this.float, + nodes: this.nodes.map(n => { return { ...n }; }) + }); + const n = { ...node }; // clone node so we don't mod any settings on it but have full autoPosition and min/max as well! #1687 + this.cleanupNode(n); + delete n.el; + delete n._id; + delete n.content; + delete n.grid; + clone.addNode(n); + if (clone.getRow() <= this.maxRow) { + node._willFitPos = Utils.copyPos({}, n); + return true; + } + return false; + } + /** true if x,y or w,h are different after clamping to min/max */ + changedPosConstrain(node, p) { + // first make sure w,h are set for caller + p.w = p.w || node.w; + p.h = p.h || node.h; + if (node.x !== p.x || node.y !== p.y) + return true; + // check constrained w,h + if (node.maxW) { + p.w = Math.min(p.w, node.maxW); + } + if (node.maxH) { + p.h = Math.min(p.h, node.maxH); + } + if (node.minW) { + p.w = Math.max(p.w, node.minW); + } + if (node.minH) { + p.h = Math.max(p.h, node.minH); + } + return (node.w !== p.w || node.h !== p.h); + } + /** return true if the passed in node was actually moved (checks for no-op and locked) */ + moveNode(node, o) { + if (!node || /*node.locked ||*/ !o) + return false; + let wasUndefinedPack; + if (o.pack === undefined && !this.batchMode) { + wasUndefinedPack = o.pack = true; + } + // constrain the passed in values and check if we're still changing our node + if (typeof o.x !== 'number') { + o.x = node.x; + } + if (typeof o.y !== 'number') { + o.y = node.y; + } + if (typeof o.w !== 'number') { + o.w = node.w; + } + if (typeof o.h !== 'number') { + o.h = node.h; + } + const resizing = (node.w !== o.w || node.h !== o.h); + const nn = Utils.copyPos({}, node, true); // get min/max out first, then opt positions next + Utils.copyPos(nn, o); + this.nodeBoundFix(nn, resizing); + Utils.copyPos(o, nn); + if (!o.forceCollide && Utils.samePos(node, o)) + return false; + const prevPos = Utils.copyPos({}, node); + // check if we will need to fix collision at our new location + const collides = this.collideAll(node, nn, o.skip); + let needToMove = true; + if (collides.length) { + const activeDrag = node._moving && !o.nested; + // check to make sure we actually collided over 50% surface area while dragging + let collide = activeDrag ? this.directionCollideCoverage(node, o, collides) : collides[0]; + // if we're enabling creation of sub-grids on the fly, see if we're covering 80% of either one, if we didn't already do that + if (activeDrag && collide && node.grid?.opts?.subGridDynamic && !node.grid._isTemp) { + const over = Utils.areaIntercept(o.rect, collide._rect); + const a1 = Utils.area(o.rect); + const a2 = Utils.area(collide._rect); + const perc = over / (a1 < a2 ? a1 : a2); + if (perc > .8) { + collide.grid.makeSubGrid(collide.el, undefined, node); + collide = undefined; + } + } + if (collide) { + needToMove = !this._fixCollisions(node, nn, collide, o); // check if already moved... + } + else { + needToMove = false; // we didn't cover >50% for a move, skip... + if (wasUndefinedPack) + delete o.pack; + } + } + // now move (to the original ask vs the collision version which might differ) and repack things + if (needToMove && !Utils.samePos(node, nn)) { + node._dirty = true; + Utils.copyPos(node, nn); + } + if (o.pack) { + this._packNodes() + ._notify(); + } + return !Utils.samePos(node, prevPos); // pack might have moved things back + } + getRow() { + return this.nodes.reduce((row, n) => Math.max(row, n.y + n.h), 0); + } + beginUpdate(node) { + if (!node._updating) { + node._updating = true; + delete node._skipDown; + if (!this.batchMode) + this.saveInitial(); + } + return this; + } + endUpdate() { + const n = this.nodes.find(n => n._updating); + if (n) { + delete n._updating; + delete n._skipDown; + } + return this; + } + /** saves a copy of the largest column layout (eg 12 even when rendering 1 column) so we don't loose orig layout, unless explicity column + * count to use is given. returning a list of widgets for serialization + * @param saveElement if true (default), the element will be saved to GridStackWidget.el field, else it will be removed. + * @param saveCB callback for each node -> widget, so application can insert additional data to be saved into the widget data structure. + * @param column if provided, the grid will be saved for the given column count (IFF we have matching internal saved layout, or current layout). + * Note: nested grids will ALWAYS save the container w to match overall layouts (parent + child) to be consistent. + */ + save(saveElement = true, saveCB, column) { + // use the highest layout for any saved info so we can have full detail on reload #1849 + // unless we're given a column to match (always set for nested grids) + const len = this._layouts?.length || 0; + let layout; + if (len) { + if (column) { + if (column !== this.column) + layout = this._layouts[column]; + } + else if (this.column !== len - 1) { + layout = this._layouts[len - 1]; + } + } + const list = []; + this.sortNodes(); + this.nodes.forEach(n => { + const wl = layout?.find(l => l._id === n._id); + // use layout info fields instead if set + const w = { ...n, ...(wl || {}) }; + Utils.removeInternalForSave(w, !saveElement); + if (saveCB) + saveCB(n, w); + list.push(w); + }); + return list; + } + /** @internal called whenever a node is added or moved - updates the cached layouts */ + layoutsNodesChange(nodes) { + if (!this._layouts || this._inColumnResize) + return this; + // remove smaller layouts - we will re-generate those on the fly... larger ones need to update + this._layouts.forEach((layout, column) => { + if (!layout || column === this.column) + return this; + if (column < this.column) { + this._layouts[column] = undefined; + } + else { + // we save the original x,y,w (h isn't cached) to see what actually changed to propagate better. + // NOTE: we don't need to check against out of bound scaling/moving as that will be done when using those cache values. #1785 + const ratio = column / this.column; + nodes.forEach(node => { + if (!node._orig) + return; // didn't change (newly added ?) + const n = layout.find(l => l._id === node._id); + if (!n) + return; // no cache for new nodes. Will use those values. + // Y changed, push down same amount + // TODO: detect doing item 'swaps' will help instead of move (especially in 1 column mode) + if (n.y >= 0 && node.y !== node._orig.y) { + n.y += (node.y - node._orig.y); + } + // X changed, scale from new position + if (node.x !== node._orig.x) { + n.x = Math.round(node.x * ratio); + } + // width changed, scale from new width + if (node.w !== node._orig.w) { + n.w = Math.round(node.w * ratio); + } + // ...height always carries over from cache + }); + } + }); + return this; + } + /** + * @internal Called to scale the widget width & position up/down based on the column change. + * Note we store previous layouts (especially original ones) to make it possible to go + * from say 12 -> 1 -> 12 and get back to where we were. + * + * @param prevColumn previous number of columns + * @param column new column number + * @param layout specify the type of re-layout that will happen (position, size, etc...). + * Note: items will never be outside of the current column boundaries. default (moveScale). Ignored for 1 column + */ + columnChanged(prevColumn, column, layout = 'moveScale') { + if (!this.nodes.length || !column || prevColumn === column) + return this; + // simpler shortcuts layouts + const doCompact = layout === 'compact' || layout === 'list'; + if (doCompact) { + this.sortNodes(1); // sort with original layout once and only once (new column will affect order otherwise) + } + // cache the current layout in case they want to go back (like 12 -> 1 -> 12) as it requires original data IFF we're sizing down (see below) + if (column < prevColumn) + this.cacheLayout(this.nodes, prevColumn); + this.batchUpdate(); // do this EARLY as it will call saveInitial() so we can detect where we started for _dirty and collision + let newNodes = []; + let nodes = doCompact ? this.nodes : Utils.sort(this.nodes, -1); // current column reverse sorting so we can insert last to front (limit collision) + // see if we have cached previous layout IFF we are going up in size (restore) otherwise always + // generate next size down from where we are (looks more natural as you gradually size down). + if (column > prevColumn && this._layouts) { + const cacheNodes = this._layouts[column] || []; + // ...if not, start with the largest layout (if not already there) as down-scaling is more accurate + // by pretending we came from that larger column by assigning those values as starting point + const lastIndex = this._layouts.length - 1; + if (!cacheNodes.length && prevColumn !== lastIndex && this._layouts[lastIndex]?.length) { + prevColumn = lastIndex; + this._layouts[lastIndex].forEach(cacheNode => { + const n = nodes.find(n => n._id === cacheNode._id); + if (n) { + // still current, use cache info positions + if (!doCompact && !cacheNode.autoPosition) { + n.x = cacheNode.x ?? n.x; + n.y = cacheNode.y ?? n.y; + } + n.w = cacheNode.w ?? n.w; + if (cacheNode.x == undefined || cacheNode.y === undefined) + n.autoPosition = true; + } + }); + } + // if we found cache re-use those nodes that are still current + cacheNodes.forEach(cacheNode => { + const j = nodes.findIndex(n => n._id === cacheNode._id); + if (j !== -1) { + const n = nodes[j]; + // still current, use cache info positions + if (doCompact) { + n.w = cacheNode.w; // only w is used, and don't trim the list + return; + } + if (cacheNode.autoPosition || isNaN(cacheNode.x) || isNaN(cacheNode.y)) { + this.findEmptyPosition(cacheNode, newNodes); + } + if (!cacheNode.autoPosition) { + n.x = cacheNode.x ?? n.x; + n.y = cacheNode.y ?? n.y; + n.w = cacheNode.w ?? n.w; + newNodes.push(n); + } + nodes.splice(j, 1); + } + }); + } + // much simpler layout that just compacts + if (doCompact) { + this.compact(layout, false); + } + else { + // ...and add any extra non-cached ones + if (nodes.length) { + if (typeof layout === 'function') { + layout(column, prevColumn, newNodes, nodes); + } + else { + const ratio = (doCompact || layout === 'none') ? 1 : column / prevColumn; + const move = (layout === 'move' || layout === 'moveScale'); + const scale = (layout === 'scale' || layout === 'moveScale'); + nodes.forEach(node => { + // NOTE: x + w could be outside of the grid, but addNode() below will handle that + node.x = (column === 1 ? 0 : (move ? Math.round(node.x * ratio) : Math.min(node.x, column - 1))); + node.w = ((column === 1 || prevColumn === 1) ? 1 : scale ? (Math.round(node.w * ratio) || 1) : (Math.min(node.w, column))); + newNodes.push(node); + }); + nodes = []; + } + } + // finally re-layout them in reverse order (to get correct placement) + newNodes = Utils.sort(newNodes, -1); + this._inColumnResize = true; // prevent cache update + this.nodes = []; // pretend we have no nodes to start with (add() will use same structures) to simplify layout + newNodes.forEach(node => { + this.addNode(node, false); // 'false' for add event trigger + delete node._orig; // make sure the commit doesn't try to restore things back to original + }); + } + this.nodes.forEach(n => delete n._orig); // clear _orig before batch=false so it doesn't handle float=true restore + this.batchUpdate(false, !doCompact); + delete this._inColumnResize; + return this; + } + /** + * call to cache the given layout internally to the given location so we can restore back when column changes size + * @param nodes list of nodes + * @param column corresponding column index to save it under + * @param clear if true, will force other caches to be removed (default false) + */ + cacheLayout(nodes, column, clear = false) { + const copy = []; + nodes.forEach((n, i) => { + // make sure we have an id in case this is new layout, else re-use id already set + if (n._id === undefined) { + const existing = n.id ? this.nodes.find(n2 => n2.id === n.id) : undefined; // find existing node using users id + n._id = existing?._id ?? GridStackEngine._idSeq++; + } + copy[i] = { x: n.x, y: n.y, w: n.w, _id: n._id }; // only thing we change is x,y,w and id to find it back + }); + this._layouts = clear ? [] : this._layouts || []; // use array to find larger quick + this._layouts[column] = copy; + return this; + } + /** + * call to cache the given node layout internally to the given location so we can restore back when column changes size + * @param node single node to cache + * @param column corresponding column index to save it under + */ + cacheOneLayout(n, column) { + n._id = n._id ?? GridStackEngine._idSeq++; + const l = { x: n.x, y: n.y, w: n.w, _id: n._id }; + if (n.autoPosition || n.x === undefined) { + delete l.x; + delete l.y; + if (n.autoPosition) + l.autoPosition = true; + } + this._layouts = this._layouts || []; + this._layouts[column] = this._layouts[column] || []; + const index = this.findCacheLayout(n, column); + if (index === -1) + this._layouts[column].push(l); + else + this._layouts[column][index] = l; + return this; + } + findCacheLayout(n, column) { + return this._layouts?.[column]?.findIndex(l => l._id === n._id) ?? -1; + } + removeNodeFromLayoutCache(n) { + if (!this._layouts) { + return; + } + for (let i = 0; i < this._layouts.length; i++) { + const index = this.findCacheLayout(n, i); + if (index !== -1) { + this._layouts[i].splice(index, 1); + } + } + } + /** called to remove all internal values but the _id */ + cleanupNode(node) { + for (const prop in node) { + if (prop[0] === '_' && prop !== '_id') + delete node[prop]; + } + return this; + } +} +/** @internal unique global internal _id counter */ +GridStackEngine._idSeq = 0; + +//# sourceMappingURL=gridstack-engine.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/types.js +/** + * types.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ +/** + * Default values for grid options - used during initialization and when saving out grid configuration. + * These values are applied when options are not explicitly provided. + */ +const gridDefaults = { + alwaysShowResizeHandle: 'mobile', + animate: true, + auto: true, + cellHeight: 'auto', + cellHeightThrottle: 100, + cellHeightUnit: 'px', + column: 12, + draggable: { handle: '.grid-stack-item-content', appendTo: 'body', scroll: true }, + handle: '.grid-stack-item-content', + itemClass: 'grid-stack-item', + margin: 10, + marginUnit: 'px', + maxRow: 0, + minRow: 0, + placeholderClass: 'grid-stack-placeholder', + placeholderText: '', + removableOptions: { accept: 'grid-stack-item', decline: 'grid-stack-non-removable' }, + resizable: { handles: 'se' }, + rtl: 'auto', + // **** same as not being set **** + // disableDrag: false, + // disableResize: false, + // float: false, + // handleClass: null, + // removable: false, + // staticGrid: false, + //removable +}; +//# sourceMappingURL=types.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/dd-manager.js +/** + * dd-manager.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ +/** + * Global state manager for all Drag & Drop instances. + * + * This class maintains shared state across all drag & drop operations, + * ensuring proper coordination between multiple grids and drag/drop elements. + * All properties are static to provide global access throughout the DD system. + */ +class DDManager { +} +//# sourceMappingURL=dd-manager.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/dd-touch.js +/** + * touch.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ + + +/** + * Detect touch support - Windows Surface devices and other touch devices + * should we use this instead ? (what we had for always showing resize handles) + * /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) + */ +const isTouch = typeof window !== 'undefined' && typeof document !== 'undefined' && + ('ontouchstart' in document + || 'ontouchstart' in window + // || !!window.TouchEvent // true on Windows 10 Chrome desktop so don't use this + // eslint-disable-next-line @typescript-eslint/no-explicit-any + || (window.DocumentTouch && document instanceof window.DocumentTouch) + || navigator.maxTouchPoints > 0 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + || navigator.msMaxTouchPoints > 0); +// interface TouchCoord {x: number, y: number}; +class DDTouch { +} +/** +* Get the x,y position of a touch event +*/ +// function getTouchCoords(e: TouchEvent): TouchCoord { +// return { +// x: e.changedTouches[0].pageX, +// y: e.changedTouches[0].pageY +// }; +// } +/** + * Simulate a mouse event based on a corresponding touch event + * @param {Object} e A touch event + * @param {String} simulatedType The corresponding mouse event + */ +function simulateMouseEvent(e, simulatedType) { + // Ignore multi-touch events + if (e.touches.length > 1) + return; + // Prevent "Ignored attempt to cancel a touchmove event with cancelable=false" errors + if (e.cancelable) + e.preventDefault(); + // Dispatch the simulated event to the target element + Utils.simulateMouseEvent(e.changedTouches[0], simulatedType); +} +/** + * Simulate a mouse event based on a corresponding Pointer event + * @param {Object} e A pointer event + * @param {String} simulatedType The corresponding mouse event + */ +function simulatePointerMouseEvent(e, simulatedType) { + // Prevent "Ignored attempt to cancel a touchmove event with cancelable=false" errors + if (e.cancelable) + e.preventDefault(); + // Dispatch the simulated event to the target element + Utils.simulateMouseEvent(e, simulatedType); +} +/** + * Handle the touchstart events + * @param {Object} e The widget element's touchstart event + */ +function touchstart(e) { + // Ignore the event if another widget is already being handled + if (DDTouch.touchHandled) + return; + DDTouch.touchHandled = true; + // Simulate the mouse events + // simulateMouseEvent(e, 'mouseover'); + // simulateMouseEvent(e, 'mousemove'); + simulateMouseEvent(e, 'mousedown'); +} +/** + * Handle the touchmove events + * @param {Object} e The document's touchmove event + */ +function touchmove(e) { + // Ignore event if not handled by us + if (!DDTouch.touchHandled) + return; + simulateMouseEvent(e, 'mousemove'); +} +/** + * Handle the touchend events + * @param {Object} e The document's touchend event + */ +function touchend(e) { + // Ignore event if not handled + if (!DDTouch.touchHandled) + return; + // cancel delayed leave event when we release on ourself which happens BEFORE we get this! + if (DDTouch.pointerLeaveTimeout) { + window.clearTimeout(DDTouch.pointerLeaveTimeout); + delete DDTouch.pointerLeaveTimeout; + } + const wasDragging = !!DDManager.dragElement; + // Simulate the mouseup event + simulateMouseEvent(e, 'mouseup'); + // simulateMouseEvent(event, 'mouseout'); + // If the touch interaction did not move, it should trigger a click + if (!wasDragging) { + simulateMouseEvent(e, 'click'); + } + // Unset the flag to allow other widgets to inherit the touch event + DDTouch.touchHandled = false; +} +/** + * Note we don't get touchenter/touchleave (which are deprecated) + * see https://stackoverflow.com/questions/27908339/js-touch-equivalent-for-mouseenter + * so instead of PointerEvent to still get enter/leave and send the matching mouse event. + */ +function pointerdown(e) { + // console.log("pointer down") + if (e.pointerType === 'mouse') + return; + e.target.releasePointerCapture(e.pointerId); // <- Important! +} +function pointerenter(e) { + // ignore the initial one we get on pointerdown on ourself + if (!DDManager.dragElement) { + // console.log('pointerenter ignored'); + return; + } + // console.log('pointerenter'); + if (e.pointerType === 'mouse') + return; + simulatePointerMouseEvent(e, 'mouseenter'); +} +function pointerleave(e) { + // ignore the leave on ourself we get before releasing the mouse over ourself + // by delaying sending the event and having the up event cancel us + if (!DDManager.dragElement) { + // console.log('pointerleave ignored'); + return; + } + if (e.pointerType === 'mouse') + return; + DDTouch.pointerLeaveTimeout = window.setTimeout(() => { + delete DDTouch.pointerLeaveTimeout; + // console.log('pointerleave delayed'); + simulatePointerMouseEvent(e, 'mouseleave'); + }, 10); +} +//# sourceMappingURL=dd-touch.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/dd-resizable-handle.js +/** + * dd-resizable-handle.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ + +class DDResizableHandle { + constructor(host, dir, option) { + this.host = host; + this.dir = dir; + this.option = option; + /** @internal true after we've moved enough pixels to start a resize */ + this.moving = false; + // create var event binding so we can easily remove and still look like TS methods (unlike anonymous functions) + this._mouseDown = this._mouseDown.bind(this); + this._mouseMove = this._mouseMove.bind(this); + this._mouseUp = this._mouseUp.bind(this); + this._keyEvent = this._keyEvent.bind(this); + this._init(); + } + /** @internal */ + _init() { + const el = this.el = document.createElement('div'); + el.classList.add('ui-resizable-handle'); + el.classList.add(`${DDResizableHandle.prefix}${this.dir}`); + el.style.zIndex = '100'; + el.style.userSelect = 'none'; + this.host.appendChild(this.el); + this.el.addEventListener('mousedown', this._mouseDown); + if (isTouch) { + this.el.addEventListener('touchstart', touchstart); + this.el.addEventListener('pointerdown', pointerdown); + // this.el.style.touchAction = 'none'; // not needed unlike pointerdown doc comment + } + return this; + } + /** call this when resize handle needs to be removed and cleaned up */ + destroy() { + if (this.moving) + this._mouseUp(this.mouseDownEvent); + this.el.removeEventListener('mousedown', this._mouseDown); + if (isTouch) { + this.el.removeEventListener('touchstart', touchstart); + this.el.removeEventListener('pointerdown', pointerdown); + } + this.host.removeChild(this.el); + delete this.el; + delete this.host; + return this; + } + /** @internal called on mouse down on us: capture move on the entire document (mouse might not stay on us) until we release the mouse */ + _mouseDown(e) { + this.mouseDownEvent = e; + document.addEventListener('mousemove', this._mouseMove, { capture: true, passive: true }); // capture, not bubble + document.addEventListener('mouseup', this._mouseUp, true); + if (isTouch) { + this.el.addEventListener('touchmove', touchmove); + this.el.addEventListener('touchend', touchend); + } + e.stopPropagation(); + e.preventDefault(); + } + /** @internal */ + _mouseMove(e) { + const s = this.mouseDownEvent; + if (this.moving) { + this._triggerEvent('move', e); + } + else if (Math.abs(e.x - s.x) + Math.abs(e.y - s.y) > 2) { + // don't start unless we've moved at least 3 pixels + this.moving = true; + this._triggerEvent('start', this.mouseDownEvent); + this._triggerEvent('move', e); + // now track keyboard events to cancel + document.addEventListener('keydown', this._keyEvent); + } + e.stopPropagation(); + // e.preventDefault(); passive = true + } + /** @internal */ + _mouseUp(e) { + if (this.moving) { + this._triggerEvent('stop', e); + document.removeEventListener('keydown', this._keyEvent); + } + document.removeEventListener('mousemove', this._mouseMove, true); + document.removeEventListener('mouseup', this._mouseUp, true); + if (isTouch) { + this.el.removeEventListener('touchmove', touchmove); + this.el.removeEventListener('touchend', touchend); + } + delete this.moving; + delete this.mouseDownEvent; + e.stopPropagation(); + e.preventDefault(); + } + /** @internal call when keys are being pressed - use Esc to cancel */ + _keyEvent(e) { + if (e.key === 'Escape') { + this.host.gridstackNode?.grid?.engine.restoreInitial(); + this._mouseUp(this.mouseDownEvent); + } + } + /** @internal */ + _triggerEvent(name, event) { + if (this.option[name]) + this.option[name](event); + return this; + } +} +/** @internal */ +DDResizableHandle.prefix = 'ui-resizable-'; + +//# sourceMappingURL=dd-resizable-handle.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/dd-base-impl.js +/** + * dd-base-impl.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ +/** + * Abstract base class for all drag & drop implementations. + * Provides common functionality for event handling, enable/disable state, + * and lifecycle management used by draggable, droppable, and resizable implementations. + */ +class DDBaseImplement { + constructor() { + /** @internal */ + this._eventRegister = {}; + } + /** + * Returns the current disabled state. + * Note: Use enable()/disable() methods to change state as other operations need to happen. + */ + get disabled() { return this._disabled; } + /** + * Register an event callback for the specified event. + * + * @param event - Event name to listen for + * @param callback - Function to call when event occurs + */ + on(event, callback) { + this._eventRegister[event] = callback; + } + /** + * Unregister an event callback for the specified event. + * + * @param event - Event name to stop listening for + */ + off(event) { + delete this._eventRegister[event]; + } + /** + * Enable this drag & drop implementation. + * Subclasses should override to perform additional setup. + */ + enable() { + this._disabled = false; + } + /** + * Disable this drag & drop implementation. + * Subclasses should override to perform additional cleanup. + */ + disable() { + this._disabled = true; + } + /** + * Destroy this drag & drop implementation and clean up resources. + * Removes all event handlers and clears internal state. + */ + destroy() { + delete this._eventRegister; + } + /** + * Trigger a registered event callback if one exists and the implementation is enabled. + * + * @param eventName - Name of the event to trigger + * @param event - DOM event object to pass to the callback + * @returns Result from the callback function, if any + */ + triggerEvent(eventName, event) { + if (!this.disabled && this._eventRegister && this._eventRegister[eventName]) + return this._eventRegister[eventName](event); + } +} +//# sourceMappingURL=dd-base-impl.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/dd-resizable.js +/** + * dd-resizable.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ + + + + +class DDResizable extends DDBaseImplement { + // have to be public else complains for HTMLElementExtendOpt ? + constructor(el, option = {}) { + super(); + this.el = el; + this.option = option; + /** @internal */ + this.rectScale = { x: 1, y: 1 }; + /** @internal */ + this._ui = () => { + const containmentEl = this.el.parentElement; + const containmentRect = containmentEl.getBoundingClientRect(); + const newRect = { + width: this.originalRect.width, + height: this.originalRect.height + this.scrolled, + left: this.originalRect.left, + top: this.originalRect.top - this.scrolled + }; + const rect = this.temporalRect || newRect; + return { + position: { + left: (rect.left - containmentRect.left) * this.rectScale.x, + top: (rect.top - containmentRect.top) * this.rectScale.y + }, + size: { + width: rect.width * this.rectScale.x, + height: rect.height * this.rectScale.y + } + /* Gridstack ONLY needs position set above... keep around in case. + element: [this.el], // The object representing the element to be resized + helper: [], // TODO: not support yet - The object representing the helper that's being resized + originalElement: [this.el],// we don't wrap here, so simplify as this.el //The object representing the original element before it is wrapped + originalPosition: { // The position represented as { left, top } before the resizable is resized + left: this.originalRect.left - containmentRect.left, + top: this.originalRect.top - containmentRect.top + }, + originalSize: { // The size represented as { width, height } before the resizable is resized + width: this.originalRect.width, + height: this.originalRect.height + } + */ + }; + }; + // create var event binding so we can easily remove and still look like TS methods (unlike anonymous functions) + this._mouseOver = this._mouseOver.bind(this); + this._mouseOut = this._mouseOut.bind(this); + this.enable(); + this._setupAutoHide(this.option.autoHide); + this._setupHandlers(); + } + on(event, callback) { + super.on(event, callback); + } + off(event) { + super.off(event); + } + enable() { + super.enable(); + this.el.classList.remove('ui-resizable-disabled'); + this._setupAutoHide(this.option.autoHide); + } + disable() { + super.disable(); + this.el.classList.add('ui-resizable-disabled'); + this._setupAutoHide(false); + } + destroy() { + this._removeHandlers(); + this._setupAutoHide(false); + delete this.el; + super.destroy(); + } + updateOption(opts) { + const updateHandles = (opts.handles && opts.handles !== this.option.handles); + const updateAutoHide = (opts.autoHide && opts.autoHide !== this.option.autoHide); + Object.keys(opts).forEach(key => this.option[key] = opts[key]); + if (updateHandles) { + this._removeHandlers(); + this._setupHandlers(); + } + if (updateAutoHide) { + this._setupAutoHide(this.option.autoHide); + } + return this; + } + /** @internal turns auto hide on/off */ + _setupAutoHide(auto) { + if (auto) { + this.el.classList.add('ui-resizable-autohide'); + // use mouseover and not mouseenter to get better performance and track for nested cases + this.el.addEventListener('mouseover', this._mouseOver); + this.el.addEventListener('mouseout', this._mouseOut); + } + else { + this.el.classList.remove('ui-resizable-autohide'); + this.el.removeEventListener('mouseover', this._mouseOver); + this.el.removeEventListener('mouseout', this._mouseOut); + if (DDManager.overResizeElement === this) { + delete DDManager.overResizeElement; + } + } + return this; + } + /** @internal */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _mouseOver(e) { + // console.log(`${count++} pre-enter ${(this.el as GridItemHTMLElement).gridstackNode._id}`) + // already over a child, ignore. Ideally we just call e.stopPropagation() but see https://github.com/gridstack/gridstack.js/issues/2018 + if (DDManager.overResizeElement || DDManager.dragElement) + return; + DDManager.overResizeElement = this; + // console.log(`${count++} enter ${(this.el as GridItemHTMLElement).gridstackNode._id}`) + this.el.classList.remove('ui-resizable-autohide'); + } + /** @internal */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _mouseOut(e) { + // console.log(`${count++} pre-leave ${(this.el as GridItemHTMLElement).gridstackNode._id}`) + if (DDManager.overResizeElement !== this) + return; + delete DDManager.overResizeElement; + // console.log(`${count++} leave ${(this.el as GridItemHTMLElement).gridstackNode._id}`) + this.el.classList.add('ui-resizable-autohide'); + } + /** @internal */ + _setupHandlers() { + this.handlers = this.option.handles.split(',') + .map(dir => dir.trim()) + .map(dir => new DDResizableHandle(this.el, dir, { + start: (event) => { + this._resizeStart(event); + }, + stop: (event) => { + this._resizeStop(event); + }, + move: (event) => { + this._resizing(event, dir); + } + })); + return this; + } + /** @internal */ + _resizeStart(event) { + this.sizeToContent = Utils.shouldSizeToContent(this.el.gridstackNode, true); // strick true only and not number + this.originalRect = this.el.getBoundingClientRect(); + this.scrollEl = Utils.getScrollElement(this.el); + this.scrollY = this.scrollEl.scrollTop; + this.scrolled = 0; + this.startEvent = event; + this._setupHelper(); + this._applyChange(); + const ev = Utils.initEvent(event, { type: 'resizestart', target: this.el }); + if (this.option.start) { + this.option.start(ev, this._ui()); + } + this.el.classList.add('ui-resizable-resizing'); + this.triggerEvent('resizestart', ev); + return this; + } + /** @internal */ + _resizing(event, dir) { + this.scrolled = this.scrollEl.scrollTop - this.scrollY; + this.temporalRect = this._getChange(event, dir); + this._applyChange(); + const ev = Utils.initEvent(event, { type: 'resize', target: this.el }); + if (this.option.resize) { + this.option.resize(ev, this._ui()); + } + this.triggerEvent('resize', ev); + return this; + } + /** @internal */ + _resizeStop(event) { + const ev = Utils.initEvent(event, { type: 'resizestop', target: this.el }); + // Remove style attr now, so the stop handler can rebuild style attrs + this._cleanHelper(); + if (this.option.stop) { + this.option.stop(ev); // Note: ui() not used by gridstack so don't pass + } + this.el.classList.remove('ui-resizable-resizing'); + this.triggerEvent('resizestop', ev); + delete this.startEvent; + delete this.originalRect; + delete this.temporalRect; + delete this.scrollY; + delete this.scrolled; + return this; + } + /** @internal */ + _setupHelper() { + this.elOriginStyleVal = DDResizable._originStyleProp.map(prop => this.el.style[prop]); + this.parentOriginStylePosition = this.el.parentElement.style.position; + const parent = this.el.parentElement; + const dragTransform = Utils.getValuesFromTransformedElement(parent); + this.rectScale = { + x: dragTransform.xScale, + y: dragTransform.yScale + }; + if (getComputedStyle(this.el.parentElement).position.match(/static/)) { + this.el.parentElement.style.position = 'relative'; + } + this.el.style.position = 'absolute'; + this.el.style.opacity = '0.8'; + return this; + } + /** @internal */ + _cleanHelper() { + DDResizable._originStyleProp.forEach((prop, i) => { + this.el.style[prop] = this.elOriginStyleVal[i] || null; + }); + this.el.parentElement.style.position = this.parentOriginStylePosition || null; + return this; + } + /** @internal */ + _getChange(event, dir) { + const oEvent = this.startEvent; + const newRect = { + width: this.originalRect.width, + height: this.originalRect.height + this.scrolled, + left: this.originalRect.left, + top: this.originalRect.top - this.scrolled + }; + const offsetX = event.clientX - oEvent.clientX; + const offsetY = this.sizeToContent ? 0 : event.clientY - oEvent.clientY; // prevent vert resize + let moveLeft; + let moveUp; + if (dir.indexOf('e') > -1) { + newRect.width += offsetX; + } + else if (dir.indexOf('w') > -1) { + newRect.width -= offsetX; + newRect.left += offsetX; + moveLeft = true; + } + if (dir.indexOf('s') > -1) { + newRect.height += offsetY; + } + else if (dir.indexOf('n') > -1) { + newRect.height -= offsetY; + newRect.top += offsetY; + moveUp = true; + } + const constrain = this._constrainSize(newRect.width, newRect.height, moveLeft, moveUp); + if (Math.round(newRect.width) !== Math.round(constrain.width)) { // round to ignore slight round-off errors + if (dir.indexOf('w') > -1) { + newRect.left += newRect.width - constrain.width; + } + newRect.width = constrain.width; + } + if (Math.round(newRect.height) !== Math.round(constrain.height)) { + if (dir.indexOf('n') > -1) { + newRect.top += newRect.height - constrain.height; + } + newRect.height = constrain.height; + } + return newRect; + } + /** @internal constrain the size to the set min/max values */ + _constrainSize(oWidth, oHeight, moveLeft, moveUp) { + const o = this.option; + const maxWidth = (moveLeft ? o.maxWidthMoveLeft : o.maxWidth) || Number.MAX_SAFE_INTEGER; + const minWidth = o.minWidth / this.rectScale.x || oWidth; + const maxHeight = (moveUp ? o.maxHeightMoveUp : o.maxHeight) || Number.MAX_SAFE_INTEGER; + const minHeight = o.minHeight / this.rectScale.y || oHeight; + const width = Math.min(maxWidth, Math.max(minWidth, oWidth)); + const height = Math.min(maxHeight, Math.max(minHeight, oHeight)); + return { width, height }; + } + /** @internal */ + _applyChange() { + let containmentRect = { left: 0, top: 0, width: 0, height: 0 }; + if (this.el.style.position === 'absolute') { + const containmentEl = this.el.parentElement; + const { left, top } = containmentEl.getBoundingClientRect(); + containmentRect = { left, top, width: 0, height: 0 }; + } + if (!this.temporalRect) + return this; + Object.keys(this.temporalRect).forEach(key => { + const value = this.temporalRect[key]; + const scaleReciprocal = key === 'width' || key === 'left' ? this.rectScale.x : key === 'height' || key === 'top' ? this.rectScale.y : 1; + this.el.style[key] = (value - containmentRect[key]) * scaleReciprocal + 'px'; + }); + return this; + } + /** @internal */ + _removeHandlers() { + this.handlers.forEach(handle => handle.destroy()); + delete this.handlers; + return this; + } +} +/** @internal */ +DDResizable._originStyleProp = ['width', 'height', 'position', 'left', 'top', 'opacity', 'zIndex']; + +//# sourceMappingURL=dd-resizable.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/dd-draggable.js +/** + * dd-draggable.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ + + + + +// make sure we are not clicking on known object that handles mouseDown +const skipMouseDown = 'input,textarea,button,select,option,[contenteditable="true"],.ui-resizable-handle'; +// let count = 0; // TEST +class DDDraggable extends DDBaseImplement { + constructor(el, option = {}) { + super(); + this.el = el; + this.option = option; + /** @internal */ + this.dragTransform = { + xScale: 1, + yScale: 1, + xOffset: 0, + yOffset: 0 + }; + // get the element that is actually supposed to be dragged by + const handleName = option?.handle?.substring(1); + const n = el.gridstackNode; + this.dragEls = !handleName || el.classList.contains(handleName) ? [el] : (n?.subGrid ? [el.querySelector(option.handle) || el] : Array.from(el.querySelectorAll(option.handle))); + if (this.dragEls.length === 0) { + this.dragEls = [el]; + } + // create var event binding so we can easily remove and still look like TS methods (unlike anonymous functions) + this._mouseDown = this._mouseDown.bind(this); + this._mouseMove = this._mouseMove.bind(this); + this._mouseUp = this._mouseUp.bind(this); + this._keyEvent = this._keyEvent.bind(this); + this.enable(); + } + on(event, callback) { + super.on(event, callback); + } + off(event) { + super.off(event); + } + enable() { + if (this.disabled === false) + return; + super.enable(); + this.dragEls.forEach(dragEl => { + dragEl.addEventListener('mousedown', this._mouseDown); + if (isTouch) { + dragEl.addEventListener('touchstart', touchstart); + dragEl.addEventListener('pointerdown', pointerdown); + // dragEl.style.touchAction = 'none'; // not needed unlike pointerdown doc comment + } + }); + this.el.classList.remove('ui-draggable-disabled'); + } + disable(forDestroy = false) { + if (this.disabled === true) + return; + super.disable(); + this.dragEls.forEach(dragEl => { + dragEl.removeEventListener('mousedown', this._mouseDown); + if (isTouch) { + dragEl.removeEventListener('touchstart', touchstart); + dragEl.removeEventListener('pointerdown', pointerdown); + } + }); + if (!forDestroy) + this.el.classList.add('ui-draggable-disabled'); + } + destroy() { + if (this.dragTimeout) + window.clearTimeout(this.dragTimeout); + delete this.dragTimeout; + if (this.mouseDownEvent) + this._mouseUp(this.mouseDownEvent); + this.disable(true); + delete this.el; + delete this.helper; + delete this.option; + super.destroy(); + } + updateOption(opts) { + Object.keys(opts).forEach(key => this.option[key] = opts[key]); + return this; + } + /** @internal call when mouse goes down before a dragstart happens */ + _mouseDown(e) { + // don't let more than one widget handle mouseStart + if (DDManager.mouseHandled) + return; + if (e.button !== 0) + return true; // only left click + // make sure we are not clicking on known object that handles mouseDown, or ones supplied by the user + if (!this.dragEls.find(el => el === e.target) && e.target.closest(skipMouseDown)) + return true; + if (this.option.cancel) { + if (e.target.closest(this.option.cancel)) + return true; + } + this.mouseDownEvent = e; + delete this.dragging; + delete DDManager.dragElement; + delete DDManager.dropElement; + // document handler so we can continue receiving moves as the item is 'fixed' position, and capture=true so WE get a first crack + document.addEventListener('mousemove', this._mouseMove, { capture: true, passive: true }); // true=capture, not bubble + document.addEventListener('mouseup', this._mouseUp, true); + if (isTouch) { + e.currentTarget.addEventListener('touchmove', touchmove); + e.currentTarget.addEventListener('touchend', touchend); + } + e.preventDefault(); + // preventDefault() prevents blur event which occurs just after mousedown event. + // if an editable content has focus, then blur must be call + if (document.activeElement) + document.activeElement.blur(); + DDManager.mouseHandled = true; + return true; + } + /** @internal method to call actual drag event */ + _callDrag(e) { + if (!this.dragging) + return; + const ev = Utils.initEvent(e, { target: this.el, type: 'drag' }); + if (this.option.drag) { + this.option.drag(ev, this.ui()); + } + this.triggerEvent('drag', ev); + } + /** @internal called when the main page (after successful mousedown) receives a move event to drag the item around the screen */ + _mouseMove(e) { + // console.log(`${count++} move ${e.x},${e.y}`) + const s = this.mouseDownEvent; + this.lastDrag = e; + if (this.dragging) { + this._dragFollow(e); + // delay actual grid handling drag until we pause for a while if set + if (DDManager.pauseDrag) { + const pause = Number.isInteger(DDManager.pauseDrag) ? DDManager.pauseDrag : 100; + if (this.dragTimeout) + window.clearTimeout(this.dragTimeout); + this.dragTimeout = window.setTimeout(() => this._callDrag(e), pause); + } + else { + this._callDrag(e); + } + } + else if (Math.abs(e.x - s.x) + Math.abs(e.y - s.y) > 3) { + /** + * don't start unless we've moved at least 3 pixels + */ + this.dragging = true; + DDManager.dragElement = this; + // if we're dragging an actual grid item, set the current drop as the grid (to detect enter/leave) + const grid = this.el.gridstackNode?.grid; + if (grid) { + DDManager.dropElement = grid.el.ddElement.ddDroppable; + } + else { + delete DDManager.dropElement; + } + this.helper = this._createHelper(); + this._setupHelperContainmentStyle(); + this.dragTransform = Utils.getValuesFromTransformedElement(this.helperContainment); + this.dragOffset = this._getDragOffset(e, this.el, this.helperContainment); + this._setupHelperStyle(e); + const ev = Utils.initEvent(e, { target: this.el, type: 'dragstart' }); + if (this.option.start) { + this.option.start(ev, this.ui()); + } + this.triggerEvent('dragstart', ev); + // now track keyboard events to cancel or rotate + document.addEventListener('keydown', this._keyEvent); + } + // e.preventDefault(); // passive = true. OLD: was needed otherwise we get text sweep text selection as we drag around + return true; + } + /** @internal call when the mouse gets released to drop the item at current location */ + _mouseUp(e) { + document.removeEventListener('mousemove', this._mouseMove, true); + document.removeEventListener('mouseup', this._mouseUp, true); + if (isTouch && e.currentTarget) { // destroy() during nested grid call us again wit fake _mouseUp + e.currentTarget.removeEventListener('touchmove', touchmove, true); + e.currentTarget.removeEventListener('touchend', touchend, true); + } + if (this.dragging) { + delete this.dragging; + delete this.el.gridstackNode?._origRotate; + document.removeEventListener('keydown', this._keyEvent); + // reset the drop target if dragging over ourself (already parented, just moving during stop callback below) + if (DDManager.dropElement?.el === this.el.parentElement) { + delete DDManager.dropElement; + } + this.helperContainment.style.position = this.parentOriginStylePosition || null; + if (this.helper !== this.el) + this.helper.remove(); // hide now + this._removeHelperStyle(); + const ev = Utils.initEvent(e, { target: this.el, type: 'dragstop' }); + if (this.option.stop) { + this.option.stop(ev); // NOTE: destroy() will be called when removing item, so expect NULL ptr after! + } + this.triggerEvent('dragstop', ev); + // call the droppable method to receive the item + if (DDManager.dropElement) { + DDManager.dropElement.drop(e); + } + } + delete this.helper; + delete this.mouseDownEvent; + delete DDManager.dragElement; + delete DDManager.dropElement; + delete DDManager.mouseHandled; + e.preventDefault(); + } + /** @internal call when keys are being pressed - use Esc to cancel, R to rotate */ + _keyEvent(e) { + const n = this.el.gridstackNode; + const grid = n?.grid || DDManager.dropElement?.el?.gridstack; + if (e.key === 'Escape') { + if (n && n._origRotate) { + n._orig = n._origRotate; + delete n._origRotate; + } + grid?.cancelDrag(); + this._mouseUp(this.mouseDownEvent); + } + else if (n && grid && (e.key === 'r' || e.key === 'R')) { + if (!Utils.canBeRotated(n)) + return; + n._origRotate = n._origRotate || { ...n._orig }; // store the real orig size in case we Esc after doing rotation + delete n._moving; // force rotate to happen (move waits for >50% coverage otherwise) + grid.setAnimation(false) // immediate rotate so _getDragOffset() gets the right dom size below + .rotate(n.el, { top: -this.dragOffset.offsetTop, left: -this.dragOffset.offsetLeft }) + .setAnimation(); + n._moving = true; + this.dragOffset = this._getDragOffset(this.lastDrag, n.el, this.helperContainment); + this.helper.style.width = this.dragOffset.width + 'px'; + this.helper.style.height = this.dragOffset.height + 'px'; + Utils.swap(n._orig, 'w', 'h'); + delete n._rect; + this._mouseMove(this.lastDrag); + } + } + /** @internal create a clone copy (or user defined method) of the original drag item if set */ + _createHelper() { + let helper = this.el; + if (typeof this.option.helper === 'function') { + helper = this.option.helper(this.el); + } + else if (this.option.helper === 'clone') { + helper = Utils.cloneNode(this.el); + } + if (!helper.parentElement) { + Utils.appendTo(helper, this.option.appendTo === 'parent' ? this.el.parentElement : this.option.appendTo); + } + this.dragElementOriginStyle = DDDraggable.originStyleProp.map(prop => this.el.style[prop]); + return helper; + } + /** @internal set the fix position of the dragged item */ + _setupHelperStyle(e) { + this.helper.classList.add('ui-draggable-dragging'); + this.el.gridstackNode?.grid?.el.classList.add('grid-stack-dragging'); + // TODO: set all at once with style.cssText += ... ? https://stackoverflow.com/questions/3968593 + const style = this.helper.style; + style.pointerEvents = 'none'; // needed for over items to get enter/leave + // style.cursor = 'move'; // TODO: can't set with pointerEvents=none ! (no longer in CSS either as no-op) + style.width = this.dragOffset.width + 'px'; + style.height = this.dragOffset.height + 'px'; + style.willChange = 'left, top'; + style.position = 'fixed'; // let us drag between grids by not clipping as parent .grid-stack is position: 'relative' + this._dragFollow(e); // now position it + style.transition = 'none'; // show up instantly + setTimeout(() => { + if (this.helper) { + style.transition = null; // recover animation + } + }, 0); + return this; + } + /** @internal restore back the original style before dragging */ + _removeHelperStyle() { + this.helper.classList.remove('ui-draggable-dragging'); + this.el.gridstackNode?.grid?.el.classList.remove('grid-stack-dragging'); + const node = this.helper?.gridstackNode; + // don't bother restoring styles if we're gonna remove anyway... + if (!node?._isAboutToRemove && this.dragElementOriginStyle) { + const helper = this.helper; + // don't animate, otherwise we animate offseted when switching back to 'absolute' from 'fixed'. + // TODO: this also removes resizing animation which doesn't have this issue, but others. + // Ideally both would animate ('move' would immediately restore 'absolute' and adjust coordinate to match, + // then trigger a delay (repaint) to restore to final dest with animate) but then we need to make sure 'resizestop' + // is called AFTER 'transitionend' event is received (see https://github.com/gridstack/gridstack.js/issues/2033) + const transition = this.dragElementOriginStyle['transition'] || null; + helper.style.transition = this.dragElementOriginStyle['transition'] = 'none'; // can't be NULL #1973 + DDDraggable.originStyleProp.forEach(prop => helper.style[prop] = this.dragElementOriginStyle[prop] || null); + setTimeout(() => helper.style.transition = transition, 50); // recover animation from saved vars after a pause (0 isn't enough #1973) + } + delete this.dragElementOriginStyle; + return this; + } + /** @internal updates the top/left position to follow the mouse */ + _dragFollow(e) { + const containmentRect = { left: 0, top: 0 }; + // if (this.helper.style.position === 'absolute') { // we use 'fixed' + // const { left, top } = this.helperContainment.getBoundingClientRect(); + // containmentRect = { left, top }; + // } + const style = this.helper.style; + const offset = this.dragOffset; + style.left = (e.clientX + offset.offsetLeft - containmentRect.left) * this.dragTransform.xScale + 'px'; + style.top = (e.clientY + offset.offsetTop - containmentRect.top) * this.dragTransform.yScale + 'px'; + } + /** @internal */ + _setupHelperContainmentStyle() { + this.helperContainment = this.helper.parentElement; + if (this.helper.style.position !== 'fixed') { + this.parentOriginStylePosition = this.helperContainment.style.position; + if (getComputedStyle(this.helperContainment).position.match(/static/)) { + this.helperContainment.style.position = 'relative'; + } + } + return this; + } + /** @internal */ + _getDragOffset(event, el, parent) { + // in case ancestor has transform/perspective css properties that change the viewpoint + let xformOffsetX = 0; + let xformOffsetY = 0; + if (parent) { + xformOffsetX = this.dragTransform.xOffset; + xformOffsetY = this.dragTransform.yOffset; + } + const targetOffset = el.getBoundingClientRect(); + return { + left: targetOffset.left, + top: targetOffset.top, + offsetLeft: -event.clientX + targetOffset.left - xformOffsetX, + offsetTop: -event.clientY + targetOffset.top - xformOffsetY, + width: targetOffset.width * this.dragTransform.xScale, + height: targetOffset.height * this.dragTransform.yScale + }; + } + /** @internal TODO: set to public as called by DDDroppable! */ + ui() { + const containmentEl = this.el.parentElement; + const containmentRect = containmentEl.getBoundingClientRect(); + const offset = this.helper.getBoundingClientRect(); + return { + position: { + top: (offset.top - containmentRect.top) * this.dragTransform.yScale, + left: (offset.left - containmentRect.left) * this.dragTransform.xScale + } + /* not used by GridStack for now... + helper: [this.helper], //The object arr representing the helper that's being dragged. + offset: { top: offset.top, left: offset.left } // Current offset position of the helper as { top, left } object. + */ + }; + } +} +/** @internal properties we change during dragging, and restore back */ +DDDraggable.originStyleProp = ['width', 'height', 'transform', 'transform-origin', 'transition', 'pointerEvents', 'position', 'left', 'top', 'minWidth', 'willChange']; + +//# sourceMappingURL=dd-draggable.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/dd-droppable.js +/** + * dd-droppable.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ + + + + +// let count = 0; // TEST +class DDDroppable extends DDBaseImplement { + constructor(el, option = {}) { + super(); + this.el = el; + this.option = option; + // create var event binding so we can easily remove and still look like TS methods (unlike anonymous functions) + this._mouseEnter = this._mouseEnter.bind(this); + this._mouseLeave = this._mouseLeave.bind(this); + this.enable(); + this._setupAccept(); + } + on(event, callback) { + super.on(event, callback); + } + off(event) { + super.off(event); + } + enable() { + if (this.disabled === false) + return; + super.enable(); + this.el.classList.add('ui-droppable'); + this.el.classList.remove('ui-droppable-disabled'); + this.el.addEventListener('mouseenter', this._mouseEnter); + this.el.addEventListener('mouseleave', this._mouseLeave); + if (isTouch) { + this.el.addEventListener('pointerenter', pointerenter); + this.el.addEventListener('pointerleave', pointerleave); + } + } + disable(forDestroy = false) { + if (this.disabled === true) + return; + super.disable(); + this.el.classList.remove('ui-droppable'); + if (!forDestroy) + this.el.classList.add('ui-droppable-disabled'); + this.el.removeEventListener('mouseenter', this._mouseEnter); + this.el.removeEventListener('mouseleave', this._mouseLeave); + if (isTouch) { + this.el.removeEventListener('pointerenter', pointerenter); + this.el.removeEventListener('pointerleave', pointerleave); + } + } + destroy() { + this.disable(true); + this.el.classList.remove('ui-droppable'); + this.el.classList.remove('ui-droppable-disabled'); + super.destroy(); + } + updateOption(opts) { + Object.keys(opts).forEach(key => this.option[key] = opts[key]); + this._setupAccept(); + return this; + } + /** @internal called when the cursor enters our area - prepare for a possible drop and track leaving */ + _mouseEnter(e) { + // console.log(`${count++} Enter ${this.el.id || (this.el as GridHTMLElement).gridstack.opts.id}`); // TEST + if (!DDManager.dragElement) + return; + if (!this._canDrop(DDManager.dragElement.el)) + return; + e.preventDefault(); + e.stopPropagation(); + // make sure when we enter this, that the last one gets a leave FIRST to correctly cleanup as we don't always do + if (DDManager.dropElement && DDManager.dropElement !== this) { + DDManager.dropElement._mouseLeave(e, true); // calledByEnter = true + } + DDManager.dropElement = this; + const ev = Utils.initEvent(e, { target: this.el, type: 'dropover' }); + if (this.option.over) { + this.option.over(ev, this._ui(DDManager.dragElement)); + } + this.triggerEvent('dropover', ev); + this.el.classList.add('ui-droppable-over'); + // console.log('tracking'); // TEST + } + /** @internal called when the item is leaving our area, stop tracking if we had moving item */ + _mouseLeave(e, calledByEnter = false) { + // console.log(`${count++} Leave ${this.el.id || (this.el as GridHTMLElement).gridstack.opts.id}`); // TEST + if (!DDManager.dragElement || DDManager.dropElement !== this) + return; + e.preventDefault(); + e.stopPropagation(); + const ev = Utils.initEvent(e, { target: this.el, type: 'dropout' }); + if (this.option.out) { + this.option.out(ev, this._ui(DDManager.dragElement)); + } + this.triggerEvent('dropout', ev); + if (DDManager.dropElement === this) { + delete DDManager.dropElement; + // console.log('not tracking'); // TEST + // if we're still over a parent droppable, send it an enter as we don't get one from leaving nested children + if (!calledByEnter) { + let parentDrop; + let parent = this.el.parentElement; + while (!parentDrop && parent) { + parentDrop = parent.ddElement?.ddDroppable; + parent = parent.parentElement; + } + if (parentDrop) { + parentDrop._mouseEnter(e); + } + } + } + } + /** item is being dropped on us - called by the drag mouseup handler - this calls the client drop event */ + drop(e) { + e.preventDefault(); + const ev = Utils.initEvent(e, { target: this.el, type: 'drop' }); + if (this.option.drop) { + this.option.drop(ev, this._ui(DDManager.dragElement)); + } + this.triggerEvent('drop', ev); + } + /** @internal true if element matches the string/method accept option */ + _canDrop(el) { + return el && (!this.accept || this.accept(el)); + } + /** @internal */ + _setupAccept() { + if (!this.option.accept) + return this; + if (typeof this.option.accept === 'string') { + this.accept = (el) => el.classList.contains(this.option.accept) || el.matches(this.option.accept); + } + else { + this.accept = this.option.accept; + } + return this; + } + /** @internal */ + _ui(drag) { + return { + draggable: drag.el, + ...drag.ui() + }; + } +} +//# sourceMappingURL=dd-droppable.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/dd-element.js +/** + * dd-elements.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ + + + +class DDElement { + static init(el) { + if (!el.ddElement) { + el.ddElement = new DDElement(el); + } + return el.ddElement; + } + constructor(el) { + this.el = el; + } + on(eventName, callback) { + if (this.ddDraggable && ['drag', 'dragstart', 'dragstop'].indexOf(eventName) > -1) { + this.ddDraggable.on(eventName, callback); + } + else if (this.ddDroppable && ['drop', 'dropover', 'dropout'].indexOf(eventName) > -1) { + this.ddDroppable.on(eventName, callback); + } + else if (this.ddResizable && ['resizestart', 'resize', 'resizestop'].indexOf(eventName) > -1) { + this.ddResizable.on(eventName, callback); + } + return this; + } + off(eventName) { + if (this.ddDraggable && ['drag', 'dragstart', 'dragstop'].indexOf(eventName) > -1) { + this.ddDraggable.off(eventName); + } + else if (this.ddDroppable && ['drop', 'dropover', 'dropout'].indexOf(eventName) > -1) { + this.ddDroppable.off(eventName); + } + else if (this.ddResizable && ['resizestart', 'resize', 'resizestop'].indexOf(eventName) > -1) { + this.ddResizable.off(eventName); + } + return this; + } + setupDraggable(opts) { + if (!this.ddDraggable) { + this.ddDraggable = new DDDraggable(this.el, opts); + } + else { + this.ddDraggable.updateOption(opts); + } + return this; + } + cleanDraggable() { + if (this.ddDraggable) { + this.ddDraggable.destroy(); + delete this.ddDraggable; + } + return this; + } + setupResizable(opts) { + if (!this.ddResizable) { + this.ddResizable = new DDResizable(this.el, opts); + } + else { + this.ddResizable.updateOption(opts); + } + return this; + } + cleanResizable() { + if (this.ddResizable) { + this.ddResizable.destroy(); + delete this.ddResizable; + } + return this; + } + setupDroppable(opts) { + if (!this.ddDroppable) { + this.ddDroppable = new DDDroppable(this.el, opts); + } + else { + this.ddDroppable.updateOption(opts); + } + return this; + } + cleanDroppable() { + if (this.ddDroppable) { + this.ddDroppable.destroy(); + delete this.ddDroppable; + } + return this; + } +} +//# sourceMappingURL=dd-element.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/dd-gridstack.js +/** + * dd-gridstack.ts 12.3.2 + * Copyright (c) 2021-2025 Alain Dumesny - see GridStack root license + */ + + + +// let count = 0; // TEST +/** + * HTML Native Mouse and Touch Events Drag and Drop functionality. + * + * This class provides the main drag & drop implementation for GridStack, + * handling resizing, dragging, and dropping of grid items using native HTML5 events. + * It manages the interaction between different DD components and the grid system. + */ +class DDGridStack { + /** + * Enable/disable/configure resizing for grid elements. + * + * @param el - Grid item element(s) to configure + * @param opts - Resize options or command ('enable', 'disable', 'destroy', 'option', or config object) + * @param key - Option key when using 'option' command + * @param value - Option value when using 'option' command + * @returns this instance for chaining + * + * @example + * dd.resizable(element, 'enable'); // Enable resizing + * dd.resizable(element, 'option', 'minWidth', 100); // Set minimum width + */ + resizable(el, opts, key, value) { + this._getDDElements(el, opts).forEach(dEl => { + if (opts === 'disable' || opts === 'enable') { + dEl.ddResizable && dEl.ddResizable[opts](); // can't create DD as it requires options for setupResizable() + } + else if (opts === 'destroy') { + dEl.ddResizable && dEl.cleanResizable(); + } + else if (opts === 'option') { + dEl.setupResizable({ [key]: value }); + } + else { + const n = dEl.el.gridstackNode; + const grid = n.grid; + let handles = dEl.el.getAttribute('gs-resize-handles') || grid.opts.resizable.handles || 'e,s,se'; + if (handles === 'all') + handles = 'n,e,s,w,se,sw,ne,nw'; + // NOTE: keep the resize handles as e,w don't have enough space (10px) to show resize corners anyway. limit during drag instead + // restrict vertical resize if height is done to match content anyway... odd to have it spring back + // if (Utils.shouldSizeToContent(n, true)) { + // const doE = handles.indexOf('e') !== -1; + // const doW = handles.indexOf('w') !== -1; + // handles = doE ? (doW ? 'e,w' : 'e') : (doW ? 'w' : ''); + // } + const autoHide = !grid.opts.alwaysShowResizeHandle; + dEl.setupResizable({ + ...grid.opts.resizable, + ...{ handles, autoHide }, + ...{ + start: opts.start, + stop: opts.stop, + resize: opts.resize + } + }); + } + }); + return this; + } + /** + * Enable/disable/configure dragging for grid elements. + * + * @param el - Grid item element(s) to configure + * @param opts - Drag options or command ('enable', 'disable', 'destroy', 'option', or config object) + * @param key - Option key when using 'option' command + * @param value - Option value when using 'option' command + * @returns this instance for chaining + * + * @example + * dd.draggable(element, 'enable'); // Enable dragging + * dd.draggable(element, {handle: '.drag-handle'}); // Configure drag handle + */ + draggable(el, opts, key, value) { + this._getDDElements(el, opts).forEach(dEl => { + if (opts === 'disable' || opts === 'enable') { + dEl.ddDraggable && dEl.ddDraggable[opts](); // can't create DD as it requires options for setupDraggable() + } + else if (opts === 'destroy') { + dEl.ddDraggable && dEl.cleanDraggable(); + } + else if (opts === 'option') { + dEl.setupDraggable({ [key]: value }); + } + else { + const grid = dEl.el.gridstackNode.grid; + dEl.setupDraggable({ + ...grid.opts.draggable, + ...{ + // containment: (grid.parentGridNode && grid.opts.dragOut === false) ? grid.el.parentElement : (grid.opts.draggable.containment || null), + start: opts.start, + stop: opts.stop, + drag: opts.drag + } + }); + } + }); + return this; + } + dragIn(el, opts) { + this._getDDElements(el).forEach(dEl => dEl.setupDraggable(opts)); + return this; + } + droppable(el, opts, key, value) { + if (typeof opts.accept === 'function' && !opts._accept) { + opts._accept = opts.accept; + opts.accept = (el) => opts._accept(el); + } + this._getDDElements(el, opts).forEach(dEl => { + if (opts === 'disable' || opts === 'enable') { + dEl.ddDroppable && dEl.ddDroppable[opts](); + } + else if (opts === 'destroy') { + dEl.ddDroppable && dEl.cleanDroppable(); + } + else if (opts === 'option') { + dEl.setupDroppable({ [key]: value }); + } + else { + dEl.setupDroppable(opts); + } + }); + return this; + } + /** true if element is droppable */ + isDroppable(el) { + return !!(el?.ddElement?.ddDroppable && !el.ddElement.ddDroppable.disabled); + } + /** true if element is draggable */ + isDraggable(el) { + return !!(el?.ddElement?.ddDraggable && !el.ddElement.ddDraggable.disabled); + } + /** true if element is draggable */ + isResizable(el) { + return !!(el?.ddElement?.ddResizable && !el.ddElement.ddResizable.disabled); + } + on(el, name, callback) { + this._getDDElements(el).forEach(dEl => dEl.on(name, (event) => { + callback(event, DDManager.dragElement ? DDManager.dragElement.el : event.target, DDManager.dragElement ? DDManager.dragElement.helper : null); + })); + return this; + } + off(el, name) { + this._getDDElements(el).forEach(dEl => dEl.off(name)); + return this; + } + /** @internal returns a list of DD elements, creating them on the fly by default unless option is to destroy or disable */ + _getDDElements(els, opts) { + // don't force create if we're going to destroy it, unless it's a grid which is used as drop target for it's children + const create = els.gridstack || opts !== 'destroy' && opts !== 'disable'; + const hosts = Utils.getElements(els); + if (!hosts.length) + return []; + const list = hosts.map(e => e.ddElement || (create ? DDElement.init(e) : null)).filter(d => d); // remove nulls + return list; + } +} +//# sourceMappingURL=dd-gridstack.js.map +;// CONCATENATED MODULE: ../node_modules/.pnpm/gridstack@12.3.3/node_modules/gridstack/dist/gridstack.js +/*! + * GridStack 12.3.2 + * https://gridstackjs.com/ + * + * Copyright (c) 2021-2025 Alain Dumesny + * see root license https://github.com/gridstack/gridstack.js/tree/master/LICENSE + */ + + + +/* + * and include D&D by default + * TODO: while we could generate a gridstack-static.js at smaller size - saves about 31k (41k -> 72k) + * I don't know how to generate the DD only code at the remaining 31k to delay load as code depends on Gridstack.ts + * also it caused loading issues in prod - see https://github.com/gridstack/gridstack.js/issues/2039 + */ + + + +const dd = new DDGridStack; +// export all dependent file as well to make it easier for users to just import the main file + + + + + + + + + + + +/** + * Main gridstack class - you will need to call `GridStack.init()` first to initialize your grid. + * Note: your grid elements MUST have the following classes for the CSS layout to work: + * @example + *
+ *
+ *
Item 1
+ *
+ *
+ */ +class GridStack { + /** + * initializing the HTML element, or selector string, into a grid will return the grid. Calling it again will + * simply return the existing instance (ignore any passed options). There is also an initAll() version that support + * multiple grids initialization at once. Or you can use addGrid() to create the entire grid from JSON. + * @param options grid options (optional) + * @param elOrString element or CSS selector (first one used) to convert to a grid (default to '.grid-stack' class selector) + * + * @example + * const grid = GridStack.init(); + * + * Note: the HTMLElement (of type GridHTMLElement) will store a `gridstack: GridStack` value that can be retrieve later + * const grid = document.querySelector('.grid-stack').gridstack; + */ + static init(options = {}, elOrString = '.grid-stack') { + if (typeof document === 'undefined') + return null; // temp workaround SSR + const el = GridStack.getGridElement(elOrString); + if (!el) { + if (typeof elOrString === 'string') { + console.error('GridStack.initAll() no grid was found with selector "' + elOrString + '" - element missing or wrong selector ?' + + '\nNote: ".grid-stack" is required for proper CSS styling and drag/drop, and is the default selector.'); + } + else { + console.error('GridStack.init() no grid element was passed.'); + } + return null; + } + if (!el.gridstack) { + el.gridstack = new GridStack(el, Utils.cloneDeep(options)); + } + return el.gridstack; + } + /** + * Will initialize a list of elements (given a selector) and return an array of grids. + * @param options grid options (optional) + * @param selector elements selector to convert to grids (default to '.grid-stack' class selector) + * + * @example + * const grids = GridStack.initAll(); + * grids.forEach(...) + */ + static initAll(options = {}, selector = '.grid-stack') { + const grids = []; + if (typeof document === 'undefined') + return grids; // temp workaround SSR + GridStack.getGridElements(selector).forEach(el => { + if (!el.gridstack) { + el.gridstack = new GridStack(el, Utils.cloneDeep(options)); + } + grids.push(el.gridstack); + }); + if (grids.length === 0) { + console.error('GridStack.initAll() no grid was found with selector "' + selector + '" - element missing or wrong selector ?' + + '\nNote: ".grid-stack" is required for proper CSS styling and drag/drop, and is the default selector.'); + } + return grids; + } + /** + * call to create a grid with the given options, including loading any children from JSON structure. This will call GridStack.init(), then + * grid.load() on any passed children (recursively). Great alternative to calling init() if you want entire grid to come from + * JSON serialized data, including options. + * @param parent HTML element parent to the grid + * @param opt grids options used to initialize the grid, and list of children + */ + static addGrid(parent, opt = {}) { + if (!parent) + return null; + let el = parent; + if (el.gridstack) { + // already a grid - set option and load data + const grid = el.gridstack; + if (opt) + grid.opts = { ...grid.opts, ...opt }; + if (opt.children !== undefined) + grid.load(opt.children); + return grid; + } + // create the grid element, but check if the passed 'parent' already has grid styling and should be used instead + const parentIsGrid = parent.classList.contains('grid-stack'); + if (!parentIsGrid || GridStack.addRemoveCB) { + if (GridStack.addRemoveCB) { + el = GridStack.addRemoveCB(parent, opt, true, true); + } + else { + el = Utils.createDiv(['grid-stack', opt.class], parent); + } + } + // create grid class and load any children + const grid = GridStack.init(opt, el); + return grid; + } + /** call this method to register your engine instead of the default one. + * See instead `GridStackOptions.engineClass` if you only need to + * replace just one instance. + */ + static registerEngine(engineClass) { + GridStack.engineClass = engineClass; + } + /** + * @internal create placeholder DIV as needed + * @returns the placeholder element for indicating drop zones during drag operations + */ + get placeholder() { + if (!this._placeholder) { + this._placeholder = Utils.createDiv([this.opts.placeholderClass, gridDefaults.itemClass, this.opts.itemClass]); + const placeholderChild = Utils.createDiv(['placeholder-content'], this._placeholder); + if (this.opts.placeholderText) { + placeholderChild.textContent = this.opts.placeholderText; + } + } + return this._placeholder; + } + /** + * Construct a grid item from the given element and options + * @param el the HTML element tied to this grid after it's been initialized + * @param opts grid options - public for classes to access, but use methods to modify! + */ + constructor(el, opts = {}) { + this.el = el; + this.opts = opts; + /** time to wait for animation (if enabled) to be done so content sizing can happen */ + this.animationDelay = 300 + 10; + /** @internal */ + this._gsEventHandler = {}; + /** @internal extra row added when dragging at the bottom of the grid */ + this._extraDragRow = 0; + /** @internal meant to store the scale of the active grid */ + this.dragTransform = { xScale: 1, yScale: 1, xOffset: 0, yOffset: 0 }; + el.gridstack = this; + this.opts = opts = opts || {}; // handles null/undefined/0 + if (!el.classList.contains('grid-stack')) { + this.el.classList.add('grid-stack'); + } + // if row property exists, replace minRow and maxRow instead + if (opts.row) { + opts.minRow = opts.maxRow = opts.row; + delete opts.row; + } + const rowAttr = Utils.toNumber(el.getAttribute('gs-row')); + // flag only valid in sub-grids (handled by parent, not here) + if (opts.column === 'auto') { + delete opts.column; + } + // save original setting so we can restore on save + if (opts.alwaysShowResizeHandle !== undefined) { + opts._alwaysShowResizeHandle = opts.alwaysShowResizeHandle; + } + // cleanup responsive opts (must have columnWidth | breakpoints) then sort breakpoints by size (so we can match during resize) + const resp = opts.columnOpts; + if (resp) { + const bk = resp.breakpoints; + if (!resp.columnWidth && !bk?.length) { + delete opts.columnOpts; + } + else { + resp.columnMax = resp.columnMax || 12; + if (bk?.length > 1) + bk.sort((a, b) => (b.w || 0) - (a.w || 0)); + } + } + // elements DOM attributes override any passed options (like CSS style) - merge the two together + const defaults = { + ...Utils.cloneDeep(gridDefaults), + column: Utils.toNumber(el.getAttribute('gs-column')) || gridDefaults.column, + minRow: rowAttr ? rowAttr : Utils.toNumber(el.getAttribute('gs-min-row')) || gridDefaults.minRow, + maxRow: rowAttr ? rowAttr : Utils.toNumber(el.getAttribute('gs-max-row')) || gridDefaults.maxRow, + staticGrid: Utils.toBool(el.getAttribute('gs-static')) || gridDefaults.staticGrid, + sizeToContent: Utils.toBool(el.getAttribute('gs-size-to-content')) || undefined, + draggable: { + handle: (opts.handleClass ? '.' + opts.handleClass : (opts.handle ? opts.handle : '')) || gridDefaults.draggable.handle, + }, + removableOptions: { + accept: opts.itemClass || gridDefaults.removableOptions.accept, + decline: gridDefaults.removableOptions.decline + }, + }; + if (el.getAttribute('gs-animate')) { // default to true, but if set to false use that instead + defaults.animate = Utils.toBool(el.getAttribute('gs-animate')); + } + opts = Utils.defaults(opts, defaults); + this._initMargin(); // part of settings defaults... + // Now check if we're loading into !12 column mode FIRST so we don't do un-necessary work (like cellHeight = width / 12 then go 1 column) + this.checkDynamicColumn(); + this._updateColumnVar(opts); + if (opts.rtl === 'auto') { + opts.rtl = (el.style.direction === 'rtl'); + } + if (opts.rtl) { + this.el.classList.add('grid-stack-rtl'); + } + // check if we're been nested, and if so update our style and keep pointer around (used during save) + const parentGridItem = this.el.closest('.' + gridDefaults.itemClass); + const parentNode = parentGridItem?.gridstackNode; + if (parentNode) { + parentNode.subGrid = this; + this.parentGridNode = parentNode; + this.el.classList.add('grid-stack-nested'); + parentNode.el.classList.add('grid-stack-sub-grid'); + } + this._isAutoCellHeight = (opts.cellHeight === 'auto'); + if (this._isAutoCellHeight || opts.cellHeight === 'initial') { + // make the cell content square initially (will use resize/column event to keep it square) + this.cellHeight(undefined); + } + else { + // append unit if any are set + if (typeof opts.cellHeight == 'number' && opts.cellHeightUnit && opts.cellHeightUnit !== gridDefaults.cellHeightUnit) { + opts.cellHeight = opts.cellHeight + opts.cellHeightUnit; + delete opts.cellHeightUnit; + } + const val = opts.cellHeight; + delete opts.cellHeight; // force initial cellHeight() call to set the value + this.cellHeight(val); + } + // see if we need to adjust auto-hide + if (opts.alwaysShowResizeHandle === 'mobile') { + opts.alwaysShowResizeHandle = isTouch; + } + this._setStaticClass(); + const engineClass = opts.engineClass || GridStack.engineClass || GridStackEngine; + this.engine = new engineClass({ + column: this.getColumn(), + float: opts.float, + maxRow: opts.maxRow, + onChange: (cbNodes) => { + cbNodes.forEach(n => { + const el = n.el; + if (!el) + return; + if (n._removeDOM) { + if (el) + el.remove(); + delete n._removeDOM; + } + else { + this._writePosAttr(el, n); + } + }); + this._updateContainerHeight(); + } + }); + if (opts.auto) { + this.batchUpdate(); // prevent in between re-layout #1535 TODO: this only set float=true, need to prevent collision check... + this.engine._loading = true; // loading collision check + this.getGridItems().forEach(el => this._prepareElement(el)); + delete this.engine._loading; + this.batchUpdate(false); + } + // load any passed in children as well, which overrides any DOM layout done above + if (opts.children) { + const children = opts.children; + delete opts.children; + if (children.length) + this.load(children); // don't load empty + } + this.setAnimation(); + // dynamic grids require pausing during drag to detect over to nest vs push + if (opts.subGridDynamic && !DDManager.pauseDrag) + DDManager.pauseDrag = true; + if (opts.draggable?.pause !== undefined) + DDManager.pauseDrag = opts.draggable.pause; + this._setupRemoveDrop(); + this._setupAcceptWidget(); + this._updateResizeEvent(); + } + _updateColumnVar(opts = this.opts) { + this.el.classList.add('gs-' + opts.column); + if (typeof opts.column === 'number') + this.el.style.setProperty('--gs-column-width', `${100 / opts.column}%`); + } + /** + * add a new widget and returns it. + * + * Widget will be always placed even if result height is more than actual grid height. + * You need to use `willItFit()` before calling addWidget for additional check. + * See also `makeWidget(el)` for DOM element. + * + * @example + * const grid = GridStack.init(); + * grid.addWidget({w: 3, content: 'hello'}); + * + * @param w GridStackWidget definition. used MakeWidget(el) if you have dom element instead. + */ + addWidget(w) { + if (!w) + return; + if (typeof w === 'string') { + console.error('V11: GridStack.addWidget() does not support string anymore. see #2736'); + return; + } + if (w.ELEMENT_NODE) { + console.error('V11: GridStack.addWidget() does not support HTMLElement anymore. use makeWidget()'); + return this.makeWidget(w); + } + let el; + let node = w; + node.grid = this; + if (node.el) { + el = node.el; // re-use element stored in the node + } + else if (GridStack.addRemoveCB) { + el = GridStack.addRemoveCB(this.el, w, true, false); + } + else { + el = this.createWidgetDivs(node); + } + if (!el) + return; + // if the caller ended up initializing the widget in addRemoveCB, or we stared with one already, skip the rest + node = el.gridstackNode; + if (node && el.parentElement === this.el && this.engine.nodes.find(n => n._id === node._id)) + return el; + // Tempting to initialize the passed in opt with default and valid values, but this break knockout demos + // as the actual value are filled in when _prepareElement() calls el.getAttribute('gs-xyz') before adding the node. + // So make sure we load any DOM attributes that are not specified in passed in options (which override) + const domAttr = this._readAttr(el); + Utils.defaults(w, domAttr); + this.engine.prepareNode(w); + // this._writeAttr(el, w); why write possibly incorrect values back when makeWidget() will ? + this.el.appendChild(el); + this.makeWidget(el, w); + return el; + } + /** + * Create the default grid item divs and content (possibly lazy loaded) by using GridStack.renderCB(). + * + * @param n GridStackNode definition containing widget configuration + * @returns the created HTML element with proper grid item structure + * + * @example + * const element = grid.createWidgetDivs({ w: 2, h: 1, content: 'Hello World' }); + */ + createWidgetDivs(n) { + const el = Utils.createDiv(['grid-stack-item', this.opts.itemClass]); + const cont = Utils.createDiv(['grid-stack-item-content'], el); + if (Utils.lazyLoad(n)) { + if (!n.visibleObservable) { + n.visibleObservable = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting) { + n.visibleObservable?.disconnect(); + delete n.visibleObservable; + GridStack.renderCB(cont, n); + n.grid?.prepareDragDrop(n.el); + } + }); + window.setTimeout(() => n.visibleObservable?.observe(el)); // wait until callee sets position attributes + } + } + else + GridStack.renderCB(cont, n); + return el; + } + /** + * Convert an existing gridItem element into a sub-grid with the given (optional) options, else inherit them + * from the parent's subGrid options. + * @param el gridItem element to convert + * @param ops (optional) sub-grid options, else default to node, then parent settings, else defaults + * @param nodeToAdd (optional) node to add to the newly created sub grid (used when dragging over existing regular item) + * @param saveContent if true (default) the html inside .grid-stack-content will be saved to child widget + * @returns newly created grid + */ + makeSubGrid(el, ops, nodeToAdd, saveContent = true) { + let node = el.gridstackNode; + if (!node) { + node = this.makeWidget(el).gridstackNode; + } + if (node.subGrid?.el) + return node.subGrid; // already done + // find the template subGrid stored on a parent as fallback... + let subGridTemplate; // eslint-disable-next-line @typescript-eslint/no-this-alias + let grid = this; + while (grid && !subGridTemplate) { + subGridTemplate = grid.opts?.subGridOpts; + grid = grid.parentGridNode?.grid; + } + //... and set the create options + ops = Utils.cloneDeep({ + // by default sub-grid inherit from us | parent, other than id, children, etc... + ...this.opts, id: undefined, children: undefined, column: 'auto', columnOpts: undefined, layout: 'list', subGridOpts: undefined, + ...(subGridTemplate || {}), + ...(ops || node.subGridOpts || {}) + }); + node.subGridOpts = ops; + // if column special case it set, remember that flag and set default + let autoColumn; + if (ops.column === 'auto') { + autoColumn = true; + ops.column = Math.max(node.w || 1, nodeToAdd?.w || 1); + delete ops.columnOpts; // driven by parent + } + // if we're converting an existing full item, move over the content to be the first sub item in the new grid + let content = node.el.querySelector('.grid-stack-item-content'); + let newItem; + let newItemOpt; + if (saveContent) { + this._removeDD(node.el); // remove D&D since it's set on content div + newItemOpt = { ...node, x: 0, y: 0 }; + Utils.removeInternalForSave(newItemOpt); + delete newItemOpt.subGridOpts; + if (node.content) { + newItemOpt.content = node.content; + delete node.content; + } + if (GridStack.addRemoveCB) { + newItem = GridStack.addRemoveCB(this.el, newItemOpt, true, false); + } + else { + newItem = Utils.createDiv(['grid-stack-item']); + newItem.appendChild(content); + content = Utils.createDiv(['grid-stack-item-content'], node.el); + } + this.prepareDragDrop(node.el); // ... and restore original D&D + } + // if we're adding an additional item, make the container large enough to have them both + if (nodeToAdd) { + const w = autoColumn ? ops.column : node.w; + const h = node.h + nodeToAdd.h; + const style = node.el.style; + style.transition = 'none'; // show up instantly so we don't see scrollbar with nodeToAdd + this.update(node.el, { w, h }); + setTimeout(() => style.transition = null); // recover animation + } + const subGrid = node.subGrid = GridStack.addGrid(content, ops); + if (nodeToAdd?._moving) + subGrid._isTemp = true; // prevent re-nesting as we add over + if (autoColumn) + subGrid._autoColumn = true; + // add the original content back as a child of the newly created grid + if (saveContent) { + subGrid.makeWidget(newItem, newItemOpt); + } + // now add any additional node + if (nodeToAdd) { + if (nodeToAdd._moving) { + // create an artificial event even for the just created grid to receive this item + window.setTimeout(() => Utils.simulateMouseEvent(nodeToAdd._event, 'mouseenter', subGrid.el), 0); + } + else { + subGrid.makeWidget(node.el, node); + } + } + // if sizedToContent, we need to re-calc the size of ourself + this.resizeToContentCheck(false, node); + return subGrid; + } + /** + * called when an item was converted into a nested grid to accommodate a dragged over item, but then item leaves - return back + * to the original grid-item. Also called to remove empty sub-grids when last item is dragged out (since re-creating is simple) + */ + removeAsSubGrid(nodeThatRemoved) { + const pGrid = this.parentGridNode?.grid; + if (!pGrid) + return; + pGrid.batchUpdate(); + pGrid.removeWidget(this.parentGridNode.el, true, true); + this.engine.nodes.forEach(n => { + // migrate any children over and offsetting by our location + n.x += this.parentGridNode.x; + n.y += this.parentGridNode.y; + pGrid.makeWidget(n.el, n); + }); + pGrid.batchUpdate(false); + if (this.parentGridNode) + delete this.parentGridNode.subGrid; + delete this.parentGridNode; + // create an artificial event for the original grid now that this one is gone (got a leave, but won't get enter) + if (nodeThatRemoved) { + window.setTimeout(() => Utils.simulateMouseEvent(nodeThatRemoved._event, 'mouseenter', pGrid.el), 0); + } + } + /** + * saves the current layout returning a list of widgets for serialization which might include any nested grids. + * @param saveContent if true (default) the latest html inside .grid-stack-content will be saved to GridStackWidget.content field, else it will + * be removed. + * @param saveGridOpt if true (default false), save the grid options itself, so you can call the new GridStack.addGrid() + * to recreate everything from scratch. GridStackOptions.children would then contain the widget list instead. + * @param saveCB callback for each node -> widget, so application can insert additional data to be saved into the widget data structure. + * @param column if provided, the grid will be saved for the given column size (IFF we have matching internal saved layout, or current layout). + * Otherwise it will use the largest possible layout (say 12 even if rendering at 1 column) so we can restore to all layouts. + * NOTE: if you want to save to currently display layout, pass this.getColumn() as column. + * NOTE2: nested grids will ALWAYS save to the container size to be in sync with parent. + * @returns list of widgets or full grid option, including .children list of widgets + */ + save(saveContent = true, saveGridOpt = false, saveCB = GridStack.saveCB, column) { + // return copied GridStackWidget (with optionally .el) we can modify at will... + const list = this.engine.save(saveContent, saveCB, column); + // check for HTML content and nested grids + list.forEach(n => { + if (saveContent && n.el && !n.subGrid && !saveCB) { // sub-grid are saved differently, not plain content + const itemContent = n.el.querySelector('.grid-stack-item-content'); + n.content = itemContent?.innerHTML; + if (!n.content) + delete n.content; + } + else { + if (!saveContent && !saveCB) { + delete n.content; + } + // check for nested grid - make sure it saves to the given container size to be consistent + if (n.subGrid?.el) { + const column = n.w || n.subGrid.getColumn(); + const listOrOpt = n.subGrid.save(saveContent, saveGridOpt, saveCB, column); + n.subGridOpts = (saveGridOpt ? listOrOpt : { children: listOrOpt }); + delete n.subGrid; + } + } + delete n.el; + }); + // check if save entire grid options (needed for recursive) + children... + if (saveGridOpt) { + const o = Utils.cloneDeep(this.opts); + // delete default values that will be recreated on launch + if (o.marginBottom === o.marginTop && o.marginRight === o.marginLeft && o.marginTop === o.marginRight) { + o.margin = o.marginTop; + delete o.marginTop; + delete o.marginRight; + delete o.marginBottom; + delete o.marginLeft; + } + if (o.rtl === (this.el.style.direction === 'rtl')) { + o.rtl = 'auto'; + } + if (this._isAutoCellHeight) { + o.cellHeight = 'auto'; + } + if (this._autoColumn) { + o.column = 'auto'; + } + const origShow = o._alwaysShowResizeHandle; + delete o._alwaysShowResizeHandle; + if (origShow !== undefined) { + o.alwaysShowResizeHandle = origShow; + } + else { + delete o.alwaysShowResizeHandle; + } + Utils.removeInternalAndSame(o, gridDefaults); + o.children = list; + return o; + } + return list; + } + /** + * Load widgets from a list. This will call update() on each (matching by id) or add/remove widgets that are not there. + * Used to restore a grid layout for a saved layout list (see `save()`). + * + * @param items list of widgets definition to update/create + * @param addRemove boolean (default true) or callback method can be passed to control if and how missing widgets can be added/removed, giving + * the user control of insertion. + * @returns the grid instance for chaining + * + * @example + * // Basic usage with saved layout + * const savedLayout = grid.save(); // Save current layout + * // ... later restore it + * grid.load(savedLayout); + * + * // Load with custom add/remove callback + * grid.load(layout, (items, grid, add) => { + * if (add) { + * // Custom logic for adding new widgets + * items.forEach(item => { + * const el = document.createElement('div'); + * el.innerHTML = item.content || ''; + * grid.addWidget(el, item); + * }); + * } else { + * // Custom logic for removing widgets + * items.forEach(item => grid.removeWidget(item.el)); + * } + * }); + * + * // Load without adding/removing missing widgets + * grid.load(layout, false); + * + * @see {@link http://gridstackjs.com/demo/serialization.html} for complete example + */ + load(items, addRemove = GridStack.addRemoveCB || true) { + items = Utils.cloneDeep(items); // so we can mod + const column = this.getColumn(); + // make sure size 1x1 (default) is present as it may need to override current sizes + items.forEach(n => { n.w = n.w || n.minW || 1; n.h = n.h || n.minH || 1; }); + // sort items. those without coord will be appended last + items = Utils.sort(items); + this.engine.skipCacheUpdate = this._ignoreLayoutsNodeChange = true; // skip layout update + // if we're loading a layout into for example 1 column and items don't fit, make sure to save + // the original wanted layout so we can scale back up correctly #1471 + let maxColumn = 0; + items.forEach(n => { maxColumn = Math.max(maxColumn, (n.x || 0) + n.w); }); + if (maxColumn > this.engine.defaultColumn) + this.engine.defaultColumn = maxColumn; + if (maxColumn > column) { + // if we're loading (from empty) into a smaller column, check for special responsive layout + if (this.engine.nodes.length === 0 && this.responseLayout) { + this.engine.nodes = items; + this.engine.columnChanged(maxColumn, column, this.responseLayout); + items = this.engine.nodes; + this.engine.nodes = []; + delete this.responseLayout; + } + else + this.engine.cacheLayout(items, maxColumn, true); + } + // if given a different callback, temporally set it as global option so creating will use it + const prevCB = GridStack.addRemoveCB; + if (typeof (addRemove) === 'function') + GridStack.addRemoveCB = addRemove; + const removed = []; + this.batchUpdate(); + // if we are loading from empty temporarily remove animation + const blank = !this.engine.nodes.length; + const noAnim = blank && this.opts.animate; + if (noAnim) + this.setAnimation(false); + // see if any items are missing from new layout and need to be removed first + if (!blank && addRemove) { + const copyNodes = [...this.engine.nodes]; // don't loop through array you modify + copyNodes.forEach(n => { + if (!n.id) + return; + const item = Utils.find(items, n.id); + if (!item) { + if (GridStack.addRemoveCB) + GridStack.addRemoveCB(this.el, n, false, false); + removed.push(n); // batch keep track + this.removeWidget(n.el, true, false); + } + }); + } + // now add/update the widgets - starting with removing items in the new layout we will reposition + // to reduce collision and add no-coord ones at next available spot + this.engine._loading = true; // help with collision + const updateNodes = []; + this.engine.nodes = this.engine.nodes.filter(n => { + if (Utils.find(items, n.id)) { + updateNodes.push(n); + return false; + } // remove if found from list + return true; + }); + items.forEach(w => { + const item = Utils.find(updateNodes, w.id); + if (item) { + // if item sizes to content, re-use the exiting height so it's a better guess at the final size (same if width doesn't change) + if (Utils.shouldSizeToContent(item)) + w.h = item.h; + // check if missing coord, in which case find next empty slot with new (or old if missing) sizes + this.engine.nodeBoundFix(w); + if (w.autoPosition || w.x === undefined || w.y === undefined) { + w.w = w.w || item.w; + w.h = w.h || item.h; + this.engine.findEmptyPosition(w); + } + // add back to current list BUT force a collision check if it 'appears' we didn't change to make sure we don't overlap others now + this.engine.nodes.push(item); + if (Utils.samePos(item, w) && this.engine.nodes.length > 1) { + this.moveNode(item, { ...w, forceCollide: true }); + Utils.copyPos(w, item); // use possily updated values before update() is called next (no-op since already moved) + } + this.update(item.el, w); + if (w.subGridOpts?.children) { // update any sub grid as well + const sub = item.el.querySelector('.grid-stack'); + if (sub && sub.gridstack) { + sub.gridstack.load(w.subGridOpts.children); // TODO: support updating grid options ? + } + } + } + else if (addRemove) { + this.addWidget(w); + } + }); + delete this.engine._loading; // done loading + this.engine.removedNodes = removed; + this.batchUpdate(false); + // after commit, clear that flag + delete this._ignoreLayoutsNodeChange; + delete this.engine.skipCacheUpdate; + prevCB ? GridStack.addRemoveCB = prevCB : delete GridStack.addRemoveCB; + if (noAnim) + this.setAnimation(true, true); // delay adding animation back + return this; + } + /** + * use before calling a bunch of `addWidget()` to prevent un-necessary relayouts in between (more efficient) + * and get a single event callback. You will see no changes until `batchUpdate(false)` is called. + */ + batchUpdate(flag = true) { + this.engine.batchUpdate(flag); + if (!flag) { + this._updateContainerHeight(); + this._triggerRemoveEvent(); + this._triggerAddEvent(); + this._triggerChangeEvent(); + } + return this; + } + /** + * Gets the current cell height in pixels. This takes into account the unit type and converts to pixels if necessary. + * + * @param forcePixel if true, forces conversion to pixels even when cellHeight is specified in other units + * @returns the cell height in pixels + * + * @example + * const height = grid.getCellHeight(); + * console.log('Cell height:', height, 'px'); + * + * // Force pixel conversion + * const pixelHeight = grid.getCellHeight(true); + */ + getCellHeight(forcePixel = false) { + if (this.opts.cellHeight && this.opts.cellHeight !== 'auto' && + (!forcePixel || !this.opts.cellHeightUnit || this.opts.cellHeightUnit === 'px')) { + return this.opts.cellHeight; + } + // do rem/em/cm/mm to px conversion + if (this.opts.cellHeightUnit === 'rem') { + return this.opts.cellHeight * parseFloat(getComputedStyle(document.documentElement).fontSize); + } + if (this.opts.cellHeightUnit === 'em') { + return this.opts.cellHeight * parseFloat(getComputedStyle(this.el).fontSize); + } + if (this.opts.cellHeightUnit === 'cm') { + // 1cm = 96px/2.54. See https://www.w3.org/TR/css-values-3/#absolute-lengths + return this.opts.cellHeight * (96 / 2.54); + } + if (this.opts.cellHeightUnit === 'mm') { + return this.opts.cellHeight * (96 / 2.54) / 10; + } + // else get first cell height + const el = this.el.querySelector('.' + this.opts.itemClass); + if (el) { + const h = Utils.toNumber(el.getAttribute('gs-h')) || 1; // since we don't write 1 anymore + return Math.round(el.offsetHeight / h); + } + // else do entire grid and # of rows (but doesn't work if min-height is the actual constrain) + const rows = parseInt(this.el.getAttribute('gs-current-row')); + return rows ? Math.round(this.el.getBoundingClientRect().height / rows) : this.opts.cellHeight; + } + /** + * Update current cell height - see `GridStackOptions.cellHeight` for format by updating eh Browser CSS variable. + * + * @param val the cell height. Options: + * - `undefined`: cells content will be made square (match width minus margin) + * - `0`: the CSS will be generated by the application instead + * - number: height in pixels + * - string: height with units (e.g., '70px', '5rem', '2em') + * @returns the grid instance for chaining + * + * @example + * grid.cellHeight(100); // 100px height + * grid.cellHeight('70px'); // explicit pixel height + * grid.cellHeight('5rem'); // relative to root font size + * grid.cellHeight(grid.cellWidth() * 1.2); // aspect ratio + * grid.cellHeight('auto'); // auto-size based on content + */ + cellHeight(val) { + // if not called internally, check if we're changing mode + if (val !== undefined) { + if (this._isAutoCellHeight !== (val === 'auto')) { + this._isAutoCellHeight = (val === 'auto'); + this._updateResizeEvent(); + } + } + if (val === 'initial' || val === 'auto') { + val = undefined; + } + // make item content be square + if (val === undefined) { + const marginDiff = -this.opts.marginRight - this.opts.marginLeft + + this.opts.marginTop + this.opts.marginBottom; + val = this.cellWidth() + marginDiff; + } + const data = Utils.parseHeight(val); + if (this.opts.cellHeightUnit === data.unit && this.opts.cellHeight === data.h) { + return this; + } + this.opts.cellHeightUnit = data.unit; + this.opts.cellHeight = data.h; + // finally update var and container + this.el.style.setProperty('--gs-cell-height', `${this.opts.cellHeight}${this.opts.cellHeightUnit}`); + this._updateContainerHeight(); + this.resizeToContentCheck(); + return this; + } + /** Gets current cell width. */ + /** + * Gets the current cell width in pixels. This is calculated based on the grid container width divided by the number of columns. + * + * @returns the cell width in pixels + * + * @example + * const width = grid.cellWidth(); + * console.log('Cell width:', width, 'px'); + * + * // Use cell width to calculate widget dimensions + * const widgetWidth = width * 3; // For a 3-column wide widget + */ + cellWidth() { + return this._widthOrContainer() / this.getColumn(); + } + /** return our expected width (or parent) , and optionally of window for dynamic column check */ + _widthOrContainer(forBreakpoint = false) { + // use `offsetWidth` or `clientWidth` (no scrollbar) ? + // https://stackoverflow.com/questions/21064101/understanding-offsetwidth-clientwidth-scrollwidth-and-height-respectively + return forBreakpoint && this.opts.columnOpts?.breakpointForWindow ? window.innerWidth : (this.el.clientWidth || this.el.parentElement.clientWidth || window.innerWidth); + } + /** checks for dynamic column count for our current size, returning true if changed */ + checkDynamicColumn() { + const resp = this.opts.columnOpts; + if (!resp || (!resp.columnWidth && !resp.breakpoints?.length)) + return false; + const column = this.getColumn(); + let newColumn = column; + const w = this._widthOrContainer(true); + if (resp.columnWidth) { + newColumn = Math.min(Math.round(w / resp.columnWidth) || 1, resp.columnMax); + } + else { + // find the closest breakpoint (already sorted big to small) that matches + newColumn = resp.columnMax; + let i = 0; + while (i < resp.breakpoints.length && w <= resp.breakpoints[i].w) { + newColumn = resp.breakpoints[i++].c || column; + } + } + if (newColumn !== column) { + const bk = resp.breakpoints?.find(b => b.c === newColumn); + this.column(newColumn, bk?.layout || resp.layout); + return true; + } + return false; + } + /** + * Re-layout grid items to reclaim any empty space. This is useful after removing widgets + * or when you want to optimize the layout. + * + * @param layout layout type. Options: + * - 'compact' (default): might re-order items to fill any empty space + * - 'list': keep the widget left->right order the same, even if that means leaving an empty slot if things don't fit + * @param doSort re-sort items first based on x,y position. Set to false to do your own sorting ahead (default: true) + * @returns the grid instance for chaining + * + * @example + * // Compact layout after removing widgets + * grid.removeWidget('.widget-to-remove'); + * grid.compact(); + * + * // Use list layout (preserve order) + * grid.compact('list'); + * + * // Compact without sorting first + * grid.compact('compact', false); + */ + compact(layout = 'compact', doSort = true) { + this.engine.compact(layout, doSort); + this._triggerChangeEvent(); + return this; + } + /** + * Set the number of columns in the grid. Will update existing widgets to conform to new number of columns, + * as well as cache the original layout so you can revert back to previous positions without loss. + * + * Requires `gridstack-extra.css` or `gridstack-extra.min.css` for [2-11] columns, + * else you will need to generate correct CSS. + * See: https://github.com/gridstack/gridstack.js#change-grid-columns + * + * @param column Integer > 0 (default 12) + * @param layout specify the type of re-layout that will happen. Options: + * - 'moveScale' (default): scale widget positions and sizes + * - 'move': keep widget sizes, only move positions + * - 'scale': keep widget positions, only scale sizes + * - 'none': don't change widget positions or sizes + * Note: items will never be outside of the current column boundaries. + * Ignored for `column=1` as we always want to vertically stack. + * @returns the grid instance for chaining + * + * @example + * // Change to 6 columns with default scaling + * grid.column(6); + * + * // Change to 4 columns, only move positions + * grid.column(4, 'move'); + * + * // Single column layout (vertical stack) + * grid.column(1); + */ + column(column, layout = 'moveScale') { + if (!column || column < 1 || this.opts.column === column) + return this; + const oldColumn = this.getColumn(); + this.opts.column = column; + if (!this.engine) { + // called in constructor, noting else to do but remember that breakpoint layout + this.responseLayout = layout; + return this; + } + this.engine.column = column; + this.el.classList.remove('gs-' + oldColumn); + this._updateColumnVar(); + // update the items now + this.engine.columnChanged(oldColumn, column, layout); + if (this._isAutoCellHeight) + this.cellHeight(); + this.resizeToContentCheck(true); // wait for width resizing + // and trigger our event last... + this._ignoreLayoutsNodeChange = true; // skip layout update + this._triggerChangeEvent(); + delete this._ignoreLayoutsNodeChange; + return this; + } + /** + * Get the number of columns in the grid (default 12). + * + * @returns the current number of columns in the grid + * + * @example + * const columnCount = grid.getColumn(); // returns 12 by default + */ + getColumn() { return this.opts.column; } + /** + * Returns an array of grid HTML elements (no placeholder) - used to iterate through our children in DOM order. + * This method excludes placeholder elements and returns only actual grid items. + * + * @returns array of GridItemHTMLElement instances representing all grid items + * + * @example + * const items = grid.getGridItems(); + * items.forEach(item => { + * console.log('Item ID:', item.gridstackNode.id); + * }); + */ + getGridItems() { + return Array.from(this.el.children) + .filter((el) => el.matches('.' + this.opts.itemClass) && !el.matches('.' + this.opts.placeholderClass)); + } + /** + * Returns true if change callbacks should be ignored due to column change, sizeToContent, loading, etc. + * This is useful for callers who want to implement dirty flag functionality. + * + * @returns true if change callbacks are currently being ignored + * + * @example + * if (!grid.isIgnoreChangeCB()) { + * // Process the change event + * console.log('Grid layout changed'); + * } + */ + isIgnoreChangeCB() { return this._ignoreLayoutsNodeChange; } + /** + * Destroys a grid instance. DO NOT CALL any methods or access any vars after this as it will free up members. + * @param removeDOM if `false` grid and items HTML elements will not be removed from the DOM (Optional. Default `true`). + */ + destroy(removeDOM = true) { + if (!this.el) + return; // prevent multiple calls + this.offAll(); + this._updateResizeEvent(true); + this.setStatic(true, false); // permanently removes DD but don't set CSS class (we're going away) + this.setAnimation(false); + if (!removeDOM) { + this.removeAll(removeDOM); + this.el.removeAttribute('gs-current-row'); + } + else { + this.el.parentNode.removeChild(this.el); + } + if (this.parentGridNode) + delete this.parentGridNode.subGrid; + delete this.parentGridNode; + delete this.opts; + delete this._placeholder?.gridstackNode; + delete this._placeholder; + delete this.engine; + delete this.el.gridstack; // remove circular dependency that would prevent a freeing + delete this.el; + return this; + } + /** + * Enable/disable floating widgets (default: `false`). When enabled, widgets can float up to fill empty spaces. + * See [example](http://gridstackjs.com/demo/float.html) + * + * @param val true to enable floating, false to disable + * @returns the grid instance for chaining + * + * @example + * grid.float(true); // Enable floating + * grid.float(false); // Disable floating (default) + */ + float(val) { + if (this.opts.float !== val) { + this.opts.float = this.engine.float = val; + this._triggerChangeEvent(); + } + return this; + } + /** + * Get the current float mode setting. + * + * @returns true if floating is enabled, false otherwise + * + * @example + * const isFloating = grid.getFloat(); + * console.log('Floating enabled:', isFloating); + */ + getFloat() { + return this.engine.float; + } + /** + * Get the position of the cell under a pixel on screen. + * @param position the position of the pixel to resolve in + * absolute coordinates, as an object with top and left properties + * @param useDocRelative if true, value will be based on document position vs parent position (Optional. Default false). + * Useful when grid is within `position: relative` element + * + * Returns an object with properties `x` and `y` i.e. the column and row in the grid. + */ + getCellFromPixel(position, useDocRelative = false) { + const box = this.el.getBoundingClientRect(); + // console.log(`getBoundingClientRect left: ${box.left} top: ${box.top} w: ${box.w} h: ${box.h}`) + let containerPos; + if (useDocRelative) { + containerPos = { top: box.top + document.documentElement.scrollTop, left: box.left }; + // console.log(`getCellFromPixel scrollTop: ${document.documentElement.scrollTop}`) + } + else { + containerPos = { top: this.el.offsetTop, left: this.el.offsetLeft }; + // console.log(`getCellFromPixel offsetTop: ${containerPos.left} offsetLeft: ${containerPos.top}`) + } + const relativeLeft = position.left - containerPos.left; + const relativeTop = position.top - containerPos.top; + const columnWidth = (box.width / this.getColumn()); + const rowHeight = (box.height / parseInt(this.el.getAttribute('gs-current-row'))); + return { x: Math.floor(relativeLeft / columnWidth), y: Math.floor(relativeTop / rowHeight) }; + } + /** + * Returns the current number of rows, which will be at least `minRow` if set. + * The row count is based on the highest positioned widget in the grid. + * + * @returns the current number of rows in the grid + * + * @example + * const rowCount = grid.getRow(); + * console.log('Grid has', rowCount, 'rows'); + */ + getRow() { + return Math.max(this.engine.getRow(), this.opts.minRow || 0); + } + /** + * Checks if the specified rectangular area is empty (no widgets occupy any part of it). + * + * @param x the x coordinate (column) of the area to check + * @param y the y coordinate (row) of the area to check + * @param w the width in columns of the area to check + * @param h the height in rows of the area to check + * @returns true if the area is completely empty, false if any widget overlaps + * + * @example + * // Check if a 2x2 area at position (1,1) is empty + * if (grid.isAreaEmpty(1, 1, 2, 2)) { + * console.log('Area is available for placement'); + * } + */ + isAreaEmpty(x, y, w, h) { + return this.engine.isAreaEmpty(x, y, w, h); + } + /** + * If you add elements to your grid by hand (or have some framework creating DOM), you have to tell gridstack afterwards to make them widgets. + * If you want gridstack to add the elements for you, use `addWidget()` instead. + * Makes the given element a widget and returns it. + * + * @param els widget or single selector to convert. + * @param options widget definition to use instead of reading attributes or using default sizing values + * @returns the converted GridItemHTMLElement + * + * @example + * const grid = GridStack.init(); + * + * // Create HTML content manually, possibly looking like: + * //
+ * grid.el.innerHTML = '
'; + * + * // Convert existing elements to widgets + * grid.makeWidget('#item-1'); // Uses gs-* attributes from DOM + * grid.makeWidget('#item-2', {w: 2, h: 1, content: 'Hello World'}); + * + * // Or pass DOM element directly + * const element = document.getElementById('item-3'); + * grid.makeWidget(element, {x: 0, y: 1, w: 4, h: 2}); + */ + makeWidget(els, options) { + const el = GridStack.getElement(els); + if (!el || el.gridstackNode) + return el; + if (!el.parentElement) + this.el.appendChild(el); + this._prepareElement(el, true, options); + const node = el.gridstackNode; + this._updateContainerHeight(); + // see if there is a sub-grid to create + if (node.subGridOpts) { + this.makeSubGrid(el, node.subGridOpts, undefined, false); // node.subGrid will be used as option in method, no need to pass + } + // if we're adding an item into 1 column make sure + // we don't override the larger 12 column layout that was already saved. #1985 + let resetIgnoreLayoutsNodeChange; + if (this.opts.column === 1 && !this._ignoreLayoutsNodeChange) { + resetIgnoreLayoutsNodeChange = this._ignoreLayoutsNodeChange = true; + } + this._triggerAddEvent(); + this._triggerChangeEvent(); + if (resetIgnoreLayoutsNodeChange) + delete this._ignoreLayoutsNodeChange; + return el; + } + on(name, callback) { + // check for array of names being passed instead + if (name.indexOf(' ') !== -1) { + const names = name.split(' '); + names.forEach(name => this.on(name, callback)); + return this; + } + // native CustomEvent handlers - cash the generic handlers so we can easily remove + if (name === 'change' || name === 'added' || name === 'removed' || name === 'enable' || name === 'disable') { + const noData = (name === 'enable' || name === 'disable'); + if (noData) { + this._gsEventHandler[name] = (event) => callback(event); + } + else { + this._gsEventHandler[name] = (event) => { if (event.detail) + callback(event, event.detail); }; + } + this.el.addEventListener(name, this._gsEventHandler[name]); + } + else if (name === 'drag' || name === 'dragstart' || name === 'dragstop' || name === 'resizestart' || name === 'resize' + || name === 'resizestop' || name === 'dropped' || name === 'resizecontent') { + // drag&drop stop events NEED to be call them AFTER we update node attributes so handle them ourself. + // do same for start event to make it easier... + this._gsEventHandler[name] = callback; + } + else { + console.error('GridStack.on(' + name + ') event not supported'); + } + return this; + } + /** + * unsubscribe from the 'on' event GridStackEvent + * @param name of the event (see possible values) or list of names space separated + */ + off(name) { + // check for array of names being passed instead + if (name.indexOf(' ') !== -1) { + const names = name.split(' '); + names.forEach(name => this.off(name)); + return this; + } + if (name === 'change' || name === 'added' || name === 'removed' || name === 'enable' || name === 'disable') { + // remove native CustomEvent handlers + if (this._gsEventHandler[name]) { + this.el.removeEventListener(name, this._gsEventHandler[name]); + } + } + delete this._gsEventHandler[name]; + return this; + } + /** + * Remove all event handlers from the grid. This is useful for cleanup when destroying a grid. + * + * @returns the grid instance for chaining + * + * @example + * grid.offAll(); // Remove all event listeners + */ + offAll() { + Object.keys(this._gsEventHandler).forEach((key) => this.off(key)); + return this; + } + /** + * Removes widget from the grid. + * @param el widget or selector to modify + * @param removeDOM if `false` DOM element won't be removed from the tree (Default? true). + * @param triggerEvent if `false` (quiet mode) element will not be added to removed list and no 'removed' callbacks will be called (Default? true). + */ + removeWidget(els, removeDOM = true, triggerEvent = true) { + if (!els) { + console.error('Error: GridStack.removeWidget(undefined) called'); + return this; + } + GridStack.getElements(els).forEach(el => { + if (el.parentElement && el.parentElement !== this.el) + return; // not our child! + let node = el.gridstackNode; + // For Meteor support: https://github.com/gridstack/gridstack.js/pull/272 + if (!node) { + node = this.engine.nodes.find(n => el === n.el); + } + if (!node) + return; + if (removeDOM && GridStack.addRemoveCB) { + GridStack.addRemoveCB(this.el, node, false, false); + } + // remove our DOM data (circular link) and drag&drop permanently + delete el.gridstackNode; + this._removeDD(el); + this.engine.removeNode(node, removeDOM, triggerEvent); + if (removeDOM && el.parentElement) { + el.remove(); // in batch mode engine.removeNode doesn't call back to remove DOM + } + }); + if (triggerEvent) { + this._triggerRemoveEvent(); + this._triggerChangeEvent(); + } + return this; + } + /** + * Removes all widgets from the grid. + * @param removeDOM if `false` DOM elements won't be removed from the tree (Default? `true`). + * @param triggerEvent if `false` (quiet mode) element will not be added to removed list and no 'removed' callbacks will be called (Default? true). + */ + removeAll(removeDOM = true, triggerEvent = true) { + // always remove our DOM data (circular link) before list gets emptied and drag&drop permanently + this.engine.nodes.forEach(n => { + if (removeDOM && GridStack.addRemoveCB) { + GridStack.addRemoveCB(this.el, n, false, false); + } + delete n.el.gridstackNode; + if (!this.opts.staticGrid) + this._removeDD(n.el); + }); + this.engine.removeAll(removeDOM, triggerEvent); + if (triggerEvent) + this._triggerRemoveEvent(); + return this; + } + /** + * Toggle the grid animation state. Toggles the `grid-stack-animate` class. + * @param doAnimate if true the grid will animate. + * @param delay if true setting will be set on next event loop. + */ + setAnimation(doAnimate = this.opts.animate, delay) { + if (delay) { + // delay, but check to make sure grid (opt) is still around + setTimeout(() => { if (this.opts) + this.setAnimation(doAnimate); }); + } + else if (doAnimate) { + this.el.classList.add('grid-stack-animate'); + } + else { + this.el.classList.remove('grid-stack-animate'); + } + this.opts.animate = doAnimate; + return this; + } + /** @internal */ + hasAnimationCSS() { return this.el.classList.contains('grid-stack-animate'); } + /** + * Toggle the grid static state, which permanently removes/add Drag&Drop support, unlike disable()/enable() that just turns it off/on. + * Also toggle the grid-stack-static class. + * @param val if true the grid become static. + * @param updateClass true (default) if css class gets updated + * @param recurse true (default) if sub-grids also get updated + */ + setStatic(val, updateClass = true, recurse = true) { + if (!!this.opts.staticGrid === val) + return this; + val ? this.opts.staticGrid = true : delete this.opts.staticGrid; + this._setupRemoveDrop(); + this._setupAcceptWidget(); + this.engine.nodes.forEach(n => { + this.prepareDragDrop(n.el); // either delete or init Drag&drop + if (n.subGrid && recurse) + n.subGrid.setStatic(val, updateClass, recurse); + }); + if (updateClass) { + this._setStaticClass(); + } + return this; + } + /** + * Updates the passed in options on the grid (similar to update(widget) for for the grid options). + * @param options PARTIAL grid options to update - only items specified will be updated. + * NOTE: not all options updating are currently supported (lot of code, unlikely to change) + */ + updateOptions(o) { + const opts = this.opts; + if (o === opts) + return this; // nothing to do + if (o.acceptWidgets !== undefined) { + opts.acceptWidgets = o.acceptWidgets; + this._setupAcceptWidget(); + } + if (o.animate !== undefined) + this.setAnimation(o.animate); + if (o.cellHeight) + this.cellHeight(o.cellHeight); + if (o.class !== undefined && o.class !== opts.class) { + if (opts.class) + this.el.classList.remove(opts.class); + if (o.class) + this.el.classList.add(o.class); + } + // responsive column take over actual count (keep what we have now) + if (o.columnOpts) { + this.opts.columnOpts = o.columnOpts; + this.checkDynamicColumn(); + } + else if (o.columnOpts === null && this.opts.columnOpts) { + delete this.opts.columnOpts; + this._updateResizeEvent(); + } + else if (typeof (o.column) === 'number') + this.column(o.column); + if (o.margin !== undefined) + this.margin(o.margin); + if (o.staticGrid !== undefined) + this.setStatic(o.staticGrid); + if (o.disableDrag !== undefined && !o.staticGrid) + this.enableMove(!o.disableDrag); + if (o.disableResize !== undefined && !o.staticGrid) + this.enableResize(!o.disableResize); + if (o.float !== undefined) + this.float(o.float); + if (o.row !== undefined) { + opts.minRow = opts.maxRow = opts.row = o.row; + this._updateContainerHeight(); + } + else { + if (o.minRow !== undefined) { + opts.minRow = o.minRow; + this._updateContainerHeight(); + } + if (o.maxRow !== undefined) + opts.maxRow = o.maxRow; + } + if (o.children?.length) + this.load(o.children); + // TBD if we have a real need for these (more complex code) + // alwaysShowResizeHandle, draggable, handle, handleClass, itemClass, layout, placeholderClass, placeholderText, resizable, removable, row,... + return this; + } + /** + * Updates widget position/size and other info. This is used to change widget properties after creation. + * Can update position, size, content, and other widget properties. + * + * Note: If you need to call this on all nodes, use load() instead which will update what changed. + * Setting the same x,y for multiple items will be indeterministic and likely unwanted. + * + * @param els widget element(s) or selector to modify + * @param opt new widget options (x,y,w,h, etc.). Only those set will be updated. + * @returns the grid instance for chaining + * + * @example + * // Update widget size and position + * grid.update('.my-widget', { x: 2, y: 1, w: 3, h: 2 }); + * + * // Update widget content + * grid.update(widget, { content: '

New content

' }); + * + * // Update multiple properties + * grid.update('#my-widget', { + * w: 4, + * h: 3, + * noResize: true, + * locked: true + * }); + */ + update(els, opt) { + GridStack.getElements(els).forEach(el => { + const n = el?.gridstackNode; + if (!n) + return; + const w = { ...Utils.copyPos({}, n), ...Utils.cloneDeep(opt) }; // make a copy we can modify in case they re-use it or multiple items + this.engine.nodeBoundFix(w); + delete w.autoPosition; + // move/resize widget if anything changed + const keys = ['x', 'y', 'w', 'h']; + let m; + if (keys.some(k => w[k] !== undefined && w[k] !== n[k])) { + m = {}; + keys.forEach(k => { + m[k] = (w[k] !== undefined) ? w[k] : n[k]; + delete w[k]; + }); + } + // for a move as well IFF there is any min/max fields set + if (!m && (w.minW || w.minH || w.maxW || w.maxH)) { + m = {}; // will use node position but validate values + } + // check for content changing + if (w.content !== undefined) { + const itemContent = el.querySelector('.grid-stack-item-content'); + if (itemContent && itemContent.textContent !== w.content) { + n.content = w.content; + GridStack.renderCB(itemContent, w); + // restore any sub-grid back + if (n.subGrid?.el) { + itemContent.appendChild(n.subGrid.el); + n.subGrid._updateContainerHeight(); + } + } + delete w.content; + } + // any remaining fields are assigned, but check for dragging changes, resize constrain + let changed = false; + let ddChanged = false; + for (const key in w) { + if (key[0] !== '_' && n[key] !== w[key]) { + n[key] = w[key]; + changed = true; + ddChanged = ddChanged || (!this.opts.staticGrid && (key === 'noResize' || key === 'noMove' || key === 'locked')); + } + } + Utils.sanitizeMinMax(n); + // finally move the widget and update attr + if (m) { + const widthChanged = (m.w !== undefined && m.w !== n.w); + this.moveNode(n, m); + if (widthChanged && n.subGrid) { + // if we're animating the client size hasn't changed yet, so force a change (not exact size) + n.subGrid.onResize(this.hasAnimationCSS() ? n.w : undefined); + } + else { + this.resizeToContentCheck(widthChanged, n); + } + delete n._orig; // clear out original position now that we moved #2669 + } + if (m || changed) { + this._writeAttr(el, n); + } + if (ddChanged) { + this.prepareDragDrop(n.el); + } + if (GridStack.updateCB) + GridStack.updateCB(n); // call user callback so they know widget got updated + }); + return this; + } + moveNode(n, m) { + const wasUpdating = n._updating; + if (!wasUpdating) + this.engine.cleanNodes().beginUpdate(n); + this.engine.moveNode(n, m); + this._updateContainerHeight(); + if (!wasUpdating) { + this._triggerChangeEvent(); + this.engine.endUpdate(); + } + } + /** + * Updates widget height to match the content height to avoid vertical scrollbars or dead space. + * This automatically adjusts the widget height based on its content size. + * + * Note: This assumes only 1 child under resizeToContentParent='.grid-stack-item-content' + * (sized to gridItem minus padding) that represents the entire content size. + * + * @param el the grid item element to resize + * + * @example + * // Resize a widget to fit its content + * const widget = document.querySelector('.grid-stack-item'); + * grid.resizeToContent(widget); + * + * // This is commonly used with dynamic content: + * widget.querySelector('.content').innerHTML = 'New longer content...'; + * grid.resizeToContent(widget); + */ + resizeToContent(el) { + if (!el) + return; + el.classList.remove('size-to-content-max'); + if (!el.clientHeight) + return; // 0 when hidden, skip + const n = el.gridstackNode; + if (!n) + return; + const grid = n.grid; + if (!grid || el.parentElement !== grid.el) + return; // skip if we are not inside a grid + const cell = grid.getCellHeight(true); + if (!cell) + return; + let height = n.h ? n.h * cell : el.clientHeight; // getBoundingClientRect().height seem to flicker back and forth + let item; + if (n.resizeToContentParent) + item = el.querySelector(n.resizeToContentParent); + if (!item) + item = el.querySelector(GridStack.resizeToContentParent); + if (!item) + return; + const padding = el.clientHeight - item.clientHeight; // full - available height to our child (minus border, padding...) + const itemH = n.h ? n.h * cell - padding : item.clientHeight; // calculated to what cellHeight is or will become (rather than actual to prevent waiting for animation to finish) + let wantedH; + if (n.subGrid) { + // sub-grid - use their actual row count * their cell height, BUT append any content outside of the grid (eg: above text) + wantedH = n.subGrid.getRow() * n.subGrid.getCellHeight(true); + const subRec = n.subGrid.el.getBoundingClientRect(); + const parentRec = el.getBoundingClientRect(); + wantedH += subRec.top - parentRec.top; + } + else if (n.subGridOpts?.children?.length) { + // not sub-grid just yet (case above) wait until we do + return; + } + else { + // NOTE: clientHeight & getBoundingClientRect() is undefined for text and other leaf nodes. use
container! + const child = item.firstElementChild; + if (!child) { + console.error(`Error: GridStack.resizeToContent() widget id:${n.id} '${GridStack.resizeToContentParent}'.firstElementChild is null, make sure to have a div like container. Skipping sizing.`); + return; + } + wantedH = child.getBoundingClientRect().height || itemH; + } + if (itemH === wantedH) + return; + height += wantedH - itemH; + let h = Math.ceil(height / cell); + // check for min/max and special sizing + const softMax = Number.isInteger(n.sizeToContent) ? n.sizeToContent : 0; + if (softMax && h > softMax) { + h = softMax; + el.classList.add('size-to-content-max'); // get v-scroll back + } + if (n.minH && h < n.minH) + h = n.minH; + else if (n.maxH && h > n.maxH) + h = n.maxH; + if (h !== n.h) { + grid._ignoreLayoutsNodeChange = true; + grid.moveNode(n, { h }); + delete grid._ignoreLayoutsNodeChange; + } + } + /** call the user resize (so they can do extra work) else our build in version */ + resizeToContentCBCheck(el) { + if (GridStack.resizeToContentCB) + GridStack.resizeToContentCB(el); + else + this.resizeToContent(el); + } + /** + * Rotate widgets by swapping their width and height. This is typically called when the user presses 'r' during dragging. + * The rotation swaps the w/h dimensions and adjusts min/max constraints accordingly. + * + * @param els widget element(s) or selector to rotate + * @param relative optional pixel coordinate relative to upper/left corner to rotate around (keeps that cell under cursor) + * @returns the grid instance for chaining + * + * @example + * // Rotate a specific widget + * grid.rotate('.my-widget'); + * + * // Rotate with relative positioning during drag + * grid.rotate(widget, { left: 50, top: 30 }); + */ + rotate(els, relative) { + GridStack.getElements(els).forEach(el => { + const n = el.gridstackNode; + if (!Utils.canBeRotated(n)) + return; + const rot = { w: n.h, h: n.w, minH: n.minW, minW: n.minH, maxH: n.maxW, maxW: n.maxH }; + // if given an offset, adjust x/y by column/row bounds when user presses 'r' during dragging + if (relative) { + const pivotX = relative.left > 0 ? Math.floor(relative.left / this.cellWidth()) : 0; + const pivotY = relative.top > 0 ? Math.floor(relative.top / this.opts.cellHeight) : 0; + rot.x = n.x + pivotX - (n.h - (pivotY + 1)); + rot.y = (n.y + pivotY) - pivotX; + } + Object.keys(rot).forEach(k => { if (rot[k] === undefined) + delete rot[k]; }); + const _orig = n._orig; + this.update(el, rot); + n._orig = _orig; // restore as move() will delete it + }); + return this; + } + /** + * Updates the margins which will set all 4 sides at once - see `GridStackOptions.margin` for format options. + * Supports CSS string format of 1, 2, or 4 values or a single number. + * + * @param value margin value - can be: + * - Single number: `10` (applies to all sides) + * - Two values: `'10px 20px'` (top/bottom, left/right) + * - Four values: `'10px 20px 5px 15px'` (top, right, bottom, left) + * @returns the grid instance for chaining + * + * @example + * grid.margin(10); // 10px all sides + * grid.margin('10px 20px'); // 10px top/bottom, 20px left/right + * grid.margin('5px 10px 15px 20px'); // Different for each side + */ + margin(value) { + const isMultiValue = (typeof value === 'string' && value.split(' ').length > 1); + // check if we can skip... won't check if multi values (too much hassle) + if (!isMultiValue) { + const data = Utils.parseHeight(value); + if (this.opts.marginUnit === data.unit && this.opts.margin === data.h) + return; + } + // re-use existing margin handling + this.opts.margin = value; + this.opts.marginTop = this.opts.marginBottom = this.opts.marginLeft = this.opts.marginRight = undefined; + this._initMargin(); + return this; + } + /** + * Returns the current margin value as a number (undefined if the 4 sides don't match). + * This only returns a number if all sides have the same margin value. + * + * @returns the margin value in pixels, or undefined if sides have different values + * + * @example + * const margin = grid.getMargin(); + * if (margin !== undefined) { + * console.log('Uniform margin:', margin, 'px'); + * } else { + * console.log('Margins are different on different sides'); + * } + */ + getMargin() { return this.opts.margin; } + /** + * Returns true if the height of the grid will be less than the vertical + * constraint. Always returns true if grid doesn't have height constraint. + * @param node contains x,y,w,h,auto-position options + * + * @example + * if (grid.willItFit(newWidget)) { + * grid.addWidget(newWidget); + * } else { + * alert('Not enough free space to place the widget'); + * } + */ + willItFit(node) { + // support legacy call for now + if (arguments.length > 1) { + console.warn('gridstack.ts: `willItFit(x,y,w,h,autoPosition)` is deprecated. Use `willItFit({x, y,...})`. It will be removed soon'); + // eslint-disable-next-line prefer-rest-params + const a = arguments; + let i = 0, w = { x: a[i++], y: a[i++], w: a[i++], h: a[i++], autoPosition: a[i++] }; + return this.willItFit(w); + } + return this.engine.willItFit(node); + } + /** @internal */ + _triggerChangeEvent() { + if (this.engine.batchMode) + return this; + const elements = this.engine.getDirtyNodes(true); // verify they really changed + if (elements && elements.length) { + if (!this._ignoreLayoutsNodeChange) { + this.engine.layoutsNodesChange(elements); + } + this._triggerEvent('change', elements); + } + this.engine.saveInitial(); // we called, now reset initial values & dirty flags + return this; + } + /** @internal */ + _triggerAddEvent() { + if (this.engine.batchMode) + return this; + if (this.engine.addedNodes?.length) { + if (!this._ignoreLayoutsNodeChange) { + this.engine.layoutsNodesChange(this.engine.addedNodes); + } + // prevent added nodes from also triggering 'change' event (which is called next) + this.engine.addedNodes.forEach(n => { delete n._dirty; }); + const addedNodes = [...this.engine.addedNodes]; + this.engine.addedNodes = []; + this._triggerEvent('added', addedNodes); + } + return this; + } + /** @internal */ + _triggerRemoveEvent() { + if (this.engine.batchMode) + return this; + if (this.engine.removedNodes?.length) { + const removedNodes = [...this.engine.removedNodes]; + this.engine.removedNodes = []; + this._triggerEvent('removed', removedNodes); + } + return this; + } + /** @internal */ + _triggerEvent(type, data) { + const event = data ? new CustomEvent(type, { bubbles: false, detail: data }) : new Event(type); + // check if we're nested, and if so call the outermost grid to trigger the event + // eslint-disable-next-line @typescript-eslint/no-this-alias + let grid = this; + while (grid.parentGridNode) + grid = grid.parentGridNode.grid; + grid.el.dispatchEvent(event); + return this; + } + /** @internal */ + _updateContainerHeight() { + if (!this.engine || this.engine.batchMode) + return this; + const parent = this.parentGridNode; + let row = this.getRow() + this._extraDragRow; // this checks for minRow already + const cellHeight = this.opts.cellHeight; + const unit = this.opts.cellHeightUnit; + if (!cellHeight) + return this; + // check for css min height (non nested grid). TODO: support mismatch, say: min % while unit is px. + // If `minRow` was applied, don't override it with this check, and avoid performance issues + // (reflows) using `getComputedStyle` + if (!parent && !this.opts.minRow) { + const cssMinHeight = Utils.parseHeight(getComputedStyle(this.el)['minHeight']); + if (cssMinHeight.h > 0 && cssMinHeight.unit === unit) { + const minRow = Math.floor(cssMinHeight.h / cellHeight); + if (row < minRow) { + row = minRow; + } + } + } + this.el.setAttribute('gs-current-row', String(row)); + this.el.style.removeProperty('min-height'); + this.el.style.removeProperty('height'); + if (row) { + // nested grids have 'insert:0' to fill the space of parent by default, but we may be taller so use min-height for possible scrollbars + this.el.style[parent ? 'minHeight' : 'height'] = row * cellHeight + unit; + } + // if we're a nested grid inside an sizeToContent item, tell it to resize itself too + if (parent && Utils.shouldSizeToContent(parent)) { + parent.grid.resizeToContentCBCheck(parent.el); + } + return this; + } + /** @internal */ + _prepareElement(el, triggerAddEvent = false, node) { + node = node || this._readAttr(el); + el.gridstackNode = node; + node.el = el; + node.grid = this; + node = this.engine.addNode(node, triggerAddEvent); + // write the dom sizes and class + this._writeAttr(el, node); + el.classList.add(gridDefaults.itemClass, this.opts.itemClass); + const sizeToContent = Utils.shouldSizeToContent(node); + sizeToContent ? el.classList.add('size-to-content') : el.classList.remove('size-to-content'); + if (sizeToContent) + this.resizeToContentCheck(false, node); + if (!Utils.lazyLoad(node)) + this.prepareDragDrop(node.el); + return this; + } + /** @internal write position CSS vars and x,y,w,h attributes (not used for CSS but by users) back to element */ + _writePosAttr(el, n) { + // Avoid overwriting the inline style of the element during drag/resize, but always update the placeholder + if ((!n._moving && !n._resizing) || this._placeholder === el) { + // width/height:1 x/y:0 is set by default in the main CSS, so no need to set inlined vars + el.style.top = n.y ? (n.y === 1 ? `var(--gs-cell-height)` : `calc(${n.y} * var(--gs-cell-height))`) : null; + el.style.left = n.x ? (n.x === 1 ? `var(--gs-column-width)` : `calc(${n.x} * var(--gs-column-width))`) : null; + el.style.width = n.w > 1 ? `calc(${n.w} * var(--gs-column-width))` : null; + el.style.height = n.h > 1 ? `calc(${n.h} * var(--gs-cell-height))` : null; + } + // NOTE: those are technically not needed anymore (v12+) as we have CSS vars for everything, but some users depends on them to render item size using CSS + n.x > 0 ? el.setAttribute('gs-x', String(n.x)) : el.removeAttribute('gs-x'); + n.y > 0 ? el.setAttribute('gs-y', String(n.y)) : el.removeAttribute('gs-y'); + n.w > 1 ? el.setAttribute('gs-w', String(n.w)) : el.removeAttribute('gs-w'); + n.h > 1 ? el.setAttribute('gs-h', String(n.h)) : el.removeAttribute('gs-h'); + return this; + } + /** @internal call to write any default attributes back to element */ + _writeAttr(el, node) { + if (!node) + return this; + this._writePosAttr(el, node); + const attrs /*: GridStackWidget but strings */ = { + // autoPosition: 'gs-auto-position', // no need to write out as already in node and doesn't affect CSS + noResize: 'gs-no-resize', + noMove: 'gs-no-move', + locked: 'gs-locked', + id: 'gs-id', + sizeToContent: 'gs-size-to-content', + }; + for (const key in attrs) { + if (node[key]) { // 0 is valid for x,y only but done above already and not in list anyway + el.setAttribute(attrs[key], String(node[key])); + } + else { + el.removeAttribute(attrs[key]); + } + } + return this; + } + /** @internal call to read any default attributes from element */ + _readAttr(el, clearDefaultAttr = true) { + const n = {}; + n.x = Utils.toNumber(el.getAttribute('gs-x')); + n.y = Utils.toNumber(el.getAttribute('gs-y')); + n.w = Utils.toNumber(el.getAttribute('gs-w')); + n.h = Utils.toNumber(el.getAttribute('gs-h')); + n.autoPosition = Utils.toBool(el.getAttribute('gs-auto-position')); + n.noResize = Utils.toBool(el.getAttribute('gs-no-resize')); + n.noMove = Utils.toBool(el.getAttribute('gs-no-move')); + n.locked = Utils.toBool(el.getAttribute('gs-locked')); + const attr = el.getAttribute('gs-size-to-content'); + if (attr) { + if (attr === 'true' || attr === 'false') + n.sizeToContent = Utils.toBool(attr); + else + n.sizeToContent = parseInt(attr, 10); + } + n.id = el.getAttribute('gs-id'); + // read but never written out + n.maxW = Utils.toNumber(el.getAttribute('gs-max-w')); + n.minW = Utils.toNumber(el.getAttribute('gs-min-w')); + n.maxH = Utils.toNumber(el.getAttribute('gs-max-h')); + n.minH = Utils.toNumber(el.getAttribute('gs-min-h')); + // v8.x optimization to reduce un-needed attr that don't render or are default CSS + if (clearDefaultAttr) { + if (n.w === 1) + el.removeAttribute('gs-w'); + if (n.h === 1) + el.removeAttribute('gs-h'); + if (n.maxW) + el.removeAttribute('gs-max-w'); + if (n.minW) + el.removeAttribute('gs-min-w'); + if (n.maxH) + el.removeAttribute('gs-max-h'); + if (n.minH) + el.removeAttribute('gs-min-h'); + } + // remove any key not found (null or false which is default, unless sizeToContent=false override) + for (const key in n) { + if (!n.hasOwnProperty(key)) + return; + if (!n[key] && n[key] !== 0 && key !== 'sizeToContent') { // 0 can be valid value (x,y only really) + delete n[key]; + } + } + return n; + } + /** @internal */ + _setStaticClass() { + const classes = ['grid-stack-static']; + if (this.opts.staticGrid) { + this.el.classList.add(...classes); + this.el.setAttribute('gs-static', 'true'); + } + else { + this.el.classList.remove(...classes); + this.el.removeAttribute('gs-static'); + } + return this; + } + /** + * called when we are being resized - check if the one Column Mode needs to be turned on/off + * and remember the prev columns we used, or get our count from parent, as well as check for cellHeight==='auto' (square) + * or `sizeToContent` gridItem options. + */ + onResize(clientWidth = this.el?.clientWidth) { + if (!clientWidth) + return; // return if we're gone or no size yet (will get called again) + if (this.prevWidth === clientWidth) + return; // no-op + this.prevWidth = clientWidth; + // console.log('onResize ', clientWidth); + this.batchUpdate(); + // see if we're nested and take our column count from our parent.... + let columnChanged = false; + if (this._autoColumn && this.parentGridNode) { + if (this.opts.column !== this.parentGridNode.w) { + this.column(this.parentGridNode.w, this.opts.layout || 'list'); + columnChanged = true; + } + } + else { + // else check for dynamic column + columnChanged = this.checkDynamicColumn(); + } + // make the cells content square again + if (this._isAutoCellHeight) + this.cellHeight(); + // update any nested grids, or items size + this.engine.nodes.forEach(n => { + if (n.subGrid) + n.subGrid.onResize(); + }); + if (!this._skipInitialResize) + this.resizeToContentCheck(columnChanged); // wait for anim of column changed (DOM reflow before we can size correctly) + delete this._skipInitialResize; + this.batchUpdate(false); + return this; + } + /** resizes content for given node (or all) if shouldSizeToContent() is true */ + resizeToContentCheck(delay = false, n = undefined) { + if (!this.engine) + return; // we've been deleted in between! + // update any gridItem height with sizeToContent, but wait for DOM $animation_speed to settle if we changed column count + // TODO: is there a way to know what the final (post animation) size of the content will be so we can animate the column width and height together rather than sequentially ? + if (delay && this.hasAnimationCSS()) + return setTimeout(() => this.resizeToContentCheck(false, n), this.animationDelay); + if (n) { + if (Utils.shouldSizeToContent(n)) + this.resizeToContentCBCheck(n.el); + } + else if (this.engine.nodes.some(n => Utils.shouldSizeToContent(n))) { + const nodes = [...this.engine.nodes]; // in case order changes while resizing one + this.batchUpdate(); + nodes.forEach(n => { + if (Utils.shouldSizeToContent(n)) + this.resizeToContentCBCheck(n.el); + }); + this._ignoreLayoutsNodeChange = true; // loop through each node will set/reset around each move, so set it here again + this.batchUpdate(false); + this._ignoreLayoutsNodeChange = false; + } + // call this regardless of shouldSizeToContent because widget might need to stretch to take available space after a resize + if (this._gsEventHandler['resizecontent']) + this._gsEventHandler['resizecontent'](null, n ? [n] : this.engine.nodes); + } + /** add or remove the grid element size event handler */ + _updateResizeEvent(forceRemove = false) { + // only add event if we're not nested (parent will call us) and we're auto sizing cells or supporting dynamic column (i.e. doing work) + // or supporting new sizeToContent option. + const trackSize = !this.parentGridNode && (this._isAutoCellHeight || this.opts.sizeToContent || this.opts.columnOpts + || this.engine.nodes.find(n => n.sizeToContent)); + if (!forceRemove && trackSize && !this.resizeObserver) { + this._sizeThrottle = Utils.throttle(() => this.onResize(), this.opts.cellHeightThrottle); + this.resizeObserver = new ResizeObserver(() => this._sizeThrottle()); + this.resizeObserver.observe(this.el); + this._skipInitialResize = true; // makeWidget will originally have called on startup + } + else if ((forceRemove || !trackSize) && this.resizeObserver) { + this.resizeObserver.disconnect(); + delete this.resizeObserver; + delete this._sizeThrottle; + } + return this; + } + /** @internal convert a potential selector into actual element */ + static getElement(els = '.grid-stack-item') { return Utils.getElement(els); } + /** @internal */ + static getElements(els = '.grid-stack-item') { return Utils.getElements(els); } + /** @internal */ + static getGridElement(els) { return GridStack.getElement(els); } + /** @internal */ + static getGridElements(els) { return Utils.getElements(els); } + /** @internal initialize margin top/bottom/left/right and units */ + _initMargin() { + let data; + let margin = 0; + // support passing multiple values like CSS (ex: '5px 10px 0 20px') + let margins = []; + if (typeof this.opts.margin === 'string') { + margins = this.opts.margin.split(' '); + } + if (margins.length === 2) { // top/bot, left/right like CSS + this.opts.marginTop = this.opts.marginBottom = margins[0]; + this.opts.marginLeft = this.opts.marginRight = margins[1]; + } + else if (margins.length === 4) { // Clockwise like CSS + this.opts.marginTop = margins[0]; + this.opts.marginRight = margins[1]; + this.opts.marginBottom = margins[2]; + this.opts.marginLeft = margins[3]; + } + else { + data = Utils.parseHeight(this.opts.margin); + this.opts.marginUnit = data.unit; + margin = this.opts.margin = data.h; + } + // see if top/bottom/left/right need to be set as well + const keys = ['marginTop', 'marginRight', 'marginBottom', 'marginLeft']; + keys.forEach(k => { + if (this.opts[k] === undefined) { + this.opts[k] = margin; + } + else { + data = Utils.parseHeight(this.opts[k]); + this.opts[k] = data.h; + delete this.opts.margin; + } + }); + this.opts.marginUnit = data.unit; // in case side were spelled out, use those units instead... + if (this.opts.marginTop === this.opts.marginBottom && this.opts.marginLeft === this.opts.marginRight && this.opts.marginTop === this.opts.marginRight) { + this.opts.margin = this.opts.marginTop; // makes it easier to check for no-ops in setMargin() + } + // finally Update the CSS margin variables (inside the cell height) */ + const style = this.el.style; + style.setProperty('--gs-item-margin-top', `${this.opts.marginTop}${this.opts.marginUnit}`); + style.setProperty('--gs-item-margin-bottom', `${this.opts.marginBottom}${this.opts.marginUnit}`); + style.setProperty('--gs-item-margin-right', `${this.opts.marginRight}${this.opts.marginUnit}`); + style.setProperty('--gs-item-margin-left', `${this.opts.marginLeft}${this.opts.marginUnit}`); + return this; + } + /* =========================================================================================== + * drag&drop methods that used to be stubbed out and implemented in dd-gridstack.ts + * but caused loading issues in prod - see https://github.com/gridstack/gridstack.js/issues/2039 + * =========================================================================================== + */ + /** + * Get the global drag & drop implementation instance. + * This provides access to the underlying drag & drop functionality. + * + * @returns the DDGridStack instance used for drag & drop operations + * + * @example + * const dd = GridStack.getDD(); + * // Access drag & drop functionality + */ + static getDD() { + return dd; + } + /** + * call to setup dragging in from the outside (say toolbar), by specifying the class selection and options. + * Called during GridStack.init() as options, but can also be called directly (last param are used) in case the toolbar + * is dynamically create and needs to be set later. + * @param dragIn string selector (ex: '.sidebar-item') or list of dom elements + * @param dragInOptions options - see DDDragOpt. (default: {handle: '.grid-stack-item-content', appendTo: 'body'} + * @param widgets GridStackWidget def to assign to each element which defines what to create on drop + * @param root optional root which defaults to document (for shadow dom pass the parent HTMLDocument) + */ + static setupDragIn(dragIn, dragInOptions, widgets, root = document) { + if (dragInOptions?.pause !== undefined) { + DDManager.pauseDrag = dragInOptions.pause; + } + dragInOptions = { appendTo: 'body', helper: 'clone', ...(dragInOptions || {}) }; // default to handle:undefined = drag by the whole item + const els = (typeof dragIn === 'string') ? Utils.getElements(dragIn, root) : dragIn; + els.forEach((el, i) => { + if (!dd.isDraggable(el)) + dd.dragIn(el, dragInOptions); + if (widgets?.[i]) + el.gridstackNode = widgets[i]; + }); + } + /** + * Enables/Disables dragging by the user for specific grid elements. + * For all items and future items, use enableMove() instead. No-op for static grids. + * + * Note: If you want to prevent an item from moving due to being pushed around by another + * during collision, use the 'locked' property instead. + * + * @param els widget element(s) or selector to modify + * @param val if true widget will be draggable, assuming the parent grid isn't noMove or static + * @returns the grid instance for chaining + * + * @example + * // Make specific widgets draggable + * grid.movable('.my-widget', true); + * + * // Disable dragging for specific widgets + * grid.movable('#fixed-widget', false); + */ + movable(els, val) { + if (this.opts.staticGrid) + return this; // can't move a static grid! + GridStack.getElements(els).forEach(el => { + const n = el.gridstackNode; + if (!n) + return; + val ? delete n.noMove : n.noMove = true; + this.prepareDragDrop(n.el); // init DD if need be, and adjust + }); + return this; + } + /** + * Enables/Disables user resizing for specific grid elements. + * For all items and future items, use enableResize() instead. No-op for static grids. + * + * @param els widget element(s) or selector to modify + * @param val if true widget will be resizable, assuming the parent grid isn't noResize or static + * @returns the grid instance for chaining + * + * @example + * // Make specific widgets resizable + * grid.resizable('.my-widget', true); + * + * // Disable resizing for specific widgets + * grid.resizable('#fixed-size-widget', false); + */ + resizable(els, val) { + if (this.opts.staticGrid) + return this; // can't resize a static grid! + GridStack.getElements(els).forEach(el => { + const n = el.gridstackNode; + if (!n) + return; + val ? delete n.noResize : n.noResize = true; + this.prepareDragDrop(n.el); // init DD if need be, and adjust + }); + return this; + } + /** + * Temporarily disables widgets moving/resizing. + * If you want a more permanent way (which freezes up resources) use `setStatic(true)` instead. + * + * Note: This is a no-op for static grids. + * + * This is a shortcut for: + * ```typescript + * grid.enableMove(false); + * grid.enableResize(false); + * ``` + * + * @param recurse if true (default), sub-grids also get updated + * @returns the grid instance for chaining + * + * @example + * // Disable all interactions + * grid.disable(); + * + * // Disable only this grid, not sub-grids + * grid.disable(false); + */ + disable(recurse = true) { + if (this.opts.staticGrid) + return; + this.enableMove(false, recurse); + this.enableResize(false, recurse); + this._triggerEvent('disable'); + return this; + } + /** + * Re-enables widgets moving/resizing - see disable(). + * Note: This is a no-op for static grids. + * + * This is a shortcut for: + * ```typescript + * grid.enableMove(true); + * grid.enableResize(true); + * ``` + * + * @param recurse if true (default), sub-grids also get updated + * @returns the grid instance for chaining + * + * @example + * // Re-enable all interactions + * grid.enable(); + * + * // Enable only this grid, not sub-grids + * grid.enable(false); + */ + enable(recurse = true) { + if (this.opts.staticGrid) + return; + this.enableMove(true, recurse); + this.enableResize(true, recurse); + this._triggerEvent('enable'); + return this; + } + /** + * Enables/disables widget moving for all widgets. No-op for static grids. + * Note: locally defined items (with noMove property) still override this setting. + * + * @param doEnable if true widgets will be movable, if false moving is disabled + * @param recurse if true (default), sub-grids also get updated + * @returns the grid instance for chaining + * + * @example + * // Enable moving for all widgets + * grid.enableMove(true); + * + * // Disable moving for all widgets + * grid.enableMove(false); + * + * // Enable only this grid, not sub-grids + * grid.enableMove(true, false); + */ + enableMove(doEnable, recurse = true) { + if (this.opts.staticGrid) + return this; // can't move a static grid! + doEnable ? delete this.opts.disableDrag : this.opts.disableDrag = true; // FIRST before we update children as grid overrides #1658 + this.engine.nodes.forEach(n => { + this.prepareDragDrop(n.el); + if (n.subGrid && recurse) + n.subGrid.enableMove(doEnable, recurse); + }); + return this; + } + /** + * Enables/disables widget resizing for all widgets. No-op for static grids. + * Note: locally defined items (with noResize property) still override this setting. + * + * @param doEnable if true widgets will be resizable, if false resizing is disabled + * @param recurse if true (default), sub-grids also get updated + * @returns the grid instance for chaining + * + * @example + * // Enable resizing for all widgets + * grid.enableResize(true); + * + * // Disable resizing for all widgets + * grid.enableResize(false); + * + * // Enable only this grid, not sub-grids + * grid.enableResize(true, false); + */ + enableResize(doEnable, recurse = true) { + if (this.opts.staticGrid) + return this; // can't size a static grid! + doEnable ? delete this.opts.disableResize : this.opts.disableResize = true; // FIRST before we update children as grid overrides #1658 + this.engine.nodes.forEach(n => { + this.prepareDragDrop(n.el); + if (n.subGrid && recurse) + n.subGrid.enableResize(doEnable, recurse); + }); + return this; + } + /** @internal call when drag (and drop) needs to be cancelled (Esc key) */ + cancelDrag() { + const n = this._placeholder?.gridstackNode; + if (!n) + return; + if (n._isExternal) { + // remove any newly inserted nodes (from outside) + n._isAboutToRemove = true; + this.engine.removeNode(n); + } + else if (n._isAboutToRemove) { + // restore any temp removed (dragged over trash) + GridStack._itemRemoving(n.el, false); + } + this.engine.restoreInitial(); + } + /** @internal removes any drag&drop present (called during destroy) */ + _removeDD(el) { + dd.draggable(el, 'destroy').resizable(el, 'destroy'); + if (el.gridstackNode) { + delete el.gridstackNode._initDD; // reset our DD init flag + } + delete el.ddElement; + return this; + } + /** @internal called to add drag over to support widgets being added externally */ + _setupAcceptWidget() { + // check if we need to disable things + if (this.opts.staticGrid || (!this.opts.acceptWidgets && !this.opts.removable)) { + dd.droppable(this.el, 'destroy'); + return this; + } + // vars shared across all methods + let cellHeight, cellWidth; + const onDrag = (event, el, helper) => { + helper = helper || el; + const node = helper.gridstackNode; + if (!node) + return; + // if the element is being dragged from outside, scale it down to match the grid's scale + // and slightly adjust its position relative to the mouse + if (!node.grid?.el) { + // this scales the helper down + helper.style.transform = `scale(${1 / this.dragTransform.xScale},${1 / this.dragTransform.yScale})`; + // this makes it so that the helper is well positioned relative to the mouse after scaling + const helperRect = helper.getBoundingClientRect(); + helper.style.left = helperRect.x + (this.dragTransform.xScale - 1) * (event.clientX - helperRect.x) / this.dragTransform.xScale + 'px'; + helper.style.top = helperRect.y + (this.dragTransform.yScale - 1) * (event.clientY - helperRect.y) / this.dragTransform.yScale + 'px'; + helper.style.transformOrigin = `0px 0px`; + } + let { top, left } = helper.getBoundingClientRect(); + const rect = this.el.getBoundingClientRect(); + left -= rect.left; + top -= rect.top; + const ui = { + position: { + top: top * this.dragTransform.xScale, + left: left * this.dragTransform.yScale + } + }; + if (node._temporaryRemoved) { + node.x = Math.max(0, Math.round(left / cellWidth)); + node.y = Math.max(0, Math.round(top / cellHeight)); + delete node.autoPosition; + this.engine.nodeBoundFix(node); + // don't accept *initial* location if doesn't fit #1419 (locked drop region, or can't grow), but maybe try if it will go somewhere + if (!this.engine.willItFit(node)) { + node.autoPosition = true; // ignore x,y and try for any slot... + if (!this.engine.willItFit(node)) { + dd.off(el, 'drag'); // stop calling us + return; // full grid or can't grow + } + if (node._willFitPos) { + // use the auto position instead #1687 + Utils.copyPos(node, node._willFitPos); + delete node._willFitPos; + } + } + // re-use the existing node dragging method + this._onStartMoving(helper, event, ui, node, cellWidth, cellHeight); + } + else { + // re-use the existing node dragging that does so much of the collision detection + this._dragOrResize(helper, event, ui, node, cellWidth, cellHeight); + } + }; + dd.droppable(this.el, { + accept: (el) => { + const node = el.gridstackNode || this._readAttr(el, false); + // set accept drop to true on ourself (which we ignore) so we don't get "can't drop" icon in HTML5 mode while moving + if (node?.grid === this) + return true; + if (!this.opts.acceptWidgets) + return false; + // check for accept method or class matching + let canAccept = true; + if (typeof this.opts.acceptWidgets === 'function') { + canAccept = this.opts.acceptWidgets(el); + } + else { + const selector = (this.opts.acceptWidgets === true ? '.grid-stack-item' : this.opts.acceptWidgets); + canAccept = el.matches(selector); + } + // finally check to make sure we actually have space left #1571 #2633 + if (canAccept && node && this.opts.maxRow) { + const n = { w: node.w, h: node.h, minW: node.minW, minH: node.minH }; // only width/height matters and autoPosition + canAccept = this.engine.willItFit(n); + } + return canAccept; + } + }) + /** + * entering our grid area + */ + .on(this.el, 'dropover', (event, el, helper) => { + // console.log(`over ${this.el.gridstack.opts.id} ${count++}`); // TEST + let node = helper?.gridstackNode || el.gridstackNode; + // ignore drop enter on ourself (unless we temporarily removed) which happens on a simple drag of our item + if (node?.grid === this && !node._temporaryRemoved) { + // delete node._added; // reset this to track placeholder again in case we were over other grid #1484 (dropout doesn't always clear) + return false; // prevent parent from receiving msg (which may be a grid as well) + } + // If sidebar item, restore the sidebar node size to ensure consistent behavior when dragging between grids + if (node?._sidebarOrig) { + node.w = node._sidebarOrig.w; + node.h = node._sidebarOrig.h; + } + // fix #1578 when dragging fast, we may not get a leave on the previous grid so force one now + if (node?.grid && node.grid !== this && !node._temporaryRemoved) { + // console.log('dropover without leave'); // TEST + const otherGrid = node.grid; + otherGrid._leave(el, helper); + } + helper = helper || el; + // cache cell dimensions (which don't change), position can animate if we removed an item in otherGrid that affects us... + cellWidth = this.cellWidth(); + cellHeight = this.getCellHeight(true); + // sidebar items: load any element attributes if we don't have a node on first enter from the sidebar + if (!node) { + const attr = helper.getAttribute('data-gs-widget') || helper.getAttribute('gridstacknode'); // TBD: temp support for old V11.0.0 attribute + if (attr) { + try { + node = JSON.parse(attr); + } + catch (error) { + console.error("Gridstack dropover: Bad JSON format: ", attr); + } + helper.removeAttribute('data-gs-widget'); + helper.removeAttribute('gridstacknode'); + } + if (!node) + node = this._readAttr(helper); // used to pass false for #2354, but now we clone top level node + // On first grid enter from sidebar, set the initial sidebar item size properties for the node + node._sidebarOrig = { w: node.w, h: node.h }; + } + if (!node.grid) { // sidebar item + if (!node.el) + node = { ...node }; // clone first time we're coming from sidebar (since 'clone' doesn't copy vars) + node._isExternal = true; + helper.gridstackNode = node; + } + // calculate the grid size based on element outer size + const w = node.w || Math.round(helper.offsetWidth / cellWidth) || 1; + const h = node.h || Math.round(helper.offsetHeight / cellHeight) || 1; + // if the item came from another grid, make a copy and save the original info in case we go back there + if (node.grid && node.grid !== this) { + // copy the node original values (min/max/id/etc...) but override width/height/other flags which are this grid specific + // console.log('dropover cloning node'); // TEST + if (!el._gridstackNodeOrig) + el._gridstackNodeOrig = node; // shouldn't have multiple nested! + el.gridstackNode = node = { ...node, w, h, grid: this }; + delete node.x; + delete node.y; + this.engine.cleanupNode(node) + .nodeBoundFix(node); + // restore some internal fields we need after clearing them all + node._initDD = + node._isExternal = // DOM needs to be re-parented on a drop + node._temporaryRemoved = true; // so it can be inserted onDrag below + } + else { + node.w = w; + node.h = h; + node._temporaryRemoved = true; // so we can insert it + } + // clear any marked for complete removal (Note: don't check _isAboutToRemove as that is cleared above - just do it) + GridStack._itemRemoving(node.el, false); + dd.on(el, 'drag', onDrag); + // make sure this is called at least once when going fast #1578 + onDrag(event, el, helper); + return false; // prevent parent from receiving msg (which may be a grid as well) + }) + /** + * Leaving our grid area... + */ + .on(this.el, 'dropout', (event, el, helper) => { + // console.log(`out ${this.el.gridstack.opts.id} ${count++}`); // TEST + const node = helper?.gridstackNode || el.gridstackNode; + if (!node) + return false; + // fix #1578 when dragging fast, we might get leave after other grid gets enter (which calls us to clean) + // so skip this one if we're not the active grid really.. + if (!node.grid || node.grid === this) { + this._leave(el, helper); + // if we were created as temporary nested grid, go back to before state + if (this._isTemp) { + this.removeAsSubGrid(node); + } + } + return false; // prevent parent from receiving msg (which may be grid as well) + }) + /** + * end - releasing the mouse + */ + .on(this.el, 'drop', (event, el, helper) => { + const node = helper?.gridstackNode || el.gridstackNode; + // ignore drop on ourself from ourself that didn't come from the outside - dragend will handle the simple move instead + if (node?.grid === this && !node._isExternal) + return false; + const wasAdded = !!this.placeholder.parentElement; // skip items not actually added to us because of constrains, but do cleanup #1419 + const wasSidebar = el !== helper; + this.placeholder.remove(); + delete this.placeholder.gridstackNode; + // disable animation when replacing a placeholder (already positioned) with actual content + if (wasAdded && this.opts.animate) { + this.setAnimation(false); + this.setAnimation(true, true); // delay adding back + } + // notify previous grid of removal + // console.log('drop delete _gridstackNodeOrig') // TEST + const origNode = el._gridstackNodeOrig; + delete el._gridstackNodeOrig; + if (wasAdded && origNode?.grid && origNode.grid !== this) { + const oGrid = origNode.grid; + oGrid.engine.removeNodeFromLayoutCache(origNode); + oGrid.engine.removedNodes.push(origNode); + oGrid._triggerRemoveEvent()._triggerChangeEvent(); + // if it's an empty sub-grid that got auto-created, nuke it + if (oGrid.parentGridNode && !oGrid.engine.nodes.length && oGrid.opts.subGridDynamic) { + oGrid.removeAsSubGrid(); + } + } + if (!node) + return false; + // use existing placeholder node as it's already in our list with drop location + if (wasAdded) { + this.engine.cleanupNode(node); // removes all internal _xyz values + node.grid = this; + } + delete node.grid?._isTemp; + dd.off(el, 'drag'); + // if we made a copy insert that instead of the original (sidebar item) + if (helper !== el) { + helper.remove(); + el = helper; + } + else { + el.remove(); // reduce flicker as we change depth here, and size further down + } + this._removeDD(el); + if (!wasAdded) + return false; + const subGrid = node.subGrid?.el?.gridstack; // set when actual sub-grid present + Utils.copyPos(node, this._readAttr(this.placeholder)); // placeholder values as moving VERY fast can throw things off #1578 + Utils.removePositioningStyles(el); + // give the user a chance to alter the widget that will get inserted if new sidebar item + if (wasSidebar && (node.content || node.subGridOpts || GridStack.addRemoveCB)) { + delete node.el; + el = this.addWidget(node); + } + else { + this._prepareElement(el, true, node); + this.el.appendChild(el); + // resizeToContent is skipped in _prepareElement() until node is visible (clientHeight=0) so call it now + this.resizeToContentCheck(false, node); + if (subGrid) { + subGrid.parentGridNode = node; + } + this._updateContainerHeight(); + } + this.engine.addedNodes.push(node); + this._triggerAddEvent(); + this._triggerChangeEvent(); + this.engine.endUpdate(); + if (this._gsEventHandler['dropped']) { + this._gsEventHandler['dropped']({ ...event, type: 'dropped' }, origNode && origNode.grid ? origNode : undefined, node); + } + return false; // prevent parent from receiving msg (which may be grid as well) + }); + return this; + } + /** @internal mark item for removal */ + static _itemRemoving(el, remove) { + if (!el) + return; + const node = el ? el.gridstackNode : undefined; + if (!node?.grid || el.classList.contains(node.grid.opts.removableOptions.decline)) + return; + remove ? node._isAboutToRemove = true : delete node._isAboutToRemove; + remove ? el.classList.add('grid-stack-item-removing') : el.classList.remove('grid-stack-item-removing'); + } + /** @internal called to setup a trash drop zone if the user specifies it */ + _setupRemoveDrop() { + if (typeof this.opts.removable !== 'string') + return this; + const trashEl = document.querySelector(this.opts.removable); + if (!trashEl) + return this; + // only register ONE static drop-over/dropout callback for the 'trash', and it will + // update the passed in item and parent grid because the '.trash' is a shared resource anyway, + // and Native DD only has 1 event CB (having a list and technically a per grid removableOptions complicates things greatly) + if (!this.opts.staticGrid && !dd.isDroppable(trashEl)) { + dd.droppable(trashEl, this.opts.removableOptions) + .on(trashEl, 'dropover', (event, el) => GridStack._itemRemoving(el, true)) + .on(trashEl, 'dropout', (event, el) => GridStack._itemRemoving(el, false)); + } + return this; + } + /** + * prepares the element for drag&drop - this is normally called by makeWidget() unless are are delay loading + * @param el GridItemHTMLElement of the widget + * @param [force=false] + * */ + prepareDragDrop(el, force = false) { + const node = el?.gridstackNode; + if (!node) + return; + const noMove = node.noMove || this.opts.disableDrag; + const noResize = node.noResize || this.opts.disableResize; + // check for disabled grid first + const disable = this.opts.staticGrid || (noMove && noResize); + if (force || disable) { + if (node._initDD) { + this._removeDD(el); // nukes everything instead of just disable, will add some styles back next + delete node._initDD; + } + if (disable) + el.classList.add('ui-draggable-disabled', 'ui-resizable-disabled'); // add styles one might depend on #1435 + if (!force) + return this; + } + if (!node._initDD) { + // variables used/cashed between the 3 start/move/end methods, in addition to node passed above + let cellWidth; + let cellHeight; + /** called when item starts moving/resizing */ + const onStartMoving = (event, ui) => { + // trigger any 'dragstart' / 'resizestart' manually + this.triggerEvent(event, event.target); + cellWidth = this.cellWidth(); + cellHeight = this.getCellHeight(true); // force pixels for calculations + this._onStartMoving(el, event, ui, node, cellWidth, cellHeight); + }; + /** called when item is being dragged/resized */ + const dragOrResize = (event, ui) => { + this._dragOrResize(el, event, ui, node, cellWidth, cellHeight); + }; + /** called when the item stops moving/resizing */ + const onEndMoving = (event) => { + this.placeholder.remove(); + delete this.placeholder.gridstackNode; + delete node._moving; + delete node._resizing; + delete node._event; + delete node._lastTried; + const widthChanged = node.w !== node._orig.w; + // if the item has moved to another grid, we're done here + const target = event.target; + if (!target.gridstackNode || target.gridstackNode.grid !== this) + return; + node.el = target; + if (node._isAboutToRemove) { + const grid = el.gridstackNode.grid; + if (grid._gsEventHandler[event.type]) { + grid._gsEventHandler[event.type](event, target); + } + grid.engine.nodes.push(node); // temp add it back so we can proper remove it next + grid.removeWidget(el, true, true); + } + else { + Utils.removePositioningStyles(target); + if (node._temporaryRemoved) { + // use last position we were at (not _orig as we may have pushed others and moved) and add it back + this._writePosAttr(target, node); + this.engine.addNode(node); + } + else { + // move to new placeholder location + this._writePosAttr(target, node); + } + this.triggerEvent(event, target); + } + // @ts-ignore + this._extraDragRow = 0; // @ts-ignore + this._updateContainerHeight(); // @ts-ignore + this._triggerChangeEvent(); + this.engine.endUpdate(); + if (event.type === 'resizestop') { + if (Number.isInteger(node.sizeToContent)) + node.sizeToContent = node.h; // new soft limit + this.resizeToContentCheck(widthChanged, node); // wait for width animation if changed + } + }; + dd.draggable(el, { + start: onStartMoving, + stop: onEndMoving, + drag: dragOrResize + }).resizable(el, { + start: onStartMoving, + stop: onEndMoving, + resize: dragOrResize + }); + node._initDD = true; // we've set DD support now + } + // finally fine tune move vs resize by disabling any part... + dd.draggable(el, noMove ? 'disable' : 'enable') + .resizable(el, noResize ? 'disable' : 'enable'); + return this; + } + /** @internal handles actual drag/resize start */ + _onStartMoving(el, event, ui, node, cellWidth, cellHeight) { + this.engine.cleanNodes() + .beginUpdate(node); + // @ts-ignore + this._writePosAttr(this.placeholder, node); + this.el.appendChild(this.placeholder); + this.placeholder.gridstackNode = node; + // console.log('_onStartMoving placeholder') // TEST + // if the element is inside a grid, it has already been scaled + // we can use that as a scale reference + if (node.grid?.el) { + this.dragTransform = Utils.getValuesFromTransformedElement(el); + } + // if the element is being dragged from outside (not from any grid) + // we use the grid as the transformation reference, since the helper is not subject to transformation + else if (this.placeholder && this.placeholder.closest('.grid-stack')) { + const gridEl = this.placeholder.closest('.grid-stack'); + this.dragTransform = Utils.getValuesFromTransformedElement(gridEl); + } + // Fallback + else { + this.dragTransform = { + xScale: 1, + xOffset: 0, + yScale: 1, + yOffset: 0, + }; + } + node.el = this.placeholder; + node._lastUiPosition = ui.position; + node._prevYPix = ui.position.top; + node._moving = (event.type === 'dragstart'); // 'dropover' are not initially moving so they can go exactly where they enter (will push stuff out of the way) + node._resizing = (event.type === 'resizestart'); + delete node._lastTried; + if (event.type === 'dropover' && node._temporaryRemoved) { + // console.log('engine.addNode x=' + node.x); // TEST + this.engine.addNode(node); // will add, fix collisions, update attr and clear _temporaryRemoved + node._moving = true; // AFTER, mark as moving object (wanted fix location before) + } + // set the min/max resize info taking into account the column count and position (so we don't resize outside the grid) + this.engine.cacheRects(cellWidth, cellHeight, this.opts.marginTop, this.opts.marginRight, this.opts.marginBottom, this.opts.marginLeft); + if (event.type === 'resizestart') { + const colLeft = this.getColumn() - node.x; + const rowLeft = (this.opts.maxRow || Number.MAX_SAFE_INTEGER) - node.y; + dd.resizable(el, 'option', 'minWidth', cellWidth * Math.min(node.minW || 1, colLeft)) + .resizable(el, 'option', 'minHeight', cellHeight * Math.min(node.minH || 1, rowLeft)) + .resizable(el, 'option', 'maxWidth', cellWidth * Math.min(node.maxW || Number.MAX_SAFE_INTEGER, colLeft)) + .resizable(el, 'option', 'maxWidthMoveLeft', cellWidth * Math.min(node.maxW || Number.MAX_SAFE_INTEGER, node.x + node.w)) + .resizable(el, 'option', 'maxHeight', cellHeight * Math.min(node.maxH || Number.MAX_SAFE_INTEGER, rowLeft)) + .resizable(el, 'option', 'maxHeightMoveUp', cellHeight * Math.min(node.maxH || Number.MAX_SAFE_INTEGER, node.y + node.h)); + } + } + /** @internal handles actual drag/resize */ + _dragOrResize(el, event, ui, node, cellWidth, cellHeight) { + const p = { ...node._orig }; // could be undefined (_isExternal) which is ok (drag only set x,y and w,h will default to node value) + let resizing; + let mLeft = this.opts.marginLeft, mRight = this.opts.marginRight, mTop = this.opts.marginTop, mBottom = this.opts.marginBottom; + // if margins (which are used to pass mid point by) are large relative to cell height/width, reduce them down #1855 + const mHeight = Math.round(cellHeight * 0.1), mWidth = Math.round(cellWidth * 0.1); + mLeft = Math.min(mLeft, mWidth); + mRight = Math.min(mRight, mWidth); + mTop = Math.min(mTop, mHeight); + mBottom = Math.min(mBottom, mHeight); + if (event.type === 'drag') { + if (node._temporaryRemoved) + return; // handled by dropover + const distance = ui.position.top - node._prevYPix; + node._prevYPix = ui.position.top; + if (this.opts.draggable.scroll !== false) { + Utils.updateScrollPosition(el, ui.position, distance); + } + // get new position taking into account the margin in the direction we are moving! (need to pass mid point by margin) + const left = ui.position.left + (ui.position.left > node._lastUiPosition.left ? -mRight : mLeft); + const top = ui.position.top + (ui.position.top > node._lastUiPosition.top ? -mBottom : mTop); + p.x = Math.round(left / cellWidth); + p.y = Math.round(top / cellHeight); + // @ts-ignore// if we're at the bottom hitting something else, grow the grid so cursor doesn't leave when trying to place below others + const prev = this._extraDragRow; + if (this.engine.collide(node, p)) { + const row = this.getRow(); + let extra = Math.max(0, (p.y + node.h) - row); + if (this.opts.maxRow && row + extra > this.opts.maxRow) { + extra = Math.max(0, this.opts.maxRow - row); + } // @ts-ignore + this._extraDragRow = extra; // @ts-ignore + } + else + this._extraDragRow = 0; // @ts-ignore + if (this._extraDragRow !== prev) + this._updateContainerHeight(); + if (node.x === p.x && node.y === p.y) + return; // skip same + // DON'T skip one we tried as we might have failed because of coverage <50% before + // if (node._lastTried && node._lastTried.x === x && node._lastTried.y === y) return; + } + else if (event.type === 'resize') { + if (p.x < 0) + return; + // Scrolling page if needed + Utils.updateScrollResize(event, el, cellHeight); + // get new size + p.w = Math.round((ui.size.width - mLeft) / cellWidth); + p.h = Math.round((ui.size.height - mTop) / cellHeight); + if (node.w === p.w && node.h === p.h) + return; + if (node._lastTried && node._lastTried.w === p.w && node._lastTried.h === p.h) + return; // skip one we tried (but failed) + // if we size on left/top side this might move us, so get possible new position as well + const left = ui.position.left + mLeft; + const top = ui.position.top + mTop; + p.x = Math.round(left / cellWidth); + p.y = Math.round(top / cellHeight); + resizing = true; + } + node._event = event; + node._lastTried = p; // set as last tried (will nuke if we go there) + const rect = { + x: ui.position.left + mLeft, + y: ui.position.top + mTop, + w: (ui.size ? ui.size.width : node.w * cellWidth) - mLeft - mRight, + h: (ui.size ? ui.size.height : node.h * cellHeight) - mTop - mBottom + }; + if (this.engine.moveNodeCheck(node, { ...p, cellWidth, cellHeight, rect, resizing })) { + node._lastUiPosition = ui.position; + this.engine.cacheRects(cellWidth, cellHeight, mTop, mRight, mBottom, mLeft); + delete node._skipDown; + if (resizing && node.subGrid) + node.subGrid.onResize(); + this._extraDragRow = 0; // @ts-ignore + this._updateContainerHeight(); + const target = event.target; // @ts-ignore + // Do not write sidebar item attributes back to the original sidebar el + if (!node._sidebarOrig) { + this._writePosAttr(target, node); + } + this.triggerEvent(event, target); + } + } + /** call given event callback on our main top-most grid (if we're nested) */ + triggerEvent(event, target) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let grid = this; + while (grid.parentGridNode) + grid = grid.parentGridNode.grid; + if (grid._gsEventHandler[event.type]) { + grid._gsEventHandler[event.type](event, target); + } + } + /** @internal called when item leaving our area by either cursor dropout event + * or shape is outside our boundaries. remove it from us, and mark temporary if this was + * our item to start with else restore prev node values from prev grid it came from. + */ + _leave(el, helper) { + helper = helper || el; + const node = helper.gridstackNode; + if (!node) + return; + // remove the scale of the helper on leave + helper.style.transform = helper.style.transformOrigin = null; + dd.off(el, 'drag'); // no need to track while being outside + // this gets called when cursor leaves and shape is outside, so only do this once + if (node._temporaryRemoved) + return; + node._temporaryRemoved = true; + this.engine.removeNode(node); // remove placeholder as well, otherwise it's a sign node is not in our list, which is a bigger issue + node.el = node._isExternal && helper ? helper : el; // point back to real item being dragged + const sidebarOrig = node._sidebarOrig; + if (node._isExternal) + this.engine.cleanupNode(node); + // Restore sidebar item initial size info to stay consistent when dragging between multiple grids + node._sidebarOrig = sidebarOrig; + if (this.opts.removable === true) { // boolean vs a class string + // item leaving us and we are supposed to remove on leave (no need to drag onto trash) mark it so + GridStack._itemRemoving(el, true); + } + // finally if item originally came from another grid, but left us, restore things back to prev info + if (el._gridstackNodeOrig) { + // console.log('leave delete _gridstackNodeOrig') // TEST + el.gridstackNode = el._gridstackNodeOrig; + delete el._gridstackNodeOrig; + } + else if (node._isExternal) { + // item came from outside restore all nodes back to original + this.engine.restoreInitial(); + } + } + // legacy method removed + commit() { obsolete(this, this.batchUpdate(false), 'commit', 'batchUpdate', '5.2'); return this; } +} +/** + * callback to create the content of widgets so the app can control how to store and restore it + * By default this lib will do 'el.textContent = w.content' forcing text only support for avoiding potential XSS issues. + */ +GridStack.renderCB = (el, w) => { if (el && w?.content) + el.textContent = w.content; }; +/** parent class for sizing content. defaults to '.grid-stack-item-content' */ +GridStack.resizeToContentParent = '.grid-stack-item-content'; +/** scoping so users can call GridStack.Utils.sort() for example */ +GridStack.Utils = Utils; +/** scoping so users can call new GridStack.Engine(12) for example */ +GridStack.Engine = GridStackEngine; +/** @internal current version compiled in code */ +GridStack.GDRev = '12.3.2'; + +//# sourceMappingURL=gridstack.js.map +;// CONCATENATED MODULE: ./web/fluentdashboard/layout/gridstack-adapter.ts + + + +class GridstackAdapter { + container; + options; + grids = new Map(); + elements = new Map(); + nodes = []; + columns = 12; + editing = !1; + applying = !1; + notification; + listeners = new Set(); + mount(e, t) { + this.container = e, this.options = t; + } + setEditing(e) { + for (let t of (this.editing = e, this.grids.values()))t.setStatic(!e || 12 !== this.columns); + } + onChange(e) { + return this.listeners.add(e), ()=>this.listeners.delete(e); + } + apply(l, r) { + if (!this.container || !this.options) throw Error("Layout not mounted"); + this.applying = !0, this.nodes = l, this.columns = r; + let a = indexNodes(l), h = resolveLayout(l, r, { + includeHidden: this.editing, + collapsed: this.options.collapsed() + }), c = this.options.collapsed(), g = new Set([ + ...h.items.keys() + ].filter((e)=>!ancestorsOf(a, e).some((e)=>c.has(e.id)))); + for (let [e, t] of [ + ...this.grids + ].reverse())null !== e && (!g.has(e) || c.has(e)) && (t.destroy(!1), this.grids.delete(e)); + for (let [e, t] of [ + ...this.elements + ].reverse())if (!g.has(e)) { + let i = t.gridstackNode?.grid; + i?.engine && i.removeWidget(t, !0, !1), t.remove(), this.elements.delete(e); + } + let p = new Set(), m = (o, l)=>{ + let c = h.grids.get(o); + if (!c) return; + let g = this.grids.get(o); + for (let h of (g || (l.classList.add("grid-stack"), g = GridStack.init({ + column: c.columns, + cellHeight: 40, + margin: 6, + minRow: 2, + float: !0, + animate: !1, + staticGrid: !this.editing || 12 !== r, + handle: ".fluent-dashboard__card-header", + removable: !1, + acceptWidgets: (e)=>{ + let l = e.dataset.nodeId, r = indexNodes(this.nodes), a = l ? r.byId.get(l) : void 0; + if (!a || !this.editing || 12 !== this.columns || a.locked || ancestorsOf(r, a.id).some((e)=>e.locked)) return !1; + if (null === o) return !0; + let h = r.byId.get(o); + return h?.kind === "group" && !h.locked && !ancestorsOf(r, o).some((e)=>e.locked) && !isSelfOrAncestor(r, a.id, o) && depthOf(r, o) + 1 + groupLevels(r, a.id) <= 2 && a.tile.w <= h.tile.w; + } + }, l), this.grids.set(o, g), g.on("dragstop resizestop dropped", ()=>this.scheduleChange())), g.batchUpdate(), g.column(c.columns, "none"), g.setStatic(!this.editing || 12 !== r), c.items)){ + let e = a.byId.get(h.id); + if (!e) continue; + p.add(e.id); + let i = this.elements.get(e.id); + if (i && i.parentElement !== l && (i.gridstackNode?.grid?.engine && i.gridstackNode.grid.removeWidget(i, !1, !1), l.appendChild(i)), !i) { + (i = document.createElement("div")).className = "grid-stack-item", i.dataset.nodeId = e.id; + let t = document.createElement("div"); + t.className = "grid-stack-item-content", i.appendChild(t), this.elements.set(e.id, i), l.appendChild(i); + } + let s = i.querySelector(":scope > .grid-stack-item-content"), d = this.options?.render(e); + d && d.parentElement !== s && s.replaceChildren(d); + let n = !!(e.locked || ancestorsOf(a, e.id).some((e)=>e.locked)), o = { + id: e.id, + x: h.x, + y: h.y, + w: h.w, + h: h.h, + locked: n, + noMove: n, + noResize: n || "group" === e.kind, + minW: 1, + minH: 1, + maxH: 48 + }; + if (i.gridstackNode?.grid === g ? g.update(i, o) : g.makeWidget(i, o), "group" === e.kind) { + let t = d?.querySelector(".fluent-dashboard__group-body"); + t && (t.hidden = h.collapsed, h.collapsed || m(e.id, t)); + } + } + g.batchUpdate(!1); + }; + try { + m(null, this.container), this.options.visible(p); + } finally{ + this.applying = !1; + } + } + scheduleChange() { + !this.applying && this.editing && 12 === this.columns && (clearTimeout(this.notification), this.notification = setTimeout(()=>{ + let e = []; + for (let [i, s] of this.grids)for (let n of s.engine.nodes){ + let s = this.nodes.find((e)=>e.id === (n.el?.dataset.nodeId ?? n.id)); + if (!s || s.locked || ancestorsOf(indexNodes(this.nodes), s.id).some((e)=>e.locked)) continue; + let o = { + id: s.id, + parentId: i, + x: n.x ?? 0, + y: n.y ?? 0, + w: n.w ?? 1, + h: "group" === s.kind ? s.tile.h : n.h ?? 2 + }; + (o.parentId !== s.parentId || o.x !== s.tile.x || o.y !== s.tile.y || o.w !== s.tile.w || o.h !== s.tile.h) && e.push(o); + } + if (e.length) for (let t of this.listeners)t(e); + }, 0)); + } + destroy() { + for (let e of (clearTimeout(this.notification), [ + ...this.grids.values() + ].reverse()))e.engine && e.destroy(!1); + this.grids.clear(), this.elements.clear(), this.listeners.clear(), this.container?.replaceChildren(), this.container = void 0; + } +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/layout/responsive.ts +function columnsForWidth(e) { + return e >= 992 ? 12 : e >= 576 ? 6 : 2; +} +function observeColumns(e, t) { + let n = 0, o = new ResizeObserver(([e])=>{ + let o = columnsForWidth(e.contentRect.width); + o !== n && (n = o, t(o)); + }); + return o.observe(e), ()=>o.disconnect(); +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/widgets/frame.ts +class WidgetFrame { + element = document.createElement("section"); + header = document.createElement("header"); + body = document.createElement("div"); + status = document.createElement("p"); + title = document.createElement("h2"); + constructor(e, t, s, a){ + this.element.className = "fluent-dashboard__card", this.header.className = "fluent-dashboard__card-header", this.header.tabIndex = 0, this.title.className = "fluent-dashboard__card-title", this.body.className = "fluent-dashboard__card-content", this.status.className = "fluent-dashboard__widget-status", this.status.setAttribute("role", "status"); + let n = document.createElement("button"); + n.type = "button", n.textContent = _("Refresh"), n.addEventListener("click", (e)=>{ + e.stopPropagation(), s(); + }); + let d = document.createElement("button"); + d.type = "button", d.textContent = _("Fullscreen"), d.addEventListener("click", (e)=>{ + e.stopPropagation(), document.fullscreenElement === this.element ? document.exitFullscreen().catch(()=>{}) : this.element.requestFullscreen().catch(()=>this.setStatus(_("Fullscreen is unavailable."))); + }), this.header.append(this.title, n, d), this.header.addEventListener("click", (e)=>{ + e.target instanceof HTMLButtonElement || a(); + }), this.header.addEventListener("keydown", (e)=>{ + e.target === this.header && ("Enter" === e.key || " " === e.key) && (e.preventDefault(), a()); + }), this.element.append(this.header, this.status, this.body), this.update(e, t, !1); + } + update(e, t, s) { + this.title.textContent = e.title || t, this.element.classList.toggle("fluent-dashboard__card--locked", !!e.locked), this.element.classList.toggle("fluent-dashboard__card--hidden", !!e.hidden), this.element.classList.toggle("fluent-dashboard__card--editing", s); + } + setStatus(e) { + this.status.textContent = e, this.status.hidden = !e; + } +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/widgets/runtime.ts + +class WidgetRuntime { + registry; + data; + select; + mounted = new Map(); + constructor(e, t, i){ + this.registry = e, this.data = t, this.select = i; + } + render(t, i) { + let n = this.registry.get(t.widgetType), s = this.mounted.get(t.id); + if (s && (s.type !== t.widgetType || s.version !== t.widgetVersion) && (this.remove(t.id), s = void 0), !s) { + let i = new WidgetFrame(t, n?.title ?? t.widgetType, ()=>{ + this.data.refresh(n?.requiredData ?? []); + }, ()=>this.select(t.id)), o = new ResizeObserver((e)=>{ + let i = e[0]?.contentRect; + i && this.mounted.get(t.id)?.instance?.resize(i.width, i.height); + }); + if (s = { + frame: i, + type: t.widgetType, + version: t.widgetVersion, + config: "", + observer: o + }, this.mounted.set(t.id, s), o.observe(i.body), n) try { + let e = t.widgetVersion === n.version ? t.config : t.widgetVersion < n.version && n.migrateConfig ? n.migrateConfig(t.config, t.widgetVersion) : void 0; + if (!e || !n.validateConfig(e)) throw Error(_("Unsupported widget configuration version.")); + s.instance = n.createInstance({ + data: this.data, + config: e, + setStatus: (e)=>i.setStatus(e) + }), s.instance.mount(i.body), s.config = JSON.stringify(t.config); + } catch (e) { + s.instance?.destroy(), s.instance = void 0, i.setStatus(String(e)); + } + else i.setStatus(_("Widget extension unavailable. Configuration has been preserved.")); + } + let o = JSON.stringify(t.config); + if (s.instance && o !== s.config) { + let e = t.widgetVersion === n?.version ? t.config : t.widgetVersion < (n?.version ?? 0) ? n?.migrateConfig?.(t.config, t.widgetVersion) : void 0; + e && n?.validateConfig(e) ? (s.instance.updateConfig(e), s.config = o) : s.frame.setStatus(_("Invalid widget configuration.")); + } + return s.frame.update(t, n?.title ?? t.widgetType, i), s.frame.element; + } + retain(e, t) { + for (let [i, n] of this.mounted)e.has(i) ? n.instance?.setVisible(t.has(i)) : this.remove(i); + } + destroy() { + for (let e of this.mounted.keys())this.remove(e); + } + remove(e) { + let t = this.mounted.get(e); + t?.observer.disconnect(), t?.instance?.destroy(), t?.frame.element.remove(), this.mounted.delete(e); + } +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/editor/workbench.ts + + + + + + + + + +function workbench_k(e, t = "", i = "") { + let s = document.createElement(e); + return s.className = t, s.textContent = i, s; +} +function workbench_x(e, t, i = !1) { + let s = workbench_k("button", "cbi-button", e); + return s.type = "button", s.disabled = i, s.addEventListener("click", t), s; +} +function workbench_L(e, t, i = "text") { + let s = workbench_k("label", "fluent-dashboard__field", e), r = workbench_k("input"); + return r.type = i, r.value = t, s.append(r), [ + s, + r + ]; +} +class Workbench { + registry; + data; + persistence; + writable; + root = workbench_k("div", "fluent-dashboard"); + store; + toolbar = workbench_k("fieldset", "fluent-dashboard__toolbar"); + message = workbench_k("p", "fluent-dashboard__notice"); + workspace = workbench_k("div", "fluent-dashboard__workspace"); + library = workbench_k("fieldset", "fluent-dashboard__library"); + canvas = workbench_k("div", "fluent-dashboard__canvas"); + properties = workbench_k("fieldset", "fluent-dashboard__properties"); + adapter = new GridstackAdapter(); + runtime; + groups = new Map(); + off = []; + columns = 12; + mountedDashboard = ""; + busy = !1; + paused = !1; + destroyed = !1; + constructor(e, t, i, s, r){ + this.registry = t, this.data = i, this.persistence = s, this.writable = r, this.store = new DashboardStore(e), this.runtime = new WidgetRuntime(t, i, (e)=>this.select(e)), this.message.setAttribute("role", "status"), this.workspace.append(this.library, this.canvas, this.properties), this.root.append(workbench_k("h1", "", _("Fluent Dashboard")), this.toolbar, this.message, this.workspace), this.off.push(this.store.subscribe(()=>this.render())), this.off.push(observeColumns(this.canvas, (e)=>{ + this.columns = e, this.renderLayout(); + })); + let o = (e)=>this.onKey(e); + this.root.addEventListener("keydown", o), this.off.push(()=>this.root.removeEventListener("keydown", o)); + let a = (e)=>{ + this.store.history.dirty && (e.preventDefault(), e.returnValue = ""); + }; + window.addEventListener("beforeunload", a), this.off.push(()=>window.removeEventListener("beforeunload", a)), this.render(); + } + report(e) { + this.message.textContent = e instanceof Error ? e.message : String(e); + } + command(e) { + if (!this.busy) try { + this.store.execute(e); + } catch (e) { + this.report(e), this.renderLayout(); + } + } + select(e) { + this.store.isEditing && !this.busy && (this.store.selection.has(e) ? this.store.selection.delete(e) : this.store.selection.add(e), this.store.emit()); + } + render() { + if (this.destroyed) return; + this.store.history.dirty && this.writable && !this.persistence.cacheDraft(this.store.document) && (this.message.textContent = _("Browser draft storage is unavailable. Export your changes before leaving.")), this.toolbar.replaceChildren(); + let e = workbench_k("select"); + for (let t of (e.setAttribute("aria-label", _("Dashboard")), this.store.document.dashboards)){ + let i = workbench_k("option", "", t.name); + i.value = t.id, i.selected = t.id === this.store.activeDashboard.id, e.append(i); + } + if (e.addEventListener("change", ()=>this.store.switchDashboard(e.value)), this.toolbar.append(e, workbench_x(this.paused ? _("Resume") : _("Pause"), ()=>{ + this.paused = !this.paused, this.data.pause("manual", this.paused), this.render(); + }), workbench_x(_("Export"), ()=>this.exportDocument(this.store.document))), this.store.isEditing) { + let e = workbench_k("select"); + for (let [t, i] of (e.setAttribute("aria-label", _("Preview width")), [ + [ + "0", + _("Automatic width") + ], + [ + "12", + _("Desktop") + ], + [ + "6", + _("Tablet") + ], + [ + "2", + _("Phone") + ] + ])){ + let s = workbench_k("option", "", i); + s.value = t, e.append(s); + } + e.value = String(this.store.previewColumns ?? 0), e.addEventListener("change", ()=>{ + this.store.previewColumns = Number(e.value) || null, this.renderLayout(); + }), this.toolbar.append(workbench_x(_("Undo"), ()=>this.store.undo(), !this.store.history.canUndo), workbench_x(_("Redo"), ()=>this.store.redo(), !this.store.history.canRedo), e, workbench_x(_("Save"), ()=>{ + this.save(); + }, !this.store.history.dirty), workbench_x(_("Cancel"), ()=>{ + this.persistence.clearDraft(), this.store.cancel(); + }), workbench_x(_("New dashboard"), ()=>{ + let e = createId("dashboard"); + this.command(new EditCommand("New dashboard", (t)=>{ + t.dashboards.push({ + id: e, + name: _("New dashboard"), + nodes: [] + }); + })), this.store.document.dashboards.some((t)=>t.id === e) && this.store.switchDashboard(e); + }), workbench_x(_("Duplicate dashboard"), ()=>this.duplicateDashboard()), workbench_x(_("Import"), ()=>this.importDocument()), workbench_x(_("Restore defaults"), ()=>this.replaceDocument(createDefaultDocument()))); + } else this.writable && this.toolbar.append(workbench_x(_("Edit"), ()=>this.store.edit())); + let t = this.persistence.readDraft(); + t && !this.store.isEditing && this.writable && this.toolbar.append(workbench_x(_("Restore draft"), ()=>{ + if (t.revision !== this.store.document.revision) { + this.report(_("This draft belongs to an older revision. It has been exported so you can review it before importing.")), this.exportDocument(t); + return; + } + this.store.edit(), this.replaceDocument(t); + })), this.library.hidden = this.properties.hidden = !this.store.isEditing, this.workspace.classList.toggle("fluent-dashboard__workspace--editing", this.store.isEditing), this.renderLibrary(), this.renderProperties(), this.renderLayout(), this.setBusy(this.busy); + } + renderLibrary() { + for (let t of (this.library.replaceChildren(workbench_k("legend", "", _("Widget library"))), this.registry.list()))this.library.append(workbench_x(`${t.category} \xb7 ${t.title}`, ()=>{ + let i = presetDimensions(t.defaultSize, t.constraints), s = { + id: createId("widget"), + parentId: null, + kind: "widget", + widgetType: t.type, + widgetVersion: t.version, + config: { + ...t.defaultConfig + }, + tile: { + ...i, + size: t.defaultSize, + x: 0, + y: 0, + order: this.store.activeDashboard.nodes.length + } + }; + this.command(new AddNodeCommand(this.store.activeDashboard.id, s)); + })); + this.library.append(workbench_x(_("Add group"), ()=>this.command(new AddNodeCommand(this.store.activeDashboard.id, { + kind: "group", + id: createId("group"), + title: _("Group"), + parentId: null, + tile: { + x: 0, + y: 0, + w: 12, + h: 8, + order: this.store.activeDashboard.nodes.length, + size: "full" + } + })))); + } + renderProperties() { + let e = this.store.activeDashboard; + this.properties.replaceChildren(workbench_k("legend", "", _("Properties"))); + let [p, c] = workbench_L(_("Dashboard name"), e.name); + this.properties.append(p, workbench_x(_("Rename"), ()=>this.command(new EditCommand("Rename dashboard", (t)=>{ + let i = t.dashboards.find((t)=>t.id === e.id); + i && (i.name = c.value); + }))), workbench_x(_("Set as default"), ()=>this.command(new EditCommand("Default dashboard", (t)=>{ + t.defaultDashboardId = e.id; + }))), workbench_x(_("Delete dashboard"), ()=>this.command(new EditCommand("Delete dashboard", (t)=>{ + t.dashboards = t.dashboards.filter((t)=>t.id !== e.id), t.defaultDashboardId === e.id && (t.defaultDashboardId = t.dashboards[0]?.id); + })), this.store.document.dashboards.length <= 1)); + let u = e.nodes.filter((e)=>this.store.selection.has(e.id)); + if (!u.length) return void this.properties.append(workbench_k("p", "", _("Select tile headers to edit one or more tiles."))); + let f = u.filter((t)=>!ancestorsOf(indexNodes(e.nodes), t.id).some((e)=>this.store.selection.has(e.id))), w = (e)=>this.command(new BatchCommand(f.map((t)=>e(t.id)))); + this.properties.append(workbench_x(_("Lock / unlock"), ()=>w((t)=>new ToggleLockCommand(e.id, t))), workbench_x(_("Show / hide"), ()=>w((t)=>new ToggleHideCommand(e.id, t))), workbench_x(_("Remove selected and contents"), ()=>w((t)=>new RemoveNodeCommand(e.id, t)))); + let v = workbench_k("select"); + v.setAttribute("aria-label", _("Move to group")); + let D = workbench_k("option", "", _("Root")); + for (let t of (D.value = "", v.append(D), e.nodes.filter((e)=>"group" === e.kind))){ + let e = workbench_k("option", "", t.title); + e.value = t.id, v.append(e); + } + if (this.properties.append(v, workbench_x(_("Move to group"), ()=>this.command(new MoveNodeCommand(e.id, f.map((e)=>({ + id: e.id, + parentId: v.value || null, + ...e.tile, + x: 0, + y: 0 + })))))), 1 !== u.length) return; + let E = u[0], [S, I] = workbench_L(_("Title"), E.title ?? ""); + if (I.maxLength = 256, this.properties.append(S), "widget" === E.kind) { + let t = this.registry.get(E.widgetType), i = []; + if (t?.type === "network.traffic-trend" || t?.type.startsWith("network.interface-")) { + let [e, t] = workbench_L(_("Interface name (empty: first)"), String(E.config.interface ?? "")); + this.properties.append(e), i.push([ + "interface", + t + ]); + } + if (t?.type === "wireless.radio-status" || t?.type === "wireless.channel") { + let [e, t] = workbench_L(_("Radio name (empty: first)"), String(E.config.radio ?? "")); + this.properties.append(e), i.push([ + "radio", + t + ]); + } + if (t?.type === "network.traffic-trend") { + let [e, t] = workbench_L(_("History samples (10\u2013120)"), String(E.config.history ?? 40), "number"); + t.min = "10", t.max = "120", this.properties.append(e), i.push([ + "history", + t + ]); + } + if ([ + "network.interfaces", + "wireless.radios", + "dhcp.leases", + "wireless.clients" + ].includes(E.widgetType)) { + let [e, t] = workbench_L(_("Filter text"), String(E.config.query ?? "")), [s, r] = workbench_L(_("Maximum rows"), String(E.config.limit ?? 200), "number"); + r.min = "1", r.max = "1000"; + let [o, a] = workbench_L(_("Sort field (for example: name, signal, expires)"), String(E.config.sortBy ?? "")), [d, n] = workbench_L(_("Descending"), "", "checkbox"); + n.checked = !!E.config.descending, this.properties.append(e, s, o, d), i.push([ + "query", + t + ], [ + "limit", + r + ], [ + "sortBy", + a + ], [ + "descending", + n + ]); + } + if ("navigation.shortcuts" === E.widgetType) { + let [e, t] = workbench_L(_("Quick links (status, interfaces, wireless, dhcp, firewall, system)"), Array.isArray(E.config.links) ? E.config.links.join(", ") : ""); + this.properties.append(e), i.push([ + "links", + t + ]); + } + if (this.properties.append(workbench_x(_("Apply properties"), ()=>{ + let t = { + ...E.config + }; + for (let [e, s] of i)t[e] = "links" === e ? s.value.split(",").map((e)=>e.trim()).filter(Boolean) : "checkbox" === s.type ? s.checked : "number" === s.type ? Number(s.value) : s.value; + this.registry.get(E.widgetType)?.validateConfig(t) ? this.command(new UpdateConfigCommand(e.id, E.id, t, I.value)) : this.report(_("Invalid widget configuration.")); + })), t) for (let i of allowedPresets(t.constraints))this.properties.append(workbench_x(i, ()=>{ + let { w: s, h: r } = presetDimensions(i, t.constraints); + this.command(new ResizeNodeCommand(e.id, E.id, s, r)); + })); + } else { + let [t, r] = workbench_L(_("Description"), E.description ?? ""); + r.maxLength = 2048, this.properties.append(t, workbench_x(_("Apply group properties"), ()=>this.command(new EditCommand("Configure group", (t)=>{ + let i = t.dashboards.find((t)=>t.id === e.id)?.nodes.find((e)=>e.id === E.id); + i?.kind !== "group" || i.locked || (i.title = I.value, i.description = r.value); + }))), workbench_x(_("Default collapsed / expanded"), ()=>this.command(new ToggleCollapseCommand(e.id, E.id))), workbench_x(_("Dissolve and keep contents"), ()=>this.command(new DissolveGroupCommand(e.id, E.id)))); + } + this.properties.append(workbench_x(_("Earlier"), ()=>this.reorder(E, -1)), workbench_x(_("Later"), ()=>this.reorder(E, 1)), workbench_x(_("Duplicate tile"), ()=>this.duplicateNode(E))); + } + renderLayout() { + if (this.destroyed) return; + let e = this.store.activeDashboard; + for (let [t, i] of (e.id !== this.mountedDashboard && (this.adapter.destroy(), this.runtime.destroy(), this.groups.clear(), this.adapter = new GridstackAdapter(), this.adapter.mount(this.canvas, { + render: (e)=>this.renderNode(e), + collapsed: ()=>this.store.isEditing ? new Set() : this.store.collapsed, + visible: (e)=>this.runtime.retain(new Set(this.store.activeDashboard.nodes.filter((e)=>"widget" === e.kind).map((e)=>e.id)), e) + }), this.adapter.onChange((e)=>this.command(new MoveNodeCommand(this.store.activeDashboard.id, e))), this.mountedDashboard = e.id), this.adapter.setEditing(this.store.isEditing && !this.busy), this.adapter.apply(e.nodes, this.store.previewColumns ?? this.columns), this.groups))e.nodes.some((e)=>e.id === t) || (i.remove(), this.groups.delete(t)); + for (let e of this.canvas.querySelectorAll("[data-node-id]"))e.classList.toggle("fluent-dashboard__tile--selected", this.store.selection.has(e.dataset.nodeId ?? "")); + } + renderNode(e) { + if ("widget" === e.kind) return this.runtime.render(e, this.store.isEditing); + let t = this.groups.get(e.id); + if (!t) { + t = workbench_k("section", "fluent-dashboard__group"); + let i = workbench_k("header", "fluent-dashboard__card-header"); + i.tabIndex = 0, i.append(workbench_k("h2", "fluent-dashboard__card-title")), i.append(workbench_x(_("Collapse / expand"), ()=>{ + this.store.isEditing ? this.command(new ToggleCollapseCommand(this.store.activeDashboard.id, e.id)) : (this.store.collapsed.has(e.id) ? this.store.collapsed.delete(e.id) : this.store.collapsed.add(e.id), this.renderLayout()); + })), i.addEventListener("click", (t)=>{ + t.target instanceof HTMLButtonElement || this.select(e.id); + }), i.addEventListener("keydown", (t)=>{ + t.target === i && ("Enter" === t.key || " " === t.key) && (t.preventDefault(), this.select(e.id)); + }), t.append(i, workbench_k("div", "fluent-dashboard__group-body")), this.groups.set(e.id, t); + } + let i = t.querySelector("h2"); + i && (i.textContent = e.title); + let s = t.querySelector(".fluent-dashboard__group-description"); + return e.description ? (s || (s = workbench_k("p", "fluent-dashboard__group-description"), t.querySelector(".fluent-dashboard__card-header")?.after(s)), s.textContent = e.description) : s?.remove(), t; + } + reorder(e, t) { + this.command(new EditCommand("Reorder tile", (i)=>{ + let s = i.dashboards.find((e)=>e.id === this.store.activeDashboard.id); + if (!s || e.locked) return; + let r = s.nodes.filter((t)=>t.parentId === e.parentId).sort((e, t)=>e.tile.order - t.tile.order), o = r.findIndex((t)=>t.id === e.id), a = o + t; + a < 0 || a >= r.length || r[a].locked || ([r[o], r[a]] = [ + r[a], + r[o] + ], r.forEach((e, t)=>{ + e.tile.order = t; + })); + })); + } + duplicateNode(e) { + this.command(new EditCommand("Duplicate tile", (t)=>{ + let i = t.dashboards.find((e)=>e.id === this.store.activeDashboard.id); + if (!i) return; + let s = indexNodes(i.nodes), r = i.nodes.filter((t)=>t.id === e.id || ancestorsOf(s, t.id).some((t)=>t.id === e.id)), o = new Map(r.map((e)=>[ + e.id, + createId(e.kind) + ])), a = Math.max(0, ...i.nodes.filter((t)=>t.parentId === e.parentId).map((e)=>e.tile.y + e.tile.h)); + for (let t of r){ + let s = JSON.parse(JSON.stringify(t)); + s.id = o.get(t.id), s.parentId = t.parentId ? o.get(t.parentId) ?? t.parentId : null, t.id === e.id && (s.tile.y = a, s.tile.order = i.nodes.length), i.nodes.push(s); + } + })); + } + duplicateDashboard() { + let e = createId("dashboard"); + this.command(new EditCommand("Duplicate dashboard", (t)=>{ + let i = JSON.parse(JSON.stringify(this.store.activeDashboard)); + i.id = e, i.name = `${i.name} (${_("Copy")})`, t.dashboards.push(i); + })), this.store.document.dashboards.some((t)=>t.id === e) && this.store.switchDashboard(e); + } + replaceDocument(e) { + this.command(new EditCommand("Replace layout", (t)=>{ + let i = migrateDocument(e); + i.revision = t.revision, Object.assign(t, i); + })); + } + exportDocument(e) { + let t = URL.createObjectURL(new Blob([ + JSON.stringify(e, null, 2) + ], { + type: "application/json" + })), i = workbench_k("a"); + i.href = t, i.download = "fluentdashboard.json", i.click(), setTimeout(()=>URL.revokeObjectURL(t), 1000); + } + importDocument() { + let e = workbench_k("input"); + e.type = "file", e.accept = ".json,application/json", e.addEventListener("change", ()=>{ + let t = e.files?.[0]; + if (t) { + if (t.size > 262144) return void this.report(_("Layout file is too large.")); + t.text().then((e)=>{ + this.destroyed || this.replaceDocument(migrateDocument(JSON.parse(e))); + }).catch((e)=>this.report(e)); + } + }), e.click(); + } + async save() { + if (!this.busy) { + this.setBusy(!0), this.adapter.setEditing(!1); + try { + let e = await this.persistence.save(this.store.document); + this.destroyed || (this.store.saved(e), this.message.textContent = _("Shared layout saved.")); + } catch (e) { + this.destroyed || this.report(e); + } finally{ + this.destroyed || (this.setBusy(!1), this.renderLayout()); + } + } + } + setBusy(e) { + this.busy = e, this.toolbar.disabled = this.library.disabled = this.properties.disabled = e; + } + onKey(e) { + if (!this.store.isEditing || this.busy || e.target instanceof Element && e.target.closest("input, textarea, select")) return; + (e.ctrlKey || e.metaKey) && "z" === e.key.toLowerCase() && (e.preventDefault(), e.shiftKey ? this.store.redo() : this.store.undo()); + let t = { + ArrowLeft: [ + -1, + 0 + ], + ArrowRight: [ + 1, + 0 + ], + ArrowUp: [ + 0, + -1 + ], + ArrowDown: [ + 0, + 1 + ] + }; + if (!t[e.key] || !this.store.selection.size) return; + e.preventDefault(); + let [i, s] = t[e.key], o = indexNodes(this.store.activeDashboard.nodes), a = this.store.activeDashboard.nodes.filter((e)=>this.store.selection.has(e.id) && !ancestorsOf(o, e.id).some((e)=>this.store.selection.has(e.id))); + if ((this.store.previewColumns ?? this.columns) !== 12) { + 1 === a.length && this.reorder(a[0], i || s); + return; + } + this.command(new MoveNodeCommand(this.store.activeDashboard.id, a.map((t)=>({ + id: t.id, + parentId: t.parentId, + x: t.tile.x + (e.shiftKey ? 0 : i), + y: t.tile.y + (e.shiftKey ? 0 : s), + w: t.tile.w + (e.shiftKey ? i : 0), + h: t.tile.h + (e.shiftKey ? s : 0) + })))); + } + destroy() { + for (let e of (this.destroyed = !0, this.off))e(); + this.adapter.destroy(), this.runtime.destroy(), this.data.destroy(); + } +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/persistence/uci.ts + +class RevisionConflictError extends Error { + revision; + constructor(r){ + super("Dashboard changed on another device. Export your draft or reload the shared layout before saving."), this.revision = r; + } +} +class DashboardPersistence { + transport; + storage; + draftKey; + saving = !1; + constructor(r, t, e = "fluentdashboard.draft.v1"){ + this.transport = r, this.storage = t, this.draftKey = e; + } + async load() { + let r = await this.transport.load(); + if (r.error) throw Error(r.message ?? r.error); + return null === r.document && 0 === r.revision ? createDefaultDocument() : migrateDocument(r.document); + } + async save(t) { + if (this.saving) throw Error("A save is already in progress"); + validateDocument(t); + let a = cloneDocument(t); + a.meta = { + ...a.meta, + updatedAt: new Date().toISOString() + }, this.saving = !0; + try { + let r = await this.transport.save(JSON.stringify(a), t.revision); + if ("conflict" === r.error) throw new RevisionConflictError(r.revision ?? 0); + if (r.error) throw Error(r.message ?? r.error); + let s = migrateDocument(r.document); + if (s.revision <= t.revision) throw Error("Server returned a non-increasing revision"); + return this.clearDraft(), s; + } finally{ + this.saving = !1; + } + } + cacheDraft(r) { + try { + return validateDocument(r), this.storage?.setItem(this.draftKey, JSON.stringify(r)), !!this.storage; + } catch { + return !1; + } + } + readDraft() { + try { + let r = this.storage?.getItem(this.draftKey); + return r ? migrateDocument(JSON.parse(r)) : void 0; + } catch { + return; + } + } + clearDraft() { + try { + this.storage?.removeItem(this.draftKey); + } catch {} + } +} + ;// CONCATENATED MODULE: ./web/fluentdashboard/model.ts function model_t(t) { return null === t || "object" != typeof t || Array.isArray(t) ? {} : t; @@ -167,42 +8187,35 @@ function formatLocalTime(t) { }).format(new Date(1000 * t)); } -;// CONCATENATED MODULE: ./web/fluentdashboard/index.tsx +;// CONCATENATED MODULE: ./web/fluentdashboard/widgets/cards.tsx -let fluentdashboard_l = rpc.declare({ +let cards_l = rpc.declare({ object: "system", method: "board" -}), fluentdashboard_o = rpc.declare({ +}), cards_d = rpc.declare({ object: "system", method: "info" -}), c = rpc.declare({ +}), cards_c = rpc.declare({ object: "luci", method: "getVersion" -}), fluentdashboard_u = rpc.declare({ +}), cards_u = rpc.declare({ object: "luci-rpc", method: "getDHCPLeases", expect: { "": {} } }); -function fluentdashboard_h(e) { +function cards_m(e) { return null === e || "object" != typeof e || Array.isArray(e) ? {} : e; } -function fluentdashboard_m(e) { +function cards_p(e) { return "string" == typeof e ? e : ""; } -function p(e) { +function cards_f(e) { return "number" == typeof e && Number.isFinite(e) ? e : 0; } -async function fluentdashboard_f(e, t) { - try { - return await e; - } catch { - return t; - } -} -function y(e, t = !1) { +function cards_h(e, t = !1) { let r = e.getL3Device() ?? e.getDevice(); return { name: e.getName(), @@ -216,110 +8229,148 @@ function y(e, t = !1) { tx: r?.getTXBytes() ?? 0 }; } -async function g() { - await fluentdashboard_f(network.flushCache(), {}); - let [e, t, r, a, s, n, d, u] = await Promise.all([ - fluentdashboard_f(fluentdashboard_l(), {}), - fluentdashboard_f(fluentdashboard_o(), {}), - fluentdashboard_f(c(), {}), - fluentdashboard_f(network.getWANNetworks(), []), - fluentdashboard_f(network.getWAN6Networks(), []), - fluentdashboard_f(network.getNetworks(), []), - fluentdashboard_f(network.getWifiDevices(), []), - fluentdashboard_f(network.getWifiNetworks(), []) - ]), h = u.map((e)=>{ - let t = d.find((t)=>t.getName() === e.getWifiDeviceName()); +async function cards_g() { + let [e, t] = await Promise.all([ + network.getWifiDevices(), + network.getWifiNetworks() + ]), r = t.map((t)=>{ + let r = e.find((e)=>e.getName() === t.getWifiDeviceName()); return { - radio: t?.getName() ?? e.getWifiDeviceName() ?? "", - hardware: t?.getI18n() ?? "", - up: e.isUp() && !t?.isDisabled(), - ssid: e.getActiveSSID() || e.getSSID() || e.getMeshID() || "", - mode: e.getActiveModeI18n() || e.getMode(), - channel: e.getChannel(), - frequency: e.getFrequency() ?? "", - encryption: e.getActiveEncryption() + radio: r?.getName() ?? t.getWifiDeviceName() ?? "", + hardware: r?.getI18n() ?? "", + up: t.isUp() && !r?.isDisabled(), + ssid: t.getActiveSSID() || t.getSSID() || t.getMeshID() || "", + mode: t.getActiveModeI18n() || t.getMode(), + channel: t.getChannel(), + frequency: t.getFrequency() ?? "", + encryption: t.getActiveEncryption() }; }); - for (let e of d)h.some((t)=>t.radio === e.getName()) || h.push({ - radio: e.getName(), - hardware: e.getI18n(), - up: e.isUp() && !e.isDisabled(), + for (let t of e)r.some((e)=>e.radio === t.getName()) || r.push({ + radio: t.getName(), + hardware: t.getI18n(), + up: t.isUp() && !t.isDisabled(), ssid: "", mode: "", channel: null, frequency: "", encryption: "" }); - return { - system: normalizeSystemSnapshot(e, t, r), - wan4: a.map((e)=>y(e)), - wan6: s.map((e)=>y(e, !0)), - interfaces: n.filter((e)=>"loopback" !== e.getName()).map((e)=>y(e)), - wireless: h, - updatedAt: new Date() - }; + return r; } -async function w() { - let [e, t, r] = await Promise.all([ - fluentdashboard_f(fluentdashboard_u(), {}), - fluentdashboard_f(network.getWifiNetworks(), []), - fluentdashboard_f(network.getHostHints(), null) - ]), a = await Promise.all(t.map(async (e)=>({ +async function cards_y() { + let [e, t] = await Promise.all([ + network.getWifiNetworks(), + network.getHostHints() + ]), r = await Promise.all(e.map(async (e)=>({ wifi: e, - peers: await fluentdashboard_f(e.getAssocList(), []) - }))), s = []; - for (let { wifi: e, peers: t } of a)for (let a of t){ - let t = r?.getHostnameByMACAddr(a.mac) || r?.getIPAddrByMACAddr(a.mac) || r?.getIP6AddrByMACAddr(a.mac) || ""; - s.push({ + peers: await e.getAssocList() + }))), a = []; + for (let { wifi: e, peers: n } of r)for (let r of n){ + let n = t?.getHostnameByMACAddr(r.mac) || t?.getIPAddrByMACAddr(r.mac) || t?.getIP6AddrByMACAddr(r.mac) || ""; + a.push({ network: e.getActiveSSID() || e.getSSID() || e.getName(), - hostname: t, - mac: a.mac, - signal: a.signal, - noise: a.noise ?? null, - rxRate: a.rx?.rate ?? 0, - txRate: a.tx?.rate ?? 0 + hostname: n, + mac: r.mac, + signal: r.signal, + noise: r.noise ?? null, + rxRate: r.rx?.rate ?? 0, + txRate: r.tx?.rate ?? 0 }); } - return { - leases: function(e) { - let t = fluentdashboard_h(e), r = []; - if (Array.isArray(t.dhcp_leases)) for (let e of t.dhcp_leases){ - let t = fluentdashboard_h(e); - r.push({ - family: "IPv4", - hostname: fluentdashboard_m(t.hostname), - address: fluentdashboard_m(t.ipaddr), - identifier: fluentdashboard_m(t.macaddr), - expires: !1 !== t.expires && p(t.expires) - }); - } - if (Array.isArray(t.dhcp6_leases)) for (let e of t.dhcp6_leases){ - let t = fluentdashboard_h(e), a = Array.isArray(t.ip6addrs) ? t.ip6addrs.filter((e)=>"string" == typeof e) : []; - r.push({ - family: "IPv6", - hostname: fluentdashboard_m(t.hostname), - address: a.join(", ") || fluentdashboard_m(t.ip6addr), - identifier: fluentdashboard_m(t.duid), - expires: !1 !== t.expires && p(t.expires) - }); - } - return r; - }(e), - clients: s - }; + return a; } -function b(e) { +function registerSources(e) { + let t; + e.register({ + key: "system.board", + interval: 60000, + load: cards_l + }), e.register({ + key: "system.info", + interval: 3000, + load: cards_d + }), e.register({ + key: "system.version", + interval: 60000, + load: cards_c + }), e.register({ + key: "dhcp.leases", + interval: 15000, + load: async ()=>(function(e) { + let t = cards_m(e), r = []; + if (Array.isArray(t.dhcp_leases)) for (let e of t.dhcp_leases){ + let t = cards_m(e); + r.push({ + family: "IPv4", + hostname: cards_p(t.hostname), + address: cards_p(t.ipaddr), + identifier: cards_p(t.macaddr), + expires: !1 !== t.expires && cards_f(t.expires) + }); + } + if (Array.isArray(t.dhcp6_leases)) for (let e of t.dhcp6_leases){ + let t = cards_m(e), a = Array.isArray(t.ip6addrs) ? t.ip6addrs.filter((e)=>"string" == typeof e) : []; + r.push({ + family: "IPv6", + hostname: cards_p(t.hostname), + address: a.join(", ") || cards_p(t.ip6addr), + identifier: cards_p(t.duid), + expires: !1 !== t.expires && cards_f(t.expires) + }); + } + return r; + })(await cards_u()) + }); + let r = 0, a = async ()=>{ + !t && Date.now() - r > 1000 && (t = network.flushCache().then((e)=>(r = Date.now(), e)).finally(()=>{ + t = void 0; + })), await t; + }; + e.register({ + key: "network.interfaces", + interval: 3000, + load: async ()=>(await a(), (await network.getNetworks()).filter((e)=>"loopback" !== e.getName()).map((e)=>cards_h(e))) + }), e.register({ + key: "network.wan", + interval: 10000, + load: async ()=>{ + await a(); + let [e, t] = await Promise.all([ + network.getWANNetworks(), + network.getWAN6Networks() + ]); + return { + wan4: e.map((e)=>cards_h(e)), + wan6: t.map((e)=>cards_h(e, !0)) + }; + } + }), e.register({ + key: "wireless.radios", + interval: 15000, + load: async ()=>(await a(), cards_g()) + }), e.register({ + key: "wireless.clients", + interval: 10000, + load: async ()=>(await a(), cards_y()) + }), e.register({ + key: "network.hostHints", + interval: 60000, + load: ()=>network.getHostHints() + }); +} +function cards_w(e) { return null == e || "" === e ? "-" : String(e); } -function v(t) { +function cards_v(t) { return jsx("span", { class: `fluent-dashboard__status fluent-dashboard__status--${t ? "up" : "down"}`, children: t ? _("Online") : _("Offline") }); } -function A(r, a, s, n = !1) { +function cards_b(r, a, n, s = !1) { return jsxs("div", { - class: `fluent-dashboard__card${n ? " fluent-dashboard__card--wide" : ""}`, + class: `fluent-dashboard__card${s ? " fluent-dashboard__card--wide" : ""}`, "data-dashboard-section": r, children: [ jsx("h2", { @@ -328,12 +8379,12 @@ function A(r, a, s, n = !1) { }), jsx("div", { class: "fluent-dashboard__card-content", - children: s + children: n }) ] }); } -function S(r, a, s) { +function cards_x(r, a, n) { return jsx("div", { class: "fluent-dashboard__table-wrap", children: jsxs("table", { @@ -349,13 +8400,13 @@ function S(r, a, s) { jsx("tbody", { children: a.length ? a.map((t)=>jsx("tr", { children: t.map((t)=>jsx("td", { - children: t instanceof Node ? t : b(t) + children: t instanceof Node ? t : cards_w(t) })) })) : jsx("tr", { children: jsx("td", { class: "fluent-dashboard__empty", colSpan: r.length, - children: s + children: n }) }) }) @@ -363,8 +8414,8 @@ function S(r, a, s) { }) }); } -function x(t) { - return A("system", _("System"), jsx("div", { +function renderSystem(t) { + return cards_b("system", _("System"), jsx("div", { class: "fluent-dashboard__details", children: [ [ @@ -414,13 +8465,13 @@ function x(t) { }), jsx("div", { class: "fluent-dashboard__details-value", - children: r instanceof Node ? r : b(r) + children: r instanceof Node ? r : cards_w(r) }) ]) })); } -function I(a) { - let s = [ +function renderResources(a) { + let n = [ [ _("Memory"), a.memory @@ -438,9 +8489,9 @@ function I(a) { a.tmp ] ]; - return A("resources", _("Resources"), s.map(([a, s])=>{ - let n, i; - return n = Math.max(0, s.total - s.free), i = usedPercent(s), jsxs("div", { + return cards_b("resources", _("Resources"), n.map(([a, n])=>{ + let s, i; + return s = Math.max(0, n.total - n.free), i = usedPercent(n), jsxs("div", { class: "fluent-dashboard__meter", children: [ jsxs("div", { @@ -450,7 +8501,7 @@ function I(a) { children: a }), jsx("span", { - children: `${formatBytes(n)} / ${formatBytes(s.total)}` + children: `${formatBytes(s)} / ${formatBytes(n.total)}` }) ] }), @@ -469,7 +8520,7 @@ function I(a) { }); })); } -function N(e) { +function renderInternet(e) { let t = [ ...e.wan4.map((e)=>[ "IPv4", @@ -480,7 +8531,7 @@ function N(e) { e ]) ]; - return A("internet", _("Internet"), S([ + return cards_b("internet", _("Internet"), cards_x([ _("Family"), _("Interface"), _("Status"), @@ -491,15 +8542,15 @@ function N(e) { ], t.map(([e, t])=>[ e, t.name, - v(t.up), + cards_v(t.up), t.protocol, t.addresses.join(", "), t.gateway, formatDuration(t.uptime) ]), _("No Internet connection information is available.")), !0); } -function D(e) { - return A("interfaces", _("Interfaces"), S([ +function renderInterfaces(e) { + return cards_b("interfaces", _("Interfaces"), cards_x([ _("Interface"), _("Device"), _("Status"), @@ -509,14 +8560,14 @@ function D(e) { ], e.map((e)=>[ e.name, e.device, - v(e.up), + cards_v(e.up), e.addresses.join(", "), formatBytes(e.rx), formatBytes(e.tx) ]), _("No network interfaces were found.")), !0); } -function k(t) { - return A("wireless", _("Wireless"), S([ +function renderWireless(t) { + return cards_b("wireless", _("Wireless"), cards_x([ _("Radio"), _("SSID"), _("Status"), @@ -526,17 +8577,17 @@ function k(t) { ], t.map((t)=>[ jsx("span", { title: t.hardware, - children: b(t.radio) + children: cards_w(t.radio) }), t.ssid, - v(t.up), + cards_v(t.up), t.mode, null === t.channel ? "-" : t.frequency ? `${t.channel} (${t.frequency} GHz)` : t.channel, t.encryption ]), _("No wireless radios were found.")), !0); } -function C(e) { - return A("leases", _("Active DHCP Leases"), S([ +function renderLeases(e) { + return cards_b("leases", _("Active DHCP Leases"), cards_x([ _("Family"), _("Hostname"), _("Address"), @@ -553,11 +8604,11 @@ function C(e) { ]; }), _("There are no active DHCP leases.")), !0); } -function P(e) { +function A(e) { return e > 0 ? `${(e / 1000).toFixed(1)} ${_("Mbit/s")}` : "-"; } -function M(e) { - return A("clients", _("Wireless Clients"), S([ +function renderClients(e) { + return cards_b("clients", _("Wireless Clients"), cards_x([ _("Network"), _("Host"), _("MAC address"), @@ -568,87 +8619,757 @@ function M(e) { e.hostname, e.mac, null === e.noise ? `${e.signal} dBm` : `${e.signal} / ${e.noise} dBm`, - `${P(e.rxRate)} / ${P(e.txRate)}` + `${A(e.rxRate)} / ${A(e.txRate)}` ]), _("No wireless clients are connected.")), !0); } -class $ extends L.view { + + +;// CONCATENATED MODULE: ./web/fluentdashboard/widgets/dom.ts +function reconcile(e, o) { + if (e.nodeType === Node.TEXT_NODE && o.nodeType === Node.TEXT_NODE) { + e.nodeValue !== o.nodeValue && (e.nodeValue = o.nodeValue); + return; + } + if (e instanceof Element && o instanceof Element) { + for (let t of Array.from(e.attributes))o.hasAttribute(t.name) || e.removeAttribute(t.name); + for (let t of Array.from(o.attributes))e.getAttribute(t.name) !== t.value && e.setAttribute(t.name, t.value); + } + let t = Array.from(o.childNodes); + for(t.forEach((o, t)=>{ + let n = e.childNodes[t]; + n ? n.nodeType !== o.nodeType || n.nodeName !== o.nodeName ? e.replaceChild(o.cloneNode(!0), n) : reconcile(n, o) : e.appendChild(o.cloneNode(!0)); + }); e.childNodes.length > t.length;)e.removeChild(e.lastChild); +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/widgets/table.ts +function filtered(e, t) { + let r = String(t.query ?? "").toLocaleLowerCase(), i = String(t.sortBy ?? ""), o = e.filter((e)=>!r || Object.values(e).some((e)=>String(e).toLocaleLowerCase().includes(r))); + return i && o.sort((e, r)=>{ + let o = e[i], n = r[i], l = "number" == typeof o && "number" == typeof n ? o - n : String(o ?? "").localeCompare(String(n ?? ""), void 0, { + numeric: !0 + }); + return t.descending ? -l : l; + }), o.slice(0, Number(t.limit ?? 200)); +} +function validTableConfig(e) { + return (void 0 === e.query || "string" == typeof e.query && e.query.length <= 256) && (void 0 === e.limit || Number.isInteger(e.limit) && Number(e.limit) >= 1 && 1000 >= Number(e.limit)) && (void 0 === e.sortBy || "string" == typeof e.sortBy && e.sortBy.length <= 64) && (void 0 === e.descending || "boolean" == typeof e.descending); +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/widgets/builtins.ts + + + + +class builtins_f { + context; + keys; + render; + container; + subscriptions = []; + states = new Map(); + visible = !1; + config; + constructor(e, t, s){ + this.context = e, this.keys = t, this.render = s, this.config = e.config; + } + mount(e) { + this.container = e, this.setVisible(!0); + } + updateConfig(e) { + this.config = e, this.paint(); + } + resize(e, t) {} + setVisible(e) { + if (this.visible !== e) { + for (let t of (this.visible = e, this.subscriptions))t(); + this.subscriptions = [], e && (this.subscriptions = this.keys.map((e)=>this.context.data.subscribe(e, (t)=>{ + this.states.set(e, t), this.paint(); + }))); + } + } + destroy() { + this.setVisible(!1), this.container = void 0, this.states.clear(); + } + paint() { + if (!this.container || !this.visible) return; + let e = this.keys.map((e)=>this.states.get(e)), t = e.find((e)=>e?.status === "error"); + if (this.context.setStatus(t ? t.stale ? _("Data is stale. Refresh to retry.") : _("Unable to load data. Refresh to retry.") : e.some((e)=>e?.data === void 0) ? _("Loading\u2026") : ""), e.some((e)=>e?.data === void 0)) return; + let s = this.render(this.states, this.config), i = s.querySelector(".fluent-dashboard__card-content") ?? s, r = document.createElement("div"); + for (let e of (r.className = this.container.className, Array.from(i.childNodes)))r.appendChild(e); + reconcile(this.container, r); + } +} +function registerBuiltins(l) { + let m = (e)=>normalizeSystemSnapshot(e.get("system.board")?.data, e.get("system.info")?.data, e.get("system.version")?.data), u = (t, s, i, r, a)=>l.register({ + type: t, + title: s, + description: s, + category: i, + icon: "", + version: 1, + supportedSizes: [ + "wide", + "large", + "xlarge", + "full", + "custom" + ], + defaultSize: "xlarge", + constraints: { + ...DEFAULT_CONSTRAINTS, + min: { + w: 2, + h: 4 + } + }, + defaultConfig: {}, + requiredData: r, + validateConfig: validTableConfig, + createInstance: (e)=>new builtins_f(e, r, a) + }); + u("system.info", _("System"), _("System"), [ + "system.board", + "system.info", + "system.version" + ], (e)=>renderSystem(m(e))), u("system.resources", _("Resources"), _("System"), [ + "system.info" + ], (e)=>renderResources(m(e))), u("network.internet", _("Internet"), _("Network"), [ + "network.wan" + ], (e)=>renderInternet(e.get("network.wan")?.data)), u("network.interfaces", _("Interfaces"), _("Network"), [ + "network.interfaces" + ], (e, t)=>renderInterfaces(filtered(e.get("network.interfaces")?.data, t))), u("wireless.radios", _("Wireless"), _("Wireless"), [ + "wireless.radios" + ], (e, t)=>renderWireless(filtered(e.get("wireless.radios")?.data, t))), u("dhcp.leases", _("Active DHCP Leases"), _("Clients"), [ + "dhcp.leases" + ], (e, t)=>renderLeases(filtered(e.get("dhcp.leases")?.data, t))), u("wireless.clients", _("Wireless Clients"), _("Clients"), [ + "wireless.clients" + ], (e, t)=>renderClients(filtered(e.get("wireless.clients")?.data, t))); +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/widgets/traffic.ts +function trafficRate(t, x) { + let r = (x.at - t.at) / 1000; + if (!(r <= 0) && Number.isFinite(r) && [ + t.rx, + t.tx, + x.rx, + x.tx + ].every((t)=>Number.isFinite(t) && t >= 0)) return { + rx: x.rx < t.rx ? 0 : (x.rx - t.rx) / r, + tx: x.tx < t.tx ? 0 : (x.tx - t.tx) / r + }; +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/widgets/metrics.ts + + + + +function metrics_l(e, t, i) { + let s = document.createElement(e); + return s.className = t, s.textContent = i, s; +} +function metrics_o(e) { + return normalizeSystemSnapshot(e.get("system.board")?.data, e.get("system.info")?.data, e.get("system.version")?.data); +} +function metrics_d(e, t) { + return e.get(t)?.data ?? []; +} +function metrics_c(e, t) { + return e.find((e)=>e.name === t.interface) ?? (t.interface ? void 0 : e[0]); +} +class metrics_u { + ctx; + sources; + calculate; + body; + off = []; + states = new Map(); + config; + visible = !1; + constructor(e, t, i){ + this.ctx = e, this.sources = t, this.calculate = i, this.config = e.config; + } + mount(e) { + this.body = e, this.setVisible(!0); + } + updateConfig(e) { + this.config = e, this.paint(); + } + resize(e, t) {} + setVisible(e) { + if (this.visible !== e) { + for (let t of (this.visible = e, this.off))t(); + this.off = [], e && (this.off = this.sources.map((e)=>this.ctx.data.subscribe(e, (t)=>{ + this.states.set(e, t), this.paint(); + }))); + } + } + destroy() { + this.setVisible(!1), this.body = void 0; + } + paint() { + if (!this.body) return; + let e = this.sources.map((e)=>this.states.get(e)); + if (this.ctx.setStatus(e.some((e)=>e?.status === "error") ? _("Data is stale or unavailable.") : e.some((e)=>e?.data === void 0) ? _("Loading\u2026") : ""), e.some((e)=>e?.data === void 0)) return; + let t = this.calculate(this.states, this.config), i = this.body.querySelector(":scope > .fluent-dashboard__metric"); + i || (i = metrics_l("div", "fluent-dashboard__metric", ""), this.body.replaceChildren(i)); + let s = i.querySelector(".fluent-dashboard__metric-value"); + s || (s = metrics_l("strong", "fluent-dashboard__metric-value", ""), i.append(s)), s.textContent = t.value; + let a = i.querySelector(".fluent-dashboard__metric-detail"); + t.detail ? (a || (a = metrics_l("span", "fluent-dashboard__metric-detail", ""), i.append(a)), a.textContent = t.detail) : a?.remove(); + let r = i.querySelector("progress"); + void 0 !== t.percent ? (r || (r = document.createElement("progress"), i.append(r)), r.max = 100, r.value = t.percent, r.setAttribute("aria-label", t.detail ?? _("Usage"))) : r?.remove(); + } +} +class metrics_f { + ctx; + body; + off; + config; + samples = []; + last; + sampledAt; + state; + visible = !1; + constructor(e){ + this.ctx = e, this.config = e.config; + } + mount(e) { + this.body = e, this.setVisible(!0); + } + updateConfig(e) { + this.config = e, this.samples = [], this.last = void 0, this.sampledAt = void 0, this.paint(); + } + resize(e, t) {} + setVisible(e) { + this.visible !== e && (this.visible = e, this.off?.(), this.off = void 0, e && (this.off = this.ctx.data.subscribe("network.interfaces", (e)=>{ + this.state = e, "ready" === e.status && e.updatedAt && e.updatedAt !== this.sampledAt && (this.sample(e.data ?? [], e.updatedAt), this.sampledAt = e.updatedAt), this.paint(); + }))); + } + destroy() { + this.setVisible(!1), this.body = void 0; + } + sample(e, t) { + let i = metrics_c(e, this.config); + if (!i) { + this.last = void 0; + return; + } + let s = this.last; + if (s && s.name === i.name) { + let e = trafficRate(s, { + rx: i.rx, + tx: i.tx, + at: t + }); + e && this.samples.push(e), this.samples = this.samples.slice(-Number(this.config.history ?? 40)); + } + this.last = { + name: i.name, + rx: i.rx, + tx: i.tx, + at: t + }; + } + paint() { + if (!this.body || (this.ctx.setStatus(this.state?.status === "error" ? _("Data is stale or unavailable.") : this.state?.data ? "" : _("Loading\u2026")), !this.state?.data)) return; + let e = this.body.querySelector(":scope > .fluent-dashboard__traffic"); + e || (e = metrics_l("div", "fluent-dashboard__traffic", ""), this.body.replaceChildren(e)); + let i = metrics_c(this.state.data, this.config), s = e.querySelector(".fluent-dashboard__metric-detail"); + s || (s = metrics_l("span", "fluent-dashboard__metric-detail", ""), e.append(s)), s.textContent = i?.name ?? _("No interface available"); + let a = this.samples.at(-1), r = e.querySelector(".fluent-dashboard__metric-value"); + if (r || (r = metrics_l("strong", "fluent-dashboard__metric-value", ""), e.append(r)), r.textContent = a ? `${_("Down")} ${formatBytes(a.rx)}/s \xb7 ${_("Up")} ${formatBytes(a.tx)}/s` : _("Waiting for second sample\u2026"), e.querySelector("svg")?.remove(), this.samples.length > 1) { + let t = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + t.setAttribute("viewBox", "0 0 100 40"), t.setAttribute("preserveAspectRatio", "none"), t.setAttribute("role", "img"), t.setAttribute("aria-label", _("Receive and transmit throughput history")); + let i = Math.max(1, ...this.samples.flatMap((e)=>[ + e.rx, + e.tx + ])); + for (let e of [ + "rx", + "tx" + ]){ + let s = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); + s.setAttribute("class", `fluent-dashboard__traffic-${e}`), s.setAttribute("points", this.samples.map((t, s)=>`${(100 * s / (this.samples.length - 1)).toFixed(2)},${(39 - t[e] / i * 38).toFixed(2)}`).join(" ")), t.append(s); + } + e.append(t); + } + } +} +function registerMetrics(r) { + let n = (t, i, s, a, n, l = {})=>r.register({ + type: t, + title: i, + description: i, + category: s, + icon: "", + version: 1, + defaultSize: "medium", + supportedSizes: [ + "medium", + "wide", + "large", + "xlarge", + "full", + "custom" + ], + constraints: { + ...DEFAULT_CONSTRAINTS, + min: { + w: 2, + h: 2 + } + }, + defaultConfig: l.interface ? { + interface: "" + } : l.radio ? { + radio: "" + } : {}, + requiredData: a, + validateConfig: (e)=>(!l.interface || void 0 === e.interface || "string" == typeof e.interface && e.interface.length <= 64) && (!l.radio || void 0 === e.radio || "string" == typeof e.radio && e.radio.length <= 64), + createInstance: (e)=>new metrics_u(e, a, n) + }); + for (let [e, r, l] of (n("system.hostname", _("Device name"), _("System"), [ + "system.board", + "system.info", + "system.version" + ], (e)=>({ + value: metrics_o(e).hostname || "\u2013", + detail: metrics_o(e).model + })), n("system.uptime", _("Uptime"), _("System"), [ + "system.info" + ], (e)=>({ + value: formatDuration(metrics_o(e).uptime) + })), n("system.load", _("System load"), _("System"), [ + "system.info" + ], (e)=>({ + value: formatLoad(metrics_o(e).load), + detail: _("1 / 5 / 15 minutes") + })), n("system.firmware", _("Firmware"), _("System"), [ + "system.board" + ], (e)=>({ + value: metrics_o(e).firmware || "\u2013", + detail: metrics_o(e).target + })), n("system.kernel", _("Kernel"), _("System"), [ + "system.board" + ], (e)=>({ + value: metrics_o(e).kernel || "\u2013", + detail: metrics_o(e).architecture + })), [ + [ + "memory", + _("Memory"), + "memory" + ], + [ + "swap", + _("Swap"), + "swap" + ], + [ + "root", + _("Root storage"), + "root" + ], + [ + "tmp", + _("Temporary storage"), + "tmp" + ] + ]))n(`system.${e}`, r, _("Resources"), [ + "system.info" + ], (e)=>{ + let i = metrics_o(e)[l]; + return i.total ? { + value: `${Math.round(usedPercent(i))}%`, + detail: `${formatBytes(i.total - i.free)} / ${formatBytes(i.total)}`, + percent: usedPercent(i) + } : { + value: _("Not available") + }; + }); + n("network.interface-status", _("Interface status"), _("Network"), [ + "network.interfaces" + ], (e, t)=>{ + let i = metrics_c(metrics_d(e, "network.interfaces"), t); + return { + value: i ? i.up ? _("Online") : _("Offline") : _("Not available"), + detail: i ? `${i.name} \xb7 ${i.protocol}` : void 0 + }; + }, { + interface: !0 + }), n("network.interface-address", _("Interface address"), _("Network"), [ + "network.interfaces" + ], (e, t)=>{ + let i = metrics_c(metrics_d(e, "network.interfaces"), t); + return { + value: i?.addresses.join(", ") || "\u2013", + detail: i?.name + }; + }, { + interface: !0 + }), n("network.interface-traffic", _("Interface bytes"), _("Network"), [ + "network.interfaces" + ], (e, i)=>{ + let s = metrics_c(metrics_d(e, "network.interfaces"), i); + return { + value: s ? `${formatBytes(s.rx)} \u{2193} ${formatBytes(s.tx)} \u{2191}` : "\u2013", + detail: s ? `${s.name} \xb7 ${_("Cumulative counters")}` : void 0 + }; + }, { + interface: !0 + }), n("network.wan-status", _("WAN status"), _("Network"), [ + "network.wan" + ], (e)=>{ + let t = e.get("network.wan")?.data, i = [ + ...t.wan4, + ...t.wan6 + ][0]; + return { + value: i ? i.up ? _("Online") : _("Offline") : _("Not available"), + detail: i?.addresses.join(", ") + }; + }), n("network.interface-count", _("Active interfaces"), _("Network"), [ + "network.interfaces" + ], (e)=>({ + value: String(metrics_d(e, "network.interfaces").filter((e)=>e.up).length), + detail: _("Connected network interfaces") + })), n("network.interface-uptime", _("Interface uptime"), _("Network"), [ + "network.interfaces" + ], (e, t)=>{ + let s = metrics_c(metrics_d(e, "network.interfaces"), t); + return { + value: s?.up ? formatDuration(s.uptime) : _("Offline"), + detail: s?.name + }; + }, { + interface: !0 + }), n("network.interface-gateway", _("Interface gateway"), _("Network"), [ + "network.interfaces" + ], (e, t)=>{ + let i = metrics_c(metrics_d(e, "network.interfaces"), t); + return { + value: i?.gateway || _("Not available"), + detail: i?.name + }; + }, { + interface: !0 + }), n("network.interface-protocol", _("Interface protocol"), _("Network"), [ + "network.interfaces" + ], (e, t)=>{ + let i = metrics_c(metrics_d(e, "network.interfaces"), t); + return { + value: i?.protocol || _("Not available"), + detail: i?.name + }; + }, { + interface: !0 + }), n("wireless.radio-count", _("Active Wi-Fi networks"), _("Wireless"), [ + "wireless.radios" + ], (e)=>({ + value: String(metrics_d(e, "wireless.radios").filter((e)=>e.up).length) + })), n("wireless.ssid-count", _("Broadcast SSIDs"), _("Wireless"), [ + "wireless.radios" + ], (e)=>({ + value: String(new Set(metrics_d(e, "wireless.radios").filter((e)=>e.up && e.ssid).map((e)=>e.ssid)).size) + })), n("wireless.radio-status", _("Radio status"), _("Wireless"), [ + "wireless.radios" + ], (e, t)=>{ + let i = metrics_d(e, "wireless.radios"), s = i.find((e)=>e.radio === t.radio) ?? (t.radio ? void 0 : i[0]); + return { + value: s ? s.up ? _("Online") : _("Offline") : _("Not available"), + detail: s ? `${s.radio} \xb7 ${s.ssid}` : void 0 + }; + }, { + radio: !0 + }), n("wireless.channel", _("Wi-Fi channel"), _("Wireless"), [ + "wireless.radios" + ], (e, t)=>{ + let i = metrics_d(e, "wireless.radios"), s = i.find((e)=>e.radio === t.radio) ?? (t.radio ? void 0 : i[0]); + return { + value: s?.channel == null ? _("Not available") : String(s.channel), + detail: s ? `${s.radio} \xb7 ${s.frequency}` : void 0 + }; + }, { + radio: !0 + }), n("wireless.client-count", _("Wireless client count"), _("Wireless"), [ + "wireless.clients" + ], (e)=>({ + value: String(metrics_d(e, "wireless.clients").length) + })), n("wireless.average-signal", _("Average Wi-Fi signal"), _("Wireless"), [ + "wireless.clients" + ], (e)=>{ + let t = metrics_d(e, "wireless.clients").map((e)=>e.signal).filter(Number.isFinite); + return { + value: t.length ? `${Math.round(t.reduce((e, t)=>e + t, 0) / t.length)} dBm` : _("No clients"), + detail: `${t.length} ${_("clients")}` + }; + }), n("dhcp.lease-count", _("DHCP lease count"), _("Clients"), [ + "dhcp.leases" + ], (e)=>({ + value: String(metrics_d(e, "dhcp.leases").length) + })), n("dhcp.ipv6-count", _("IPv6 lease count"), _("Clients"), [ + "dhcp.leases" + ], (e)=>({ + value: String(metrics_d(e, "dhcp.leases").filter((e)=>"IPv6" === e.family).length) + })), n("dhcp.ipv4-count", _("IPv4 lease count"), _("Clients"), [ + "dhcp.leases" + ], (e)=>({ + value: String(metrics_d(e, "dhcp.leases").filter((e)=>"IPv4" === e.family).length) + })), n("wireless.strongest-signal", _("Strongest Wi-Fi signal"), _("Wireless"), [ + "wireless.clients" + ], (e)=>{ + let t = metrics_d(e, "wireless.clients").filter((e)=>Number.isFinite(e.signal)).reduce((e, t)=>!e || t.signal > e.signal ? t : e, void 0); + return { + value: t ? `${t.signal} dBm` : _("No clients"), + detail: t?.hostname || t?.mac + }; + }), r.register({ + type: "network.traffic-trend", + title: _("Live traffic trend"), + description: _("Receive and transmit bytes per second"), + category: _("Network"), + icon: "", + version: 1, + defaultSize: "wide", + supportedSizes: [ + "wide", + "large", + "xlarge", + "full", + "custom" + ], + constraints: { + ...DEFAULT_CONSTRAINTS, + min: { + w: 3, + h: 2 + } + }, + defaultConfig: { + interface: "", + history: 40 + }, + requiredData: [ + "network.interfaces" + ], + validateConfig: (e)=>"string" == typeof e.interface && e.interface.length <= 64 && Number.isInteger(e.history) && Number(e.history) >= 10 && 120 >= Number(e.history), + createInstance: (e)=>new metrics_f(e) + }); +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/widgets/registry.ts +class WidgetRegistry { + definitions = new Map(); + register(i) { + if (this.definitions.has(i.type)) throw Error(`Duplicate widget: ${i.type}`); + if (!i.validateConfig(i.defaultConfig)) throw Error(`Invalid default config: ${i.type}`); + this.definitions.set(i.type, i); + } + get(i) { + return this.definitions.get(i); + } + list() { + return [ + ...this.definitions.values() + ]; + } +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/widgets/shortcuts.ts + +let shortcuts_t = [ + { + id: "status", + label: ()=>_("System status"), + path: [ + "admin", + "status", + "overview" + ] + }, + { + id: "interfaces", + label: ()=>_("Interfaces"), + path: [ + "admin", + "network", + "network" + ] + }, + { + id: "wireless", + label: ()=>_("Wireless"), + path: [ + "admin", + "network", + "wireless" + ] + }, + { + id: "dhcp", + label: ()=>_("DHCP and DNS"), + path: [ + "admin", + "network", + "dhcp" + ] + }, + { + id: "firewall", + label: ()=>_("Firewall"), + path: [ + "admin", + "network", + "firewall" + ] + }, + { + id: "system", + label: ()=>_("System settings"), + path: [ + "admin", + "system", + "system" + ] + } +]; +class shortcuts_i { + container; + config; + constructor(e){ + this.config = e.config; + } + mount(e) { + this.container = e, this.paint(); + } + updateConfig(e) { + this.config = e, this.paint(); + } + resize(e, t) {} + setVisible(e) {} + destroy() { + this.container = void 0; + } + paint() { + if (!this.container) return; + let e = document.createElement("nav"); + for (let i of (e.className = "fluent-dashboard__shortcuts", shortcuts_t)){ + if (Array.isArray(this.config.links) && !this.config.links.includes(i.id)) continue; + let t = document.createElement("a"); + t.href = L.url(...i.path), t.textContent = i.label(), e.append(t); + } + this.container.replaceChildren(e); + } +} +function registerShortcuts(n) { + n.register({ + type: "navigation.shortcuts", + title: _("Quick links"), + description: _("Open common LuCI pages"), + category: _("Navigation"), + icon: "", + version: 1, + defaultSize: "wide", + supportedSizes: [ + "medium", + "wide", + "large", + "xlarge", + "full", + "custom" + ], + constraints: { + ...DEFAULT_CONSTRAINTS, + min: { + w: 2, + h: 2 + } + }, + defaultConfig: { + links: shortcuts_t.map((e)=>e.id) + }, + requiredData: [], + validateConfig: (e)=>Array.isArray(e.links) && e.links.length <= shortcuts_t.length && new Set(e.links).size === e.links.length && e.links.every((e)=>"string" == typeof e && shortcuts_t.some((t)=>t.id === e)), + createInstance: (e)=>new shortcuts_i(e) + }); +} + +;// CONCATENATED MODULE: ./web/fluentdashboard/index.tsx + + + + + + + + + +let fluentdashboard_a = rpc.declare({ + object: "luci.fluentdashboard", + method: "load", + reject: !0 +}), fluentdashboard_l = rpc.declare({ + object: "luci.fluentdashboard", + method: "save", + params: [ + "layout", + "expectedRevision" + ], + reject: !0 +}), fluentdashboard_h = rpc.declare({ + object: "session", + method: "access", + params: [ + "scope", + "object", + "function" + ], + expect: { + access: !1 + } +}); +class fluentdashboard_m extends L.view { handleSave = null; handleSaveApply = null; handleReset = null; - root = null; - load() { - return Promise.all([ - g(), - w() + workbench; + observer; + persistence; + async load() { + let e; + try { + e = window.localStorage; + } catch {} + return this.persistence = new DashboardPersistence({ + load: fluentdashboard_a, + save: fluentdashboard_l + }, e, `fluentdashboard.draft.v1:${location.host}`), Promise.all([ + this.persistence.load(), + fluentdashboard_h("ubus", "luci.fluentdashboard", "save").catch(()=>!1) ]); } - replaceSection(e, t) { - this.root?.querySelector(`[data-dashboard-section="${e}"]`)?.replaceWith(t); - } - async refreshSummary() { - if (!this.root?.isConnected) return; - let e = await g(); - if (!this.root?.isConnected) return; - this.replaceSection("system", x(e.system)), this.replaceSection("resources", I(e.system)), this.replaceSection("internet", N(e)), this.replaceSection("interfaces", D(e.interfaces)), this.replaceSection("wireless", k(e.wireless)); - let t = this.root.querySelector("[data-dashboard-updated]"); - t && (t.textContent = e.updatedAt.toLocaleTimeString()); - } - async refreshDetails() { - if (!this.root?.isConnected) return; - let e = await w(); - this.root?.isConnected && (this.replaceSection("leases", C(e.leases)), this.replaceSection("clients", M(e.clients))); - } render([r, a]) { - return document.getElementById("fluent-dashboard-styles") || document.head.appendChild(jsx("link", { + window.document.getElementById("fluent-dashboard-styles") || window.document.head.appendChild(jsx("link", { id: "fluent-dashboard-styles", rel: "stylesheet", href: L.resource("view/fluentdashboard/index.css") - })), this.root = jsxs("div", { - class: "fluent-dashboard", - children: [ - jsxs("div", { - class: "fluent-dashboard__header", - children: [ - jsxs("div", { - children: [ - jsx("h1", { - children: _("Fluent Dashboard") - }), - jsx("p", { - children: _("A live overview of this OpenWrt device.") - }) - ] - }), - jsxs("p", { - class: "fluent-dashboard__updated", - children: [ - _("Updated"), - " ", - jsx("time", { - "data-dashboard-updated": "", - children: r.updatedAt.toLocaleTimeString() - }) - ] - }) - ] - }), - jsxs("div", { - class: "fluent-dashboard__grid", - children: [ - x(r.system), - I(r.system), - N(r), - D(r.interfaces), - k(r.wireless), - C(a.leases), - M(a.clients) - ] - }) - ] - }), poll.add(()=>this.refreshSummary(), 5), poll.add(()=>this.refreshDetails(), 15), this.root; + })), this.workbench?.destroy(), this.observer?.disconnect(); + let l = new DataService(); + registerSources(l), l.observeVisibility(window.document); + let h = new WidgetRegistry(); + registerBuiltins(h), registerMetrics(h), registerShortcuts(h), this.workbench = new Workbench(r, h, l, this.persistence, a); + let m = this.workbench.root, u = !1; + return this.observer = new MutationObserver(()=>{ + m.isConnected ? u = !0 : u && (this.workbench?.destroy(), this.observer?.disconnect()); + }), this.observer.observe(window.document.body, { + childList: !0, + subtree: !0 + }), m; } } -const main = $; +const main = fluentdashboard_m; return main; \ No newline at end of file diff --git a/luci-mod-fluentdashboard/root/etc/config/fluentdashboard b/luci-mod-fluentdashboard/root/etc/config/fluentdashboard new file mode 100644 index 000000000..2ee3afaad --- /dev/null +++ b/luci-mod-fluentdashboard/root/etc/config/fluentdashboard @@ -0,0 +1,3 @@ +config dashboard 'global' + option schema_version '1' + option revision '0' diff --git a/luci-mod-fluentdashboard/root/usr/share/rpcd/acl.d/luci-mod-fluentdashboard.json b/luci-mod-fluentdashboard/root/usr/share/rpcd/acl.d/luci-mod-fluentdashboard.json index 39d80d304..adff696e8 100644 --- a/luci-mod-fluentdashboard/root/usr/share/rpcd/acl.d/luci-mod-fluentdashboard.json +++ b/luci-mod-fluentdashboard/root/usr/share/rpcd/acl.d/luci-mod-fluentdashboard.json @@ -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" ] + } + } } } diff --git a/luci-mod-fluentdashboard/root/usr/share/rpcd/ucode/luci.fluentdashboard b/luci-mod-fluentdashboard/root/usr/share/rpcd/ucode/luci.fluentdashboard new file mode 100644 index 000000000..bf97196c4 --- /dev/null +++ b/luci-mod-fluentdashboard/root/usr/share/rpcd/ucode/luci.fluentdashboard @@ -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; + } + } + } +}; diff --git a/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js b/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js index 3e859a156..38e4b0cd1 100644 --- a/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js +++ b/luci-mod-network/htdocs/luci-static/resources/view/network/wireless.js @@ -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') diff --git a/luci-mod-network/root/usr/share/rpcd/acl.d/luci-mod-network.json b/luci-mod-network/root/usr/share/rpcd/acl.d/luci-mod-network.json index 7f589c938..b2b2479c3 100644 --- a/luci-mod-network/root/usr/share/rpcd/acl.d/luci-mod-network.json +++ b/luci-mod-network/root/usr/share/rpcd/acl.d/luci-mod-network.json @@ -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" ], diff --git a/luci-mod-system/htdocs/luci-static/resources/view/system/flash.js b/luci-mod-system/htdocs/luci-static/resources/view/system/flash.js index b471fe813..b1b6d4de5 100644 --- a/luci-mod-system/htdocs/luci-static/resources/view/system/flash.js +++ b/luci-mod-system/htdocs/luci-static/resources/view/system/flash.js @@ -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.') diff --git a/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json b/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json index 5323eb9de..3c7ef8716 100644 --- a/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json +++ b/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json @@ -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": { diff --git a/luci-theme-design/Makefile b/luci-theme-design/Makefile index d8165a287..b0dd14389 100644 --- a/luci-theme-design/Makefile +++ b/luci-theme-design/Makefile @@ -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:= diff --git a/luci-theme-design/htdocs/luci-static/design/css/style.css b/luci-theme-design/htdocs/luci-static/design/css/style.css index 9de4a3c0f..bc8d3e496 100644 --- a/luci-theme-design/htdocs/luci-static/design/css/style.css +++ b/luci-theme-design/htdocs/luci-static/design/css/style.css @@ -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 { diff --git a/luci-theme-design/htdocs/luci-static/design/js/style.js b/luci-theme-design/htdocs/luci-static/design/js/style.js index 4112bd3e2..02065aae1 100644 --- a/luci-theme-design/htdocs/luci-static/design/js/style.js +++ b/luci-theme-design/htdocs/luci-static/design/js/style.js @@ -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); } diff --git a/luci-theme-design/ucode/template/themes/design/header.ut b/luci-theme-design/ucode/template/themes/design/header.ut index 5e1494abf..d7ef00b1c 100644 --- a/luci-theme-design/ucode/template/themes/design/header.ut +++ b/luci-theme-design/ucode/template/themes/design/header.ut @@ -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'); -%} diff --git a/luci-theme-fluent/Makefile b/luci-theme-fluent/Makefile index 46838f46d..490a8b38a 100644 --- a/luci-theme-fluent/Makefile +++ b/luci-theme-fluent/Makefile @@ -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 diff --git a/luci-theme-fluent/htdocs/luci-static/fluent/css/fluent.css b/luci-theme-fluent/htdocs/luci-static/fluent/css/fluent.css index a8407abb0..6e1054f2b 100644 --- a/luci-theme-fluent/htdocs/luci-static/fluent/css/fluent.css +++ b/luci-theme-fluent/htdocs/luci-static/fluent/css/fluent.css @@ -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); diff --git a/luci-theme-fluent/htdocs/luci-static/resources/menu-fluent.js b/luci-theme-fluent/htdocs/luci-static/resources/menu-fluent.js index a7e328574..2a10bc1a0 100644 --- a/luci-theme-fluent/htdocs/luci-static/resources/menu-fluent.js +++ b/luci-theme-fluent/htdocs/luci-static/resources/menu-fluent.js @@ -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); + } }); } diff --git a/luci-theme-fluent/htdocs/luci-static/resources/view/fluent-config.js b/luci-theme-fluent/htdocs/luci-static/resources/view/fluent-config.js index e9b825169..6cfb3745b 100644 --- a/luci-theme-fluent/htdocs/luci-static/resources/view/fluent-config.js +++ b/luci-theme-fluent/htdocs/luci-static/resources/view/fluent-config.js @@ -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 diff --git a/luci-theme-fluent/root/usr/libexec/fluent/update b/luci-theme-fluent/root/usr/libexec/fluent/update new file mode 100755 index 000000000..c56223ce5 --- /dev/null +++ b/luci-theme-fluent/root/usr/libexec/fluent/update @@ -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 diff --git a/luci-theme-fluent/root/usr/libexec/rpcd/luci.fluent b/luci-theme-fluent/root/usr/libexec/rpcd/luci.fluent index e5fcf9ff2..6282c69fc 100755 --- a/luci-theme-fluent/root/usr/libexec/rpcd/luci.fluent +++ b/luci-theme-fluent/root/usr/libexec/rpcd/luci.fluent @@ -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 - diff --git a/luci-theme-fluent/root/usr/share/rpcd/acl.d/luci-theme-fluent.json b/luci-theme-fluent/root/usr/share/rpcd/acl.d/luci-theme-fluent.json index c991b0f8b..dd3c88ce9 100644 --- a/luci-theme-fluent/root/usr/share/rpcd/acl.d/luci-theme-fluent.json +++ b/luci-theme-fluent/root/usr/share/rpcd/acl.d/luci-theme-fluent.json @@ -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" ] } diff --git a/luci-theme-vantage/Makefile b/luci-theme-vantage/Makefile index c7e5ce2f4..714c048f3 100644 --- a/luci-theme-vantage/Makefile +++ b/luci-theme-vantage/Makefile @@ -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 diff --git a/luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js b/luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js index b49a0c705..0473fc887 100644 --- a/luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js +++ b/luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js @@ -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) */ diff --git a/luci-theme-vantage/htdocs/luci-static/vantage/login.js b/luci-theme-vantage/htdocs/luci-static/vantage/login.js index 901afc629..e9bcc97dd 100644 --- a/luci-theme-vantage/htdocs/luci-static/vantage/login.js +++ b/luci-theme-vantage/htdocs/luci-static/vantage/login.js @@ -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]; diff --git a/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css b/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css index 14f6d5ce2..2571a01f4 100644 --- a/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css +++ b/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css @@ -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 { diff --git a/luci-theme-vantage/po/templates/vantage-theme.pot b/luci-theme-vantage/po/templates/vantage-theme.pot index 89189ece1..e8fc632c0 100644 --- a/luci-theme-vantage/po/templates/vantage-theme.pot +++ b/luci-theme-vantage/po/templates/vantage-theme.pot @@ -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 "" diff --git a/luci-theme-vantage/po/zh_Hans/vantage-theme.po b/luci-theme-vantage/po/zh_Hans/vantage-theme.po index ea19dbddd..75fa63de0 100644 --- a/luci-theme-vantage/po/zh_Hans/vantage-theme.po +++ b/luci-theme-vantage/po/zh_Hans/vantage-theme.po @@ -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 "已用" diff --git a/luci-theme-vantage/root/etc/config/vantage_theme b/luci-theme-vantage/root/etc/config/vantage_theme index 2ca9ce0c3..0d4e59172 100644 --- a/luci-theme-vantage/root/etc/config/vantage_theme +++ b/luci-theme-vantage/root/etc/config/vantage_theme @@ -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 diff --git a/luci-theme-vantage/ucode/template/themes/vantage/header.ut b/luci-theme-vantage/ucode/template/themes/vantage/header.ut index a73c52347..85af0fbf2 100644 --- a/luci-theme-vantage/ucode/template/themes/vantage/header.ut +++ b/luci-theme-vantage/ucode/template/themes/vantage/header.ut @@ -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 ?? '?'); -%} - + @@ -108,7 +107,7 @@ const hostname = striptags(boardinfo?.hostname ?? '?'); -

{{ hostname }}

+
    @@ -126,6 +125,8 @@ const hostname = striptags(boardinfo?.hostname ?? '?'); +{% else %} + {% endif %}
    diff --git a/luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut b/luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut index 23c4704e7..b2d1a6b04 100644 --- a/luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut +++ b/luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut @@ -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; })(); -%} @@ -54,10 +54,7 @@ -
    -

    {{ hostname }}

    - -
    +

    {{ entityencode(host ?? _('Authorization Required'), true) }}

    {% if (fuser): %} diff --git a/wwand/Makefile b/wwand/Makefile index 0506ce819..2a36b7d95 100644 --- a/wwand/Makefile +++ b/wwand/Makefile @@ -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