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; + }
192.168.1.0/24"
+"code>."
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:681
+msgid "Tailscale DNS Status"
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:503
+msgid "Tailscale Enabled"
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:663
+msgid "Tailscale IP"
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:669
+msgid "Tailscale Interface"
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:492
+#: root/usr/share/luci/menu.d/luci-app-tailscale-ng.json:3
+msgid "Tailscale NG"
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:675
+msgid "Tailscale Netcheck"
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:508
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:657
+msgid "Tailscale Status"
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:512
+msgid "Tailscale Version"
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:651
+msgid "Tailscale documentation"
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:493
+msgid "Tailscale is a cross-platform and easy to use virtual LAN."
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:564
+msgid "UDP port to listen on for WireGuard and peer-to-peer traffic."
+msgstr ""
+
+#: htdocs/luci-static/resources/view/tailscale-ng/index.js:540
+msgid ""
+"Use a non-reusable auth key and disable key expiry for trusted machines via "
+"the provider admin console."
+msgstr ""
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(_('Authorization Required'), true) }}
-
+ {{ 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