mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 11:03:56 +08:00
🍓 Sync 2026-10-12 00:41:33
This commit is contained in:
1 parent
5ce75c9f2b
commit
66fee38686
16 files changed
+2264
-1094
No files matched your search
@@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
PKG_VERSION:=0.1.52
|
||||
PKG_RELEASE:=63
|
||||
PKG_RELEASE:=64
|
||||
|
||||
# Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI).
|
||||
ifdef SOURCE_DATE_EPOCH
|
||||
|
||||
@@ -2932,8 +2932,6 @@ return view.extend({
|
||||
this.hostnameCache = new Map();
|
||||
this.hostnameFailed = new Map();
|
||||
this.resolveGeneration = 0;
|
||||
this.lastPollError = false;
|
||||
this.lastPollErrorCode = null;
|
||||
this.applyHash();
|
||||
this.attachHandlers();
|
||||
this.applyRowTintMode();
|
||||
|
||||
@@ -394,6 +394,10 @@ wan_firewall_zone_same() {
|
||||
wan_log_staged_line_section() {
|
||||
_line="$1"
|
||||
case "$_line" in
|
||||
firewall.*.log+=*|firewall.*.log-=*)
|
||||
_rest=${_line#firewall.}
|
||||
printf '%s' "${_rest%%.log*}"
|
||||
;;
|
||||
firewall.*.log=*)
|
||||
_rest=${_line#firewall.}
|
||||
printf '%s' "${_rest%%.log=*}"
|
||||
@@ -467,6 +471,8 @@ wan_log_count_our_staged_lines() {
|
||||
if (id[i] == "") continue
|
||||
np++
|
||||
p[np] = "firewall." id[i] ".log="
|
||||
la[np] = "firewall." id[i] ".log+="
|
||||
ld[np] = "firewall." id[i] ".log-="
|
||||
d[np] = "-firewall." id[i] ".log"
|
||||
ds[np] = "- firewall." id[i] ".log"
|
||||
}
|
||||
@@ -474,7 +480,7 @@ wan_log_count_our_staged_lines() {
|
||||
NF == 0 { next }
|
||||
{
|
||||
for (i = 1; i <= np; i++) {
|
||||
if (index($0, p[i]) == 1) { c++; break }
|
||||
if (index($0, p[i]) == 1 || index($0, la[i]) == 1 || index($0, ld[i]) == 1) { c++; break }
|
||||
if ($0 == d[i] || $0 == ds[i]) { c++; break }
|
||||
}
|
||||
}
|
||||
@@ -482,6 +488,83 @@ wan_log_count_our_staged_lines() {
|
||||
'
|
||||
}
|
||||
|
||||
# Revert only a single staged log delta that still has the value this call
|
||||
# requested. Repeated or changed same-option entries may belong to a competing
|
||||
# UCI writer; preserve them rather than reverting an indistinguishable edit.
|
||||
wan_log_staged_option_matches_target() {
|
||||
_zone="$1"
|
||||
_target="$2"
|
||||
_staged="$3"
|
||||
_ids=$(wan_log_staged_zone_ids "$_zone" "$_staged")
|
||||
printf '%s\n' "$_staged" | awk -v ids="$_ids" -v target="$_target" '
|
||||
BEGIN {
|
||||
n = split(ids, id, "\n")
|
||||
for (i = 1; i <= n; i++) {
|
||||
if (id[i] == "") continue
|
||||
p[i] = "firewall." id[i] ".log="
|
||||
la[i] = "firewall." id[i] ".log+="
|
||||
ld[i] = "firewall." id[i] ".log-="
|
||||
d[i] = "-firewall." id[i] ".log"
|
||||
ds[i] = "- firewall." id[i] ".log"
|
||||
}
|
||||
}
|
||||
NF == 0 { next }
|
||||
{
|
||||
for (i = 1; i <= n; i++) {
|
||||
if (p[i] != "" && index($0, p[i]) == 1) {
|
||||
count++
|
||||
value = substr($0, length(p[i]) + 1)
|
||||
if (value == target || value == sprintf("%c%s%c", 39, target, 39)) matched = 1
|
||||
break
|
||||
}
|
||||
if (index($0, la[i]) == 1 || index($0, ld[i]) == 1) {
|
||||
count++
|
||||
break
|
||||
}
|
||||
if ($0 == d[i] || $0 == ds[i]) {
|
||||
count++
|
||||
if (target == "") matched = 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
END { exit !(count == 1 && matched) }
|
||||
'
|
||||
}
|
||||
|
||||
wan_log_revert_staged_option_if_ours() {
|
||||
_zone="$1"
|
||||
_target="$2"
|
||||
_staged="$3"
|
||||
if ! _before_revert=$(uci -q changes firewall 2>/dev/null); then
|
||||
logger -t fwlive "WAN log option-scoped revert could not re-read staged changes" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
if [ "$_before_revert" != "$_staged" ] \
|
||||
|| ! wan_log_staged_option_matches_target "$_zone" "$_target" "$_before_revert"; then
|
||||
logger -t fwlive "WAN log option-scoped revert skipped: staged changes changed before cleanup" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
# UCI's CLI has no atomic compare-and-revert: a non-cooperating writer
|
||||
# can still stage the same option after this read and before revert (tracked separately).
|
||||
uci -q revert "firewall.${_zone}.log" 2>/dev/null || {
|
||||
logger -t fwlive "WAN log option-scoped revert failed for firewall.${_zone}.log" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
# OpenWrt 24.10/25.12 UCI can return success even when its internal
|
||||
# revert operation fails. Confirm through a fresh delta read that no log
|
||||
# option changes remain before reporting cleanup success.
|
||||
if ! _after_revert=$(uci -q changes firewall 2>/dev/null); then
|
||||
logger -t fwlive "WAN log option-scoped revert could not be verified" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
if [ "$(wan_log_count_our_staged_lines "$_zone" "$_after_revert")" -ne 0 ]; then
|
||||
logger -t fwlive "WAN log option-scoped revert left a staged delta" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
wan_log_baseline_path() {
|
||||
printf '%s' "$WAN_LOG_BASELINE_FILE"
|
||||
}
|
||||
@@ -852,8 +935,8 @@ restore_wan_zone_log() {
|
||||
}
|
||||
# Capture the committed value before staging our rollback. If another
|
||||
# writer stages a firewall delta after this point, the post-stage guard
|
||||
# below must be able to undo only our rollback staging without reverting
|
||||
# the other writer's change.
|
||||
# may revert our option only when its staged log delta is still uniquely
|
||||
# identifiable; otherwise it preserves the ambiguous same-option change.
|
||||
committed=$(wan_zone_log_value "$zone")
|
||||
# Fail closed if set/delete never staged. Swallowed rc plus an empty
|
||||
# changes list would make `uci commit` succeed as a no-op.
|
||||
@@ -868,25 +951,21 @@ restore_wan_zone_log() {
|
||||
fi
|
||||
_staged=$(uci -q changes firewall 2>/dev/null || true)
|
||||
_foreign=$(wan_log_foreign_staged_lines "$zone" "$_staged")
|
||||
if [ -n "$_foreign" ]; then
|
||||
if [ -z "$committed" ]; then
|
||||
uci delete "firewall.${zone}.log" 2>/dev/null || true
|
||||
else
|
||||
uci set "firewall.${zone}.log=${committed}" 2>/dev/null || true
|
||||
_our_count=$(wan_log_count_our_staged_lines "$zone" "$_staged")
|
||||
if [ -n "$_foreign" ] || [ "$_our_count" -ne 1 ] \
|
||||
|| ! wan_log_staged_option_matches_target "$zone" "$previous" "$_staged"; then
|
||||
if ! wan_log_revert_staged_option_if_ours "$zone" "$previous" "$_staged"; then
|
||||
logger -t fwlive "WAN log rollback left staged log option after foreign or ambiguous change" 2>/dev/null || true
|
||||
fi
|
||||
logger -t fwlive "WAN log rollback skipped after stage: firewall changes staged by another writer" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
if ! uci commit firewall 2>/dev/null; then
|
||||
# Drop our staged rollback delta so a later toggle is not stuck
|
||||
# on firewall_changes_pending from this package's own orphaned
|
||||
# write. Mirror commit_wan_log_change: revert only when the
|
||||
# remaining staging is entirely our log option.
|
||||
# Remove only a uniquely identifiable staged rollback option. A
|
||||
# config-wide revert could discard unrelated or competing UCI writes.
|
||||
_staged=$(uci -q changes firewall 2>/dev/null || true)
|
||||
_total=$(printf '%s\n' "$_staged" | grep -c . 2>/dev/null || true)
|
||||
_ours=$(wan_log_count_our_staged_lines "$zone" "$_staged")
|
||||
if [ "${_total:-0}" -gt 0 ] && [ "${_total:-0}" -eq "${_ours:-0}" ]; then
|
||||
uci -q revert firewall 2>/dev/null || true
|
||||
if ! wan_log_revert_staged_option_if_ours "$zone" "$previous" "$_staged"; then
|
||||
logger -t fwlive "WAN log rollback cleanup failed; staged option may remain" 2>/dev/null || true
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
@@ -902,8 +981,8 @@ restore_wan_zone_log() {
|
||||
# Staging and committing therefore live INSIDE this function, gated by:
|
||||
# 1. a pending re-check BEFORE our own delta exists in staging (foreign-only);
|
||||
# 2. a post-stage re-check AFTER our set/delete: if anything besides our
|
||||
# log option is staged, undo our staging (restore the pre-stage committed
|
||||
# value) and abort without commit — foreign staging stays intact.
|
||||
# log option is staged, revert only a single exact log delta and abort;
|
||||
# preserve ambiguous same-option changes and all foreign staging.
|
||||
# The reload-failure rollback has its own pre/post-stage guard for the same
|
||||
# reason; it must not bypass this invariant merely because it is compensating
|
||||
# for a failed reload.
|
||||
@@ -953,8 +1032,9 @@ commit_wan_log_change() {
|
||||
}
|
||||
WAN_LOG_COMMIT_GENERATION="$_generation"
|
||||
|
||||
# Staging is empty here — capture the committed value so a post-stage
|
||||
# foreign race can undo our delta without `uci revert firewall`.
|
||||
# Staging is empty here — capture the committed value for verification and
|
||||
# recovery. A post-stage abort uses option-scoped revert, not a compensating
|
||||
# set to the old value (which leaves another staged delta in real UCI).
|
||||
previous=$(wan_zone_log_value "$zone")
|
||||
|
||||
if [ -n "$target" ]; then
|
||||
@@ -969,16 +1049,16 @@ commit_wan_log_change() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Post-stage guard: anything besides our log option is foreign.
|
||||
# Undo our staging only (set previous / delete to match committed); leave
|
||||
# foreign deltas untouched and abort without commit.
|
||||
# Post-stage guard: anything besides our log option is foreign. Revert the
|
||||
# single expected log delta without touching other options; if the staged
|
||||
# log state is ambiguous, preserve it rather than discard a same-option edit.
|
||||
_staged=$(uci -q changes firewall 2>/dev/null || true)
|
||||
_foreign=$(wan_log_foreign_staged_lines "$zone" "$_staged")
|
||||
if [ -n "$_foreign" ]; then
|
||||
if [ -z "$previous" ]; then
|
||||
uci delete "firewall.${zone}.log" 2>/dev/null || true
|
||||
else
|
||||
uci set "firewall.${zone}.log=${previous}" 2>/dev/null || true
|
||||
_our_count=$(wan_log_count_our_staged_lines "$zone" "$_staged")
|
||||
if [ -n "$_foreign" ] || [ "$_our_count" -ne 1 ] \
|
||||
|| ! wan_log_staged_option_matches_target "$zone" "$target" "$_staged"; then
|
||||
if ! wan_log_revert_staged_option_if_ours "$zone" "$target" "$_staged"; then
|
||||
logger -t fwlive "WAN log toggle left staged log option after foreign or ambiguous change" 2>/dev/null || true
|
||||
fi
|
||||
logger -t fwlive "WAN log toggle aborted after stage: firewall changes staged by another writer" 2>/dev/null || true
|
||||
wan_log_error_json "$zone_json" firewall_changes_pending
|
||||
@@ -986,19 +1066,12 @@ commit_wan_log_change() {
|
||||
fi
|
||||
|
||||
if ! uci commit firewall; then
|
||||
# Drop our staged log delta so a later toggle is not stuck on
|
||||
# firewall_changes_pending from this package's own orphaned write —
|
||||
# but ONLY when nothing foreign is staged: `uci revert firewall` is
|
||||
# config-wide (uci has no option-level revert), and reverting would
|
||||
# clobber a concurrent writer's uncommitted delta. With foreign
|
||||
# staging present, leave it and warn.
|
||||
# UCI option-scoped revert clears our exact delta without discarding
|
||||
# unrelated staging. If a competing same-option write makes ownership
|
||||
# ambiguous, preserve it and leave a diagnostic instead.
|
||||
_staged=$(uci -q changes firewall 2>/dev/null || true)
|
||||
_total=$(printf '%s\n' "$_staged" | grep -c . 2>/dev/null || true)
|
||||
_ours=$(wan_log_count_our_staged_lines "$zone" "$_staged")
|
||||
if [ "${_total:-0}" -gt 0 ] && [ "${_total:-0}" -eq "${_ours:-0}" ]; then
|
||||
uci -q revert firewall 2>/dev/null || true
|
||||
else
|
||||
logger -t fwlive "WAN log commit failed with foreign changes staged — not reverting (uci_commit_failed)" 2>/dev/null || true
|
||||
if ! wan_log_revert_staged_option_if_ours "$zone" "$target" "$_staged"; then
|
||||
logger -t fwlive "WAN log commit failed with ambiguous or foreign changes staged — not reverting (uci_commit_failed)" 2>/dev/null || true
|
||||
fi
|
||||
wan_log_error_json "$zone_json" uci_commit_failed
|
||||
return 1
|
||||
|
||||
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-netstat
|
||||
PKG_VERSION:=1.1.5
|
||||
PKG_RELEASE:=3
|
||||
PKG_RELEASE:=4
|
||||
|
||||
PKG_MAINTAINER:=NoobLK <liyanagelsofficial@gmail.com>
|
||||
PKG_LICENSE:=GPL-3.0
|
||||
|
||||
@@ -89,7 +89,37 @@ end
|
||||
local ifaces = get_interfaces()
|
||||
local saved_iface = uci:get("netstats", "@config[0]", "prefer") or ""
|
||||
local saved_mode = uci:get("netstats", "@config[0]", "mode") or "daily"
|
||||
local cur_iface = http.formvalue("iface") or saved_iface or ifaces[1] or ""
|
||||
|
||||
local function has_db(name)
|
||||
for _, i in ipairs(ifaces) do if i == name then return true end end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Accepts a vnStat netdev name (e.g. "wwan0") or a logical interface name
|
||||
-- (e.g. "wan") resolved to its l3_device. Returns nil if it has no database.
|
||||
local function resolve_iface(name)
|
||||
if not name or name == "" or not name:match("^[%w%-%._]+$") then return nil end
|
||||
if has_db(name) then return name end
|
||||
local l3 = sys.exec("ubus call network.interface." .. name ..
|
||||
" status 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null") or ""
|
||||
l3 = l3:match("^%s*(.-)%s*$")
|
||||
if l3 ~= "" and has_db(l3) then return l3 end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Auto detect: the default-route device is the real uplink.
|
||||
local function default_route_iface()
|
||||
local out = sys.exec("ip route show default 2>/dev/null") or ""
|
||||
for dev in out:gmatch("dev%s+(%S+)") do
|
||||
if has_db(dev) then return dev end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local cur_iface = resolve_iface(http.formvalue("iface"))
|
||||
or resolve_iface(saved_iface)
|
||||
or default_route_iface()
|
||||
or ifaces[1] or ""
|
||||
local view_mode = http.formvalue("mode") or saved_mode
|
||||
if view_mode ~= "daily" and view_mode ~= "monthly" then view_mode = "daily" end
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-open-box
|
||||
PKG_VERSION:=0.1.313
|
||||
PKG_RELEASE:=24
|
||||
PKG_VERSION:=0.1.315
|
||||
PKG_RELEASE:=25
|
||||
|
||||
PKG_MAINTAINER:=liandu2024
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-vantage
|
||||
PKG_VERSION:=1.0.1
|
||||
PKG_RELEASE:=7
|
||||
PKG_VERSION:=1.0.2
|
||||
PKG_RELEASE:=8
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
|
||||
LUCI_TITLE:=Vantage dashboard
|
||||
|
||||
@@ -277,40 +277,80 @@ html[data-darkmode="true"] .vt-app {
|
||||
|
||||
/* ------------------------------------------------------------ 3. layout */
|
||||
|
||||
.vt-app .vt-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas: "clients" "talk" "radios" "hero" "ssids" "chart" "sys";
|
||||
/* Tiles are placed by the view (vantage/layout.js): it sets each tile's
|
||||
column (--c), width in columns (--w) and top (--y), and the grid's
|
||||
column count (--cols) and height. Widths and columns follow the grid
|
||||
width here, so only heights wait for script. */
|
||||
.vt-app .vt-grid { --vt-gap: 14px; --cols: 1; position: relative; min-height: 200px; }
|
||||
.vt-app .vt-grid-wait { visibility: hidden; }
|
||||
.vt-app .vt-grid > .vt-cell, .vt-app .vt-grid > .vt-drop-ghost {
|
||||
--c: 0; --w: 1; --y: 0px; --vt-cw: calc((100% - (var(--cols) - 1) * var(--vt-gap)) / var(--cols));
|
||||
position: absolute; top: var(--y); box-sizing: border-box; min-width: 0;
|
||||
left: calc(var(--vt-cw) * var(--c) + var(--vt-gap) * var(--c));
|
||||
width: calc(var(--vt-cw) * var(--w) + var(--vt-gap) * (var(--w) - 1));
|
||||
}
|
||||
.vt-app .vt-hero { grid-area: hero; }
|
||||
.vt-app .vt-sys { grid-area: sys; }
|
||||
.vt-app .vt-chart { grid-area: chart; }
|
||||
.vt-app .vt-radios { grid-area: radios; }
|
||||
.vt-app .vt-clients { grid-area: clients; }
|
||||
.vt-app .vt-side { grid-area: side; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
|
||||
/* phones: the side column dissolves so Top talkers can follow Clients */
|
||||
.vt-app .vt-cell-off { display: none !important; }
|
||||
.vt-app .vt-drop-ghost { display: none; border: 2px dashed var(--vt-accent-line); border-radius: var(--vt-r-lg); background: var(--vt-accent-soft); }
|
||||
.vt-app .vt-drop-ghost.vt-on { display: block; }
|
||||
|
||||
/* ----------------------------------------------- 3b. arranging the tiles */
|
||||
|
||||
.vt-app .vt-layout-btn { flex: none; height: 30px; gap: 6px; font-size: 12px; }
|
||||
.vt-app .vt-layout-btn .vt-ico { width: 16px; height: 16px; }
|
||||
.vt-app .vt-layout-btn[aria-pressed="true"] { background: var(--vt-accent-soft); color: var(--vt-text); }
|
||||
@container vt (max-width: 699px) { .vt-app .vt-layout-btn span { display: none; } }
|
||||
|
||||
.vt-app .vt-editbar {
|
||||
position: sticky; top: calc(var(--v-stick-top, 48px) + 8px); z-index: 6;
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px;
|
||||
margin: 0 0 14px; padding: 12px 14px; border-radius: var(--vt-r-lg);
|
||||
border: 1px solid var(--vt-accent-line); background: var(--vt-surface); box-shadow: var(--vt-shadow);
|
||||
}
|
||||
.vt-app .vt-editbar[hidden] { display: none; }
|
||||
.vt-app .vt-editbar-text { display: flex; flex-direction: column; gap: 2px; flex: 1 1 260px; min-width: 0; }
|
||||
.vt-app .vt-editbar-text strong { font-size: 13px; font-weight: 650; }
|
||||
.vt-app .vt-edit-hidden { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.vt-app .vt-edit-hidden:empty { display: none; }
|
||||
.vt-app .vt-edit-hidden .vt-fchip .vt-ico { width: 13px; height: 13px; }
|
||||
.vt-app .vt-editbar-actions { display: flex; gap: 8px; margin-left: auto; }
|
||||
@container vt (max-width: 699px) {
|
||||
.vt-app .vt-side { display: contents; }
|
||||
.vt-app .vt-talk { grid-area: talk; }
|
||||
.vt-app .vt-ssid-tile { grid-area: ssids; }
|
||||
.vt-app .vt-editbar { padding: 8px 8px 8px 12px; gap: 8px; }
|
||||
.vt-app .vt-editbar-text { flex-basis: 0; }
|
||||
.vt-app .vt-editbar-text .vt-hint { display: none; }
|
||||
}
|
||||
|
||||
@container vt (min-width: 700px) {
|
||||
.vt-app .vt-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-areas: "hero hero" "radios radios" "clients clients" "chart chart" "sys side";
|
||||
}
|
||||
.vt-app .vt-ov { display: none; }
|
||||
.vt-app.vt-editing .vt-ov {
|
||||
display: block; position: absolute; inset: 0; z-index: 4;
|
||||
border: 2px dashed var(--vt-accent-line); border-radius: var(--vt-r-lg);
|
||||
background: color-mix(in oklab, var(--vt-surface) 55%, transparent);
|
||||
}
|
||||
@container vt (min-width: 1100px) {
|
||||
.vt-app .vt-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-areas:
|
||||
"hero hero hero sys"
|
||||
"chart chart radios radios"
|
||||
"clients clients clients side";
|
||||
}
|
||||
.vt-app .vt-ov-bar {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px;
|
||||
margin: 8px; padding: 6px; border-radius: var(--vt-r-sm);
|
||||
background: var(--vt-surface); border: 1px solid var(--vt-line-2); box-shadow: var(--vt-shadow);
|
||||
}
|
||||
.vt-app .vt-ov-name { flex: 1 1 auto; min-width: 0; font-weight: 650; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vt-app .vt-ov-btns { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }
|
||||
.vt-app .vt-ov-grip, .vt-app .vt-ov-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; flex: none;
|
||||
width: 30px; height: 30px; min-width: 0; margin: 0; padding: 0; border: 0; border-radius: 8px;
|
||||
background: transparent; color: var(--vt-text-2); box-shadow: none; cursor: pointer;
|
||||
}
|
||||
.vt-app .vt-ov-grip { cursor: grab; touch-action: none; background: var(--vt-surface-3); color: var(--vt-text); }
|
||||
.vt-app .vt-ov-btn:hover:not([disabled]), .vt-app .vt-ov-grip:hover { background: var(--vt-surface-3); color: var(--vt-text); }
|
||||
.vt-app .vt-ov-btn[disabled] { opacity: .3; cursor: default; }
|
||||
.vt-app .vt-ov-btn .vt-ico, .vt-app .vt-ov-grip .vt-ico { width: 16px; height: 16px; }
|
||||
.vt-app .vt-ov-grip .vt-ico { stroke-width: 3.2; }
|
||||
.vt-app .vt-ov-flip .vt-ico { transform: scaleX(-1); }
|
||||
.vt-app .vt-ov-hide { margin-left: 4px; }
|
||||
.vt-app .vt-ov-size { min-width: 26px; text-align: center; font-size: 11.5px; font-weight: 650; color: var(--vt-text-2); }
|
||||
.vt-app.vt-edit-1col .vt-ov-col { display: none; }
|
||||
|
||||
.vt-app.vt-editing .vt-cell { transition: top .2s ease, left .2s ease; }
|
||||
.vt-app .vt-cell.vt-dragging { z-index: 8; transition: none; opacity: .92; box-shadow: 0 18px 40px rgba(0, 0, 0, .28); }
|
||||
.vt-app .vt-dragging .vt-ov-grip { cursor: grabbing; }
|
||||
.vt-app.vt-drag-on { user-select: none; -webkit-user-select: none; }
|
||||
|
||||
.vt-app .vt-tile {
|
||||
position: relative;
|
||||
@@ -429,6 +469,9 @@ html[data-darkmode="true"] .vt-app {
|
||||
}
|
||||
@container vt (min-width: 700px) { .vt-app .vt-health { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@container vt (min-width: 1100px) { .vt-app .vt-health { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
|
||||
/* wired-only: four checks */
|
||||
@container vt (min-width: 700px) { .vt-app.vt-wired .vt-health { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@container vt (min-width: 1100px) { .vt-app.vt-wired .vt-health { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||||
.vt-app .vt-chip {
|
||||
display: flex; align-items: flex-start; gap: 10px; min-width: 0;
|
||||
padding: 11px 12px; border-radius: var(--vt-r);
|
||||
@@ -538,6 +581,7 @@ html[data-darkmode="true"] .vt-app {
|
||||
.vt-app .vt-node-ap .vt-node-title { font-size: 14.5px; }
|
||||
.vt-app .vt-node-gen { text-transform: none; letter-spacing: 0; font-weight: 560; }
|
||||
.vt-app .vt-node-radio { padding: 8px 11px; }
|
||||
.vt-app .vt-node-net, .vt-app .vt-node-net:hover { text-decoration: none; color: var(--vt-text); }
|
||||
.vt-app .vt-node-ssid { padding: 7px 10px; }
|
||||
.vt-app .vt-node-ssid .vt-node-title { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.vt-app .vt-node-ssid .vt-node-title .vt-ico { color: var(--vt-text-3); }
|
||||
@@ -593,10 +637,12 @@ html[data-darkmode="true"] .vt-app {
|
||||
|
||||
/* ------------------------------------------------------------ 6. system */
|
||||
|
||||
.vt-app .vt-sys { display: flex; flex-direction: column; gap: 16px; }
|
||||
.vt-app .vt-sys { container: vtsys / inline-size; display: flex; flex-direction: column; gap: 16px; }
|
||||
.vt-app .vt-sys .vt-tile-head { margin-bottom: 0; }
|
||||
.vt-app .vt-sys-cpu { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 16px; align-items: end; }
|
||||
.vt-app .vt-sys-cpu-l { display: flex; flex-direction: column; gap: 3px; }
|
||||
/* a narrow System column puts the sparkline under the load figures */
|
||||
@container vtsys (max-width: 330px) { .vt-app .vt-sys-cpu { grid-template-columns: minmax(0, 1fr); gap: 4px; } }
|
||||
.vt-app .vt-sys-cpu-r { display: flex; align-items: flex-end; gap: 10px; min-width: 0; }
|
||||
.vt-app .vt-spark-box { flex: 1; min-width: 0; padding-top: 12px; }
|
||||
.vt-app .vt-spark-box .vt-spark { height: 38px; }
|
||||
@@ -645,16 +691,6 @@ html[data-darkmode="true"] .vt-app {
|
||||
.vt-app .vt-chart .vt-tile-title { flex-wrap: wrap; row-gap: 0; }
|
||||
.vt-app .vt-chart-sub { font-size: 12px; font-weight: 500; color: var(--vt-text-3); }
|
||||
.vt-app .vt-chart-sub::before { content: "·"; margin: 0 6px 0 0; }
|
||||
.vt-app .vt-seg-ctl { display: inline-flex; padding: 2px; gap: 2px; border-radius: 8px; background: var(--vt-surface-3); }
|
||||
.vt-app .vt-seg-btn {
|
||||
display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 10px;
|
||||
border: 0; border-radius: 6px; background: transparent; color: var(--vt-text-2);
|
||||
font-size: 12px; font-weight: 600; cursor: pointer; box-shadow: none; min-width: 0; text-transform: none;
|
||||
}
|
||||
.vt-app .vt-seg-btn .vt-ico { width: 13px; height: 13px; stroke-width: 2.2; }
|
||||
.vt-app .vt-seg-btn:hover { color: var(--vt-text); background: transparent; }
|
||||
.vt-app .vt-seg-btn[aria-pressed="true"] { background: var(--vt-surface); color: var(--vt-text); box-shadow: 0 1px 2px rgba(0, 0, 0, .12); }
|
||||
|
||||
.vt-app .vt-c-up { --vt-sc: var(--vt-series-up); }
|
||||
.vt-app .vt-c-2g { --vt-sc: var(--vt-b2); }
|
||||
.vt-app .vt-c-5g { --vt-sc: var(--vt-b5); }
|
||||
@@ -662,8 +698,8 @@ html[data-darkmode="true"] .vt-app {
|
||||
.vt-app .vt-c-x { --vt-sc: var(--vt-text-3); }
|
||||
.vt-app .vt-lg-key { width: 12px; height: 3px; border-radius: 2px; background: var(--vt-sc); flex: none; }
|
||||
|
||||
.vt-app .vt-mult-wrap { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 240px; border-radius: var(--vt-r-sm); outline-offset: 4px; }
|
||||
.vt-app .vt-mults { flex: 1; display: grid; grid-auto-rows: minmax(64px, 1fr); }
|
||||
.vt-app .vt-mult-wrap { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 300px; border-radius: var(--vt-r-sm); outline-offset: 4px; }
|
||||
.vt-app .vt-mults { flex: 1; display: grid; grid-auto-rows: minmax(100px, 1fr); }
|
||||
.vt-app .vt-mult {
|
||||
display: grid; grid-template-columns: var(--vt-mh) minmax(0, 1fr); column-gap: var(--vt-mg);
|
||||
padding: 8px 0; border-top: 1px solid var(--vt-line); min-height: 0;
|
||||
@@ -672,18 +708,25 @@ html[data-darkmode="true"] .vt-app {
|
||||
.vt-app .vt-mult-head { display: flex; flex-direction: column; justify-content: center; gap: 1px; min-width: 0; }
|
||||
.vt-app .vt-mult-name { display: flex; align-items: center; gap: 6px; font-size: 11.5px; font-weight: 600; color: var(--vt-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vt-app .vt-mult-now { font-size: 17px; font-weight: 700; letter-spacing: -.01em; line-height: 1.25; white-space: nowrap; }
|
||||
.vt-app .vt-mult-now-up { font-size: 13px; font-weight: 650; line-height: 1.3; color: var(--vt-text-2); white-space: nowrap; }
|
||||
.vt-app .vt-mult-stats { display: flex; flex-wrap: wrap; gap: 0 8px; font-size: 11px; color: var(--vt-text-3); white-space: nowrap; }
|
||||
.vt-app .vt-mult-stats b { font-weight: 600; color: var(--vt-text-2); }
|
||||
.vt-app .vt-mult-plot { position: relative; min-height: 48px; margin-top: 12px; border-bottom: 1px solid color-mix(in oklab, var(--vt-chart-axis) 40%, transparent); --vt-sc: inherit; }
|
||||
.vt-app .vt-mult-plot { position: relative; min-height: 72px; margin: 12px 0; --vt-sc: inherit; }
|
||||
.vt-app .vt-mult-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
.vt-app .vt-chart-grid { fill: none; stroke: var(--vt-chart-grid); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||
.vt-app .vt-chart-vgrid { fill: none; stroke: var(--vt-chart-grid); stroke-width: 1; stroke-dasharray: 2 4; vector-effect: non-scaling-stroke; }
|
||||
.vt-app .vt-series-line { fill: none; stroke: var(--vt-sc); stroke-width: 1.75; vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.vt-app .vt-series-area { stroke: none; }
|
||||
/* upload, below the line: the link's colour, quieter */
|
||||
.vt-app .vt-series-line.vt-dir-up { stroke-opacity: .6; }
|
||||
.vt-app .vt-series-area.vt-dir-up { opacity: .55; }
|
||||
.vt-app .vt-mult-dot.vt-dir-up, .vt-app .vt-sk-dot.vt-dir-up { opacity: .7; }
|
||||
.vt-app .vt-chart-mid { fill: none; stroke: color-mix(in oklab, var(--vt-chart-axis) 55%, transparent); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||
.vt-app .vt-mult-max {
|
||||
position: absolute; right: 0; top: -3px; transform: translateY(-100%);
|
||||
font-size: 10.5px; line-height: 1; color: var(--vt-chart-axis); white-space: nowrap; pointer-events: none;
|
||||
}
|
||||
.vt-app .vt-mult-max-up { top: auto; bottom: -3px; transform: translateY(100%); }
|
||||
.vt-app .vt-mult-dot {
|
||||
position: absolute; width: 9px; height: 9px; margin: -4.5px 0 0 -4.5px; border-radius: 50%; z-index: 2;
|
||||
background: var(--vt-sc); box-shadow: 0 0 0 2px var(--vt-surface); pointer-events: none;
|
||||
@@ -697,15 +740,16 @@ html[data-darkmode="true"] .vt-app {
|
||||
.vt-app .vt-cursor { position: absolute; top: 0; bottom: 0; width: 0; border-left: 1px solid color-mix(in oklab, var(--vt-text) 45%, transparent); pointer-events: none; }
|
||||
.vt-app .vt-tip {
|
||||
position: absolute; top: 4px; z-index: 4; transform: translateX(12px);
|
||||
min-width: 180px; padding: 8px 10px 7px; border-radius: var(--vt-r);
|
||||
width: max-content; min-width: 180px; padding: 8px 10px 7px; border-radius: var(--vt-r);
|
||||
background: var(--vt-surface); border: 1px solid var(--vt-line-2);
|
||||
box-shadow: 0 10px 28px rgba(12, 14, 17, .16), 0 2px 6px rgba(12, 14, 17, .06);
|
||||
font-size: 12px; pointer-events: none;
|
||||
}
|
||||
.vt-app .vt-tip-left { transform: translateX(calc(-100% - 12px)); }
|
||||
.vt-app .vt-tip-time { display: flex; justify-content: space-between; gap: 12px; color: var(--vt-text-3); font-size: 11px; margin-bottom: 5px; padding-bottom: 5px; border-bottom: 1px solid var(--vt-line); }
|
||||
.vt-app .vt-tip-row { display: grid; grid-template-columns: 12px auto minmax(0, 1fr); align-items: center; column-gap: 8px; line-height: 1.6; }
|
||||
.vt-app .vt-tip-row { display: grid; grid-template-columns: 12px auto auto minmax(0, 1fr); align-items: center; column-gap: 8px; line-height: 1.6; }
|
||||
.vt-app .vt-tip-row b { font-weight: 700; color: var(--vt-text); white-space: nowrap; }
|
||||
.vt-app .vt-tip-row .vt-tip-up { font-weight: 600; color: var(--vt-text-2); }
|
||||
.vt-app .vt-tip-label { color: var(--vt-text-3); white-space: nowrap; text-align: right; }
|
||||
|
||||
/* ------------------------------------------------------------ 8. radios */
|
||||
|
||||
@@ -32,6 +32,7 @@ return baseclass.extend({
|
||||
/* ------------------------------------------------------------ health */
|
||||
|
||||
/* model + { cpu: { pct, cores } | null, uplinkRate: { rx, tx } | null }
|
||||
(model.wired: no Wi-Fi checks)
|
||||
-> [ { id, level, title, value, reason, detail, page } ]
|
||||
value and reason are short enough for a health chip; detail is the
|
||||
full sentence (tooltip / accessible description). */
|
||||
@@ -79,7 +80,20 @@ return baseclass.extend({
|
||||
detail: _('%s of %s available (%s in use)').format(fmt.bytes(mem.available), fmt.bytes(mem.total), fmt.pct(mem.usedPct)), page: PAGES.processes });
|
||||
}
|
||||
|
||||
/* radios; an SSID without clients is normal, not a fault */
|
||||
/* radios and signal; neither applies to a wired-only device */
|
||||
if (!model.wired) this.wifiHealth(model, out);
|
||||
|
||||
/* storage */
|
||||
var root = (dev.storage || [])[0];
|
||||
if (root) out.push({ id: 'storage', level: root.usedPct >= 95 ? 'err' : root.usedPct >= 85 ? 'warn' : 'ok', title: _('Storage'), value: _('%s used').format(fmt.pct(root.usedPct)),
|
||||
reason: _('%s free').format(fmt.bytes(root.free)), detail: _('%s free on the overlay (%s used)').format(fmt.bytes(root.free), fmt.pct(root.usedPct)), page: PAGES.mounts });
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
/* the Wi-Fi and Signal checks, appended to out; an SSID without
|
||||
clients is normal, not a fault */
|
||||
wifiHealth: function(model, out) {
|
||||
var radios = model.radios || [], nSsid = (model.ssids || []).length;
|
||||
var off = radios.filter(function(r) { return r.disabled; }), down = radios.filter(function(r) { return !r.disabled && !r.up; });
|
||||
var idle = (model.ssids || []).filter(function(s) { return s.up && !s.clients; });
|
||||
@@ -102,13 +116,6 @@ return baseclass.extend({
|
||||
detail: _('%s at %s; below %s roaming and throughput suffer').format(weak[0].name, fmt.dbm(weak[0].signal), fmt.dbm(WEAK_DBM)), page: PAGES.wireless, client: weak[0].mac });
|
||||
else out.push({ id: 'signal', level: 'ok', title: _('Signal'), value: _('All good'), reason: _('All above %s').format(fmt.dbm(WEAK_DBM)),
|
||||
detail: _('All %d clients above %s').format(clients.length, fmt.dbm(WEAK_DBM)), page: PAGES.wireless });
|
||||
|
||||
/* storage */
|
||||
var root = (dev.storage || [])[0];
|
||||
if (root) out.push({ id: 'storage', level: root.usedPct >= 95 ? 'err' : root.usedPct >= 85 ? 'warn' : 'ok', title: _('Storage'), value: _('%s used').format(fmt.pct(root.usedPct)),
|
||||
reason: _('%s free').format(fmt.bytes(root.free)), detail: _('%s free on the overlay (%s used)').format(fmt.bytes(root.free), fmt.pct(root.usedPct)), page: PAGES.mounts });
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
/* -> { level, count (warn+err), headline } */
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* Dashboard layout: which tiles show, where and how wide, per screen
|
||||
class. The view measures the tiles and positions them; this module
|
||||
only holds the rules. Pure functions.
|
||||
|
||||
A layout is a list of { id, c, w, hide } in reading order: the tile
|
||||
starts in column c and spans w columns. Each tile goes below what
|
||||
earlier tiles left in its columns, so a column keeps its tiles
|
||||
whatever their heights, and a short tile never stretches a tall
|
||||
neighbour. */
|
||||
|
||||
var TILES = [ 'hero', 'chart', 'radios', 'clients', 'sys', 'talk', 'ssids' ];
|
||||
|
||||
/* tiles that only exist with Wi-Fi */
|
||||
var WIFI = { radios: true, clients: true, talk: true, ssids: true };
|
||||
|
||||
var COLS = { wide: 4, mid: 2, narrow: 1 };
|
||||
|
||||
/* narrowest each tile may get, in columns of that screen class */
|
||||
var MIN = {
|
||||
wide: { hero: 3, chart: 2, clients: 2 },
|
||||
mid: { hero: 2 }
|
||||
};
|
||||
|
||||
/* tiles whose content grows to fill a taller slot */
|
||||
var FLEX = { chart: true };
|
||||
|
||||
/* how far a tile stretches to line up with the tile below it */
|
||||
var SNAP = 48;
|
||||
|
||||
/* [ id, column, width ] */
|
||||
var DEFAULTS = {
|
||||
wide: [ [ 'hero', 0, 4 ], [ 'chart', 0, 2 ], [ 'radios', 2, 2 ], [ 'clients', 0, 3 ], [ 'sys', 3, 1 ], [ 'talk', 3, 1 ], [ 'ssids', 3, 1 ] ],
|
||||
mid: [ [ 'hero', 0, 2 ], [ 'radios', 0, 2 ], [ 'clients', 0, 2 ], [ 'chart', 0, 2 ], [ 'sys', 0, 1 ], [ 'talk', 1, 1 ], [ 'ssids', 1, 1 ] ],
|
||||
narrow: [ [ 'clients', 0, 1 ], [ 'talk', 0, 1 ], [ 'radios', 0, 1 ], [ 'hero', 0, 1 ], [ 'ssids', 0, 1 ], [ 'chart', 0, 1 ], [ 'sys', 0, 1 ] ]
|
||||
};
|
||||
var DEFAULTS_WIRED = {
|
||||
wide: [ [ 'hero', 0, 4 ], [ 'chart', 0, 3 ], [ 'sys', 3, 1 ] ],
|
||||
mid: [ [ 'hero', 0, 2 ], [ 'chart', 0, 2 ], [ 'sys', 0, 2 ] ],
|
||||
narrow: [ [ 'hero', 0, 1 ], [ 'chart', 0, 1 ], [ 'sys', 0, 1 ] ]
|
||||
};
|
||||
|
||||
function own(o, k) { return o != null && Object.prototype.hasOwnProperty.call(o, k); }
|
||||
function int(v, d) { v = Math.round(+v); return isFinite(v) ? v : d; }
|
||||
|
||||
return baseclass.extend({
|
||||
TILES: TILES,
|
||||
SNAP: SNAP,
|
||||
|
||||
/* screen class for a layout width; the breakpoints match app.css */
|
||||
tier: function(width) {
|
||||
return width >= 1100 ? 'wide' : width >= 700 ? 'mid' : 'narrow';
|
||||
},
|
||||
|
||||
cols: function(tier) { return own(COLS, tier) ? COLS[tier] : 1; },
|
||||
|
||||
/* the storage key of a layout: Wi-Fi and wired devices differ */
|
||||
key: function(tier, wired) { return tier + (wired ? '-wired' : ''); },
|
||||
|
||||
available: function(id, wired) { return TILES.indexOf(id) >= 0 && !(wired && WIFI[id]); },
|
||||
|
||||
minW: function(id, tier) {
|
||||
var m = own(MIN, tier) && own(MIN[tier], id) ? MIN[tier][id] : 1;
|
||||
return Math.min(m, this.cols(tier));
|
||||
},
|
||||
|
||||
/* a tile with its width in range and its column inside the grid */
|
||||
fit: function(e, tier) {
|
||||
var n = this.cols(tier);
|
||||
var w = Math.max(this.minW(e.id, tier), Math.min(n, int(e.w, 1)));
|
||||
var c = Math.max(0, Math.min(n - w, int(e.c, 0)));
|
||||
return { id: e.id, c: c, w: w, hide: e.hide === true };
|
||||
},
|
||||
|
||||
defaults: function(tier, wired) {
|
||||
var src = own(DEFAULTS, tier) ? (wired ? DEFAULTS_WIRED : DEFAULTS)[tier] : DEFAULTS.narrow;
|
||||
var self = this;
|
||||
return src.map(function(d) { return self.fit({ id: d[0], c: d[1], w: d[2] }, tier); });
|
||||
},
|
||||
|
||||
/* a stored layout made safe: known tiles only, each once, sizes in
|
||||
range, tiles it does not mention added after the tile that
|
||||
precedes them by default */
|
||||
normalize: function(saved, tier, wired) {
|
||||
var self = this, seen = Object.create(null), out = [];
|
||||
var def = this.defaults(tier, wired);
|
||||
if (!Array.isArray(saved)) return def;
|
||||
saved.slice(0, 32).forEach(function(e) {
|
||||
if (!e || typeof e !== 'object' || typeof e.id !== 'string' || seen[e.id] || !self.available(e.id, wired)) return;
|
||||
seen[e.id] = true;
|
||||
out.push(self.fit(e, tier));
|
||||
});
|
||||
if (!out.length) return def;
|
||||
def.forEach(function(d, i) {
|
||||
if (seen[d.id]) return;
|
||||
var at = 0;
|
||||
for (var j = i - 1; j >= 0; j--) {
|
||||
var k = self.indexOf(out, def[j].id);
|
||||
if (k >= 0) { at = k + 1; break; }
|
||||
}
|
||||
out.splice(at, 0, d);
|
||||
});
|
||||
if (!out.some(function(e) { return !e.hide; })) out[0].hide = false;
|
||||
return out;
|
||||
},
|
||||
|
||||
/* what is worth storing */
|
||||
compact: function(list) {
|
||||
return list.map(function(e) { var o = { id: e.id, c: e.c, w: e.w }; if (e.hide) o.hide = true; return o; });
|
||||
},
|
||||
|
||||
same: function(a, b) {
|
||||
return a.length === b.length && a.every(function(e, i) {
|
||||
return e.id === b[i].id && e.c === b[i].c && e.w === b[i].w && !!e.hide === !!b[i].hide;
|
||||
});
|
||||
},
|
||||
|
||||
indexOf: function(list, id) {
|
||||
for (var i = 0; i < list.length; i++) if (list[i].id === id) return i;
|
||||
return -1;
|
||||
},
|
||||
|
||||
copy: function(list) { return list.map(function(e) { return { id: e.id, c: e.c, w: e.w, hide: !!e.hide }; }); },
|
||||
|
||||
/* swap with the next shown tile before (-1) or after (+1) */
|
||||
move: function(list, id, dir) {
|
||||
var out = this.copy(list), i = this.indexOf(out, id);
|
||||
if (i < 0) return out;
|
||||
for (var j = i + dir; j >= 0 && j < out.length; j += dir) {
|
||||
if (out[j].hide) continue;
|
||||
var t = out.splice(i, 1)[0];
|
||||
out.splice(j, 0, t);
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/* one column left (-1) or right (+1) */
|
||||
shift: function(list, id, dc, tier) {
|
||||
var self = this;
|
||||
return this.copy(list).map(function(e) { return e.id === id ? self.fit({ id: e.id, c: e.c + dc, w: e.w, hide: e.hide }, tier) : e; });
|
||||
},
|
||||
|
||||
/* wider or narrower; a tile at the right edge grows to the left */
|
||||
resize: function(list, id, dw, tier) {
|
||||
var self = this, n = this.cols(tier);
|
||||
return this.copy(list).map(function(e) {
|
||||
if (e.id !== id) return e;
|
||||
var w = Math.max(self.minW(id, tier), Math.min(n, e.w + dw));
|
||||
return self.fit({ id: id, c: Math.min(e.c, n - w), w: w, hide: e.hide }, tier);
|
||||
});
|
||||
},
|
||||
|
||||
/* dragging: put a tile in column c, before or after another tile */
|
||||
drop: function(list, id, c, target, after, tier) {
|
||||
var out = this.copy(list), i = this.indexOf(out, id);
|
||||
if (i < 0) return out;
|
||||
var t = this.fit({ id: id, c: c, w: out[i].w, hide: out[i].hide }, tier);
|
||||
out.splice(i, 1);
|
||||
var j = target != null && target !== id ? this.indexOf(out, target) : -1;
|
||||
if (j < 0) out.splice(i, 0, t);
|
||||
else out.splice(after ? j + 1 : j, 0, t);
|
||||
return out;
|
||||
},
|
||||
|
||||
/* hiding keeps the tile's place, so showing it again restores it;
|
||||
the last shown tile cannot be hidden */
|
||||
setHidden: function(list, id, hide) {
|
||||
var out = this.copy(list), i = this.indexOf(out, id);
|
||||
if (i < 0) return out;
|
||||
if (hide && out.filter(function(e) { return !e.hide; }).length <= 1) return out;
|
||||
out[i].hide = !!hide;
|
||||
return out;
|
||||
},
|
||||
|
||||
/* a tile's height between polls: it grows at once but shrinks only
|
||||
after its content stayed smaller for HOLD_MS, so a talker or a
|
||||
client dropping out for one poll doesn't move the page. prev is
|
||||
the last { h, since } (or null); returns the next one */
|
||||
HOLD_MS: 30000,
|
||||
settle: function(prev, natural, now) {
|
||||
if (!prev || natural >= prev.h) return { h: natural, since: null };
|
||||
var since = prev.since == null ? now : prev.since;
|
||||
if (now - since >= this.HOLD_MS) return { h: natural, since: null };
|
||||
return { h: prev.h, since: since };
|
||||
},
|
||||
|
||||
/* where shown tiles go. items: [{ id, c, w, h }] in order, h measured;
|
||||
returns { rects: { id: { c, w, top, h } }, height }. A tile that
|
||||
ends a little above the next one in its columns stretches to meet
|
||||
it (any amount for FLEX tiles), so neighbours line up. */
|
||||
pack: function(items, cols, gap) {
|
||||
var bottoms = [], last = [], rects = Object.create(null), c, i;
|
||||
for (c = 0; c < cols; c++) { bottoms.push(0); last.push(null); }
|
||||
|
||||
/* only a tile with nothing under it in any of its columns, or it
|
||||
would grow over that tile */
|
||||
function stretch(col, top) {
|
||||
var id = last[col], r = id && rects[id];
|
||||
if (!r) return;
|
||||
var g = top - bottoms[col];
|
||||
if (g <= 0 || !(FLEX[id] || g <= SNAP)) return;
|
||||
for (var j = r.c; j < r.c + r.w; j++) if (last[j] !== id) return;
|
||||
r.h += g;
|
||||
for (var k = r.c; k < r.c + r.w; k++)
|
||||
if (last[k] === id) bottoms[k] = r.top + r.h + gap;
|
||||
}
|
||||
|
||||
items.forEach(function(it) {
|
||||
var w = Math.max(1, Math.min(cols, it.w | 0)), at = Math.max(0, Math.min(cols - w, it.c | 0)), top = 0;
|
||||
for (i = at; i < at + w; i++) top = Math.max(top, bottoms[i]);
|
||||
for (i = at; i < at + w; i++) stretch(i, top);
|
||||
rects[it.id] = { c: at, w: w, top: top, h: Math.max(0, +it.h || 0) };
|
||||
for (i = at; i < at + w; i++) { bottoms[i] = top + rects[it.id].h + gap; last[i] = it.id; }
|
||||
});
|
||||
|
||||
var end = Math.max.apply(null, bottoms);
|
||||
for (c = 0; c < cols; c++) stretch(c, end);
|
||||
end = Math.max.apply(null, bottoms);
|
||||
return { rects: rects, height: Math.max(0, end - gap) };
|
||||
}
|
||||
});
|
||||
@@ -227,6 +227,34 @@ return baseclass.extend({
|
||||
};
|
||||
},
|
||||
|
||||
/* The device's other networks (lan, guest, a VPN, ...): every
|
||||
interface except loopback and the uplink (wan6 shares wan's device).
|
||||
One entry per device, by interface name.
|
||||
-> [ { name, proto, dev, up, ipv4 } ] */
|
||||
networks: function(ifaces, uplink) {
|
||||
var seen = Object.create(null);
|
||||
return arr(ifaces).filter(function(i) {
|
||||
return i && typeof i.interface === 'string' && i.interface !== 'loopback';
|
||||
}).sort(function(a, b) {
|
||||
return a.interface < b.interface ? -1 : a.interface > b.interface ? 1 : 0;
|
||||
}).map(function(i) {
|
||||
var dev = i.l3_device || i.device;
|
||||
var a4 = arr(i['ipv4-address']).filter(function(a) { return a && a.address; })[0];
|
||||
return {
|
||||
name: fmt.safeText(i.interface, 32),
|
||||
proto: typeof i.proto === 'string' ? fmt.safeText(i.proto, 24) : null,
|
||||
dev: typeof dev === 'string' && dev ? dev : null,
|
||||
up: !!i.up,
|
||||
ipv4: a4 ? a4.address + '/' + a4.mask : null
|
||||
};
|
||||
}).filter(function(n) {
|
||||
if (uplink && (n.name === uplink.iface || (n.dev && n.dev === uplink.l3dev))) return false;
|
||||
if (n.dev && seen[n.dev]) return false;
|
||||
if (n.dev) seen[n.dev] = true;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
/* The device tree model is sometimes the SoC reference design
|
||||
("Qualcomm Technologies, Inc. IPQ5332/RDP442/..."); board_name
|
||||
("zyxel,nwa50be") names the actual product then. */
|
||||
@@ -463,6 +491,7 @@ return baseclass.extend({
|
||||
storage: [ this.mount(info.root, _('Root (overlay)')), this.mount(info.tmp, _('Temporary (RAM)')) ].filter(Boolean)
|
||||
},
|
||||
uplink: uplink,
|
||||
networks: this.networks(raw.ifaces, uplink),
|
||||
radios: radios,
|
||||
ssids: ssids,
|
||||
clients: clients
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
'require vantage.model as model';
|
||||
'require vantage.insight as insight';
|
||||
'require vantage.series as series';
|
||||
'require vantage.layout as layout';
|
||||
|
||||
/* Vantage dashboard (luci-app-vantage): a live, read-only overview of the
|
||||
device, its uplink, radios, SSIDs and stations. The only write is the
|
||||
@@ -104,7 +105,13 @@ var ICONS = {
|
||||
watch: [ 'M8.5 6h7A1.5 1.5 0 0 1 17 7.5v9a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 7 16.5v-9A1.5 1.5 0 0 1 8.5 6Z', 'M9 6l.5-3h5l.5 3M9 18l.5 3h5l.5-3' ],
|
||||
iot: [ 'M9 18h6', 'M10 21h4', 'M12 3a6 6 0 0 0-3.5 10.9V16h7v-2.1A6 6 0 0 0 12 3Z' ],
|
||||
router: [ 'M4.5 13h15a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1Z', 'M7 16h.01M10 16h.01', 'M8 13 6 6.5M16 13l2-6.5' ],
|
||||
device: [ 'M7 3.5h10A1.5 1.5 0 0 1 18.5 5v14a1.5 1.5 0 0 1-1.5 1.5H7A1.5 1.5 0 0 1 5.5 19V5A1.5 1.5 0 0 1 7 3.5Z', 'M10 17.5h4', 'M9 7.5h6' ]
|
||||
device: [ 'M7 3.5h10A1.5 1.5 0 0 1 18.5 5v14a1.5 1.5 0 0 1-1.5 1.5H7A1.5 1.5 0 0 1 5.5 19V5A1.5 1.5 0 0 1 7 3.5Z', 'M10 17.5h4', 'M9 7.5h6' ],
|
||||
layout: [ 'M4.5 4.5h6v7h-6zM13.5 4.5h6v4h-6zM13.5 11.5h6v8h-6zM4.5 14.5h6v5h-6z' ],
|
||||
grip: [ 'M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01' ],
|
||||
left: [ 'M14.5 6l-6 6 6 6' ],
|
||||
plus: [ 'M12 5.5v13M5.5 12h13' ],
|
||||
minus: [ 'M5.5 12h13' ],
|
||||
hide: [ 'M3.5 12s3-6 8.5-6 8.5 6 8.5 6-3 6-8.5 6-8.5-6-8.5-6Z', 'M12 9.5a2.5 2.5 0 1 0 0 5a2.5 2.5 0 1 0 0-5Z', 'M4.5 19.5l15-15' ]
|
||||
};
|
||||
|
||||
function svgRoot(cls, viewBox) {
|
||||
@@ -150,16 +157,17 @@ function signalGlyph(sig) {
|
||||
}
|
||||
|
||||
/* vertical fade for area fills: the series colour (--vt-sc) at the
|
||||
chart fill opacity at the top, transparent at the baseline. Returns the
|
||||
paint reference for the fill attribute. */
|
||||
chart fill opacity at the top, transparent at the baseline (flip: an
|
||||
area hanging down from its baseline). Returns the paint reference for
|
||||
the fill attribute. */
|
||||
var gradSeq = 0;
|
||||
function fadeFill(svg) {
|
||||
function fadeFill(svg, flip) {
|
||||
var id = 'vt-fade-' + (++gradSeq);
|
||||
var defs = document.createElementNS(SVGNS, 'defs');
|
||||
var lg = document.createElementNS(SVGNS, 'linearGradient');
|
||||
lg.setAttribute('id', id);
|
||||
lg.setAttribute('x1', '0'); lg.setAttribute('y1', '0');
|
||||
lg.setAttribute('x2', '0'); lg.setAttribute('y2', '1');
|
||||
lg.setAttribute('x1', '0'); lg.setAttribute('y1', flip ? '1' : '0');
|
||||
lg.setAttribute('x2', '0'); lg.setAttribute('y2', flip ? '0' : '1');
|
||||
[ [ '0', 'vt-fade-top' ], [ '1', 'vt-fade-end' ] ].forEach(function(st) {
|
||||
var stop = document.createElementNS(SVGNS, 'stop');
|
||||
stop.setAttribute('offset', st[0]);
|
||||
@@ -265,6 +273,14 @@ function kv(label, value, mono) {
|
||||
]);
|
||||
}
|
||||
|
||||
/* one of the four numbers under the network path */
|
||||
function kpi(label, value, sub, tone, onclick) {
|
||||
var body = [ E('span', { 'class': 'vt-kpi-label' }, [ label ]), E('span', { 'class': 'vt-kpi-value vt-tn' }, [ value ]), E('span', { 'class': 'vt-kpi-sub' }, [ sub ]) ];
|
||||
return onclick
|
||||
? E('button', { 'type': 'button', 'class': 'vt-kpi vt-kpi-' + (tone || 'plain'), 'click': onclick }, body)
|
||||
: E('div', { 'class': 'vt-kpi vt-kpi-' + (tone || 'plain') }, body);
|
||||
}
|
||||
|
||||
function tileHead(ic, title, extra) {
|
||||
return E('header', { 'class': 'vt-tile-head' }, [
|
||||
E('h3', { 'class': 'vt-tile-title' }, [ icon(ic), E('span', {}, [ title ]) ]),
|
||||
@@ -320,6 +336,26 @@ function writeFolds(v) {
|
||||
try { window.localStorage.setItem(FOLD_KEY, JSON.stringify(v)); } catch (e) {}
|
||||
}
|
||||
|
||||
/* Tile layouts the viewer arranged, per screen class, in this browser;
|
||||
vantage.layout validates whatever comes back */
|
||||
var LAYOUT_KEY = 'vantage.layout.v1';
|
||||
|
||||
function readLayouts() {
|
||||
var v = null;
|
||||
try { v = JSON.parse(window.localStorage.getItem(LAYOUT_KEY) || 'null'); } catch (e) { v = null; }
|
||||
var out = Object.create(null);
|
||||
if (v && typeof v === 'object' && !Array.isArray(v))
|
||||
Object.keys(v).forEach(function(k) { if (/^(wide|mid|narrow)(-wired)?$/.test(k) && Array.isArray(v[k])) out[k] = v[k]; });
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeLayouts(v) {
|
||||
try {
|
||||
if (Object.keys(v).length) window.localStorage.setItem(LAYOUT_KEY, JSON.stringify(v));
|
||||
else window.localStorage.removeItem(LAYOUT_KEY);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/* ================================================================= view */
|
||||
|
||||
return view.extend({
|
||||
@@ -361,12 +397,13 @@ return view.extend({
|
||||
L.resolveDefault(callRead('/proc/stat'), ''),
|
||||
L.resolveDefault(callIfDump(), []),
|
||||
L.resolveDefault(callDevs(), {}),
|
||||
L.resolveDefault(callWifi(), {}),
|
||||
/* null when the call fails: no answer is not "no radios" */
|
||||
L.resolveDefault(callWifi(), null),
|
||||
L.resolveDefault(callHints(), {})
|
||||
]).then(function(r) {
|
||||
var wifiDevs = (r[4] && typeof r[4] === 'object') ? r[4] : {};
|
||||
var wifiDevs = (r[4] && typeof r[4] === 'object') ? r[4] : null;
|
||||
var ifnames = [];
|
||||
Object.keys(wifiDevs).forEach(function(rn) {
|
||||
Object.keys(wifiDevs || {}).forEach(function(rn) {
|
||||
var ifs = (wifiDevs[rn] && Array.isArray(wifiDevs[rn].interfaces)) ? wifiDevs[rn].interfaces : [];
|
||||
ifs.forEach(function(i) {
|
||||
if (i && typeof i.ifname === 'string' && HOSTAPD_OBJ.test(i.ifname) && ifnames.indexOf(i.ifname) < 0) ifnames.push(i.ifname);
|
||||
@@ -423,12 +460,16 @@ return view.extend({
|
||||
rdns: Object.create(null), rdnsBusy: false, rdnsOff: !data.canRdns,
|
||||
mdns: Object.create(null), mdnsOff: true, mdnsAt: 0,
|
||||
oui: null, ouiLoading: false,
|
||||
wireless: null,
|
||||
lastOk: 0, lastErr: null, paused: false
|
||||
};
|
||||
this.ui = { sort: 'live', dir: -1, filter: '', chip: 'all', chart: 'down', drawer: null, editing: null, hover: null };
|
||||
this.ui = { sort: 'live', dir: -1, filter: '', chip: 'all', drawer: null, editing: null, hover: null };
|
||||
this.folds = readFolds();
|
||||
this.foldEls = {};
|
||||
this.foldBtns = {};
|
||||
this.layouts = readLayouts();
|
||||
this.editing = false;
|
||||
this.drag = null;
|
||||
this.applyMdns(data.mdns);
|
||||
this.aliases = this.aliases || {};
|
||||
},
|
||||
@@ -461,6 +502,7 @@ return view.extend({
|
||||
aliases: this.aliases, rdns: st.rdns, mdns: st.mdns,
|
||||
vendor: st.oui ? function(k) { return k ? st.oui.lookup(k) : null; } : null
|
||||
});
|
||||
this.markWired(m, raw);
|
||||
if (m.device.memory) series.push(st.hist.mem, [ at, m.device.memory.usedPct ], WINDOW_MS, HIST_MAX);
|
||||
|
||||
/* interface counters */
|
||||
@@ -530,6 +572,18 @@ return view.extend({
|
||||
return m;
|
||||
},
|
||||
|
||||
/* Wired-only: the wireless status answered and lists no radios. A
|
||||
failed call proves nothing, and radios seen once keep the Wi-Fi
|
||||
layout for the session, so one bad poll cannot reshuffle the page. */
|
||||
markWired: function(m, raw) {
|
||||
var st = this.st;
|
||||
if (raw && raw.wifi) {
|
||||
if (m.radios.length) st.wireless = true;
|
||||
else if (st.wireless !== true) st.wireless = false;
|
||||
}
|
||||
m.wired = st.wireless === false;
|
||||
},
|
||||
|
||||
/* The generation shown is the best standard a station used this
|
||||
session: single frames (management, retries) go out at legacy
|
||||
rates and would make the badge flicker. */
|
||||
@@ -592,6 +646,7 @@ return view.extend({
|
||||
aliases: this.aliases, rdns: st.rdns, mdns: st.mdns,
|
||||
vendor: st.oui ? function(k) { return k ? st.oui.lookup(k) : null; } : null
|
||||
});
|
||||
this.markWired(m, null);
|
||||
this.stickyGen(m);
|
||||
m.clients.forEach(function(c) { c.exp = insight.experience(c); c.firstSeen = st.presence && st.presence.firstSeen[c.mac]; c.isNew = !!st.isNew[c.mac]; });
|
||||
st.talkers = insight.topTalkers(m.clients, st.staRates, 5);
|
||||
@@ -631,14 +686,8 @@ return view.extend({
|
||||
E('div', { 'class': 'vt-body' }, [
|
||||
this.buildHead(),
|
||||
this.el.health = E('div', { 'class': 'vt-health', 'role': 'list', 'aria-label': _('Health checks') }),
|
||||
E('div', { 'class': 'vt-grid' }, [
|
||||
this.buildHero(),
|
||||
this.buildSystem(),
|
||||
this.buildChart(),
|
||||
this.buildRadios(),
|
||||
this.buildClients(),
|
||||
E('div', { 'class': 'vt-side' }, [ this.buildTalkers(), this.buildSsids() ])
|
||||
])
|
||||
this.buildEditBar(),
|
||||
this.el.grid = E('div', { 'class': 'vt-grid vt-grid-wait' }, this.buildTiles())
|
||||
]),
|
||||
this.buildDrawer()
|
||||
]);
|
||||
@@ -654,12 +703,19 @@ return view.extend({
|
||||
document.addEventListener('keydown', L.bind(this.handleKey, this));
|
||||
window.setInterval(function() { self.paintLive(); }, 1000);
|
||||
if (window.ResizeObserver) new ResizeObserver(function() { self.scheduleWires(); }).observe(this.el.path);
|
||||
if (window.ResizeObserver) {
|
||||
var ro = new ResizeObserver(function() { self.scheduleLayout(); });
|
||||
ro.observe(this.el.grid);
|
||||
layout.TILES.forEach(function(id) { ro.observe(self.tiles[id]); });
|
||||
}
|
||||
window.addEventListener('resize', function() { self.scheduleLayout(); });
|
||||
window.addEventListener('hashchange', function() { self.openFromHash(); });
|
||||
window.requestAnimationFrame(function() { self.drawWires(); self.openFromHash(); });
|
||||
return root;
|
||||
},
|
||||
|
||||
paint: function() {
|
||||
this.el.root.classList.toggle('vt-wired', !!this.m.wired);
|
||||
this.paintHead();
|
||||
this.paintHealth();
|
||||
this.paintHero();
|
||||
@@ -672,6 +728,7 @@ return view.extend({
|
||||
this.paintDrawer();
|
||||
this.paintLive();
|
||||
this.scheduleWires();
|
||||
this.scheduleLayout();
|
||||
},
|
||||
|
||||
/* ------------------------------------------------------------ folds */
|
||||
@@ -701,10 +758,338 @@ return view.extend({
|
||||
this.folds[key] = !this.folds[key];
|
||||
writeFolds(this.folds);
|
||||
this.applyFold(key);
|
||||
if (this.held) delete this.held[key];
|
||||
if (key === 'hero') this.scheduleWires();
|
||||
if (key === 'chart') this.paintChart();
|
||||
},
|
||||
|
||||
/* ----------------------------------------------------------- layout */
|
||||
|
||||
/* the tiles in a fixed set; vantage.layout decides order and size */
|
||||
buildTiles: function() {
|
||||
var self = this;
|
||||
this.tiles = {
|
||||
hero: this.buildHero(), chart: this.buildChart(), radios: this.buildRadios(), clients: this.buildClients(),
|
||||
sys: this.buildSystem(), talk: this.buildTalkers(), ssids: this.buildSsids()
|
||||
};
|
||||
this.tileNames = {
|
||||
hero: _('Network path'), chart: _('Throughput'), radios: _('Radios'), clients: _('Clients'),
|
||||
sys: _('System'), talk: _('Top talkers'), ssids: _('Wireless networks')
|
||||
};
|
||||
this.ov = {};
|
||||
this.el.ghost = E('div', { 'class': 'vt-drop-ghost', 'aria-hidden': 'true' });
|
||||
return [ this.el.ghost ].concat(layout.TILES.map(function(id) {
|
||||
var t = self.tiles[id];
|
||||
t.classList.add('vt-cell');
|
||||
t.appendChild(self.buildOverlay(id));
|
||||
return t;
|
||||
}));
|
||||
},
|
||||
|
||||
/* the stored layout for this screen class, or the default */
|
||||
layoutFor: function(tier, wired) {
|
||||
var k = layout.key(tier, wired);
|
||||
return layout.normalize(this.layouts[k] || null, tier, wired);
|
||||
},
|
||||
|
||||
scheduleLayout: function() {
|
||||
if (this.layoutRaf || !this.el || !this.el.grid) return;
|
||||
var self = this;
|
||||
this.layoutRaf = window.requestAnimationFrame(function() { self.layoutRaf = null; self.doLayout(); });
|
||||
},
|
||||
|
||||
/* measure the shown tiles at their widths, pack them, place them;
|
||||
widths and columns come from CSS (--c, --w, --cols), only the
|
||||
vertical positions from here */
|
||||
doLayout: function() {
|
||||
var self = this, grid = this.el.grid, tiles = this.tiles;
|
||||
var width = grid.clientWidth;
|
||||
if (!width || !this.m) return;
|
||||
var tier = layout.tier(width), cols = layout.cols(tier), wired = !!this.m.wired;
|
||||
var list = this.drag ? this.drag.list : this.layoutFor(tier, wired);
|
||||
var gap = parseFloat(window.getComputedStyle(grid).getPropertyValue('--vt-gap')) || 14;
|
||||
var shown = list.filter(function(e) { return !e.hide; });
|
||||
var on = Object.create(null);
|
||||
shown.forEach(function(e) { on[e.id] = true; });
|
||||
|
||||
/* keep what the viewer is reading in place when tiles above change */
|
||||
var sc = document.scrollingElement || document.documentElement, anchor = null, before = 0;
|
||||
if (sc.scrollTop > 0 && !this.drag) {
|
||||
for (var i = 0; i < shown.length && !anchor; i++) {
|
||||
var r0 = tiles[shown[i].id].getBoundingClientRect();
|
||||
if (r0.height && r0.bottom > 0) { anchor = tiles[shown[i].id]; before = r0.top; }
|
||||
}
|
||||
}
|
||||
|
||||
grid.style.setProperty('--cols', cols.toFixed(0));
|
||||
layout.TILES.forEach(function(id) { tiles[id].classList.toggle('vt-cell-off', !on[id]); });
|
||||
shown.forEach(function(e) {
|
||||
var t = tiles[e.id];
|
||||
t.style.setProperty('--c', Math.min(e.c, cols - 1).toFixed(0));
|
||||
t.style.setProperty('--w', Math.min(e.w, cols).toFixed(0));
|
||||
t.style.minHeight = '';
|
||||
});
|
||||
/* live content changes hold a tile's height for a while; a new
|
||||
width or an edit shows true sizes at once */
|
||||
if (this.heldWidth !== width || this.drag) { this.held = Object.create(null); this.heldWidth = width; }
|
||||
var held = this.held, now = Date.now();
|
||||
var items = shown.map(function(e) {
|
||||
var nat = tiles[e.id].getBoundingClientRect().height;
|
||||
held[e.id] = layout.settle(held[e.id], nat, now);
|
||||
return { id: e.id, c: e.c, w: e.w, h: held[e.id].h, nat: nat };
|
||||
});
|
||||
var p = layout.pack(items, cols, gap);
|
||||
items.forEach(function(it) {
|
||||
var r = p.rects[it.id], t = tiles[it.id];
|
||||
if (r.h > it.nat + 0.5) t.style.minHeight = r.h.toFixed(1) + 'px';
|
||||
t.style.setProperty('--y', r.top.toFixed(1) + 'px');
|
||||
});
|
||||
grid.style.height = p.height.toFixed(1) + 'px';
|
||||
var g = this.drag && p.rects[this.drag.id], ghost = this.el.ghost;
|
||||
ghost.classList.toggle('vt-on', !!g);
|
||||
if (g) {
|
||||
ghost.style.setProperty('--c', g.c.toFixed(0));
|
||||
ghost.style.setProperty('--w', g.w.toFixed(0));
|
||||
ghost.style.setProperty('--y', g.top.toFixed(1) + 'px');
|
||||
ghost.style.height = g.h.toFixed(1) + 'px';
|
||||
}
|
||||
this.lay = { tier: tier, cols: cols, wired: wired, list: list, rects: p.rects, gap: gap, width: width };
|
||||
|
||||
/* reading and tab order follow the layout; not while dragging,
|
||||
moving the dragged tile would end the drag */
|
||||
if (!this.drag) {
|
||||
var want = shown.map(function(e) { return tiles[e.id]; });
|
||||
var have = Array.prototype.filter.call(grid.children, function(c) { return c.classList.contains('vt-cell') && !c.classList.contains('vt-cell-off'); });
|
||||
if (want.some(function(t, k) { return have[k] !== t; })) {
|
||||
var focus = document.activeElement;
|
||||
want.forEach(function(t) { grid.appendChild(t); });
|
||||
if (focus && focus !== document.activeElement && grid.contains(focus)) focus.focus();
|
||||
}
|
||||
}
|
||||
if (anchor) {
|
||||
var d = anchor.getBoundingClientRect().top - before;
|
||||
if (Math.abs(d) > 0.5) sc.scrollTop += d;
|
||||
}
|
||||
grid.classList.remove('vt-grid-wait');
|
||||
if (this.editing) this.paintEdit();
|
||||
},
|
||||
|
||||
setLayout: function(list) {
|
||||
var l = this.lay;
|
||||
if (!l) return;
|
||||
var k = layout.key(l.tier, l.wired);
|
||||
if (layout.same(list, layout.defaults(l.tier, l.wired))) delete this.layouts[k];
|
||||
else this.layouts[k] = layout.compact(list);
|
||||
writeLayouts(this.layouts);
|
||||
this.held = Object.create(null);
|
||||
this.doLayout();
|
||||
},
|
||||
|
||||
editTile: function(id, fn, say) {
|
||||
var l = this.lay;
|
||||
if (!l) return;
|
||||
var next = fn(l.list);
|
||||
if (layout.same(next, l.list)) return;
|
||||
this.setLayout(next);
|
||||
if (say) this.announce(say);
|
||||
},
|
||||
|
||||
announce: function(text) {
|
||||
var s = this.el.editSay;
|
||||
dom.content(s, []);
|
||||
window.setTimeout(function() { txt(s, text); }, 30);
|
||||
},
|
||||
|
||||
setEditing: function(on) {
|
||||
this.editing = !!on;
|
||||
this.el.root.classList.toggle('vt-editing', this.editing);
|
||||
this.el.layoutBtn.setAttribute('aria-pressed', String(this.editing));
|
||||
this.el.editBar.hidden = !this.editing;
|
||||
if (this.editing) {
|
||||
this.doLayout();
|
||||
this.el.editDone.focus();
|
||||
}
|
||||
else this.el.layoutBtn.focus();
|
||||
},
|
||||
|
||||
buildEditBar: function() {
|
||||
var self = this, el = this.el;
|
||||
el.editHint = E('span', { 'class': 'vt-hint' });
|
||||
el.editHidden = E('div', { 'class': 'vt-edit-hidden' });
|
||||
el.editSay = E('span', { 'class': 'vt-sr', 'role': 'status', 'aria-live': 'polite' });
|
||||
el.editBar = E('div', { 'class': 'vt-editbar', 'role': 'region', 'aria-label': _('Arrange the dashboard'), 'hidden': 'hidden' }, [
|
||||
E('div', { 'class': 'vt-editbar-text' }, [ E('strong', {}, [ _('Arrange the dashboard') ]), el.editHint ]),
|
||||
el.editHidden,
|
||||
E('div', { 'class': 'vt-editbar-actions' }, [
|
||||
E('button', { 'type': 'button', 'class': 'vt-btn', 'click': function() {
|
||||
var l = self.lay;
|
||||
if (!l) return;
|
||||
delete self.layouts[layout.key(l.tier, l.wired)];
|
||||
writeLayouts(self.layouts);
|
||||
self.held = Object.create(null);
|
||||
self.doLayout();
|
||||
self.announce(_('Layout reset'));
|
||||
} }, [ _('Reset') ]),
|
||||
el.editDone = E('button', { 'type': 'button', 'class': 'vt-btn vt-btn-primary', 'click': function() { self.setEditing(false); } }, [ _('Done') ])
|
||||
]),
|
||||
el.editSay
|
||||
]);
|
||||
return el.editBar;
|
||||
},
|
||||
|
||||
/* the controls laid over a tile while arranging */
|
||||
buildOverlay: function(id) {
|
||||
var self = this, name = this.tileNames[id], o = {};
|
||||
function btn(cls, ic, label, fn) {
|
||||
return E('button', { 'type': 'button', 'class': 'vt-ov-btn ' + cls, 'aria-label': label, 'title': label, 'click': fn }, [ icon(ic) ]);
|
||||
}
|
||||
o.grip = E('button', { 'type': 'button', 'class': 'vt-ov-grip', 'aria-label': _('Move %s: drag, or use the arrow keys').format(name), 'title': _('Drag to move') }, [ icon('grip') ]);
|
||||
o.grip.addEventListener('pointerdown', function(ev) { self.dragStart(id, ev); });
|
||||
o.grip.addEventListener('keydown', function(ev) {
|
||||
var k = ev.key, act = k === 'ArrowLeft' ? o.left : k === 'ArrowRight' ? o.right : k === 'ArrowUp' ? o.up : k === 'ArrowDown' ? o.down : null;
|
||||
if (!act) return;
|
||||
ev.preventDefault();
|
||||
if (!act.disabled) act.click();
|
||||
});
|
||||
o.left = btn('vt-ov-col', 'left', _('Move %s left').format(name), function() {
|
||||
self.editTile(id, function(l) { return layout.shift(l, id, -1, self.lay.tier); }, _('%s moved left').format(name));
|
||||
});
|
||||
o.right = btn('vt-ov-col vt-ov-flip', 'left', _('Move %s right').format(name), function() {
|
||||
self.editTile(id, function(l) { return layout.shift(l, id, 1, self.lay.tier); }, _('%s moved right').format(name));
|
||||
});
|
||||
o.up = btn('', 'up', _('Move %s up').format(name), function() {
|
||||
self.editTile(id, function(l) { return layout.move(l, id, -1); }, _('%s moved up').format(name));
|
||||
});
|
||||
o.down = btn('', 'down', _('Move %s down').format(name), function() {
|
||||
self.editTile(id, function(l) { return layout.move(l, id, 1); }, _('%s moved down').format(name));
|
||||
});
|
||||
o.narrow = btn('vt-ov-col', 'minus', _('Make %s narrower').format(name), function() {
|
||||
self.editTile(id, function(l) { return layout.resize(l, id, -1, self.lay.tier); }, _('%s is narrower').format(name));
|
||||
});
|
||||
o.wide = btn('vt-ov-col', 'plus', _('Make %s wider').format(name), function() {
|
||||
self.editTile(id, function(l) { return layout.resize(l, id, 1, self.lay.tier); }, _('%s is wider').format(name));
|
||||
});
|
||||
o.size = E('span', { 'class': 'vt-ov-size vt-ov-col vt-tn' });
|
||||
o.hide = btn('vt-ov-hide', 'hide', _('Hide %s').format(name), function() {
|
||||
self.editTile(id, function(l) { return layout.setHidden(l, id, true); }, _('%s hidden').format(name));
|
||||
self.el.editDone.focus();
|
||||
});
|
||||
o.root = E('div', { 'class': 'vt-ov vt-fold-keep', 'role': 'group', 'aria-label': name }, [
|
||||
E('div', { 'class': 'vt-ov-bar' }, [
|
||||
o.grip,
|
||||
E('span', { 'class': 'vt-ov-name' }, [ name ]),
|
||||
E('span', { 'class': 'vt-ov-btns' }, [ o.up, o.down, o.left, o.right, o.narrow, o.size, o.wide, o.hide ])
|
||||
])
|
||||
]);
|
||||
this.ov[id] = o;
|
||||
return o.root;
|
||||
},
|
||||
|
||||
/* button states, sizes and the hidden-tile list for the current layout */
|
||||
paintEdit: function() {
|
||||
var self = this, l = this.lay;
|
||||
if (!l) return;
|
||||
var shown = l.list.filter(function(e) { return !e.hide; });
|
||||
txt(this.el.editHint, l.tier === 'wide' ? _('Drag a tile by its handle or use its buttons. Saved in this browser for wide screens.')
|
||||
: l.tier === 'mid' ? _('Drag a tile by its handle or use its buttons. Saved in this browser for medium screens.')
|
||||
: _('Drag a tile by its handle or use its buttons. Saved in this browser for narrow screens.'));
|
||||
this.el.root.classList.toggle('vt-edit-1col', l.cols === 1);
|
||||
shown.forEach(function(e, i) {
|
||||
var o = self.ov[e.id];
|
||||
o.up.disabled = i === 0;
|
||||
o.down.disabled = i === shown.length - 1;
|
||||
o.left.disabled = e.c <= 0;
|
||||
o.right.disabled = e.c + e.w >= l.cols;
|
||||
o.narrow.disabled = e.w <= layout.minW(e.id, l.tier);
|
||||
o.wide.disabled = e.w >= l.cols;
|
||||
o.hide.disabled = shown.length <= 1;
|
||||
txt(o.size, e.w + '/' + l.cols);
|
||||
o.size.setAttribute('title', _('%d of %d columns').format(e.w, l.cols));
|
||||
});
|
||||
var hidden = l.list.filter(function(e) { return e.hide; });
|
||||
dom.content(this.el.editHidden, hidden.length ? [ E('span', { 'class': 'vt-label' }, [ _('Hidden') ]) ].concat(hidden.map(function(e) {
|
||||
var name = self.tileNames[e.id];
|
||||
return E('button', { 'type': 'button', 'class': 'vt-fchip', 'title': _('Show %s').format(name), 'click': function() {
|
||||
self.editTile(e.id, function(list) { return layout.setHidden(list, e.id, false); }, _('%s shown').format(name));
|
||||
} }, [ icon('plus'), E('span', {}, [ name ]) ]);
|
||||
})) : []);
|
||||
},
|
||||
|
||||
/* dragging by the handle: the tile follows the pointer, the others
|
||||
make room where it would land */
|
||||
dragStart: function(id, ev) {
|
||||
if (ev.button !== 0 || !this.lay || this.drag) return;
|
||||
ev.preventDefault();
|
||||
var self = this, t = this.tiles[id], grip = this.ov[id].grip, tr = t.getBoundingClientRect();
|
||||
try { grip.setPointerCapture(ev.pointerId); } catch (e) {}
|
||||
this.drag = { id: id, list: layout.copy(this.lay.list), start: layout.copy(this.lay.list), gx: ev.clientX - tr.left, gy: ev.clientY - tr.top, x: ev.clientX, y: ev.clientY, raf: null };
|
||||
t.classList.add('vt-dragging');
|
||||
this.el.root.classList.add('vt-drag-on');
|
||||
function move(e) { self.drag.x = e.clientX; self.drag.y = e.clientY; self.dragMove(); }
|
||||
function end(e) {
|
||||
grip.removeEventListener('pointermove', move);
|
||||
grip.removeEventListener('pointerup', end);
|
||||
grip.removeEventListener('pointercancel', end);
|
||||
self.dragEnd(e.type === 'pointercancel');
|
||||
}
|
||||
grip.addEventListener('pointermove', move);
|
||||
grip.addEventListener('pointerup', end);
|
||||
grip.addEventListener('pointercancel', end);
|
||||
this.dragMove();
|
||||
this.dragScroll();
|
||||
},
|
||||
|
||||
dragMove: function() {
|
||||
var d = this.drag, l = this.lay;
|
||||
if (!d || !l) return;
|
||||
var t = this.tiles[d.id], gr = this.el.grid.getBoundingClientRect();
|
||||
var x = d.x - gr.left - d.gx, y = d.y - gr.top - d.gy;
|
||||
t.style.left = x.toFixed(1) + 'px';
|
||||
t.style.top = y.toFixed(1) + 'px';
|
||||
|
||||
var step = (l.width + l.gap) / l.cols, me = d.list[layout.indexOf(d.list, d.id)];
|
||||
var c = Math.max(0, Math.min(l.cols - me.w, Math.round(x / step)));
|
||||
var px = d.x - gr.left, py = d.y - gr.top, target = null, after = false;
|
||||
d.list.forEach(function(e) {
|
||||
var r = l.rects[e.id];
|
||||
if (e.hide || e.id === d.id || !r) return;
|
||||
var rx = r.c * step, rw = r.w * step - l.gap;
|
||||
if (px >= rx && px <= rx + rw && py >= r.top && py <= r.top + r.h) {
|
||||
target = e.id;
|
||||
after = py > r.top + r.h / 2;
|
||||
}
|
||||
});
|
||||
var next = layout.drop(d.list, d.id, c, target, after, l.tier);
|
||||
if (!layout.same(next, d.list)) { d.list = next; this.doLayout(); }
|
||||
},
|
||||
|
||||
/* scroll while the pointer rests near the top or bottom edge */
|
||||
dragScroll: function() {
|
||||
var self = this, d = this.drag;
|
||||
if (!d) return;
|
||||
var edge = 64, v = d.y < edge ? -(edge - d.y) / 4 : d.y > window.innerHeight - edge ? (d.y - window.innerHeight + edge) / 4 : 0;
|
||||
if (v) { window.scrollBy(0, Math.round(v)); this.dragMove(); }
|
||||
d.raf = window.requestAnimationFrame(function() { self.dragScroll(); });
|
||||
},
|
||||
|
||||
dragEnd: function(cancel) {
|
||||
var d = this.drag;
|
||||
if (!d) return;
|
||||
if (d.raf) window.cancelAnimationFrame(d.raf);
|
||||
var t = this.tiles[d.id];
|
||||
t.classList.remove('vt-dragging');
|
||||
this.el.root.classList.remove('vt-drag-on');
|
||||
t.style.left = '';
|
||||
t.style.top = '';
|
||||
var list = cancel ? d.start : d.list;
|
||||
this.drag = null;
|
||||
if (!layout.same(list, d.start)) {
|
||||
this.setLayout(list);
|
||||
this.announce(_('%s moved').format(this.tileNames[d.id]));
|
||||
}
|
||||
else this.doLayout();
|
||||
},
|
||||
|
||||
/* ------------------------------------------------------------- head */
|
||||
|
||||
buildHead: function() {
|
||||
@@ -718,6 +1103,10 @@ return view.extend({
|
||||
el.live = E('div', { 'class': 'vt-livepill', 'role': 'status', 'aria-live': 'off' }, [
|
||||
E('span', { 'class': 'vt-live-beat' }),
|
||||
el.liveText = E('span', { 'class': 'vt-live-text' })
|
||||
]),
|
||||
el.layoutBtn = E('button', { 'type': 'button', 'class': 'vt-btn vt-btn-ghost vt-layout-btn', 'aria-pressed': 'false',
|
||||
'title': _('Arrange the dashboard'), 'click': L.bind(function() { this.setEditing(!this.editing); }, this) }, [
|
||||
icon('layout'), E('span', {}, [ _('Layout') ])
|
||||
])
|
||||
]);
|
||||
},
|
||||
@@ -728,7 +1117,7 @@ return view.extend({
|
||||
txt(this.el.headline, s.headline);
|
||||
var clients = this.m.clients.length;
|
||||
txt(this.el.subline, [ d.model, d.hostname, d.firmware ? d.firmware.replace(/\s+r\d+-[0-9a-f]+$/, '') : null,
|
||||
clients === 1 ? _('1 client') : _('%d clients').format(clients) ].filter(Boolean).join(' · '));
|
||||
this.m.wired ? null : clients === 1 ? _('1 client') : _('%d clients').format(clients) ].filter(Boolean).join(' · '));
|
||||
},
|
||||
|
||||
paintLive: function() {
|
||||
@@ -809,7 +1198,7 @@ return view.extend({
|
||||
var ap = E('button', { 'type': 'button', 'class': 'vt-node vt-node-ap', 'click': function() { self.openDrawer('device', 'device'); } }, [
|
||||
E('span', { 'class': 'vt-node-ico' }, [ icon('ap') ]),
|
||||
E('span', { 'class': 'vt-node-body' }, [
|
||||
E('span', { 'class': 'vt-node-kicker' }, [ _('This access point') ]),
|
||||
E('span', { 'class': 'vt-node-kicker' }, [ m.wired ? _('This device') : _('This access point') ]),
|
||||
E('span', { 'class': 'vt-node-title' }, [ d.model ]),
|
||||
E('span', { 'class': 'vt-node-sub vt-mono' }, [ up && up.ipv4[0] ? up.ipv4[0].split('/')[0] : fmt.DASH ]),
|
||||
E('span', { 'class': 'vt-node-sub' }, [ d.uptime != null ? _('up %s').format(fmt.duration(d.uptime)) : '' ])
|
||||
@@ -850,7 +1239,8 @@ return view.extend({
|
||||
self.nodes.radios.push({ el: rnode, ssids: ssNodes, active: !!(rr && rr.rx + rr.tx > 50e3), rate: rr ? rr.rx + rr.tx : 0 });
|
||||
branches.appendChild(E('div', { 'class': 'vt-branch' }, [ rnode, ssidCol ]));
|
||||
});
|
||||
if (!m.radios.length) branches.appendChild(E('div', { 'class': 'vt-empty-inline' }, [ _('No radios reported') ]));
|
||||
if (m.wired) this.netBranches(branches);
|
||||
else if (!m.radios.length) branches.appendChild(E('div', { 'class': 'vt-empty-inline' }, [ _('No radios reported') ]));
|
||||
|
||||
this.nodes.upActive = !!(up_r && up_r.rx + up_r.tx > 50e3);
|
||||
this.nodes.upRate = up_r ? up_r.rx + up_r.tx : 0;
|
||||
@@ -861,10 +1251,33 @@ return view.extend({
|
||||
this.paintKpis();
|
||||
},
|
||||
|
||||
/* wired-only: the device's other networks take the radios' place,
|
||||
wired into the path the same way (nodes.radios) */
|
||||
netBranches: function(branches) {
|
||||
var st = this.st, self = this;
|
||||
var nets = this.m.networks.filter(function(n) { return n.up; }).slice(0, 8);
|
||||
nets.forEach(function(n) {
|
||||
var r = n.dev ? st.devRates[n.dev] : null;
|
||||
var node = E('a', { 'class': 'vt-node vt-node-radio vt-node-net', 'href': L.url.apply(L, insight.PAGES.network),
|
||||
'title': [ n.name, n.dev, n.ipv4 ].filter(Boolean).join(' · ') }, [
|
||||
E('span', { 'class': 'vt-node-body' }, [
|
||||
E('span', { 'class': 'vt-node-kicker' }, [ n.proto || _('Interface') ]),
|
||||
E('span', { 'class': 'vt-node-title' }, [ n.name ]),
|
||||
E('span', { 'class': 'vt-node-sub vt-mono' }, [ n.ipv4 ? n.ipv4.split('/')[0] : (n.dev || fmt.DASH) ]),
|
||||
E('span', { 'class': 'vt-node-sub vt-tn' }, [ r ? '↓ ' + fmt.bitsShort(r.tx) + ' ↑ ' + fmt.bitsShort(r.rx) : fmt.DASH ])
|
||||
])
|
||||
]);
|
||||
self.nodes.radios.push({ el: node, ssids: [], active: !!(r && r.rx + r.tx > 50e3), rate: r ? r.rx + r.tx : 0 });
|
||||
branches.appendChild(E('div', { 'class': 'vt-branch' }, [ node ]));
|
||||
});
|
||||
if (!nets.length) branches.appendChild(E('div', { 'class': 'vt-empty-inline' }, [ _('No other interfaces are up') ]));
|
||||
},
|
||||
|
||||
/* four numbers under the path: who is here, what flows, where, and
|
||||
who is worst off */
|
||||
paintKpis: function() {
|
||||
var self = this, m = this.m, st = this.st, up = st.uplinkRate;
|
||||
if (m.wired) return this.paintNetKpis();
|
||||
var byBand = {};
|
||||
m.clients.forEach(function(c) { byBand[c.band] = (byBand[c.band] || 0) + 1; });
|
||||
var bandText = Object.keys(byBand).sort().map(function(b) { return _('%d on %s').format(byBand[b], wifi.bandLabel(b)); }).join(' · ');
|
||||
@@ -872,12 +1285,6 @@ return view.extend({
|
||||
m.radios.forEach(function(r) { var i = st.radioInsights[r.id]; if (i && i.busiest) busiest = { r: r, share: i.share }; });
|
||||
var weakest = m.clients.filter(function(c) { return c.signal != null; }).sort(function(a, b) { return a.signal - b.signal; })[0];
|
||||
|
||||
function kpi(label, value, sub, tone, onclick) {
|
||||
var body = [ E('span', { 'class': 'vt-kpi-label' }, [ label ]), E('span', { 'class': 'vt-kpi-value vt-tn' }, [ value ]), E('span', { 'class': 'vt-kpi-sub' }, [ sub ]) ];
|
||||
return onclick
|
||||
? E('button', { 'type': 'button', 'class': 'vt-kpi vt-kpi-' + (tone || 'plain'), 'click': onclick }, body)
|
||||
: E('div', { 'class': 'vt-kpi vt-kpi-' + (tone || 'plain') }, body);
|
||||
}
|
||||
dom.content(this.el.kpis, [
|
||||
kpi(_('Clients'), String(m.clients.length), bandText || _('none connected'), 'plain', function() { self.el.clients.scrollIntoView({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' }); }),
|
||||
kpi(_('Traffic now'), up ? '↓ ' + fmt.bits(up.rx) : fmt.DASH, up ? _('↑ %s · via %s').format(fmt.bits(up.tx), m.uplink ? m.uplink.dev : '') : _('measuring'), 'plain', function() { self.openDrawer('uplink', 'uplink'); }),
|
||||
@@ -888,6 +1295,26 @@ return view.extend({
|
||||
]);
|
||||
},
|
||||
|
||||
/* wired-only: what flows, how long the uplink has held, which
|
||||
networks are up and which is busiest */
|
||||
paintNetKpis: function() {
|
||||
var self = this, m = this.m, st = this.st, up = st.uplinkRate, link = m.uplink;
|
||||
var nets = m.networks, upNets = nets.filter(function(n) { return n.up; });
|
||||
var busiest = null;
|
||||
upNets.forEach(function(n) {
|
||||
var r = n.dev ? st.devRates[n.dev] : null;
|
||||
if (r && r.rx + r.tx > 0 && (!busiest || r.rx + r.tx > busiest.r.rx + busiest.r.tx)) busiest = { n: n, r: r };
|
||||
});
|
||||
function toNetwork() { window.location.href = L.url.apply(L, insight.PAGES.network); }
|
||||
dom.content(this.el.kpis, [
|
||||
kpi(_('Traffic now'), up ? '↓ ' + fmt.bits(up.rx) : fmt.DASH, up ? _('↑ %s · via %s').format(fmt.bits(up.tx), link ? link.dev : '') : _('measuring'), 'plain', function() { self.openDrawer('uplink', 'uplink'); }),
|
||||
kpi(_('Connected for'), link && link.uptime != null ? fmt.duration(link.uptime) : fmt.DASH, link ? [ link.iface, link.proto ].filter(Boolean).join(' · ') : _('no uplink'), 'plain', function() { self.openDrawer('uplink', 'uplink'); }),
|
||||
kpi(_('Networks'), String(upNets.length), nets.length > upNets.length ? _('%d down').format(nets.length - upNets.length) : _('all up'), 'plain', toNetwork),
|
||||
busiest ? kpi(_('Busiest network'), busiest.n.name, '↓ ' + fmt.bits(busiest.r.tx) + ' ↑ ' + fmt.bits(busiest.r.rx), 'plain', toNetwork)
|
||||
: kpi(_('Busiest network'), fmt.DASH, _('no traffic yet'), 'plain')
|
||||
]);
|
||||
},
|
||||
|
||||
scheduleWires: function() {
|
||||
var self = this;
|
||||
if (this.wirePending) return;
|
||||
@@ -1055,13 +1482,8 @@ return view.extend({
|
||||
el.chartTip = E('div', { 'class': 'vt-tip', 'hidden': 'hidden' });
|
||||
el.chartOver = E('div', { 'class': 'vt-mult-over', 'aria-hidden': 'true' }, [ el.chartCursor, el.chartTip ]);
|
||||
el.chartX = E('div', { 'class': 'vt-mult-x', 'aria-hidden': 'true' });
|
||||
el.chartBtns = {};
|
||||
var seg = E('div', { 'class': 'vt-seg-ctl', 'role': 'group', 'aria-label': _('Direction') }, [ 'down', 'up' ].map(function(k) {
|
||||
return el.chartBtns[k] = E('button', { 'type': 'button', 'class': 'vt-seg-btn', 'aria-pressed': 'false',
|
||||
'click': function() { self.ui.chart = k; self.paintChart(); } }, [ icon(k), E('span', {}, [ k === 'down' ? _('Download') : _('Upload') ]) ]);
|
||||
}));
|
||||
var plot = E('div', { 'class': 'vt-mult-wrap', 'tabindex': '0', 'role': 'group',
|
||||
'aria-label': _('Throughput per link; arrow keys read the values over time') }, [ el.chartRows, el.chartOver, el.chartX ]);
|
||||
'aria-label': _('Throughput per link, download above the line and upload below; arrow keys read the values over time') }, [ el.chartRows, el.chartOver, el.chartX ]);
|
||||
plot.addEventListener('pointermove', function(ev) { self.chartPointer(ev); });
|
||||
plot.addEventListener('pointerleave', function() { if (!self.ui.hover || self.ui.hover.x != null) self.chartHide(); });
|
||||
plot.addEventListener('keydown', function(ev) { self.chartKey(ev); });
|
||||
@@ -1071,27 +1493,33 @@ return view.extend({
|
||||
return this.foldable('chart', E('section', { 'class': 'vt-tile vt-chart', 'aria-label': _('Throughput') }, [
|
||||
E('header', { 'class': 'vt-tile-head' }, [
|
||||
E('h3', { 'class': 'vt-tile-title' }, [ icon('pulse'), E('span', {}, [ _('Throughput') ]), el.chartSub ]),
|
||||
E('div', { 'class': 'vt-tile-extra' }, [ seg, this.foldButton('chart', _('Show or hide the throughput chart')) ])
|
||||
E('div', { 'class': 'vt-tile-extra' }, [ this.foldButton('chart', _('Show or hide the throughput chart')) ])
|
||||
]),
|
||||
plot,
|
||||
el.chartLive
|
||||
]));
|
||||
},
|
||||
|
||||
/* one small multiple per link: the uplink, then each radio. Each has
|
||||
its own y scale (a busy uplink would flatten an idle radio), labelled
|
||||
with its unit, on a shared time axis and crosshair. */
|
||||
/* one small multiple per link: the uplink, then each radio. Download
|
||||
(towards the clients) rises above the middle line, upload hangs
|
||||
below it; each half has its own y scale (upload is often a hundredth
|
||||
of download, and a busy uplink would flatten an idle radio), labelled
|
||||
with its unit, on a shared time axis and crosshair. Columns of a
|
||||
history point [ t, rx, tx ]: the uplink receives what clients
|
||||
download, a radio sends it. */
|
||||
chartSeries: function() {
|
||||
var m = this.m, down = this.ui.chart === 'down';
|
||||
var list = [ { key: 'uplink', label: m.uplink ? _('Uplink %s').format(m.uplink.dev) : _('Uplink'), cls: 'vt-c-up', col: down ? 1 : 2 } ];
|
||||
m.radios.forEach(function(r) { list.push({ key: r.id, label: _('%s radio').format(r.bandLabel), cls: 'vt-c-' + (r.band || 'x'), col: down ? 2 : 1 }); });
|
||||
var m = this.m;
|
||||
var list = [ { key: 'uplink', label: m.uplink ? _('Uplink %s').format(m.uplink.dev) : _('Uplink'), cls: 'vt-c-up', dn: 1, up: 2 } ];
|
||||
m.radios.forEach(function(r) { list.push({ key: r.id, label: _('%s radio').format(r.bandLabel), cls: 'vt-c-' + (r.band || 'x'), dn: 2, up: 1 }); });
|
||||
return list;
|
||||
},
|
||||
|
||||
paintChart: function() {
|
||||
var self = this, el = this.el, net = this.st.hist.net, W = 600, H = 100, now = Date.now();
|
||||
var list = this.chartSeries(), down = this.ui.chart === 'down';
|
||||
Object.keys(el.chartBtns).forEach(function(k) { el.chartBtns[k].setAttribute('aria-pressed', String(self.ui.chart === k)); });
|
||||
var el = this.el, net = this.st.hist.net, W = 600, H = 100, M = H / 2, now = Date.now();
|
||||
var list = this.chartSeries();
|
||||
/* sample times follow the device's clock (model.sampleClock) and
|
||||
can run ahead of the browser's: the axis ends at the newest one */
|
||||
list.forEach(function(s) { var h = net[s.key]; if (h && h.length) now = Math.max(now, h[h.length - 1][0]); });
|
||||
|
||||
var dm = series.domain(list.map(function(s) { return net[s.key]; }), now, 60000, WINDOW_MS);
|
||||
var ticks = [];
|
||||
@@ -1101,42 +1529,54 @@ return view.extend({
|
||||
|
||||
dom.content(el.chartRows, list.map(function(s) {
|
||||
var d = (net[s.key] || []).filter(function(p) { return p[0] >= dm.t0 - POLL_S * 1000; });
|
||||
var st = series.stats(d, s.col, dm.t0);
|
||||
var sd = series.stats(d, s.dn, dm.t0), su = series.stats(d, s.up, dm.t0);
|
||||
var last = d.length ? d[d.length - 1] : null, fresh = !!(last && now - last[0] < POLL_S * 3000);
|
||||
var vmax = series.niceCeil(Math.max((st.peak || 0) * 1.05, 1000));
|
||||
samples = Math.max(samples, st.n);
|
||||
var vd = series.niceCeil(Math.max((sd.peak || 0) * 1.05, 1000)), vu = series.niceCeil(Math.max((su.peak || 0) * 1.05, 1000));
|
||||
samples = Math.max(samples, sd.n);
|
||||
|
||||
var svg = svgRoot('vt-mult-svg', '0 0 ' + W + ' ' + H);
|
||||
svg.setAttribute('preserveAspectRatio', 'none');
|
||||
svg.appendChild(svgPath('M0 0.5H' + W + 'M0 ' + (H / 2) + 'H' + W, 'vt-chart-grid'));
|
||||
svg.appendChild(svgPath('M0 0.5H' + W + 'M0 ' + (H - 0.5) + 'H' + W, 'vt-chart-grid'));
|
||||
ticks.forEach(function(k) {
|
||||
var x = (1 - k * 60000 / dm.span) * W;
|
||||
svg.appendChild(svgPath('M' + x.toFixed(1) + ' 0V' + H, 'vt-chart-vgrid'));
|
||||
});
|
||||
var segs = series.segments(d, s.col, dm.t0, dm.t1, vmax, W, H, POLL_S * 3500);
|
||||
var area = svgPath(series.areaPath(segs, H), 'vt-series-area');
|
||||
area.setAttribute('fill', fadeFill(svg));
|
||||
svg.appendChild(area);
|
||||
svg.appendChild(svgPath(series.linePath(segs), 'vt-series-line'));
|
||||
/* download: y in [0, M] with 0 at the line; upload mirrored below */
|
||||
var segD = series.segments(d, s.dn, dm.t0, dm.t1, vd, W, M, POLL_S * 3500);
|
||||
var segU = series.segments(d, s.up, dm.t0, dm.t1, vu, W, M, POLL_S * 3500).map(function(seg) {
|
||||
return seg.map(function(pt) { return [ pt[0], H - pt[1] ]; });
|
||||
});
|
||||
[ [ segD, false, '' ], [ segU, true, ' vt-dir-up' ] ].forEach(function(h) {
|
||||
var area = svgPath(series.areaPath(h[0], M), 'vt-series-area' + h[2]);
|
||||
area.setAttribute('fill', fadeFill(svg, h[1]));
|
||||
svg.appendChild(area);
|
||||
svg.appendChild(svgPath(series.linePath(h[0]), 'vt-series-line' + h[2]));
|
||||
});
|
||||
svg.appendChild(svgPath('M0 ' + M + 'H' + W, 'vt-chart-mid'));
|
||||
|
||||
var tail = segs.length ? segs[segs.length - 1] : null, pt = tail && tail[tail.length - 1];
|
||||
var dot = E('span', { 'class': 'vt-mult-dot', 'hidden': 'hidden' });
|
||||
rows.push({ s: s, d: d, vmax: vmax, dot: dot });
|
||||
function tailDot(segs, cls) {
|
||||
var tail = segs.length ? segs[segs.length - 1] : null, pt = tail && tail[tail.length - 1];
|
||||
return pt && fresh ? pos(E('span', { 'class': 'vt-sk-dot' + cls }), pt[0] / W * 100, pt[1] / H * 100) : '';
|
||||
}
|
||||
var dotD = E('span', { 'class': 'vt-mult-dot', 'hidden': 'hidden' }), dotU = E('span', { 'class': 'vt-mult-dot vt-dir-up', 'hidden': 'hidden' });
|
||||
rows.push({ s: s, d: d, vd: vd, vu: vu, dotD: dotD, dotU: dotU });
|
||||
return E('div', { 'class': 'vt-mult ' + s.cls }, [
|
||||
E('div', { 'class': 'vt-mult-head' }, [
|
||||
E('span', { 'class': 'vt-mult-name' }, [ E('span', { 'class': 'vt-lg-key' }), E('span', {}, [ s.label ]) ]),
|
||||
E('b', { 'class': 'vt-mult-now vt-tn' }, [ fresh && st.now != null ? fmt.bits(last[s.col]) : fmt.DASH ]),
|
||||
E('span', { 'class': 'vt-mult-stats vt-tn' }, st.n > 1 ? [
|
||||
E('span', {}, [ _('peak'), ' ', E('b', {}, [ fmt.bits(st.peak) ]) ]),
|
||||
E('span', {}, [ _('avg'), ' ', E('b', {}, [ fmt.bits(st.avg) ]) ])
|
||||
E('b', { 'class': 'vt-mult-now vt-tn', 'title': _('Download') }, [ '↓ ', fresh && sd.now != null ? fmt.bits(last[s.dn]) : fmt.DASH ]),
|
||||
E('b', { 'class': 'vt-mult-now-up vt-tn', 'title': _('Upload') }, [ '↑ ', fresh && su.now != null ? fmt.bits(last[s.up]) : fmt.DASH ]),
|
||||
E('span', { 'class': 'vt-mult-stats vt-tn' }, sd.n > 1 ? [
|
||||
E('span', {}, [ _('peak'), ' ', E('b', {}, [ '↓ ' + fmt.bitsShort(sd.peak) ]), ' ', E('b', {}, [ '↑ ' + fmt.bitsShort(su.peak) ]) ])
|
||||
] : [ E('span', {}, [ _('measuring…') ]) ])
|
||||
]),
|
||||
E('div', { 'class': 'vt-mult-plot' }, [
|
||||
svg,
|
||||
st.n ? E('span', { 'class': 'vt-mult-max vt-tn' }, [ bitsLabel(vmax) ]) : '',
|
||||
st.n < 2 ? E('span', { 'class': 'vt-sk-note' }, [ _('collecting…') ]) : '',
|
||||
pt && fresh ? pos(E('span', { 'class': 'vt-sk-dot' }), pt[0] / W * 100, pt[1] / H * 100) : '',
|
||||
dot
|
||||
sd.n ? E('span', { 'class': 'vt-mult-max vt-tn' }, [ '↓ ' + bitsLabel(vd) ]) : '',
|
||||
sd.n ? E('span', { 'class': 'vt-mult-max vt-mult-max-up vt-tn' }, [ '↑ ' + bitsLabel(vu) ]) : '',
|
||||
sd.n < 2 ? E('span', { 'class': 'vt-sk-note' }, [ _('collecting…') ]) : '',
|
||||
tailDot(segD, ''),
|
||||
tailDot(segU, ' vt-dir-up'),
|
||||
dotD, dotU
|
||||
])
|
||||
]);
|
||||
}));
|
||||
@@ -1152,8 +1592,8 @@ return view.extend({
|
||||
})).concat([ E('span', { 'class': 'vt-mx vt-mx-1' }, [ _('now') ]) ]));
|
||||
|
||||
var age = dm.age;
|
||||
txt(el.chartSub, (down ? _('to clients') : _('from clients')) + ' · ' + (dm.full ? _('last 5 min')
|
||||
: samples < 2 ? _('collecting · first samples') : _('collecting · %s of data').format(spanShort(age))));
|
||||
txt(el.chartSub, dm.full ? _('last 5 min')
|
||||
: samples < 2 ? _('collecting · first samples') : _('collecting · %s of data').format(spanShort(age)));
|
||||
el.plot.classList.toggle('vt-collecting', !dm.full);
|
||||
if (this.ui.hover) this.chartShow(this.ui.hover);
|
||||
},
|
||||
@@ -1184,10 +1624,16 @@ return view.extend({
|
||||
el.chartCursor.hidden = false;
|
||||
el.chartCursor.style.left = x.toFixed(2) + '%';
|
||||
var tipRows = meta.rows.map(function(r) {
|
||||
var j = series.nearest(r.d, tp), v = (j >= 0 && Math.abs(r.d[j][0] - tp) < POLL_S * 1000) ? r.d[j][r.s.col] : null;
|
||||
r.dot.hidden = v == null;
|
||||
if (v != null) pos(r.dot, x, (1 - Math.min(1, v / r.vmax)) * 100);
|
||||
return E('div', { 'class': 'vt-tip-row ' + r.s.cls }, [ E('span', { 'class': 'vt-lg-key' }), E('b', { 'class': 'vt-tn' }, [ v == null ? fmt.DASH : fmt.bits(v) ]), E('span', { 'class': 'vt-tip-label' }, [ r.s.label ]) ]);
|
||||
var j = series.nearest(r.d, tp), p = (j >= 0 && Math.abs(r.d[j][0] - tp) < POLL_S * 1000) ? r.d[j] : null;
|
||||
var vd = p ? p[r.s.dn] : null, vu = p ? p[r.s.up] : null;
|
||||
r.dotD.hidden = vd == null;
|
||||
r.dotU.hidden = vu == null;
|
||||
if (vd != null) pos(r.dotD, x, (1 - Math.min(1, vd / r.vd)) * 50);
|
||||
if (vu != null) pos(r.dotU, x, 50 + Math.min(1, vu / r.vu) * 50);
|
||||
return E('div', { 'class': 'vt-tip-row ' + r.s.cls }, [ E('span', { 'class': 'vt-lg-key' }),
|
||||
E('b', { 'class': 'vt-tn' }, [ '↓ ' + (vd == null ? fmt.DASH : fmt.bits(vd)) ]),
|
||||
E('b', { 'class': 'vt-tn vt-tip-up' }, [ '↑ ' + (vu == null ? fmt.DASH : fmt.bits(vu)) ]),
|
||||
E('span', { 'class': 'vt-tip-label' }, [ r.s.label ]) ]);
|
||||
});
|
||||
dom.content(el.chartTip, [ E('div', { 'class': 'vt-tip-time vt-tn' }, [ new Date(tp).toLocaleTimeString(), E('span', {}, [ fmt.ago(Date.now() - tp) ]) ]) ].concat(tipRows));
|
||||
el.chartTip.hidden = false;
|
||||
@@ -1201,7 +1647,7 @@ return view.extend({
|
||||
this.ui.hover = null;
|
||||
el.chartCursor.hidden = true;
|
||||
el.chartTip.hidden = true;
|
||||
(this.chartMeta ? this.chartMeta.rows : []).forEach(function(r) { r.dot.hidden = true; });
|
||||
(this.chartMeta ? this.chartMeta.rows : []).forEach(function(r) { r.dotD.hidden = true; r.dotU.hidden = true; });
|
||||
},
|
||||
|
||||
chartKey: function(ev) {
|
||||
@@ -1957,8 +2403,12 @@ return view.extend({
|
||||
this.closeDrawer();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Escape' && this.editing && !this.drag) {
|
||||
this.setEditing(false);
|
||||
return;
|
||||
}
|
||||
/* 'f' focuses the client filter; '/' and Ctrl+K belong to the theme */
|
||||
if ((ev.key === 'f' || ev.key === 'F') && !typing && !ev.ctrlKey && !ev.metaKey && !ev.altKey && !this.ui.drawer) {
|
||||
if ((ev.key === 'f' || ev.key === 'F') && !typing && !ev.ctrlKey && !ev.metaKey && !ev.altKey && !this.ui.drawer && !this.m.wired) {
|
||||
ev.preventDefault();
|
||||
this.el.filter.focus();
|
||||
this.el.filter.select();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -10,8 +10,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-theme-vantage
|
||||
PKG_VERSION:=1.0.1
|
||||
PKG_RELEASE:=14
|
||||
PKG_VERSION:=1.0.2
|
||||
PKG_RELEASE:=15
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
|
||||
LUCI_TITLE:=Vantage theme
|
||||
|
||||
@@ -140,6 +140,8 @@ html[data-darkmode="true"] {
|
||||
--v-r: var(--r);
|
||||
--v-r-lg: var(--r-lg);
|
||||
--v-shadow: 0 1px 2px rgba(20, 23, 26, .06), 0 1px 0 rgba(20, 23, 26, .03);
|
||||
/* where sticky content may start: below the top bar and page tabs */
|
||||
--v-stick-top: var(--stick-top);
|
||||
|
||||
/* charts (stock realtime graphs, channel analysis, the dashboard app).
|
||||
Categorical, fixed order, never cycled by rank: lime (the accent, series 1
|
||||
|
||||
Reference in new issue
Block a user